Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5a9f010
hack: fix DNS on IPv6-only kind clusters
ygao-g Aug 19, 2026
74ad449
atenet/router: bind the Envoy ingress listeners dual-stack
ygao-g Aug 8, 2026
9f6bcdf
atenet/router: bind the admin socket and Service dual-stack
ygao-g Aug 13, 2026
908499f
atenet/egress: bind the Envoy sockets and Service dual-stack
ygao-g Aug 13, 2026
ac9ee2d
hack/verify: keep the gateway Envoy admin sockets dual-stack
ygao-g Aug 19, 2026
f047e73
atenet/router: bind the CONNECT listeners dual-stack too
ygao-g Aug 19, 2026
5ad28ad
atunnel: support IPv6 original destination lookup
lubingtan Aug 5, 2026
cb7d66e
atunnel: preserve IPv4 original destination errors
lubingtan Aug 21, 2026
583a7b8
atunnel: stabilize original destination tests
lubingtan Aug 21, 2026
deb103f
ateom: drop the family from the atunnel ingress listen defaults
ygao-g Aug 19, 2026
05f0be0
ateomnet: enable IPv6 forwarding in worker pod netns
krsnaSuraj Aug 15, 2026
47ca748
ateomnet: return nil when sysctl path missing in writeSysctlIfUnset
krsnaSuraj Aug 16, 2026
a08ed2e
ateomnet: rename EnableIPv4Forwarding to EnableForwarding
krsnaSuraj Aug 21, 2026
44b82b3
ateomnet: cover writeSysctlIfUnset's missing-path branch
krsnaSuraj Aug 21, 2026
4243259
atenet/egress: resolve upstream names on both address families
ygao-g Aug 19, 2026
e2531ca
ci: add an IPv6-only kind e2e job
ygao-g Aug 14, 2026
7249571
hack: read the IPv6 DNS probe from the pod log
ygao-g Aug 19, 2026
1157ed1
ateomnet: move the actor nftables table to the inet family
ygao-g Aug 20, 2026
dc1bb16
ateomnet: give the actor an IPv6 address when the pod has one
ygao-g Aug 20, 2026
13a1e4b
atunnel: dispatch the original destination lookup by family
ygao-g Aug 21, 2026
561cd0e
atunnel: rework the original-destination tests
ygao-g Aug 21, 2026
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
366 changes: 366 additions & 0 deletions .github/workflows/e2e-ipv6.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions cmd/atenet/internal/router/dataplane.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ type dataplaneHealthCheck struct {
// untouched, so atunnel always authorizes by the actor's own DNS name --
// ingress.New needs no per-dataplane routing mode.

// healthCheck dials IPv4 loopback, which is why the admin socket in
// manifests/ate-install/atenet-router.yaml needs ipv4_compat.
func (r atenetRouter) healthCheck() dataplaneHealthCheck {
switch r {
case atenetRouterEnvoy:
Expand Down
25 changes: 25 additions & 0 deletions cmd/atenet/internal/router/xds.go
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,27 @@ func (x *XdsServer) buildTracing() *hcmv3.HttpConnectionManager_Tracing {
}
}

// dualStackAdditionalAddresses returns the IPv6 half of a dual-stack ingress
// listener, to pair with a primary 0.0.0.0 socket on the same port. Ipv4Compat
// stays false: clearing IPV6_V6ONLY would collide with that primary.
func dualStackAdditionalAddresses(port uint32) []*listenerv3.AdditionalAddress {
return []*listenerv3.AdditionalAddress{
{
Address: &corev3.Address{
Address: &corev3.Address_SocketAddress{
SocketAddress: &corev3.SocketAddress{
Address: "::",
Ipv4Compat: false,
PortSpecifier: &corev3.SocketAddress_PortValue{
PortValue: port,
},
},
},
},
},
}
}

func (x *XdsServer) buildListener() *listenerv3.Listener {
hcm := x.buildHcm("ingress_http", true)

Expand All @@ -1123,6 +1144,7 @@ func (x *XdsServer) buildListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.ingressPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down Expand Up @@ -1182,6 +1204,7 @@ func (x *XdsServer) buildHttpsListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.httpsPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down Expand Up @@ -1213,6 +1236,7 @@ func (x *XdsServer) buildConnectTerminateListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.connectPlainTextPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down Expand Up @@ -1246,6 +1270,7 @@ func (x *XdsServer) buildConnectTerminateTLSListener() *listenerv3.Listener {
},
},
},
AdditionalAddresses: dualStackAdditionalAddresses(uint32(x.connectTLSPort)),
FilterChains: []*listenerv3.FilterChain{
{
Filters: []*listenerv3.Filter{
Expand Down
52 changes: 35 additions & 17 deletions cmd/atenet/internal/router/xds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,36 @@ import (
"github.com/agent-substrate/substrate/internal/atunnel"
)

// assertDualStackIngress checks an ingress listener keeps its 0.0.0.0 primary
// and gains exactly one "::" socket on the same port.
func assertDualStackIngress(t *testing.T, l *listenerv3.Listener, wantPort uint32) {
t.Helper()

sa := l.GetAddress().GetSocketAddress()
if sa.GetAddress() != "0.0.0.0" {
t.Errorf("Expected address '0.0.0.0', got %s", sa.GetAddress())
}
if sa.GetPortValue() != wantPort {
t.Errorf("Expected port %d, got %d", wantPort, sa.GetPortValue())
}

addrs := l.GetAdditionalAddresses()
if len(addrs) != 1 {
t.Fatalf("Expected 1 additional address on %s, got %d", l.GetName(), len(addrs))
}

asa := addrs[0].GetAddress().GetSocketAddress()
if asa.GetAddress() != "::" {
t.Errorf("Expected additional address '::', got %s", asa.GetAddress())
}
if asa.GetIpv4Compat() {
t.Error("Expected additional address Ipv4Compat to be false")
}
if asa.GetPortValue() != wantPort {
t.Errorf("Expected additional port %d, got %d", wantPort, asa.GetPortValue())
}
}

func TestXdsServer_UpdateSnapshot(t *testing.T) {
server := NewXdsServer(18000)
server.SetConfig(8081, 50052, "10.0.0.1")
Expand Down Expand Up @@ -150,14 +180,7 @@ func TestXdsServer_UpdateSnapshot(t *testing.T) {
if raw, exists := listenersMap[IngressHTTPListener]; !exists {
t.Errorf("Listener name '%s' is missing from snapshot listeners", IngressHTTPListener)
} else {
l := raw.(*listenerv3.Listener)
sa := l.GetAddress().GetSocketAddress()
if sa.GetPortValue() != 8081 {
t.Errorf("Expected port 8081, got %d", sa.GetPortValue())
}
if sa.GetAddress() != "0.0.0.0" {
t.Errorf("Expected address '0.0.0.0', got %s", sa.GetAddress())
}
assertDualStackIngress(t, raw.(*listenerv3.Listener), 8081)
}
}

Expand Down Expand Up @@ -192,10 +215,7 @@ func TestXdsServer_UpdateSnapshot_WithHttps(t *testing.T) {
t.Errorf("Listener name '%s' is missing from snapshot listeners", IngressHTTPSListener)
} else {
l := raw.(*listenerv3.Listener)
sa := l.GetAddress().GetSocketAddress()
if sa.GetPortValue() != 8443 {
t.Errorf("Expected port 8443, got %d", sa.GetPortValue())
}
assertDualStackIngress(t, l, 8443)

// Verify the TLS config references the serving cert via SDS rather
// than embedding it: inline filename DataSources are read only once
Expand Down Expand Up @@ -354,16 +374,14 @@ func TestXdsServer_UpdateSnapshot_WithConnect(t *testing.T) {
}
if raw, exists := listenersMap["connect_terminate"]; !exists {
t.Error("connect_terminate listener missing")
} else if sa := raw.(*listenerv3.Listener).GetAddress().GetSocketAddress(); sa.GetPortValue() != 8081 {
t.Errorf("Expected connect_terminate port 8081, got %d", sa.GetPortValue())
} else {
assertDualStackIngress(t, raw.(*listenerv3.Listener), 8081)
}
if raw, exists := listenersMap["connect_terminate_tls"]; !exists {
t.Error("connect_terminate_tls listener missing")
} else {
l := raw.(*listenerv3.Listener)
if sa := l.GetAddress().GetSocketAddress(); sa.GetPortValue() != 8444 {
t.Errorf("Expected connect_terminate_tls port 8444, got %d", sa.GetPortValue())
}
assertDualStackIngress(t, l, 8444)
ts := l.GetFilterChains()[0].GetTransportSocket()
if ts.GetName() != "envoy.transport_sockets.tls" {
t.Errorf("Expected connect_terminate_tls to be TLS-wrapped, got transport socket %q", ts.GetName())
Expand Down
7 changes: 5 additions & 2 deletions cmd/ateom-gvisor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,11 @@ var (
podUID = pflag.String("pod-uid", "", "The UID of the current pod")

// TODO(liorlieberman) have a sub package for all atunnel releated things like that
atunnelListenAddress = pflag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS")
atunnelConnectListenAddress = pflag.String("atunnel-connect-listen-address", "0.0.0.0:444", "Address for actor ingress mTLS CONNECT")
//
// Every listen address here is an unspecified wildcard, which Go binds as a
// dual-stack socket.
atunnelListenAddress = pflag.String("atunnel-listen-address", ":443", "Address for actor ingress HTTPS")
atunnelConnectListenAddress = pflag.String("atunnel-connect-listen-address", ":444", "Address for actor ingress mTLS CONNECT")
workerCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS")
podIdentityTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet")
atunnelClientIdentity = pflag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS")
Expand Down
6 changes: 4 additions & 2 deletions cmd/ateom-microvm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ var (
otlpRelaySocket = flag.String("otlp-relay-socket", ateompath.AteletOTLPSocketPath(),
"Unix socket of atelet's OTLP relay to export telemetry through, keeping it off the pod network. Empty, or absent at startup, exports directly to OTEL_EXPORTER_OTLP_ENDPOINT instead.")

atunnelListenAddress = flag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS")
atunnelConnectListenAddress = flag.String("atunnel-connect-listen-address", "0.0.0.0:444", "Address for actor ingress mTLS CONNECT")
// Every listen address here is an unspecified wildcard, which Go binds as a
// dual-stack socket.
atunnelListenAddress = flag.String("atunnel-listen-address", ":443", "Address for actor ingress HTTPS")
atunnelConnectListenAddress = flag.String("atunnel-connect-listen-address", ":444", "Address for actor ingress mTLS CONNECT")
workerCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS")
podIdentityTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet")
atunnelClientIdentity = flag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS")
Expand Down
4 changes: 4 additions & 0 deletions cmd/ateom-microvm/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,10 @@ func tailString(s string, n int) string {
// agent: configure eth0 (IP/MAC/MTU), install the connected + default routes, and
// pin the gateway's ARP entry to its fixed MAC (so a restored guest's frozen
// neighbor entry stays valid).
//
// TODO(#246): the guest is configured IPv4-only, so a micro-VM actor sees no
// IPv6 even on a dual-stack pod where the host veth has one. gVisor reads the
// interior netns and picks the address up; this path has to be told.
func (s *AteomService) configureGuestNetwork(ctx context.Context, ac *kata.AgentClient, mtu uint64) error {
if err := ac.UpdateInterface(ctx, &agentpb.Interface{
Device: ateomnet.ActorVethName,
Expand Down
58 changes: 58 additions & 0 deletions hack/create-kind-cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-kind}"
KUBECTL_CONTEXT="kind-${KIND_CLUSTER_NAME}"
reg_name="kind-registry"
reg_port="${KIND_REGISTRY_PORT:-5001}"
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM:-2001:4860:4860::8888 2001:4860:4860::8844}"

if [[ $# -gt 0 ]]; then
case "$1" in
Expand All @@ -31,6 +32,8 @@ if [[ $# -gt 0 ]]; then
echo "Configured through the environment:"
echo " KIND_CLUSTER_NAME Name of the cluster to create (default: kind)."
echo " IP_FAMILY Address families for pods and Services: ipv4, ipv6 or dual (default: ipv4)."
echo " IPV6_DNS_UPSTREAM Space-separated IPv6 resolvers CoreDNS forwards to when IP_FAMILY=ipv6"
echo " (default: Google Public DNS). Override where those are unreachable."
exit 0
;;
esac
Expand Down Expand Up @@ -196,6 +199,61 @@ if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}
docker network connect "kind" "${reg_name}"
fi

# 4.5. Give CoreDNS an IPv6 forwarder and a registry entry
#
# CoreDNS runs dnsPolicy: Default, inheriting the node's IPv4 resolver, which
# no pod here can reach, so external lookups SERVFAIL. Step 3's registry
# wiring is node-side, so it misses atelet too: that pull runs in atelet's own
# netns, where "kind-registry" NXDOMAINs.
if [[ "${IP_FAMILY}" == "ipv6" ]]; then
echo "Repointing CoreDNS at an IPv6 resolver and teaching it '${reg_name}'..."
reg_v6="$(docker inspect "${reg_name}" \
--format '{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}')"
if [[ -z "${reg_v6}" ]]; then
echo "error: '${reg_name}' has no IPv6 address on the 'kind' network" >&2
exit 1
fi

corefile="$(kubectl --context="${KUBECTL_CONTEXT}" -n kube-system get cm coredns \
-o jsonpath='{.data.Corefile}')"
search="forward . /etc/resolv.conf"
replace="forward . ${IPV6_DNS_UPSTREAM}"
# $search unquoted: bash 3.2 splices the quotes in literally. Replacing just
# the target leaves kind's trailing "{ max_concurrent 1000 }" in place.
patched="${corefile/$search/$replace}"
if [[ "${patched}" == "${corefile}" ]]; then
echo "error: '${search}' not found in the CoreDNS Corefile" >&2
echo " the Corefile layout changed upstream; update this block" >&2
exit 1
fi

# Its own server block, not a hosts entry in .:53. A query is served by the
# one block whose zone is its longest suffix, so only "${reg_name}" arrives
# here -- which is why this hosts needs no fallthrough to avoid NXDOMAINing
# every other name.
patched="${patched}
${reg_name}:53 {
errors
hosts {
${reg_v6} ${reg_name}
}
}"

# A YAML patch file avoids escaping the Corefile's newlines into JSON.
{ printf 'data:\n Corefile: |\n'; printf '%s\n' "${patched}" | sed 's/^/ /'; } \
> "${ROOT}/bin/coredns-patch.yaml"
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system patch cm coredns \
--type=merge --patch-file "${ROOT}/bin/coredns-patch.yaml"
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system rollout restart deploy/coredns
kubectl --context="${KUBECTL_CONTEXT}" -n kube-system rollout status deploy/coredns \
--timeout=120s

# Its own script so it can be re-run against a live cluster: the hosts entry
# above is a snapshot of an address the registry can move off (#1049).
KUBECTL_CONTEXT="${KUBECTL_CONTEXT}" REG_NAME="${reg_name}" \
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM}" "${ROOT}"/hack/verify-ipv6-dns.sh
fi

# 5. Document the local registry in kube-public ConfigMap
echo "Documenting local registry in cluster..."
cat <<EOF | kubectl --context="${KUBECTL_CONTEXT}" apply -f -
Expand Down
128 changes: 128 additions & 0 deletions hack/verify-ipv6-dns.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env bash

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Preconditions:
# IP_FAMILY=ipv6 hack/create-kind-cluster.sh
#
# Checks the two things the CoreDNS rewrite in create-kind-cluster.sh provides:
# a pod can resolve an external name (a GCS one -- that is where atelet pulls
# sandbox tarballs from) and reach the local registry by name. That script runs
# this last; re-run it by hand after the registry moves (#1049).

set -o errexit -o nounset -o pipefail

KUBECTL_CONTEXT="${KUBECTL_CONTEXT:-kind-${KIND_CLUSTER_NAME:-kind}}"
REG_NAME="${REG_NAME:-kind-registry}"
IPV6_DNS_UPSTREAM="${IPV6_DNS_UPSTREAM:-2001:4860:4860::8888 2001:4860:4860::8844}"

if [[ $# -gt 0 ]]; then
case "$1" in
-h|--help)
echo "Usage: $0"
echo "Verifies pod DNS on the IPv6-only kind cluster '${KUBECTL_CONTEXT}'."
echo
echo "Configured through the environment:"
echo " KUBECTL_CONTEXT Context to check (default: kind-\${KIND_CLUSTER_NAME:-kind})."
echo " REG_NAME Name of the local registry container (default: kind-registry)."
echo " IPV6_DNS_UPSTREAM The resolvers CoreDNS was pointed at; reported on failure only."
exit 0
;;
*)
echo "error: unknown argument '$1'; see --help" >&2
exit 1
;;
esac
fi

# Best-effort: only used to make the registry failure message actionable.
reg_v6="$(docker inspect "${REG_NAME}" \
--format '{{.NetworkSettings.Networks.kind.GlobalIPv6Address}}' 2>/dev/null || true)"
reg_at="${reg_v6:+ at [${reg_v6}]:5000}"

echo "Verifying DNS from a pod..."
# Probe from a pod, not the node: the node is dual-stack and passes either way.
# The registry leg fetches rather than resolves -- the hosts entry is AAAA-only,
# which fails nslookup's A query but satisfies getaddrinfo.
#
# One stream carries every leg and only the last one's exit status, so each leg
# reports a marker on stdout and no failure message may contain one; PROBE_RAN
# and PROBE_DONE bracket the run so a short read is told apart from a leg that
# failed. Read the log once the pod has terminated rather than attaching to it:
# an attach can drop the tail, and a lost registry marker then reads as an
# unreachable registry. Retry the pod, not the query: one that asks before
# CoreDNS settles stays broken for ~30s, while a fresh pod 10s later resolves
# first try.
probe=""
probe_max=4
for ((probe_attempt = 1; probe_attempt <= probe_max; probe_attempt++)); do
probe_pod="coredns-probe-$$-${probe_attempt}"
kubectl --context="${KUBECTL_CONTEXT}" run "${probe_pod}" \
--restart=Never --image=busybox:1.36 --command -- \
sh -c "echo PROBE_RAN
if out=\$(nslookup storage.googleapis.com 2>&1); then
echo RESOLVE_OK
else
echo \"resolve failed: \$(echo \"\$out\" | tail -2 | tr '\n' ' ')\"
fi
if out=\$(wget -T10 -O/dev/null http://${REG_NAME}:5000/v2/ 2>&1); then
echo REGISTRY_OK
else
echo \"registry fetch failed: \$(echo \"\$out\" | tail -1)\"
fi
echo PROBE_DONE" >/dev/null || true
for ((probe_wait = 0; probe_wait < 120; probe_wait++)); do
phase="$(kubectl --context="${KUBECTL_CONTEXT}" get pod "${probe_pod}" \
-o jsonpath='{.status.phase}' 2>/dev/null || true)"
[[ "${phase}" == "Succeeded" || "${phase}" == "Failed" ]] && break
sleep 1
done
attempt_out="$(kubectl --context="${KUBECTL_CONTEXT}" logs "${probe_pod}" 2>/dev/null || true)"
kubectl --context="${KUBECTL_CONTEXT}" delete pod "${probe_pod}" \
--now --ignore-not-found --wait=false >/dev/null 2>&1 || true
# A pod that never started must not bury an earlier one's real failure.
if [[ "${attempt_out}" == *PROBE_RAN* ]]; then probe="${attempt_out}"; fi
# Only the resolve leg is a settling race; a down registry will not fix
# itself, so a finished probe is a verdict either way. An unfinished one
# reported no registry result at all, which is not the same as a failure.
if [[ "${probe}" == *RESOLVE_OK* ]] &&
[[ "${probe}" == *REGISTRY_OK* || "${probe}" == *PROBE_DONE* ]]; then
break
fi
if ((probe_attempt < probe_max)); then
echo " the probe did not come back clean; re-probing (attempt $((probe_attempt + 1)) of ${probe_max})..."
sleep 10
fi
done
if [[ "${probe}" != *RESOLVE_OK* || "${probe}" != *REGISTRY_OK* ]]; then
if [[ "${probe}" != *PROBE_RAN* ]]; then
echo "error: the probe pod never ran, so CoreDNS is unverified" >&2
echo " check that it scheduled and that 'busybox:1.36' pulled" >&2
elif [[ "${probe}" != *RESOLVE_OK* ]]; then
echo "error: a pod cannot resolve an external name" >&2
echo " IPV6_DNS_UPSTREAM is '${IPV6_DNS_UPSTREAM}'; set it to a reachable resolver" >&2
elif [[ "${probe}" != *PROBE_DONE* ]]; then
echo "error: the probe stopped early, so the registry leg is unverified" >&2
echo " re-run this script; DNS itself answered" >&2
else
echo "error: DNS works but a pod cannot reach '${REG_NAME}'${reg_at}" >&2
echo " check the registry container is up and on the 'kind' network" >&2
fi
if [[ -n "${probe}" ]]; then
echo " probe output was:" >&2
printf '%s\n' "${probe}" | sed 's/^/ /' >&2
fi
exit 1
fi
Loading
Loading