Name the unresolved host when the FedWiki farm's name does not resolve

The farm is named under localtest.me, a public domain, because FedWiki's
sign-in shares one session cookie across its sites and browsers do not
scope a cookie to .localhost. A resolver that drops public names pointing
at loopback (a router's DNS-rebind protection, some office resolvers and
VPNs, or no network) made the app's farm call fail with "no such host",
the sites walkthrough time out in its first phase, and the reachability
guard report the farm as not composed (#236).

The guard now looks the endpoint's host up first and fails the test with
the host, the reason, and the two /etc/hosts lines that fix it. A name
defined to point at this machine must resolve to loopback; any other host
may resolve anywhere. bootstrap-stack.sh runs the same check on the
farm's admin name when the fedwiki profile is on and warns on stderr
without failing. test/AGENTS.md says which names must resolve here and
why.
This commit is contained in:
2026-10-17 11:23:03 -05:00
parent 2375d6ee29
commit dbd3a89099
5 changed files with 281 additions and 3 deletions
+29
View File
@@ -68,6 +68,35 @@ re-run `docker compose up -d`. This is the **only** hand-edit `test/.env`
sanctions (see Constraints): re-running bootstrap would not pick up a change,
and a teardown would drop the database.
## Names that must resolve to this machine
Every URL the stack writes into `test/.env` names a service on this machine.
Most use `localhost` or a name under it, which the operating system resolves to
the loopback address without asking a DNS server.
The FedWiki farm is the exception. Its sites are named under `localtest.me`
(`admin.localtest.me`, `localtest.me`), a public domain whose every name
resolves to 127.0.0.1 and ::1. It cannot use `*.localhost`: FedWiki's sign-in
shares one session cookie across the farm's sites, and browsers do not scope a
cookie to `.localhost`.
A `localtest.me` name therefore needs a DNS answer, and some resolvers drop it:
a home router's DNS-rebind protection and some office resolvers and VPNs
refuse to pass on a public name that points at the loopback address, and
offline there is no answer. The app's calls to the farm then fail with
`no such host`. `bootstrap-stack.sh` checks the farm's name whenever the
`fedwiki` profile is on and prints a warning; the walkthroughs' reachability
guard fails the test and names the host. The fix is two lines in `/etc/hosts`:
```
127.0.0.1 localtest.me admin.localtest.me
::1 localtest.me admin.localtest.me
```
A hosts file takes no wildcard, so a site created by hand under `localtest.me`
needs its own entry. The sites the tests create are named under
`sites.localhost` and need none.
## After bootstrap
```
+52 -2
View File
@@ -37,6 +37,10 @@ PORT_STEP=50
# demo composition into the generated .env. Default is core-only.
COMPOSE_PROFILES="${COMPOSE_PROFILES:-}"
# The FedWiki farm's admin site. The farm is named under localtest.me, a public
# domain, so the name needs a DNS answer (see check_farm_name).
FARM_HOST="admin.localtest.me"
# ---------- helpers ----------
profile_active() {
@@ -55,7 +59,7 @@ print_summary() {
local fedwiki_lines discourse_line
if profile_active fedwiki; then
fedwiki_lines=" FedWiki: http://localhost:${FEDWIKI_PORT:-?}
FedWiki (HTTPS): https://admin.localtest.me (browser login; Caddy on :${FEDWIKI_HTTPS_PORT:-443})"
FedWiki (HTTPS): https://${FARM_HOST} (browser login; Caddy on :${FEDWIKI_HTTPS_PORT:-443})"
else
fedwiki_lines=" FedWiki: not composed (add 'fedwiki' to COMPOSE_PROFILES in .env)"
fi
@@ -99,6 +103,50 @@ databases rather than the app's:
EOF
}
loopback_problem() {
# Prints why $1 does not resolve only to this machine; prints nothing when
# every address it resolves to is loopback.
local host="$1" addrs="" a bad=()
if command -v getent >/dev/null 2>&1; then
addrs="$(getent ahosts "$host" 2>/dev/null | awk '{ print $1 }' | sort -u || true)"
elif command -v dscacheutil >/dev/null 2>&1; then
addrs="$(dscacheutil -q host -a name "$host" 2>/dev/null | awk '/^(ip_address|ipv6_address):/ { print $2 }' | sort -u || true)"
else
echo "cannot be checked here (no getent or dscacheutil)"
return 0
fi
if [[ -z "$addrs" ]]; then
echo "does not resolve"
return 0
fi
while IFS= read -r a; do
case "$a" in
127.*|::1|::ffff:127.*) ;;
*) bad+=("$a") ;;
esac
done <<<"$addrs"
if ((${#bad[@]} > 0)); then
echo "resolves to ${bad[*]}, which is not this machine"
fi
return 0
}
check_farm_name() {
# Warns on stderr when the farm's name does not resolve to this machine.
# Never changes the exit status.
profile_active fedwiki || return 0
local problem
problem="$(loopback_problem "$FARM_HOST")"
[[ -z "$problem" ]] && return 0
cat >&2 <<EOF
warning: ${FARM_HOST} ${problem}.
The FedWiki farm is reached at that name. Add these lines to /etc/hosts:
127.0.0.1 localtest.me ${FARM_HOST}
::1 localtest.me ${FARM_HOST}
See "Names that must resolve to this machine" in test/AGENTS.md.
EOF
}
fnv32() {
# FNV-1a 32-bit hash, pure shell. Output: decimal integer.
local s="$1"
@@ -194,6 +242,7 @@ if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
print_summary
check_farm_name
exit 0
fi
@@ -330,7 +379,7 @@ MC_TEMPORAL_OAUTH_TOKEN_URL=http://${KC_HOSTNAME}:${KEYCLOAK_PORT}/realms/test/p
# surface error instead of standing down. mc-config.yaml deliberately carries
# none of these: the farm URL needs this stack's allocated port.
# FEDWIKI_HTTPS_PORT overrides Caddy's host 443 when another stack holds it.
#MC_FEDWIKI_FARM_API_URL=http://admin.localtest.me:${FEDWIKI_PORT}
#MC_FEDWIKI_FARM_API_URL=http://${FARM_HOST}:${FEDWIKI_PORT}
#MC_FEDWIKI_ADMIN_TOKEN=fwuat-6R-p8TMlBBrwH7bV8l5oqsdoSbij5-SFHS5oVRJzGGM
# Allowed site domains are NOT part of the farm-connection required-together
# group above: the create-form partial reads this independently of whether a
@@ -376,3 +425,4 @@ echo "Wrote ${ENV_FILE}."
# shellcheck disable=SC1090
set -a; . "$ENV_FILE"; set +a
print_summary
check_farm_name
+63 -1
View File
@@ -25,7 +25,10 @@
package operatorwalkthroughs_test
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
@@ -74,7 +77,10 @@ func foundIn(html string, banned ...string) []string {
// phases still run against an integration nobody configured;
// - configured but refusing connections → skip;
// - configured but answering 5xx → skip (container up, service still booting
// behind its proxy).
// behind its proxy);
// - configured at a host that does not resolve, or at a name defined to
// point at this machine that resolves elsewhere → fail, naming the
// /etc/hosts lines that fix it (requireResolvesHere).
//
// Reading the URL off the settings page rather than off test/.env keeps the
// probe truthful for every bootstrap — compose profile, fake server, or a
@@ -100,6 +106,7 @@ func skipUnlessIntegrationEndpointReachable(t *testing.T, page *rod.Page, base,
if !strings.HasPrefix(endpoint, "http") {
return
}
requireResolvesHere(t, integrationKey, endpoint)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(endpoint)
if err != nil {
@@ -111,6 +118,61 @@ func skipUnlessIntegrationEndpointReachable(t *testing.T, page *rod.Page, base,
}
}
// resolutionProblem says why host's lookup answer does not let a test reach
// it, or "" when it does: the lookup failed, it returned no address, or a
// name defined to point at this machine (localhost, *.localhost,
// localtest.me, *.localtest.me) returned an address that is not loopback.
// A host outside those names may resolve anywhere: an endpoint can be hosted
// outside compose.
func resolutionProblem(host string, addrs []string, err error) string {
if err != nil {
return "lookup failed: " + err.Error()
}
if len(addrs) == 0 {
return "the lookup returned no address"
}
h := strings.ToLower(strings.TrimSuffix(host, "."))
local := false
for _, apex := range []string{"localhost", "localtest.me"} {
if h == apex || strings.HasSuffix(h, "."+apex) {
local = true
}
}
if !local {
return ""
}
var elsewhere []string
for _, a := range addrs {
if ip := net.ParseIP(a); ip == nil || !ip.IsLoopback() {
elsewhere = append(elsewhere, a)
}
}
if len(elsewhere) == 0 {
return ""
}
return "resolves to " + strings.Join(elsewhere, ", ") + ", which is not this machine"
}
// requireResolvesHere fails the test when the endpoint's host does not
// resolve to an address the test can use. A failed lookup would otherwise
// surface as a refused connection and be reported as a service that is not
// composed.
func requireResolvesHere(t *testing.T, integrationKey, endpoint string) {
t.Helper()
u, err := url.Parse(endpoint)
if err != nil || u.Hostname() == "" {
return
}
host := u.Hostname()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
if problem := resolutionProblem(host, addrs, err); problem != "" {
t.Fatalf("%s's endpoint %s: %s %s.\nThe test stack reaches it by name; add these lines to /etc/hosts:\n\t127.0.0.1 %s\n\t::1 %s\nSee \"Names that must resolve to this machine\" in test/AGENTS.md.",
integrationKey, endpoint, host, problem, host, host)
}
}
// skipUnlessFarmReachable is the FedWiki binding of the guard above. The farm
// API URL is the endpoint every FedWiki surface dials, and the `fedwiki`
// compose profile is what composes a farm to answer it.
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package operatorwalkthroughs_test
import (
"errors"
"strings"
"testing"
)
func TestResolutionProblem(t *testing.T) {
for _, c := range []struct {
name string
host string
addrs []string
err error
want string // substring of the problem; "" means no problem
}{
{"localtest name on loopback", "admin.localtest.me", []string{"127.0.0.1", "::1"}, nil, ""},
{"localtest name, lookup failed", "admin.localtest.me", nil, errors.New("no such host"), "lookup failed: no such host"},
{"localtest name resolving elsewhere", "admin.localtest.me", []string{"192.0.2.10"}, nil, "192.0.2.10"},
{"localhost name on ::1 only", "member-console.localhost", []string{"::1"}, nil, ""},
{"outside host resolving elsewhere", "forum.example.org", []string{"192.0.2.10"}, nil, ""},
{"no address and no error", "forum.example.org", nil, nil, "the lookup returned no address"},
{"uppercase with trailing dot", "ADMIN.LocalTest.Me.", []string{"127.0.0.1"}, nil, ""},
} {
t.Run(c.name, func(t *testing.T) {
got := resolutionProblem(c.host, c.addrs, c.err)
if c.want == "" && got != "" {
t.Errorf("problem %q, want none", got)
}
if c.want != "" && !strings.Contains(got, c.want) {
t.Errorf("problem %q, want it to contain %q", got, c.want)
}
})
}
}
+99
View File
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package test
import (
"fmt"
"os"
"path/filepath"
"testing"
)
// TestBootstrapChecksTheFarmName runs bootstrap-stack.sh on a worktree whose
// test/.env already exists, so it only prints the summary, with a getent shim
// standing in for the resolver. The farm's name is checked only when the
// fedwiki profile is on, and a bad answer is a warning, never a failure.
func TestBootstrapChecksTheFarmName(t *testing.T) {
const warning = "warning: admin.localtest.me %s.\n" +
"The FedWiki farm is reached at that name. Add these lines to /etc/hosts:\n" +
" 127.0.0.1 localtest.me admin.localtest.me\n" +
" ::1 localtest.me admin.localtest.me\n" +
"See \"Names that must resolve to this machine\" in test/AGENTS.md.\n"
for _, c := range []struct {
name string
profiles string
getent string // shim body; "" leaves the shim exiting 2 with no output
stderr string
ran string
}{
{"loopback answers", "fedwiki",
"echo '127.0.0.1 STREAM admin.localtest.me'\necho '::1 STREAM admin.localtest.me'\n",
"", "getent"},
{"no answer", "fedwiki", "", fmt.Sprintf(warning, "does not resolve"), "getent"},
{"answer elsewhere", "fedwiki",
"echo '192.0.2.10 STREAM admin.localtest.me'\n",
fmt.Sprintf(warning, "resolves to 192.0.2.10, which is not this machine"), "getent"},
{"fedwiki not composed", "", "", "", ""},
} {
t.Run(c.name, func(t *testing.T) {
sb := newFarmNameBox(t, c.profiles, c.getent)
r := sb.run(t)
if r.code != 0 {
t.Errorf("exit status %d, want 0 (stderr %q)", r.code, r.stderr)
}
if r.stderr != c.stderr {
t.Errorf("stderr %q, want %q", r.stderr, c.stderr)
}
want := "git"
if c.ran != "" {
want = "git, " + c.ran
}
if r.ran != want {
t.Errorf("the script ran %q, want %q", r.ran, want)
}
})
}
}
// newFarmNameBox lays bootstrap-stack.sh out at repo/test with a test/.env
// already present, a git shim that names the repo, and a getent shim that
// prints getent (or exits 2 with no output when it is empty).
func newFarmNameBox(t *testing.T, profiles, getent string) scriptBox {
t.Helper()
sb := newScriptBox(t, "bootstrap-stack.sh")
repo := filepath.Join(sb.dir, "repo")
testDir := filepath.Join(repo, "test")
if err := os.MkdirAll(testDir, 0o755); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile(sb.script)
if err != nil {
t.Fatal(err)
}
sb.script = filepath.Join(testDir, "bootstrap-stack.sh")
if err := os.WriteFile(sb.script, src, 0o755); err != nil {
t.Fatal(err)
}
env := "COMPOSE_PROJECT_NAME=farm-name\nCOMPOSE_PROFILES=" + profiles + "\n" +
"MC_BASE_URL=http://farm-name.localhost:8081\nKC_HOSTNAME=keycloak-farm-name.localhost\n" +
"KEYCLOAK_PORT=8080\nTEMPORAL_UI_URL=http://temporal-ui-farm-name.localhost:8233\n" +
"TEMPORAL_PORT=7233\nFEDWIKI_PORT=8090\nPOSTGRES_PORT=5432\nVALKEY_PORT=6379\n"
if err := os.WriteFile(filepath.Join(testDir, ".env"), []byte(env), 0o644); err != nil {
t.Fatal(err)
}
getentExit := "exit 0\n"
if getent == "" {
getentExit = "exit 2\n"
}
shims := map[string]string{
"git": "#!/bin/sh\necho 'git' >> \"$SHIM_LOG\"\n[ \"$1\" = rev-parse ] && echo '" + repo + "' && exit 0\nexit 1\n",
"getent": "#!/bin/sh\necho getent >> \"$SHIM_LOG\"\n" + getent + getentExit,
}
for name, body := range shims {
if err := os.WriteFile(filepath.Join(sb.bin, name), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
return sb
}