Skip to content
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
6 changes: 6 additions & 0 deletions deploy/helm/kubernetes-agent/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ spec:
value: {{ join "," .Values.tentacle.roles | quote }}
- name: Tentacle__Flavor
value: "{{ .Values.tentacle.flavor }}"
# Bind the health-check server to all interfaces so kubelet's httpGet
# liveness/readiness probes reach it over the pod network. Service-based
# tentacles default to loopback (avoids a Windows Firewall prompt), so the
# Kubernetes agent must opt back in here.
- name: Tentacle__HealthCheckBindHost
value: "{{ .Values.tentacle.healthCheckBindHost | default "+" }}"
- name: Tentacle__MachineName
value: "{{ .Values.tentacle.machineName }}"
- name: Tentacle__ReleaseName
Expand Down
4 changes: 4 additions & 0 deletions deploy/helm/kubernetes-agent/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ tentacle:
cpu: "500m"

healthCheckPort: 8080
# Host the in-pod health-check server binds. "+" = all interfaces so kubelet's
# httpGet liveness/readiness probes can reach it. (The tentacle binary defaults
# to loopback for service installs to avoid a Windows Firewall prompt.)
healthCheckBindHost: "+"

startupProbe:
failureThreshold: 100
Expand Down
10 changes: 10 additions & 0 deletions src/Squid.Tentacle/Configuration/TentacleSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ public class TentacleSettings
public string WorkspacePath { get; set; } = string.Empty;
public string CertsPath { get; set; } = string.Empty;
public int HealthCheckPort { get; set; } = 8080;

// Host the local health-check HTTP server binds. On Windows it defaults to the
// loopback address so the listener is NOT network-exposed -- binding all
// interfaces pops a Defender Firewall "allow access" prompt on every
// versioned-path upgrade, and the only Windows consumer of healthz is the
// upgrade script (which probes 127.0.0.1). On non-Windows it keeps "*" (all
// interfaces) so Linux/docker localhost probes and the Kubernetes agent's
// kubelet httpGet liveness/readiness probes (over the pod network) still work.
public string HealthCheckBindHost { get; set; } = OperatingSystem.IsWindows() ? "127.0.0.1" : "*";

public int ListeningPort { get; set; } = 10933;
public string ListeningHostName { get; set; } = string.Empty;

Expand Down
6 changes: 3 additions & 3 deletions src/Squid.Tentacle/Core/TentacleApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public async Task RunAsync(
return true;
};

await using var healthServer = _dependencies.HealthCheckServerFactory(tentacleSettings.HealthCheckPort, readinessCheck);
await using var healthServer = _dependencies.HealthCheckServerFactory(tentacleSettings.HealthCheckPort, tentacleSettings.HealthCheckBindHost, readinessCheck);

try
{
Expand Down Expand Up @@ -213,8 +213,8 @@ public sealed class TentacleAppDependencies
public Func<X509Certificate2, Squid.Message.Contracts.Tentacle.IScriptService, TentacleSettings, Squid.Message.Contracts.Tentacle.ICapabilitiesService, ITentacleHalibutHost> HalibutHostFactory { get; init; } =
(cert, scriptService, settings, capabilitiesService) => new TentacleHalibutHost(cert, scriptService, settings, capabilitiesService);

public Func<int, Func<bool>, IHealthCheckServer> HealthCheckServerFactory { get; init; } =
(port, readiness) => new HealthCheckServer(port, readiness);
public Func<int, string, Func<bool>, IHealthCheckServer> HealthCheckServerFactory { get; init; } =
(port, bindHost, readiness) => new HealthCheckServer(port, readiness, bindHost);

public static TentacleAppDependencies CreateDefault() => new();
}
16 changes: 13 additions & 3 deletions src/Squid.Tentacle/Health/HealthCheckServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ public class HealthCheckServer : IHealthCheckServer
private CancellationTokenSource _cts;
private Task _listenTask;

public HealthCheckServer(int port, Func<bool> readinessCheck)
public HealthCheckServer(int port, Func<bool> readinessCheck, string? bindHost = null)
{
_readinessCheck = readinessCheck;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://*:{port}/");
// Default: loopback on Windows (firewall-exempt -- the only Windows consumer,
// the upgrade script, probes 127.0.0.1), all interfaces elsewhere (Linux /
// docker localhost probes + kubelet httpGet over the pod network). Mirrors
// TentacleSettings.HealthCheckBindHost; the Kubernetes agent passes "+".
var host = string.IsNullOrWhiteSpace(bindHost)
? (OperatingSystem.IsWindows() ? "127.0.0.1" : "*")
: bindHost;
_listener.Prefixes.Add($"http://{host}:{port}/");
}

public void Start()
Expand All @@ -29,7 +36,10 @@ public void Start()
private int GetPort()
{
var prefix = _listener.Prefixes.FirstOrDefault() ?? "";
var uri = new Uri(prefix.Replace("*", "localhost"));
// "*"/"+" are valid HttpListener wildcard hosts but not valid URI hosts,
// so normalise them to a parseable host before extracting the port.
var parseable = prefix.Replace("://*:", "://localhost:").Replace("://+:", "://localhost:");
var uri = new Uri(parseable);
return uri.Port;
}

Expand Down
14 changes: 7 additions & 7 deletions tests/Squid.Tentacle.Tests/Core/TentacleAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public async Task RunAsync_Starts_And_Wires_Core_Lifecycle_Then_Shuts_Down_On_Ca
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down Expand Up @@ -93,7 +93,7 @@ public async Task RunAsync_ListeningMode_Calls_StartListening_With_Correct_Port(
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down Expand Up @@ -130,7 +130,7 @@ public async Task RunAsync_ListeningMode_Falls_Back_To_Settings_Port_When_Runtim
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down Expand Up @@ -163,7 +163,7 @@ public async Task RunAsync_Continues_When_HealthServer_Start_Fails()
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down Expand Up @@ -206,7 +206,7 @@ public async Task RunAsync_ExternalSubscriptionId_OverridesTakesPrecedence()
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down Expand Up @@ -242,7 +242,7 @@ public async Task Shutdown_FlipsReadinessToFalse()
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (port, readiness) =>
HealthCheckServerFactory = (port, _, readiness) =>
{
capturedReadiness = readiness;
return healthServer;
Expand Down Expand Up @@ -284,7 +284,7 @@ public async Task Shutdown_CallsDrainOnScriptBackend()
BuiltInFlavorsProvider = () => [flavor],
FlavorResolverFactory = flavors => new TentacleFlavorResolver(flavors),
HalibutHostFactory = (_, _, _, _) => halibutHost,
HealthCheckServerFactory = (_, _) => healthServer
HealthCheckServerFactory = (_, _, _) => healthServer
});

using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestCancellationToken);
Expand Down
30 changes: 30 additions & 0 deletions tests/Squid.Tentacle.Tests/Health/HealthCheckServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ public async Task Liveness_Endpoints_Return_200()
alias.Body.ShouldContain("alive");
}

[Fact]
public void HealthCheckBindHost_DefaultsToLoopbackOnWindows_AllInterfacesElsewhere()
{
// Windows default = loopback so the healthz listener isn't network-exposed
// and doesn't pop a Defender Firewall prompt on each versioned-path upgrade
// (the only Windows consumer, the upgrade script, probes 127.0.0.1).
// Non-Windows keeps "*" so Linux/docker localhost probes + kubelet httpGet
// (over the pod network) still reach it.
var settings = new Squid.Tentacle.Configuration.TentacleSettings();

if (OperatingSystem.IsWindows())
settings.HealthCheckBindHost.ShouldBe("127.0.0.1");
else
settings.HealthCheckBindHost.ShouldBe("*");
}

[Fact]
public async Task ExplicitLoopbackBindHost_IsReachableViaIpv4Loopback()
{
// The firewall-fix path: an explicit loopback bind still serves the upgrade
// script's 127.0.0.1 health probe.
var port = TcpPortAllocator.GetEphemeralPort();
await using var server = new HealthCheckServer(port, () => true, "127.0.0.1");
server.Start();

using var client = new HttpClient();
var result = await GetAsync(client, port, "/healthz");
result.StatusCode.ShouldBe(HttpStatusCode.OK);
}

[Fact]
public async Task Readiness_Endpoints_Reflect_Callback_State()
{
Expand Down
Loading