Files
landing_page/automation/generate-redirects-table.py
T

683 lines
27 KiB
Python

#!/usr/bin/env python3
"""Merge every redirect table qdrant.tech has into one publicly fetchable file.
Netlify applies three separate redirect tables for qdrant.tech:
1. qdrant-landing/static/_redirects -- hand-written, order-sensitive
2. netlify.toml [[redirects]] -- hand-written
3. Hugo `aliases:` front matter -- built into meta-refresh HTML stubs
Only the first is a file, none of the three is served over HTTP (both
/_redirects and /netlify.toml return 404), and the third only ever produces
HTML -- Hugo writes an alias as index.html with no index.md beside it. So
anything that mirrors the site without going through the CDN -- the /md/ route
on search.qdrant.tech -- has no way to learn that a path moved, and 404s old
paths that qdrant.tech redirects correctly.
This script merges all three into one file in _redirects syntax and writes it
into the built site as redirects.txt, where it is publicly fetchable. Run it
after `hugo`, from the repository root.
Alias targets are read back out of the built HTML rather than the front matter
on purpose: 75 of the 259 alias entries are relative (`../tutorials/x`, `how-to`,
`aws-marketplace`), and only Hugo knows what those resolve to.
The output is *not* read by Netlify -- redirects.txt is an inert static file.
Pass --augment-netlify-redirects to additionally append the derived rules to
public/_redirects, which makes qdrant.tech itself honour alias paths for .md
requests. That changes live CDN behaviour, so it is off by default; test it on
a deploy preview first.
"""
import argparse
import os
import re
import sys
from urllib.parse import urlsplit
DEFAULT_STATUS = "301" # Netlify's default when a _redirects line omits it
# A Hugo alias stub is a ~300 byte document whose entire body is a meta refresh.
# Cap the size and require both marker tags so a real page that happens to use
# a refresh header can never be mistaken for one.
ALIAS_STUB_MAX_BYTES = 2048
# Hugo emits /page/1/ -> section root for every paginated list. Those are
# pagination plumbing, not content that moved, and no markdown document ever
# lived at them.
PAGINATION_ALIAS = re.compile(r"/page/\d+/$")
class Rule:
"""One redirect rule, normalised out of whichever table it came from."""
def __init__(self, frm, to, status=DEFAULT_STATUS, force=False, source="", origin=""):
self.frm = frm
self.to = to
self.status = status
self.force = force
self.source = source # human-readable provenance, e.g. "_redirects:44"
self.origin = origin # one of: redirects, netlify.toml, alias
@property
def external(self):
# Protocol-relative targets are off-site too; without the // case
# resolve() would try to follow one as a site path.
return self.to.startswith(("http://", "https://", "//"))
def line(self):
status = self.status + ("!" if self.force else "")
return "%-70s %-70s %s" % (self.frm, self.to, status)
def __repr__(self):
return "Rule(%r -> %r, %s)" % (self.frm, self.to, self.source)
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
def parse_redirects_file(path):
"""Parse a _redirects file. Returns (raw_text, rules) with order preserved."""
with open(path, encoding="utf-8") as fh:
raw = fh.read()
rules = []
for lineno, line in enumerate(raw.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
parts = stripped.split()
if len(parts) < 2:
warn("%s:%d: cannot parse, skipping: %s" % (path, lineno, stripped))
continue
frm, to = parts[0], parts[1]
status, force = DEFAULT_STATUS, False
if len(parts) > 2:
token = parts[2]
force = token.endswith("!")
status = token.rstrip("!")
if len(parts) > 3:
# Netlify allows conditions and query params after the status
# (Country=us, Language=en, role=admin). Emitting the rule without
# them would turn a scoped redirect into an unconditional one.
warn(
"%s:%d: ignoring trailing condition(s) %r -- rule will be emitted "
"unconditionally" % (path, lineno, " ".join(parts[3:]))
)
rules.append(
Rule(frm, to, status, force, source="_redirects:%d" % lineno, origin="redirects")
)
return raw, rules
def parse_netlify_toml(path):
"""Pull [[redirects]] blocks out of netlify.toml.
Deliberately not using tomllib: it only landed in Python 3.11 and the
Netlify build image's default interpreter is older on some image versions.
The blocks we care about are flat key = value pairs.
"""
with open(path, encoding="utf-8") as fh:
lines = fh.readlines()
rules = []
block = None
start = 0
def flush():
if not block or "from" not in block or "to" not in block:
return
rules.append(
Rule(
block["from"],
block["to"],
block.get("status", DEFAULT_STATUS),
block.get("force", "false").lower() == "true",
source="netlify.toml:%d" % start,
origin="netlify.toml",
)
)
for lineno, line in enumerate(lines, start=1):
stripped = line.strip()
if stripped.startswith("[["):
flush()
block = {} if stripped.startswith("[[redirects]]") else None
start = lineno
continue
if stripped.startswith("["):
# Any other table header ends the block ([headers.values] etc).
if block is not None and stripped.startswith("[redirects."):
# [redirects.conditions] / [redirects.headers] scope a rule to a
# country, language or role. We emit the rule without them, which
# would widen it, so this must not pass silently.
warn(
"%s:%d: %s is not carried into the merged table -- the rule "
"from line %d will be emitted unconditionally"
% (path, lineno, stripped, start)
)
flush()
block = None
continue
if block is None or "=" not in stripped or stripped.startswith("#"):
continue
key, _, value = stripped.partition("=")
block[key.strip()] = value.strip().strip('"').strip("'")
flush()
return rules
def collect_alias_rules(public_dir, include_pagination=False):
"""Recover Hugo's alias redirects from the meta-refresh stubs it built."""
refresh = re.compile(r'http-equiv=["\']?refresh["\']?[^>]*url=([^"\'>\s]+)', re.I)
rules = []
for root, _dirs, files in os.walk(public_dir):
if "index.html" not in files:
continue
stub = os.path.join(root, "index.html")
try:
if os.path.getsize(stub) > ALIAS_STUB_MAX_BYTES:
continue
with open(stub, encoding="utf-8", errors="replace") as fh:
text = fh.read()
except OSError as exc:
warn("cannot read %s: %s" % (stub, exc))
continue
lowered = text.lower()
if "http-equiv" not in lowered or "<body" in lowered or "canonical" not in lowered:
continue
found = refresh.search(text)
if not found:
continue
frm = "/" + os.path.relpath(root, public_dir).replace(os.sep, "/").strip("/") + "/"
to = urlsplit(found.group(1)).path or "/"
if frm == "//": # the site root is never an alias
continue
if frm.rstrip("/") == to.rstrip("/"):
continue
if not include_pagination and PAGINATION_ALIAS.search(frm):
continue
rules.append(Rule(frm, to, "301", False, source=os.path.relpath(stub, public_dir), origin="alias"))
rules.sort(key=lambda r: r.frm)
return rules
# --------------------------------------------------------------------------
# Matching -- this is the behaviour the /md/ loader has to reproduce
# --------------------------------------------------------------------------
def match_rule(rule, path):
"""Return rule's target for path, or None. Trailing slashes are ignored.
Matching is case-SENSITIVE, deliberately, because the CDN is. The decisive
measurement, of the four in HEADER's case section:
/documentation/operations/running-with-gpu/index.md
-> 301 /documentation/deploy-intro/running-with-gpu/index.md (the
/documentation/operations/* catch-all, not _redirects:50, which
spells running-with-GPU and sits above it)
Lowercasing here would make the table promise behaviour the CDN does not
deliver for .md requests, and would hide that dead landing. HEADER documents
all three of the CDN's case behaviours and why they differ -- read it before
changing anything here.
Asset lookup is a separate matter and is case-insensitive: see
locate_case_insensitively.
"""
frm = rule.frm
if frm.endswith("/*"):
prefix = frm[:-1] # "/a/b/*" -> "/a/b/"
if path.startswith(prefix):
return rule.to.replace(":splat", path[len(prefix):])
if path.rstrip("/") == prefix.rstrip("/"):
return rule.to.replace(":splat", "")
return None
if frm.endswith("*"):
prefix = frm[:-1]
if path.startswith(prefix):
return rule.to.replace(":splat", path[len(prefix):])
return None
if ":" in frm:
return match_placeholders(rule, path)
if path.rstrip("/") == frm.rstrip("/"):
return rule.to
return None
def match_placeholders(rule, path):
"""Match a rule with :named segments. Each matches exactly one segment."""
want = [s for s in rule.frm.split("/") if s != ""]
got = [s for s in path.split("/") if s != ""]
if len(want) != len(got):
return None
captures = {}
for w, g in zip(want, got):
if w.startswith(":"):
captures[w[1:]] = g
elif w != g:
return None
to = rule.to
for name, value in captures.items():
to = to.replace(":" + name, value)
return to
def first_match(rules, path):
"""First-match-wins, in table order. Returns (rule, target) or (None, None)."""
for rule in rules:
target = match_rule(rule, path)
if target is not None:
return rule, target
return None, None
def resolve(rules, path, max_hops=10):
"""Follow the table to a fixed point.
Netlify does not chain internally -- it answers with one 301 and the client
re-requests -- but a consumer holding the whole table can flatten the chain.
Returns (final_path, hops, error).
"""
seen = [path.rstrip("/") or "/"]
current = path
for _ in range(max_hops):
rule, target = first_match(rules, current)
if target is None:
return current, len(seen) - 1, None
if rule.external:
return target, len(seen) - 1, None
target = target.split("#")[0].split("?")[0] or current
key = target.rstrip("/") or "/"
if key in seen:
return target, len(seen) - 1, "cycle: %s" % " -> ".join(seen + [key])
seen.append(key)
current = target
return current, len(seen) - 1, "exceeded %d hops: %s" % (max_hops, " -> ".join(seen))
# --------------------------------------------------------------------------
# Validation
# --------------------------------------------------------------------------
def locate_case_insensitively(public_dir, path):
"""Find the built directory for path, ignoring case as the CDN does.
Netlify's *asset lookup* is case-insensitive -- verified live, a request for
/documentation/ops-configuration/running-with-GPU/index.md is answered by the
built running-with-gpu/index.md. So _redirects:50 works in production despite
targeting a casing the build does not write.
Matching exactly here would therefore disagree with production, and disagree
with itself across platforms: the macOS filesystem hides a case mismatch and
Linux CI does not, so rule 50 would fail --strict on CI alone.
Note this is only about finding files. Rule matching is case-sensitive --
see match_rule.
Returns the real directory path, or None.
"""
current = public_dir
segments = [s for s in path.strip("/").split("/") if s]
if any(s == ".." for s in segments):
# Walking .. would step outside the build and report a page as present
# when it is not there at all. No rule target needs it today, but this
# runs over arbitrary paths once a request-path corpus is fed in.
return None
for segment in segments:
candidate = os.path.join(current, segment)
if os.path.isdir(candidate):
current = candidate
continue
try:
entries = os.listdir(current)
except OSError:
return None
lowered = segment.lower()
match = next((e for e in entries if e.lower() == lowered), None)
if match is None:
return None
current = os.path.join(current, match)
return current
def built_variants(public_dir, path):
"""Does the built site have (html, md) at this path?"""
directory = locate_case_insensitively(public_dir, path)
if directory is None:
return (False, False)
return (
os.path.isfile(os.path.join(directory, "index.html")),
os.path.isfile(os.path.join(directory, "index.md")),
)
def validate(rules, public_dir):
"""Walk every old path the rules name through the table and check the landing.
Coverage is partial, and the gap is worth stating rather than glossing. Every
rule with a concrete source is resolved and checked. A *wildcard* rule has no
single source path to test, so all we can probe is its target with the splat
stripped -- and that target is usually alive even when the paths routed
through it are not:
/documentation/operations/* -> /documentation/deploy-intro/:splat
probes as /documentation/deploy-intro/, which exists, and passes. But
/documentation/operations/running-with-gpu/ lands on
/documentation/deploy-intro/running-with-gpu/, which does not exist, because
the rule spells it running-with-GPU and matching is case-sensitive. Nothing
in the rule sources reveals that path.
Closing that gap needs a corpus of real request paths from the access log,
which is deliberately left to a follow-up: it is regression insurance rather
than a bug finder (against current traffic it reports nothing new), and it
needs a baseline mechanism so one unfixable path cannot make CI permanently
red. The rule-derived checks below cannot close it.
"""
problems = []
seen = set()
for rule in rules:
# Concrete sources resolve directly. For a wildcard source there is no
# single path to test, so fall back to probing the target's section root.
if rule.frm.endswith("*") or ":" in rule.frm:
if rule.external:
continue
probe = rule.to.replace(":splat", "").split("#")[0]
probe = re.sub(r"/:[A-Za-z_][A-Za-z0-9_]*", "", probe)
label = "target of wildcard rule"
else:
probe = rule.frm
label = "old path"
key = probe.rstrip("/") or "/"
if key in seen:
continue
seen.add(key)
final, _hops, error = resolve(rules, probe)
if error:
problems.append((rule, error))
continue
if final.startswith(("http", "//")):
continue
html, md = built_variants(public_dir, final)
if not html and not md:
problems.append((rule, "%s %s lands on %s, which is not in the build" % (label, probe, final)))
elif not md:
problems.append((rule, "%s %s lands on %s, which has no index.md" % (label, probe, final)))
return problems
def classify_aliases(hand_rules, alias_rules):
"""Decide where each alias rule has to sit relative to the hand-written table.
An alias and a hand-written rule can both match the same old path and
disagree about the destination. On qdrant.tech the alias wins for HTML --
the stub is a real file at that path, and a non-forced _redirects rule is
skipped when a file exists -- but it loses for .md, where there is no file
to shadow the rule. That is how /documentation/concepts/payload/ ends up
serving HTML correctly while its .md variant 301s to a dead path.
What decides the winner on the live site is `force`, not how specific the
rule looks: a forced rule is applied even when a file exists, a non-forced
one is not. So an alias that collides with any non-forced rule -- wildcard
or exact -- has to be emitted ahead of it, because that is what the CDN
already does for HTML at that path. Only a forced rule genuinely beats the
alias stub and keeps it suppressed.
Returns (precede, append, suppressed).
"""
precede, append, suppressed = [], [], []
for rule in alias_rules:
if rule.frm.endswith("*") or ":" in rule.frm:
append.append(rule)
continue
winner, target = first_match(hand_rules, rule.frm)
if winner is None:
append.append(rule)
elif target.rstrip("/") == rule.to.rstrip("/"):
# Same destination either way, so ordering cannot diverge.
suppressed.append((rule, winner, target, "agrees"))
elif winner.force:
suppressed.append((rule, winner, target, "overridden"))
else:
precede.append((rule, winner, target))
return precede, append, suppressed
# --------------------------------------------------------------------------
# Emission
# --------------------------------------------------------------------------
HEADER = """\
# GENERATED FILE -- DO NOT EDIT.
#
# Produced by automation/generate-redirects-table.py during the site build.
# It merges the three redirect tables Netlify applies for qdrant.tech into one
# table in _redirects syntax, for consumers that do not sit behind the CDN --
# principally the /md/ mirror on search.qdrant.tech, which has its own path
# lookup and would otherwise 404 paths that qdrant.tech redirects.
#
# Semantics, in the order they matter:
# * First match wins, in the order rules appear below. Hand-written rules
# come first so this file cannot change existing precedence.
# * A trailing /* is a prefix match; :splat in the target is the remainder.
# * A :name segment matches exactly one path segment.
# * Trailing slashes are not significant on either side.
# * A rule marked ! is forced. Consult this table only when you have no
# document at the requested path and non-forced behaviour comes for free.
# * Case. One CDN, three different behaviours. Four requests, each measured
# against production:
#
# /documentation/tutorials-develop/BULK-UPLOAD/
# -> 301 to the same path lowercased; rules then apply to the result
# /documentation/tutorials-develop/BULK-UPLOAD/index.md
# -> 404. No lowercasing, and no rule matched
# /documentation/operations/running-with-gpu/index.md
# -> 301 via the catch-all to /documentation/deploy-intro/..., NOT the
# running-with-GPU rule that sits above it
# /documentation/operations/running-with-GPU/index.md
# -> 301 to /documentation/ops-configuration/... The rule matched, on
# exact case
#
# So: rule matching is case-SENSITIVE; asset lookup is case-INSENSITIVE (the
# last row is served by the built running-with-gpu/index.md); and the
# lowercasing 301 applies only to directory-style paths, never to an
# explicit index.md request -- the only form a markdown mirror ever sees.
# This table therefore does no case folding for you. Lowercase the requested
# path in your own lookup before consulting it, or uppercase requests will
# 404 where qdrant.tech succeeds. Do not assume the table does it.
# * The third column is the status, and it is not always a redirect. 200 is
# a rewrite: Netlify proxies the target and the URL does not change, so a
# consumer that answers 301 for it would diverge from the live site. Every
# rule here is currently 301, but check the column rather than assuming.
#
# To edit a rule, edit its source: qdrant-landing/static/_redirects, the
# [[redirects]] blocks in netlify.toml, or the page's `aliases:` front matter.
"""
def emit(raw_redirects, toml_rules, precede, append, suppressed):
out = [HEADER]
out.append("\n# ---- 1. Hugo aliases that outrank a wildcard rule " + "-" * 26 + "\n")
out.append("# Each of these is an exact path that a catch-all further down also")
out.append("# matches, with a different destination. The alias is the correct one:")
out.append("# qdrant.tech already resolves these paths this way for HTML, because")
out.append("# Hugo's alias stub is a real file and shadows the non-forced rule.")
out.append("# Ordering them first is what makes .md agree with HTML.\n")
if precede:
for rule, winner, target in precede:
out.append("# instead of %s -> %s" % (winner.source, target))
out.append(rule.line())
else:
out.append("# (none)")
out.append("\n\n# ---- 2. qdrant-landing/static/_redirects (verbatim) " + "-" * 24 + "\n")
out.append(raw_redirects.rstrip("\n"))
out.append("\n\n# ---- 3. netlify.toml [[redirects]] " + "-" * 41 + "\n")
if toml_rules:
for rule in toml_rules:
out.append(rule.line())
else:
out.append("# (none)")
out.append("\n\n# ---- 4. Remaining Hugo aliases " + "-" * 45 + "\n")
out.append("# Hugo emits these as meta-refresh HTML with no index.md beside them,")
out.append("# which is why a markdown mirror cannot see them without this file.\n")
for rule in append:
out.append(rule.line())
if suppressed:
out.append("\n\n# ---- Aliases deliberately not emitted " + "-" * 38 + "\n")
for rule, winner, target, why in suppressed:
note = "already handled by" if why == "agrees" else "overridden by"
out.append("# %s %s (-> %s): %s" % (note, winner.source, target, rule.line().strip()))
out.append("")
return "\n".join(out) + "\n"
GENERATED_MARKER = "# --- generated by automation/generate-redirects-table.py:"
def strip_generated_blocks(text):
"""Remove blocks a previous --augment-netlify-redirects run inserted.
Without this the prepended rules duplicate every time the build runs twice
against the same public/ (a rebuild without a clean, or a local re-run).
A block runs from its marker to the next blank line that is not followed by
another rule of the same block.
"""
out, skipping = [], False
for line in text.splitlines():
if line.startswith(GENERATED_MARKER):
skipping = True
continue
if skipping:
# Comments and rules belong to the block; a blank line ends it.
if line.strip() == "":
skipping = False
continue
out.append(line)
return "\n".join(out).strip("\n") + "\n"
def warn(message):
sys.stderr.write("warning: %s\n" % message)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--public", default="qdrant-landing/public", help="built site directory")
parser.add_argument("--redirects", default="qdrant-landing/static/_redirects")
parser.add_argument("--netlify", default="netlify.toml")
parser.add_argument("--out", default=None, help="default: <public>/redirects.txt")
parser.add_argument(
"--augment-netlify-redirects",
action="store_true",
help="also append derived rules to <public>/_redirects, so qdrant.tech "
"honours alias paths for .md requests (changes live CDN behaviour)",
)
parser.add_argument(
"--include-pagination",
action="store_true",
help="keep Hugo's /page/N/ pagination aliases (excluded by default)",
)
parser.add_argument("--strict", action="store_true", help="exit 1 if validation finds problems")
parser.add_argument("--quiet", action="store_true", help="suppress the validation report")
args = parser.parse_args()
for path in (args.public, args.redirects, args.netlify):
if not os.path.exists(path):
sys.exit("error: %s not found (run from the repository root, after hugo)" % path)
raw_redirects, hand_rules = parse_redirects_file(args.redirects)
toml_rules = parse_netlify_toml(args.netlify)
alias_rules = collect_alias_rules(args.public, args.include_pagination)
precede, append_, suppressed = classify_aliases(hand_rules + toml_rules, alias_rules)
# First-match-wins order: specific aliases, then the hand-written tables,
# then the aliases nothing else touches.
ordered = [r for r, _w, _t in precede] + hand_rules + toml_rules + append_
text = emit(raw_redirects, toml_rules, precede, append_, suppressed)
out_path = args.out or os.path.join(args.public, "redirects.txt")
with open(out_path, "w", encoding="utf-8") as fh:
fh.write(text)
if args.augment_netlify_redirects:
# Netlify reads _redirects top to bottom, so the specific aliases have
# to be prepended, not appended, or the catch-alls keep winning for .md.
target = os.path.join(args.public, "_redirects")
with open(target, encoding="utf-8") as fh:
existing = fh.read()
existing = strip_generated_blocks(existing)
head = [
GENERATED_MARKER + " exact alias paths",
"# that a catch-all below would otherwise send to a dead .md target.",
]
head += [r.line() for r, _w, _t in precede]
tail = [
"",
GENERATED_MARKER + " netlify.toml rules",
"# and Hugo aliases, so .md requests to old paths redirect too.",
]
tail += [r.line() for r in append_]
with open(target, "w", encoding="utf-8") as fh:
fh.write("\n".join(head) + "\n\n" + existing.rstrip("\n") + "\n\n" + "\n".join(tail) + "\n")
print("rewrote %s: %d rules prepended, %d appended" % (target, len(precede), len(append_)))
problems = validate(ordered, args.public)
if not args.quiet:
print("wrote %s" % out_path)
print(
" %d hand-written + %d netlify.toml + %d alias rules"
% (len(hand_rules), len(toml_rules), len(alias_rules))
)
print(
" aliases: %d ordered ahead of a wildcard, %d appended, %d suppressed"
% (len(precede), len(append_), len(suppressed))
)
# Paths that only a catch-all routes are not covered here; closing that
# gap needs a corpus of real request paths. See validate().
if problems:
print("\n%d path(s) need attention:" % len(problems))
for rule, reason in problems:
print(" %-26s %s" % (rule.source, reason))
else:
print(" every rule-derived path lands on a page with both index.html and index.md")
if problems and args.strict:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())