committed by
GitHub
49 changed files with 2337 additions and 70 deletions
@ -0,0 +1,18 @@ |
|||
// 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.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Net.Http.Headers; |
|||
|
|||
namespace Microsoft.Tye.ConfigModel |
|||
{ |
|||
public class ConfigHttpProber |
|||
{ |
|||
[Required] public string Path { get; set; } = default!; |
|||
public int? Port { get; set; } |
|||
public string? Protocol { get; set; } |
|||
public List<KeyValuePair<string, object>> Headers { get; set; } = new List<KeyValuePair<string, object>>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// 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.
|
|||
|
|||
namespace Microsoft.Tye.ConfigModel |
|||
{ |
|||
public class ConfigProbe |
|||
{ |
|||
public ConfigHttpProber? Http { get; set; } |
|||
public int InitialDelay { get; set; } = 0; |
|||
public int Period { get; set; } = 1; |
|||
public int Timeout { get; set; } = 1; |
|||
public int SuccessThreshold { get; set; } = 1; |
|||
public int FailureThreshold { get; set; } = 3; |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// 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.Collections.Generic; |
|||
|
|||
namespace Microsoft.Tye |
|||
{ |
|||
public class HttpProberBuilder |
|||
{ |
|||
public string Path { get; set; } = default!; |
|||
public int? Port { get; set; } |
|||
public string? Protocol { get; set; } |
|||
public List<KeyValuePair<string, object>> Headers { get; set; } = new List<KeyValuePair<string, object>>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
// 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.
|
|||
|
|||
namespace Microsoft.Tye |
|||
{ |
|||
public class ProbeBuilder |
|||
{ |
|||
public HttpProberBuilder? Http { get; set; } |
|||
public int InitialDelay { get; set; } |
|||
public int Period { get; set; } |
|||
public int Timeout { get; set; } |
|||
public int SuccessThreshold { get; set; } |
|||
public int FailureThreshold { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// 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; |
|||
|
|||
namespace Microsoft.Tye.Hosting.Model |
|||
{ |
|||
public class HttpProber |
|||
{ |
|||
public string Path { get; set; } = default!; |
|||
public int? Port { get; set; } |
|||
public string? Protocol { get; set; } |
|||
public List<KeyValuePair<string, object>> Headers { get; set; } = new List<KeyValuePair<string, object>>(); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
// 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; |
|||
|
|||
namespace Microsoft.Tye.Hosting.Model |
|||
{ |
|||
public class Probe |
|||
{ |
|||
public HttpProber? Http { get; set; } |
|||
public TimeSpan InitialDelay { get; set; } |
|||
public TimeSpan Period { get; set; } |
|||
public TimeSpan Timeout { get; set; } |
|||
public int SuccessThreshold { get; set; } |
|||
public int FailureThreshold { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
// 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.
|
|||
|
|||
namespace Microsoft.Tye.Hosting.Model |
|||
{ |
|||
public class ReplicaBinding |
|||
{ |
|||
public int Port { get; set; } |
|||
public int ExternalPort { get; set; } |
|||
public string? Protocol { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,455 @@ |
|||
// 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.Concurrent; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using System.Reactive.Subjects; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Tye.Hosting.Model; |
|||
|
|||
namespace Microsoft.Tye.Hosting |
|||
{ |
|||
public class ReplicaMonitor : IApplicationProcessor |
|||
{ |
|||
private ILogger _logger; |
|||
private ConcurrentDictionary<string, ReplicaMonitorState> _states; |
|||
|
|||
public ReplicaMonitor(ILogger logger) |
|||
{ |
|||
_logger = logger; |
|||
_states = new ConcurrentDictionary<string, ReplicaMonitorState>(); |
|||
} |
|||
|
|||
public Task StartAsync(Application application) |
|||
{ |
|||
foreach (var service in application.Services.Values) |
|||
{ |
|||
service.Items[typeof(Subscription)] = service.ReplicaEvents.Subscribe(OnReplicaChanged); |
|||
} |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public Task StopAsync(Application application) |
|||
{ |
|||
foreach (var service in application.Services.Values) |
|||
{ |
|||
if (service.Items.TryGetValue(typeof(Subscription), out var item) && item is IDisposable disposable) |
|||
{ |
|||
disposable.Dispose(); |
|||
} |
|||
} |
|||
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
private void OnReplicaChanged(ReplicaEvent replicaEvent) |
|||
{ |
|||
switch (replicaEvent.State) |
|||
{ |
|||
case ReplicaState.Started: |
|||
_states.TryAdd(replicaEvent.Replica.Name, new ReplicaMonitorState(replicaEvent.Replica, _logger)); |
|||
break; |
|||
case ReplicaState.Stopped: |
|||
if (_states.TryRemove(replicaEvent.Replica.Name, out var stateToDispose)) |
|||
{ |
|||
stateToDispose.Dispose(); |
|||
} |
|||
break; |
|||
default: |
|||
if (_states.TryGetValue(replicaEvent.Replica.Name, out var state)) |
|||
{ |
|||
state.Update(replicaEvent); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
private class Subscription |
|||
{ |
|||
} |
|||
|
|||
private class ReplicaMonitorState : IDisposable |
|||
{ |
|||
private ReplicaStatus _replica; |
|||
private ILogger _logger; |
|||
|
|||
private Prober? _livenessProber; |
|||
private Prober? _readinessProber; |
|||
private IDisposable? _livenessProberObserver; |
|||
private IDisposable? _readinessProberObserver; |
|||
|
|||
private ReplicaState _currentState; |
|||
private DateTime _lastStateChange; |
|||
private object _stateChangeLocker; |
|||
|
|||
public ReplicaMonitorState(ReplicaStatus replica, ILogger logger) |
|||
{ |
|||
_replica = replica; |
|||
_logger = logger; |
|||
|
|||
_currentState = ReplicaState.Started; |
|||
_lastStateChange = DateTime.Now; |
|||
_stateChangeLocker = new object(); |
|||
|
|||
Init(); |
|||
} |
|||
|
|||
private void Init() |
|||
{ |
|||
var serviceDesc = _replica.Service.Description; |
|||
if (serviceDesc.Liveness == null && serviceDesc.Readiness == null) |
|||
{ |
|||
MoveToReady(); |
|||
} |
|||
else if (serviceDesc.Liveness == null) |
|||
{ |
|||
MoveToHealthy(from: ReplicaState.Started); |
|||
StartReadinessProbe(serviceDesc.Readiness!); |
|||
} |
|||
else if (serviceDesc.Readiness == null) |
|||
{ |
|||
StartLivenessProbe(serviceDesc.Liveness, moveToOnSuccess: ReplicaState.Ready); |
|||
} |
|||
else |
|||
{ |
|||
StartLivenessProbe(serviceDesc.Liveness); |
|||
StartReadinessProbe(serviceDesc.Readiness); |
|||
} |
|||
} |
|||
|
|||
private void StartLivenessProbe(Probe probe, ReplicaState moveToOnSuccess = ReplicaState.Healthy) |
|||
{ |
|||
// currently only HTTP is available
|
|||
if (probe.Http == null) |
|||
{ |
|||
_logger.LogWarning("Cannot start probing replica {name} because probe configuration is not set", _replica.Name); |
|||
return; |
|||
} |
|||
|
|||
_livenessProber = new HttpProber(_replica, "liveness", probe, probe.Http, _logger); |
|||
|
|||
var failureThreshold = probe.FailureThreshold; |
|||
var failures = 0; |
|||
var dead = false; |
|||
|
|||
_livenessProberObserver = _livenessProber.ProbeResults.Subscribe(entry => |
|||
{ |
|||
if (dead) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (entry) |
|||
{ |
|||
// Reset failures count on success
|
|||
failures = 0; |
|||
} |
|||
|
|||
(var currentState, _) = ReadCurrentState(); |
|||
var failuresPastThreshold = failures >= failureThreshold; |
|||
switch ((entry, currentState, moveToOnSuccess, failuresPastThreshold)) |
|||
{ |
|||
case (false, _, _, true): |
|||
dead = true; |
|||
Kill(); |
|||
break; |
|||
case (false, _, _, false): |
|||
Interlocked.Increment(ref failures); |
|||
break; |
|||
case (true, ReplicaState.Started, ReplicaState.Ready, _): |
|||
case (true, ReplicaState.Healthy, ReplicaState.Ready, _): |
|||
MoveToReady(); |
|||
break; |
|||
case (true, ReplicaState.Started, ReplicaState.Healthy, _): |
|||
MoveToHealthy(from: ReplicaState.Started); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
_livenessProber.Start(); |
|||
} |
|||
|
|||
private void StartReadinessProbe(Probe probe) |
|||
{ |
|||
// currently only HTTP is available
|
|||
if (probe.Http == null) |
|||
{ |
|||
_logger.LogWarning("Cannot start probing replica {name} because probe configuration is not set", _replica.Name); |
|||
return; |
|||
} |
|||
|
|||
_readinessProber = new HttpProber(_replica, "readiness", probe, probe.Http, _logger); |
|||
|
|||
var successThreshold = probe.SuccessThreshold; |
|||
var failureThreshold = probe.FailureThreshold; |
|||
|
|||
var successes = 0; |
|||
var failures = 0; |
|||
|
|||
_readinessProberObserver = _readinessProber.ProbeResults.Subscribe(entry => |
|||
{ |
|||
if (entry) |
|||
{ |
|||
// Reset failures count on success
|
|||
failures = 0; |
|||
} |
|||
else |
|||
{ |
|||
// Reset successes count on failure
|
|||
successes = 0; |
|||
} |
|||
|
|||
(var currentState, _) = ReadCurrentState(); |
|||
var successesPastThreshold = successes >= successThreshold; |
|||
var failuresPastThreshold = failures >= failureThreshold; |
|||
switch ((entry, currentState, failuresPastThreshold, successesPastThreshold)) |
|||
{ |
|||
case (false, ReplicaState.Ready, true, _): |
|||
MoveToHealthy(from: ReplicaState.Ready); |
|||
break; |
|||
case (false, ReplicaState.Ready, false, _): |
|||
Interlocked.Increment(ref failures); |
|||
break; |
|||
case (true, ReplicaState.Healthy, _, true): |
|||
MoveToReady(); |
|||
break; |
|||
case (true, ReplicaState.Healthy, _, false): |
|||
Interlocked.Increment(ref successes); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
_readinessProber.Start(); |
|||
} |
|||
|
|||
private void MoveToHealthy(ReplicaState from) |
|||
{ |
|||
_logger.LogDebug("Replica {name} is moving to an healthy state", _replica.Name); |
|||
ChangeState(ReplicaState.Healthy); |
|||
} |
|||
|
|||
private void MoveToReady() |
|||
{ |
|||
_logger.LogDebug("Replica {name} is moving to a ready state", _replica.Name); |
|||
ChangeState(ReplicaState.Ready); |
|||
} |
|||
|
|||
private void Kill() |
|||
{ |
|||
_logger.LogDebug("Killing replica {name} because it has failed the liveness probe", _replica.Name); |
|||
|
|||
// it is assumed that a `Started` replica should have an initialized stopping token source
|
|||
_replica.StoppingTokenSource!.Cancel(); |
|||
} |
|||
|
|||
private void ChangeState(ReplicaState state) |
|||
{ |
|||
_replica.Service.ReplicaEvents.OnNext(new ReplicaEvent(state, _replica)); |
|||
lock (_stateChangeLocker) |
|||
{ |
|||
_currentState = state; |
|||
_lastStateChange = DateTime.Now; |
|||
} |
|||
} |
|||
|
|||
private (ReplicaState state, DateTime lastChanged) ReadCurrentState() |
|||
{ |
|||
lock (_stateChangeLocker) |
|||
{ |
|||
return (_currentState, _lastStateChange); |
|||
} |
|||
} |
|||
|
|||
public void Update(ReplicaEvent replicaEvent) |
|||
{ |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_livenessProber?.Dispose(); |
|||
_readinessProber?.Dispose(); |
|||
_livenessProberObserver?.Dispose(); |
|||
_readinessProberObserver?.Dispose(); |
|||
} |
|||
} |
|||
|
|||
private abstract class Prober : IDisposable |
|||
{ |
|||
protected Prober() |
|||
{ |
|||
ProbeResults = new Subject<bool>(); |
|||
} |
|||
|
|||
public Subject<bool> ProbeResults { get; } |
|||
|
|||
public abstract void Start(); |
|||
|
|||
public abstract void Dispose(); |
|||
} |
|||
|
|||
private class HttpProber : Prober |
|||
{ |
|||
private static HttpClient _httpClient; |
|||
|
|||
static HttpProber() |
|||
{ |
|||
_httpClient = new HttpClient(); |
|||
} |
|||
|
|||
private ReplicaStatus _replica; |
|||
private ReplicaBinding? _selectedBinding; |
|||
private string _probeName; |
|||
private Probe _probe; |
|||
private Model.HttpProber _httpProberSettings; |
|||
|
|||
private Timer _probeTimer; |
|||
private CancellationTokenSource _cts; |
|||
|
|||
private ILogger _logger; |
|||
|
|||
private bool _lastStatus; |
|||
|
|||
public HttpProber(ReplicaStatus replica, string probeName, Probe probe, Model.HttpProber httpProberSettings, ILogger logger) |
|||
: base() |
|||
{ |
|||
_replica = replica; |
|||
_selectedBinding = null; |
|||
_probeName = probeName; |
|||
_probe = probe; |
|||
_httpProberSettings = httpProberSettings; |
|||
|
|||
_probeTimer = new Timer(DoProbe, null, Timeout.Infinite, Timeout.Infinite); |
|||
_cts = new CancellationTokenSource(); |
|||
|
|||
_logger = logger; |
|||
|
|||
_lastStatus = true; |
|||
} |
|||
|
|||
private void DoProbe(object? state) |
|||
{ |
|||
_probeTimer.Change(Timeout.Infinite, Timeout.Infinite); |
|||
_ = DoProbeAsync(); |
|||
} |
|||
|
|||
private async Task DoProbeAsync() |
|||
{ |
|||
if (_cts.Token.IsCancellationRequested) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var protocol = _selectedBinding!.Protocol; |
|||
var address = $"{protocol}://localhost:{_selectedBinding.Port}{_httpProberSettings.Path}"; |
|||
|
|||
using var timeoutCts = new CancellationTokenSource(_probe.Timeout); |
|||
var req = new HttpRequestMessage(HttpMethod.Get, address); |
|||
foreach (var header in _httpProberSettings.Headers) |
|||
{ |
|||
req.Headers.Add(header.Key, header.Value.ToString()); |
|||
} |
|||
|
|||
var res = await _httpClient.SendAsync(req, timeoutCts.Token); |
|||
if (!res.IsSuccessStatusCode) |
|||
{ |
|||
ShowWarning($"Replica {_replica.Name} failed http probe at address '{_httpProberSettings.Path}' due to a failed status ({res.StatusCode})"); |
|||
Send(false); |
|||
return; |
|||
} |
|||
|
|||
Send(true); |
|||
} |
|||
catch (HttpRequestException ex) |
|||
{ |
|||
ShowWarning($"Replica {_replica.Name} failed http probe at address '{_httpProberSettings.Path}' due to an http exception", ex); |
|||
Send(false); |
|||
} |
|||
catch (TaskCanceledException) |
|||
{ |
|||
ShowWarning($"Replica {_replica.Name} failed http probe at address '{_httpProberSettings.Path}' due to timeout"); |
|||
Send(false); |
|||
} |
|||
finally |
|||
{ |
|||
try |
|||
{ |
|||
_probeTimer.Change(_probe.Period, Timeout.InfiniteTimeSpan); |
|||
} |
|||
catch (ObjectDisposedException) |
|||
{ |
|||
} |
|||
} |
|||
} |
|||
|
|||
private void Send(bool status) |
|||
{ |
|||
ProbeResults.OnNext(status); |
|||
_lastStatus = status; |
|||
} |
|||
|
|||
private void ShowWarning(string message, Exception? ex = null) |
|||
{ |
|||
if (!_lastStatus) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (ex != null) |
|||
{ |
|||
_logger.LogWarning(ex, message); |
|||
} |
|||
else |
|||
{ |
|||
_logger.LogWarning(message); |
|||
} |
|||
} |
|||
|
|||
public override void Start() |
|||
{ |
|||
// the logic that selects the binding for the probing depends on the protocol and port fields that were provided in the probe
|
|||
// if neither port nor protocol were provided, we select the first binding
|
|||
// if just port was provided, we select the first binding with that port
|
|||
// if just protocol was provided, we select the first binding with that protocol (http/https)
|
|||
// if both port and protocol were provided, we select the first binding with that port and protocol
|
|||
Func<ReplicaBinding, bool> bindingClosure = (_httpProberSettings.Port.HasValue, _httpProberSettings.Protocol != null) switch |
|||
{ |
|||
(false, false) => _ => true, |
|||
(true, false) => r => r.ExternalPort == _httpProberSettings.Port!.Value, |
|||
(false, true) => r => r.Protocol == _httpProberSettings.Protocol!, |
|||
(true, true) => r => r.ExternalPort == _httpProberSettings.Port!.Value && r.Protocol == _httpProberSettings.Protocol! |
|||
}; |
|||
|
|||
var selectedBindings = _replica.Bindings.Where(bindingClosure); |
|||
if (selectedBindings.Count() == 0) |
|||
{ |
|||
_logger.LogWarning($"No suitable binding was found for replica {_replica.Name} for probe '{_probeName}'"); |
|||
return; |
|||
} |
|||
|
|||
_selectedBinding = selectedBindings.First(); |
|||
|
|||
try |
|||
{ |
|||
_probeTimer.Change(_probe.InitialDelay, Timeout.InfiniteTimeSpan); |
|||
} |
|||
catch (ObjectDisposedException) |
|||
{ |
|||
} |
|||
} |
|||
|
|||
public override void Dispose() |
|||
{ |
|||
_cts.Cancel(); |
|||
_probeTimer.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,453 @@ |
|||
// 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.IO; |
|||
using System.Linq; |
|||
using System.Net; |
|||
using System.Net.Http; |
|||
using System.Text.Json; |
|||
using System.Text.Json.Serialization; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Tye; |
|||
using Microsoft.Tye.Hosting; |
|||
using Microsoft.Tye.Hosting.Model; |
|||
using Test.Infrastructure; |
|||
using Xunit; |
|||
using Xunit.Abstractions; |
|||
using static Test.Infrastructure.TestHelpers; |
|||
|
|||
namespace E2ETest |
|||
{ |
|||
public class HealthCheckTests |
|||
{ |
|||
private readonly ITestOutputHelper _output; |
|||
private readonly TestOutputLogEventSink _sink; |
|||
private readonly JsonSerializerOptions _options; |
|||
|
|||
private static readonly ReplicaState?[] startedOrHigher = new ReplicaState?[] { ReplicaState.Started, ReplicaState.Healthy, ReplicaState.Ready }; |
|||
private static readonly ReplicaState?[] stoppedOrLower = new ReplicaState?[] { ReplicaState.Stopped, ReplicaState.Removed }; |
|||
|
|||
private static HttpClient _client; |
|||
|
|||
static HealthCheckTests() |
|||
{ |
|||
var handler = new HttpClientHandler |
|||
{ |
|||
ServerCertificateCustomValidationCallback = (a, b, c, d) => true, |
|||
AllowAutoRedirect = false |
|||
}; |
|||
|
|||
_client = new HttpClient(new RetryHandler(handler)); |
|||
} |
|||
|
|||
public HealthCheckTests(ITestOutputHelper output) |
|||
{ |
|||
_output = output; |
|||
_sink = new TestOutputLogEventSink(output); |
|||
|
|||
_options = new JsonSerializerOptions() |
|||
{ |
|||
PropertyNameCaseInsensitive = true, |
|||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |
|||
WriteIndented = true, |
|||
}; |
|||
|
|||
_options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ServiceWithoutLivenessReadinessShouldDefaultToReadyTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-none.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-none" }, ReplicaState.Ready); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ServiceWithoutLivenessShouldDefaultToHealthyTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-readiness.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-readiness" }, ReplicaState.Healthy); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ServicWithoutLivenessShouldBecomeReadyWhenReadyTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-readiness.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-readiness" }, ReplicaState.Healthy); |
|||
|
|||
var replicas = host.Application.Services["health-readiness"].Replicas.Select(r => r.Value).ToList(); |
|||
Assert.True(await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Ready, replicas.Count, replicas.Select(r => r.Name).ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await Task.WhenAll(replicas.Select(r => SetHealthyReadyInReplica(r, ready: true))); |
|||
})); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ServiceWithoutReadinessShouldDefaultToReadyWhenHealthyTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-liveness.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-liveness" }, ReplicaState.Started); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-liveness"].Replicas.Select(r => r.Value); |
|||
var replicasNamesToBecomeReady = replicasToBecomeReady.Select(r => r.Name).ToHashSet(); |
|||
Assert.True(await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Ready, replicasNamesToBecomeReady.Count, replicasNamesToBecomeReady, null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await Task.WhenAll(replicasToBecomeReady.Select(r => SetHealthyReadyInReplica(r, healthy: true))); |
|||
})); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ReadyServiceShouldBecomeHealthyWhenReadinessFailsTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-all.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-all" }, ReplicaState.Ready); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-all"].Replicas.Select(r => r.Value).ToList(); |
|||
var replicasNamesToBecomeReady = replicasToBecomeReady.Select(r => r.Name).ToHashSet(); |
|||
|
|||
var randomReplica = replicasToBecomeReady[new Random().Next(0, replicasToBecomeReady.Count)]; |
|||
|
|||
Assert.True(await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { randomReplica.Name }.ToHashSet(), replicasNamesToBecomeReady.Where(r => r != randomReplica.Name).ToHashSet(), TimeSpan.FromSeconds(1), async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(randomReplica, ready: false); |
|||
})); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ReadyServiceShouldRestartWhenLivenessFailsTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-all.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-all" }, ReplicaState.Ready); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-all"].Replicas.Select(r => r.Value).ToList(); |
|||
var replicasNamesToBecomeReady = replicasToBecomeReady.Select(r => r.Name).ToHashSet(); |
|||
|
|||
var randomReplica = replicasToBecomeReady[new Random().Next(0, replicasToBecomeReady.Count)]; |
|||
|
|||
Assert.True(await DoOperationAndWaitForReplicasToRestart(host, new[] { randomReplica.Name }.ToHashSet(), replicasNamesToBecomeReady.Where(r => r != randomReplica.Name).ToHashSet(), TimeSpan.FromSeconds(1), async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(randomReplica, healthy: false); |
|||
})); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ProbeShouldRespectTimeoutTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-all.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-all" }, ReplicaState.Ready); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-all"].Replicas.Select(r => r.Value).ToList(); |
|||
var replicasNamesToBecomeReady = replicasToBecomeReady.Select(r => r.Name).ToHashSet(); |
|||
|
|||
var randomNumber = new Random().Next(0, replicasToBecomeReady.Count); |
|||
var randomReplica1 = replicasToBecomeReady[randomNumber]; |
|||
var randomReplica2 = replicasToBecomeReady[(randomNumber + 1) % replicasToBecomeReady.Count]; |
|||
|
|||
Assert.True(await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { randomReplica1.Name }.ToHashSet(), replicasNamesToBecomeReady.Where(r => r != randomReplica1.Name).ToHashSet(), TimeSpan.FromSeconds(2), async _ => |
|||
{ |
|||
await Task.WhenAll(new[] |
|||
{ |
|||
SetHealthyReadyInReplica(randomReplica1, readyDelay: 2), |
|||
SetHealthyReadyInReplica(randomReplica2, readyDelay: 1) |
|||
}); |
|||
})); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ProxyShouldNotProxyToNonReadyReplicasTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-proxy.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-proxy" }, ReplicaState.Ready); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-proxy"].Replicas.Select(r => r.Value).ToList(); |
|||
|
|||
// we assume that proxy will continue sending http request to the same replica
|
|||
var randomReplicaPortRes1 = await _client.GetAsync($"http://localhost:{host.Application.Services["health-proxy"].Description.Bindings.First().Port}/ports"); |
|||
var randomReplicaPort1 = JsonSerializer.Deserialize<int[]>(await randomReplicaPortRes1.Content.ReadAsStringAsync())[0]; |
|||
var randomReplica1 = replicasToBecomeReady.First(r => r.Bindings.Any(b => b.Port == randomReplicaPort1)); |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { randomReplica1.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(randomReplica1, ready: false); |
|||
}); |
|||
|
|||
var randomReplicaPortRes2 = await _client.GetAsync($"http://localhost:{host.Application.Services["health-proxy"].Description.Bindings.First().Port}/ports"); |
|||
var randomReplicaPort2 = JsonSerializer.Deserialize<int[]>(await randomReplicaPortRes2.Content.ReadAsStringAsync())[0]; |
|||
var randomReplica2 = replicasToBecomeReady.First(r => r.Bindings.Any(b => b.Port == randomReplicaPort2)); |
|||
|
|||
Assert.NotEqual(randomReplicaPort1, randomReplicaPort2); |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { randomReplica2.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(randomReplica2, ready: false); |
|||
}); |
|||
|
|||
try |
|||
{ |
|||
var resShouldFail = await _client.GetAsync($"http://localhost:{host.Application.Services["health-proxy"].Description.Bindings.First().Port}/ports"); |
|||
Assert.False(resShouldFail.IsSuccessStatusCode); |
|||
} |
|||
catch (HttpRequestException) |
|||
{ |
|||
} |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Ready, 1, new[] { randomReplica2.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(randomReplica2, ready: true); |
|||
}); |
|||
|
|||
var randomReplicaPortRes3 = await _client.GetAsync($"http://localhost:{host.Application.Services["health-proxy"].Description.Bindings.First().Port}/ports"); |
|||
var randomReplicaPort3 = JsonSerializer.Deserialize<int[]>(await randomReplicaPortRes3.Content.ReadAsStringAsync())[0]; |
|||
|
|||
Assert.Equal(randomReplicaPort3, randomReplicaPort2); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task IngressShouldNotProxyToNonReadyReplicasTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-ingress.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-ingress-svc" }, ReplicaState.Ready); |
|||
|
|||
var replicasToBecomeReady = host.Application.Services["health-ingress-svc"].Replicas.Select(r => r.Value).ToList(); |
|||
var ingressBinding = host.Application.Services.First(s => s.Value.Description.RunInfo is IngressRunInfo).Value.Description.Bindings.First(); |
|||
var uniqueIdUrl = $"{ingressBinding.Protocol}://localhost:{ingressBinding.Port}/api/id"; |
|||
|
|||
var uniqueIds = await ProbeNumberOfUniqueReplicas(uniqueIdUrl); |
|||
Assert.Equal(2, uniqueIds); |
|||
|
|||
var firstReplica = replicasToBecomeReady.First(); |
|||
var secondReplica = replicasToBecomeReady.Skip(1).First(); |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { firstReplica.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(firstReplica, ready: false); |
|||
}); |
|||
|
|||
uniqueIds = await ProbeNumberOfUniqueReplicas(uniqueIdUrl); |
|||
Assert.Equal(1, uniqueIds); |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Healthy, 1, new[] { secondReplica.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(secondReplica, ready: false); |
|||
}); |
|||
|
|||
var res = await _client.GetAsync(uniqueIdUrl); |
|||
Assert.Equal(HttpStatusCode.BadGateway, res.StatusCode); |
|||
|
|||
await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Ready, 2, new[] { firstReplica.Name, secondReplica.Name }.ToHashSet(), null, TimeSpan.Zero, async _ => |
|||
{ |
|||
await SetHealthyReadyInReplica(firstReplica, ready: true); |
|||
await SetHealthyReadyInReplica(secondReplica, ready: true); |
|||
}); |
|||
|
|||
uniqueIds = await ProbeNumberOfUniqueReplicas(uniqueIdUrl); |
|||
Assert.Equal(2, uniqueIds); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task HeadersTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-all.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await using var host = new TyeHost(application.ToHostingApplication(), new HostOptions()) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
SetReplicasInitialState(host, true, true); |
|||
|
|||
await StartHostAndWaitForReplicasToStart(host, new[] { "health-all" }, ReplicaState.Ready); |
|||
|
|||
var res = await _client.GetAsync($"http://localhost:{host.Application.Services["health-all"].Description.Bindings.First().Port}/livenessHeaders"); |
|||
Assert.True(res.IsSuccessStatusCode); |
|||
|
|||
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(await res.Content.ReadAsStringAsync()); |
|||
Assert.Equal("value1", headers["name1"]); |
|||
Assert.Equal("value2", headers["name2"]); |
|||
|
|||
res = await _client.GetAsync($"http://localhost:{host.Application.Services["health-all"].Description.Bindings.First().Port}/readinessHeaders"); |
|||
Assert.True(res.IsSuccessStatusCode); |
|||
|
|||
headers = JsonSerializer.Deserialize<Dictionary<string, string>>(await res.Content.ReadAsStringAsync()); |
|||
Assert.Equal("value3", headers["name3"]); |
|||
Assert.Equal("value4", headers["name4"]); |
|||
} |
|||
|
|||
private async Task SetHealthyReadyInReplica(ReplicaStatus replica, bool? healthy = null, bool? ready = null, int? healthyDelay = null, int? readyDelay = null) |
|||
{ |
|||
var query = new List<string>(); |
|||
|
|||
if (healthy.HasValue) |
|||
{ |
|||
query.Add("healthy=" + healthy); |
|||
} |
|||
|
|||
if (ready.HasValue) |
|||
{ |
|||
query.Add("ready=" + ready); |
|||
} |
|||
|
|||
if (healthyDelay.HasValue) |
|||
{ |
|||
query.Add("healthyDelay=" + healthyDelay.Value); |
|||
} |
|||
|
|||
if (readyDelay.HasValue) |
|||
{ |
|||
query.Add("readyDelay=" + readyDelay.Value); |
|||
} |
|||
|
|||
await _client.GetAsync($"http://localhost:{replica.Ports.First()}/set?" + string.Join("&", query)); |
|||
} |
|||
|
|||
private async Task<int> ProbeNumberOfUniqueReplicas(string url) |
|||
{ |
|||
// this assumes roundrobin
|
|||
var unique = new HashSet<string>(); |
|||
|
|||
string? id = null; |
|||
while (id == null || unique.Add(id)) |
|||
{ |
|||
var res = await _client.GetAsync(url); |
|||
id = await res.Content.ReadAsStringAsync(); |
|||
} |
|||
|
|||
return unique.Count; |
|||
} |
|||
|
|||
private void SetReplicasInitialState(TyeHost host, bool? healthy, bool? ready, string[]? services = null) |
|||
{ |
|||
if (services == null) |
|||
{ |
|||
services = host.Application.Services.Select(s => s.Key).ToArray(); |
|||
} |
|||
else |
|||
{ |
|||
if (services.Any(s => !host.Application.Services.ContainsKey(s))) |
|||
{ |
|||
throw new ArgumentException($"not all services given in {nameof(services)} exist"); |
|||
} |
|||
} |
|||
|
|||
foreach (var service in services) |
|||
{ |
|||
if (healthy.HasValue) |
|||
{ |
|||
host.Application.Services[service].Description.Configuration.Add(new EnvironmentVariable("healthy") { Value = "true" }); |
|||
} |
|||
|
|||
if (ready.HasValue) |
|||
{ |
|||
host.Application.Services[service].Description.Configuration.Add(new EnvironmentVariable("ready") { Value = "true" }); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,143 @@ |
|||
// 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.IO; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using System.Text.Json; |
|||
using System.Text.Json.Serialization; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Tye; |
|||
using Microsoft.Tye.Hosting; |
|||
using Microsoft.Tye.Hosting.Model; |
|||
using Microsoft.Tye.Hosting.Model.V1; |
|||
using Test.Infrastructure; |
|||
using Xunit; |
|||
using Xunit.Abstractions; |
|||
using static Test.Infrastructure.TestHelpers; |
|||
|
|||
namespace E2ETest |
|||
{ |
|||
public class ReplicaStoppingTests |
|||
{ |
|||
private readonly ITestOutputHelper _output; |
|||
private readonly TestOutputLogEventSink _sink; |
|||
private readonly JsonSerializerOptions _options; |
|||
|
|||
private static readonly ReplicaState?[] startedOrHigher = new ReplicaState?[] { ReplicaState.Started, ReplicaState.Healthy, ReplicaState.Ready }; |
|||
private static readonly ReplicaState?[] stoppedOrLower = new ReplicaState?[] { ReplicaState.Stopped, ReplicaState.Removed }; |
|||
|
|||
public ReplicaStoppingTests(ITestOutputHelper output) |
|||
{ |
|||
_output = output; |
|||
_sink = new TestOutputLogEventSink(output); |
|||
|
|||
_options = new JsonSerializerOptions() |
|||
{ |
|||
PropertyNameCaseInsensitive = true, |
|||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |
|||
WriteIndented = true, |
|||
}; |
|||
|
|||
_options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task MultiProjectStoppingTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("health-checks"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-none.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await RunHostingApplication(application, new HostOptions(), async (host, uri) => |
|||
{ |
|||
var replicaToStop = host.Application.Services["health-none"].Replicas.First(); |
|||
Assert.Contains(replicaToStop.Value.State, startedOrHigher); |
|||
|
|||
var replicasToRestart = new[] { replicaToStop.Key }; |
|||
var restOfReplicas = host.Application.Services.SelectMany(s => s.Value.Replicas).Select(r => r.Value.Name).Where(r => r != replicaToStop.Key).ToArray(); |
|||
|
|||
Assert.True(await DoOperationAndWaitForReplicasToRestart(host, replicasToRestart.ToHashSet(), restOfReplicas.ToHashSet(), TimeSpan.FromSeconds(1), _ => |
|||
{ |
|||
replicaToStop.Value.StoppingTokenSource!.Cancel(); |
|||
return Task.CompletedTask; |
|||
})); |
|||
|
|||
Assert.Contains(replicaToStop.Value.State, stoppedOrLower); |
|||
Assert.True(host.Application.Services.SelectMany(s => s.Value.Replicas).All(r => startedOrHigher.Contains(r.Value.State))); |
|||
}); |
|||
} |
|||
|
|||
[ConditionalFact] |
|||
[SkipIfDockerNotRunning] |
|||
public async Task MultiProjectDockerStoppingTests() |
|||
{ |
|||
using var projectDirectory = CopyTestProjectDirectory("multi-project"); |
|||
|
|||
var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml")); |
|||
var outputContext = new OutputContext(_sink, Verbosity.Debug); |
|||
var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); |
|||
|
|||
await RunHostingApplication(application, new HostOptions() { Docker = true }, async (host, uri) => |
|||
{ |
|||
var replicaToStop = host.Application.Services["frontend"].Replicas.First(); |
|||
Assert.Contains(replicaToStop.Value.State, startedOrHigher); |
|||
|
|||
var replicasToRestart = new[] { replicaToStop.Key }; |
|||
var restOfReplicas = host.Application.Services.SelectMany(s => s.Value.Replicas).Select(r => r.Value.Name).Where(r => r != replicaToStop.Key).ToArray(); |
|||
|
|||
Assert.True(await DoOperationAndWaitForReplicasToRestart(host, replicasToRestart.ToHashSet(), restOfReplicas.ToHashSet(), TimeSpan.FromSeconds(1), _ => |
|||
{ |
|||
replicaToStop.Value.StoppingTokenSource!.Cancel(); |
|||
return Task.CompletedTask; |
|||
})); |
|||
|
|||
Assert.Contains(replicaToStop.Value.State, startedOrHigher); // when a container restarts, it assumes the same replica
|
|||
Assert.True(host.Application.Services.SelectMany(s => s.Value.Replicas).All(r => startedOrHigher.Contains(r.Value.State))); |
|||
}); |
|||
} |
|||
|
|||
private async Task RunHostingApplication(ApplicationBuilder application, HostOptions options, Func<TyeHost, Uri, Task> execute) |
|||
{ |
|||
await using var host = new TyeHost(application.ToHostingApplication(), options) |
|||
{ |
|||
Sink = _sink, |
|||
}; |
|||
|
|||
try |
|||
{ |
|||
await StartHostAndWaitForReplicasToStart(host); |
|||
|
|||
var uri = new Uri(host.DashboardWebApplication!.Addresses.First()); |
|||
|
|||
await execute(host, uri!); |
|||
} |
|||
finally |
|||
{ |
|||
if (host.DashboardWebApplication != null) |
|||
{ |
|||
var uri = new Uri(host.DashboardWebApplication!.Addresses.First()); |
|||
|
|||
using var client = new HttpClient(); |
|||
|
|||
foreach (var s in host.Application.Services.Values) |
|||
{ |
|||
var logs = await client.GetStringAsync(new Uri(uri, $"/api/v1/logs/{s.Description.Name}")); |
|||
|
|||
_output.WriteLine($"Logs for service: {s.Description.Name}"); |
|||
_output.WriteLine(logs); |
|||
|
|||
var description = await client.GetStringAsync(new Uri(uri, $"/api/v1/services/{s.Description.Name}")); |
|||
|
|||
_output.WriteLine($"Service defintion: {s.Description.Name}"); |
|||
_output.WriteLine(description); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
kind: Deployment |
|||
apiVersion: apps/v1 |
|||
metadata: |
|||
name: health-all |
|||
labels: |
|||
app.kubernetes.io/name: 'health-all' |
|||
app.kubernetes.io/part-of: 'health-checks' |
|||
spec: |
|||
replicas: 3 |
|||
selector: |
|||
matchLabels: |
|||
app.kubernetes.io/name: health-all |
|||
template: |
|||
metadata: |
|||
labels: |
|||
app.kubernetes.io/name: 'health-all' |
|||
app.kubernetes.io/part-of: 'health-checks' |
|||
spec: |
|||
containers: |
|||
- name: health-all |
|||
image: health-all:1.0.0 |
|||
imagePullPolicy: Always |
|||
env: |
|||
- name: ASPNETCORE_URLS |
|||
value: 'http://*:8004' |
|||
- name: PORT |
|||
value: '8004' |
|||
ports: |
|||
- containerPort: 8004 |
|||
livenessProbe: |
|||
httpGet: |
|||
path: /healthy |
|||
port: 8004 |
|||
scheme: HTTP |
|||
httpHeaders: |
|||
- name: name1 |
|||
value: value1 |
|||
- name: name2 |
|||
value: value2 |
|||
initialDelaySeconds: 5 |
|||
periodSeconds: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
readinessProbe: |
|||
httpGet: |
|||
path: /ready |
|||
port: 8004 |
|||
scheme: HTTP |
|||
httpHeaders: |
|||
- name: name3 |
|||
value: value3 |
|||
- name: name4 |
|||
value: value4 |
|||
initialDelaySeconds: 5 |
|||
periodSeconds: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
... |
|||
--- |
|||
kind: Service |
|||
apiVersion: v1 |
|||
metadata: |
|||
name: health-all |
|||
labels: |
|||
app.kubernetes.io/name: 'health-all' |
|||
app.kubernetes.io/part-of: 'health-checks' |
|||
spec: |
|||
selector: |
|||
app.kubernetes.io/name: health-all |
|||
type: ClusterIP |
|||
ports: |
|||
- name: http |
|||
protocol: TCP |
|||
port: 8004 |
|||
targetPort: 8004 |
|||
... |
|||
@ -0,0 +1,32 @@ |
|||
// 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.Linq; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Hosting; |
|||
using Microsoft.AspNetCore.Server.Kestrel.Core; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Hosting; |
|||
using Microsoft.Extensions.Logging; |
|||
|
|||
namespace api |
|||
{ |
|||
public class Program |
|||
{ |
|||
public static void Main(string[] args) |
|||
{ |
|||
CreateHostBuilder(args).Build().Run(); |
|||
} |
|||
|
|||
public static IHostBuilder CreateHostBuilder(string[] args) => |
|||
Host.CreateDefaultBuilder(args) |
|||
.ConfigureWebHostDefaults(web => |
|||
{ |
|||
web.UseStartup<Startup>() |
|||
.ConfigureKestrel(options => {}); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
{ |
|||
"$schema": "http://json.schemastore.org/launchsettings.json", |
|||
"iisSettings": { |
|||
"windowsAuthentication": false, |
|||
"anonymousAuthentication": true, |
|||
"iisExpress": { |
|||
"applicationUrl": "http://localhost:39065", |
|||
"sslPort": 44337 |
|||
} |
|||
}, |
|||
"profiles": { |
|||
"IIS Express": { |
|||
"commandName": "IISExpress", |
|||
"launchBrowser": true, |
|||
"launchUrl": "weatherforecast", |
|||
"environmentVariables": { |
|||
"ASPNETCORE_ENVIRONMENT": "Development" |
|||
} |
|||
}, |
|||
"api": { |
|||
"commandName": "Project", |
|||
"launchBrowser": true, |
|||
"launchUrl": "weatherforecast", |
|||
"applicationUrl": "https://localhost:5001;http://localhost:5000", |
|||
"environmentVariables": { |
|||
"ASPNETCORE_ENVIRONMENT": "Development" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,166 @@ |
|||
// 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.Linq; |
|||
using System.Text; |
|||
using System.Text.Json; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Builder; |
|||
using Microsoft.AspNetCore.Hosting; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.HttpsPolicy; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Microsoft.Extensions.Hosting; |
|||
using Microsoft.Extensions.Logging; |
|||
|
|||
namespace api |
|||
{ |
|||
public class Startup |
|||
{ |
|||
private static string _randomId = Guid.NewGuid().ToString(); |
|||
|
|||
private static bool _healthy = false; |
|||
private static bool _ready = false; |
|||
|
|||
private static int _healthyDelay = 0; |
|||
private static int _readyDelay = 0; |
|||
|
|||
private static Dictionary<string, string> _livenessHeaders; |
|||
private static Dictionary<string, string> _readinessHeaders; |
|||
|
|||
private static int[] _ports; |
|||
|
|||
private static object _locker = new object(); |
|||
|
|||
public Startup(IConfiguration configuration) |
|||
{ |
|||
Configuration = configuration; |
|||
|
|||
var healthyEnv = Environment.GetEnvironmentVariable("healthy"); |
|||
var readyEnv = Environment.GetEnvironmentVariable("ready"); |
|||
|
|||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("PORT"))) |
|||
{ |
|||
var portParts = Environment.GetEnvironmentVariable("PORT").Split(';'); |
|||
_ports = portParts.Select(p => int.Parse(p)).ToArray(); |
|||
} |
|||
|
|||
if (healthyEnv != null) |
|||
{ |
|||
_healthy = bool.Parse(healthyEnv); |
|||
} |
|||
|
|||
if (readyEnv != null) |
|||
{ |
|||
_ready = bool.Parse(readyEnv); |
|||
} |
|||
} |
|||
|
|||
public IConfiguration Configuration { get; } |
|||
|
|||
// This method gets called by the runtime. Use this method to add services to the container.
|
|||
public void ConfigureServices(IServiceCollection services) |
|||
{ |
|||
services.AddControllers(); |
|||
} |
|||
|
|||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
|||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) |
|||
{ |
|||
if (env.IsDevelopment()) |
|||
{ |
|||
app.UseDeveloperExceptionPage(); |
|||
} |
|||
|
|||
app.UseRouting(); |
|||
|
|||
app.UseAuthorization(); |
|||
|
|||
app.UseEndpoints(endpoints => |
|||
{ |
|||
endpoints.MapGet("/", async ctx => |
|||
{ |
|||
await ctx.Response.WriteAsync("Hello"); |
|||
}); |
|||
|
|||
endpoints.MapGet("/ports", async ctx => |
|||
{ |
|||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(_ports)); |
|||
}); |
|||
|
|||
endpoints.MapGet("/id", async ctx => |
|||
{ |
|||
await ctx.Response.WriteAsync(_randomId); |
|||
}); |
|||
|
|||
endpoints.MapGet("/healthy", async ctx => |
|||
{ |
|||
if (_healthyDelay != 0) |
|||
{ |
|||
await Task.Delay(TimeSpan.FromSeconds(_healthyDelay)); |
|||
} |
|||
|
|||
_livenessHeaders = ctx.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()); |
|||
|
|||
ctx.Response.StatusCode = _healthy ? 200 : 500; |
|||
await ctx.Response.WriteAsync(ctx.Response.StatusCode.ToString()); |
|||
}); |
|||
|
|||
endpoints.MapGet("/ready", async ctx => |
|||
{ |
|||
if (_readyDelay != 0) |
|||
{ |
|||
await Task.Delay(TimeSpan.FromSeconds(_readyDelay)); |
|||
} |
|||
|
|||
_readinessHeaders = ctx.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()); |
|||
|
|||
ctx.Response.StatusCode = _ready ? 200 : 500; |
|||
await ctx.Response.WriteAsync(ctx.Response.StatusCode.ToString()); |
|||
}); |
|||
|
|||
// Should be technically POST/PUT, but it's just for tests...
|
|||
endpoints.MapGet("/set", async ctx => |
|||
{ |
|||
var query = ctx.Request.Query.ToDictionary(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value.Value.First()); |
|||
if (query.ContainsKey("healthy")) |
|||
{ |
|||
_healthy = bool.Parse(query["healthy"]); |
|||
} |
|||
|
|||
if (query.ContainsKey("ready")) |
|||
{ |
|||
_ready = bool.Parse(query["ready"]); |
|||
} |
|||
|
|||
if (query.ContainsKey("healthyDelay")) |
|||
{ |
|||
_healthyDelay = int.Parse(query["healthyDelay"]); |
|||
} |
|||
|
|||
if (query.ContainsKey("readyDelay")) |
|||
{ |
|||
_readyDelay = int.Parse(query["readyDelay"]); |
|||
} |
|||
|
|||
await ctx.Response.WriteAsync(_randomId); |
|||
}); |
|||
|
|||
endpoints.MapGet("/livenessHeaders", async ctx => |
|||
{ |
|||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(_livenessHeaders)); |
|||
}); |
|||
|
|||
endpoints.MapGet("/readinessHeaders", async ctx => |
|||
{ |
|||
await ctx.Response.WriteAsync(JsonSerializer.Serialize(_readinessHeaders)); |
|||
}); |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk.Web"> |
|||
|
|||
<PropertyGroup> |
|||
<TargetFramework>netcoreapp3.1</TargetFramework> |
|||
</PropertyGroup> |
|||
|
|||
|
|||
</Project> |
|||
@ -0,0 +1,9 @@ |
|||
{ |
|||
"Logging": { |
|||
"LogLevel": { |
|||
"Default": "Information", |
|||
"Microsoft": "Warning", |
|||
"Microsoft.Hosting.Lifetime": "Information" |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
{ |
|||
"Logging": { |
|||
"LogLevel": { |
|||
"Default": "Information", |
|||
"Microsoft": "Warning", |
|||
"Microsoft.Hosting.Lifetime": "Information" |
|||
} |
|||
}, |
|||
"AllowedHosts": "*" |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
|
|||
Microsoft Visual Studio Solution File, Format Version 12.00 |
|||
# Visual Studio 15 |
|||
VisualStudioVersion = 15.0.26124.0 |
|||
MinimumVisualStudioVersion = 15.0.26124.0 |
|||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api", "api\api.csproj", "{239E0475-3113-4C1C-A517-38AD9188B65D}" |
|||
EndProject |
|||
Global |
|||
GlobalSection(SolutionConfigurationPlatforms) = preSolution |
|||
Debug|Any CPU = Debug|Any CPU |
|||
Debug|x64 = Debug|x64 |
|||
Debug|x86 = Debug|x86 |
|||
Release|Any CPU = Release|Any CPU |
|||
Release|x64 = Release|x64 |
|||
Release|x86 = Release|x86 |
|||
EndGlobalSection |
|||
GlobalSection(SolutionProperties) = preSolution |
|||
HideSolutionNode = FALSE |
|||
EndGlobalSection |
|||
GlobalSection(ProjectConfigurationPlatforms) = postSolution |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|Any CPU.Build.0 = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|x64.ActiveCfg = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|x64.Build.0 = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|x86.ActiveCfg = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Debug|x86.Build.0 = Debug|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|Any CPU.ActiveCfg = Release|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|Any CPU.Build.0 = Release|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|x64.ActiveCfg = Release|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|x64.Build.0 = Release|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|x86.ActiveCfg = Release|Any CPU |
|||
{239E0475-3113-4C1C-A517-38AD9188B65D}.Release|x86.Build.0 = Release|Any CPU |
|||
EndGlobalSection |
|||
EndGlobal |
|||
@ -0,0 +1,36 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
services: |
|||
- name: health-all |
|||
project: api/api.csproj |
|||
replicas: 3 |
|||
bindings: |
|||
- port: 8004 |
|||
liveness: |
|||
http: |
|||
path: /healthy |
|||
headers: |
|||
- name: name1 |
|||
value: value1 |
|||
- name: name2 |
|||
value: value2 |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
readiness: |
|||
http: |
|||
path: /ready |
|||
headers: |
|||
- name: name3 |
|||
value: value3 |
|||
- name: name4 |
|||
value: value4 |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 2 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
|
|||
@ -0,0 +1,30 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
ingress: |
|||
- name: ingress |
|||
bindings: |
|||
- port: 8006 |
|||
rules: |
|||
- path: /api |
|||
service: health-ingress-svc |
|||
services: |
|||
- name: health-ingress-svc |
|||
project: api/api.csproj |
|||
replicas: 2 |
|||
liveness: |
|||
http: |
|||
path: /healthy |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
readiness: |
|||
http: |
|||
path: /ready |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
@ -0,0 +1,17 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
services: |
|||
- name: health-liveness |
|||
project: api/api.csproj |
|||
replicas: 3 |
|||
bindings: |
|||
- port: 8002 |
|||
liveness: |
|||
http: |
|||
path: /healthy |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
@ -0,0 +1,10 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
services: |
|||
- name: health-none |
|||
project: api/api.csproj |
|||
replicas: 3 |
|||
bindings: |
|||
- port: 8001 |
|||
|
|||
@ -0,0 +1,26 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
services: |
|||
- name: health-proxy |
|||
project: api/api.csproj |
|||
replicas: 2 |
|||
bindings: |
|||
- port: 8005 |
|||
liveness: |
|||
http: |
|||
path: /healthy |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
readiness: |
|||
http: |
|||
path: /ready |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
|
|||
@ -0,0 +1,17 @@ |
|||
# tye application configuration file |
|||
# read all about it at https://github.com/dotnet/tye |
|||
name: health-checks |
|||
services: |
|||
- name: health-readiness |
|||
project: api/api.csproj |
|||
replicas: 3 |
|||
bindings: |
|||
- port: 8003 |
|||
readiness: |
|||
http: |
|||
path: /ready |
|||
initialDelay: 5 |
|||
period: 1 |
|||
timeout: 1 |
|||
successThreshold: 1 |
|||
failureThreshold: 1 |
|||
Loading…
Reference in new issue