[bug-7acxk8rf0g6b] fix(site-publish): close split migration boundaries #4
@@ -133,8 +133,10 @@ any route's mutable objects change.
|
||||
Mutable default and override partitions receive their final cache policy before
|
||||
the matching prefix-scoped stale deletion, so publication never exposes a
|
||||
provisional cache policy or a pointer to a missing immutable target.
|
||||
Generated Ingress annotations retain each bucket's prior route and immutable paths. When a move
|
||||
places that retired prefix inside the new sync scope, only its declared
|
||||
Generated Ingress annotations retain each bucket's prior route and all immutable
|
||||
key prefixes. The prefixes are bucket-relative and carried forward after a rule
|
||||
is removed or renamed, so later deployments and route moves cannot forget them.
|
||||
When a move places a retired prefix inside the new sync scope, only its declared
|
||||
immutable subtrees are excluded; a current-file collision fails publication.
|
||||
The bucket-keyed history rejects a protected-to-public transition even when the
|
||||
artifact is renamed; publishing that artifact publicly requires a new bucket.
|
||||
|
||||
@@ -178,29 +178,40 @@ def publish_route_immutables(artifact, route, site_dir, credential_env_names=Non
|
||||
publish_immutable_rule(artifact, route, rule, html_dir, aws_env)
|
||||
|
||||
|
||||
def immutable_key_prefixes(artifact, route):
|
||||
"""Return immutable partitions as bucket-relative key prefixes."""
|
||||
route_prefix = route["path"].strip("/")
|
||||
return [
|
||||
"/".join(part for part in (route_prefix, rule["path"]) if part)
|
||||
for rule in artifact["cache_rules"] if _is_immutable(rule)
|
||||
]
|
||||
|
||||
|
||||
def retained_immutable_paths(artifact, route, previous_contract):
|
||||
"""Carry all bucket history forward so later route moves cannot delete it."""
|
||||
previous_paths = previous_contract["immutable_paths"] if previous_contract else []
|
||||
return sorted(set(previous_paths) | set(immutable_key_prefixes(artifact, route)))
|
||||
|
|
||||
|
||||
|
||||
def retired_immutable_filters(artifact, route, html_dir, previous_contract):
|
||||
"""Protect immutable keys only when an old route falls inside the new scope."""
|
||||
"""Protect historical immutable keys that fall inside the current sync scope."""
|
||||
if not previous_contract:
|
||||
return []
|
||||
current_prefix = route["path"].strip("/")
|
||||
previous_prefix = previous_contract["path"].strip("/")
|
||||
filters = []
|
||||
current_immutable = set(immutable_key_prefixes(artifact, route))
|
||||
|
security
commented
You move protected The narrowed prefix fixes collateral basename matches. The collision check only sees current local files. Neither establishes that a retired object is public. Record and compare prior access class, or fail the move until explicit cleanup records that proof. You move protected `/portal` to public `/`; this emits `--exclude portal/releases/*` without proving the old route shared the new access class. `sync --delete` retains the object, then the public catch-all serves it.
The narrowed prefix fixes collateral basename matches. The collision check only sees current local files. Neither establishes that a retired object is public.
Record and compare prior access class, or fail the move until explicit cleanup records that proof.
|
||||
for immutable_path in previous_contract["immutable_paths"]:
|
||||
if immutable_path in current_immutable:
|
||||
continue
|
||||
if current_prefix:
|
||||
marker = f"{current_prefix}/"
|
||||
if previous_prefix == current_prefix:
|
||||
previous_prefix = ""
|
||||
elif not previous_prefix.startswith(marker):
|
||||
return []
|
||||
if not immutable_path.startswith(marker):
|
||||
continue
|
||||
retired_path = immutable_path[len(marker):]
|
||||
else:
|
||||
|
ux
commented
You publish You publish `docs/releases/index.html`; this wildcard silently excludes it from upload and deletion although only root `/releases` is immutable. A visitor keeps stale content, and the operator gets a successful deploy. Preserve only retired route prefixes.
ops
commented
`*/releases/*` also matches current files like `docs/releases/x`, while immutable publication covers only root `releases/`. Both upload passes skip valid default-cache content, leaving it absent or stale. Preserve retired keys during deletion without suppressing current uploads; add a nested-path regression.
dev
commented
Blocker: AWS CLI applies this wildcard to every descendant. With immutable Blocker: AWS CLI applies this wildcard to every descendant. With immutable `releases`, mutable `archive/releases/app.js` is excluded from upload and deletion, although only root `releases` owns immutable policy. Preserve actual retired prefixes; this basename wildcard silently strands current mutable content.
security
commented
You send You send `GET /portal/releases/<digest>` after this bucket moves from a protected `/portal` route to a public `/` route, and the public catch-all serves the formerly protected object because `*/releases/*` excludes it from `sync --delete`. The current bucket/access check has a history-blind twin: it rejects simultaneous protected/public reuse but doesn't prove a retired prefix had the new route's access class. Content addressing prevents replacement, but it doesn't prevent this read. Preserve only retired prefixes proven to share the new access class, or fail the move until an explicit cleanup/migration records that proof.
|
||||
previous_prefix = previous_prefix[len(marker):]
|
||||
filters = []
|
||||
current_immutable = {
|
||||
rule["path"] for rule in artifact["cache_rules"] if _is_immutable(rule)
|
||||
}
|
||||
for immutable_path in previous_contract["immutable_paths"]:
|
||||
retired_path = "/".join(part for part in (previous_prefix, immutable_path) if part)
|
||||
retired_path = immutable_path
|
||||
collision = html_dir / retired_path
|
||||
if (immutable_path not in current_immutable and collision.exists()
|
||||
and any(path.is_file() for path in collision.rglob("*"))):
|
||||
if collision.exists() and any(path.is_file() for path in collision.rglob("*")):
|
||||
raise RuntimeError(
|
||||
f"current artifact collides with retired immutable partition: {retired_path}"
|
||||
)
|
||||
@@ -308,18 +319,20 @@ def ensure_bucket_aliases(site_name, aliases, admin_token):
|
||||
raise
|
||||
|
||||
|
ux
commented
You remove You remove `releases` while moving `/foo` to `/`; the first deployment reads the old annotation and excludes `foo/releases/*`, but this current-only list writes `immutable-paths: []`. On the next unchanged deployment, history contains no retired path, so `sync --delete` removes those objects and still reports success. Carry the retained immutable history forward, with enough route history to preserve its key prefix, and cover the removal deployment plus the following deployment.
|
||||
|
||||
def render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg):
|
||||
def render_site_manifests(
|
||||
site_name, action_dir, app_dir, manifests_dir, cfg, previous_contracts=None,
|
||||
):
|
||||
"""Always re-render manifests from current site.yaml. Templates own
|
||||
|
dev
commented
Blocker: this persists only current immutable paths. Remove Blocker: this persists only current immutable paths. Remove `releases`: deployment one preserves prior keys, then writes `[]`; deployment two emits no retired filter and `sync --delete` removes them. Carry retired history forward and cover two deployments.
|
||||
domain + aliases, so changes propagate without manual edits."""
|
||||
manifests_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_by_name = {artifact["name"]: artifact for artifact in cfg["artifacts"]}
|
||||
previous_contracts = previous_contracts or {}
|
||||
routes = []
|
||||
for route in cfg["routes"]:
|
||||
artifact = artifact_by_name[route["artifact"]]
|
||||
resource_name = k8s_name(site_name) if cfg["compatibility"] else f"{k8s_name(site_name)}-{route['name']}"
|
||||
immutable_paths = [
|
||||
rule["path"] for rule in artifact["cache_rules"] if _is_immutable(rule)
|
||||
]
|
||||
previous = previous_contracts.get(artifact["bucket"])
|
||||
immutable_paths = retained_immutable_paths(artifact, route, previous)
|
||||
routes.append({
|
||||
**route, "resource_name": resource_name, "artifact_config": artifact,
|
||||
"immutable_paths_json": json.dumps(immutable_paths, separators=(",", ":")),
|
||||
@@ -413,7 +426,9 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
|
||||
if cfg["compatibility"]:
|
||||
ensure_bucket_aliases(site_name, cfg["aliases"], os.environ.get("GARAGE_ADMIN_TOKEN"))
|
||||
|
||||
render_site_manifests(site_name, action_dir, app_dir, manifests_dir, cfg)
|
||||
render_site_manifests(
|
||||
site_name, action_dir, app_dir, manifests_dir, cfg, previous_contracts,
|
||||
)
|
||||
|
||||
commit_and_push(apps_dir, f"Deploy {site_name}", token)
|
||||
|
||||
|
||||
@@ -473,7 +473,7 @@ class PublishingTests(unittest.TestCase):
|
||||
):
|
||||
deploy.s3_sync(artifact, route, root, previous_contract={
|
||||
"path": "/foo", "access": "public", "artifact": "distributions",
|
||||
"immutable_paths": ["releases"],
|
||||
"immutable_paths": ["foo/releases"],
|
||||
})
|
||||
rendered = [" ".join(command) for command in commands]
|
||||
self.assertTrue(all("foo/releases/*" in command for command in rendered[:2]))
|
||||
@@ -484,7 +484,7 @@ class PublishingTests(unittest.TestCase):
|
||||
previous = {
|
||||
"baseline-dist": {
|
||||
"path": "/dist", "access": "protected", "artifact": "old-name",
|
||||
"immutable_paths": ["releases"],
|
||||
"immutable_paths": ["dist/releases"],
|
||||
}
|
||||
}
|
||||
with self.assertRaisesRegex(RuntimeError, "cannot become public while reusing protected"):
|
||||
@@ -521,7 +521,7 @@ class PublishingTests(unittest.TestCase):
|
||||
html = Path(tmp)
|
||||
filters = deploy.retired_immutable_filters(artifact, route, html, {
|
||||
"path": "/dist", "access": "public", "artifact": "distributions",
|
||||
"immutable_paths": ["releases"],
|
||||
"immutable_paths": ["dist/releases"],
|
||||
})
|
||||
self.assertEqual(["--exclude", "releases/*"], filters)
|
||||
(html / "releases").mkdir()
|
||||
@@ -529,9 +529,44 @@ class PublishingTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "collides with retired immutable"):
|
||||
deploy.retired_immutable_filters(artifact, route, html, {
|
||||
"path": "/dist", "access": "public", "artifact": "distributions",
|
||||
"immutable_paths": ["releases"],
|
||||
"immutable_paths": ["dist/releases"],
|
||||
})
|
||||
|
||||
def test_retired_immutable_history_survives_the_following_deployment(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
artifact = next(item for item in cfg["artifacts"] if item["name"] == "distributions")
|
||||
artifact["cache_rules"] = [
|
||||
rule for rule in artifact["cache_rules"] if rule["path"] != "releases"
|
||||
]
|
||||
route = next(item for item in cfg["routes"] if item["artifact"] == "distributions")
|
||||
route["path"] = "/"
|
||||
previous = {
|
||||
"baseline-dist": {
|
||||
"path": "/foo", "access": "public", "artifact": "distributions",
|
||||
"immutable_paths": ["foo/releases"],
|
||||
}
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
app_dir = root / "app"
|
||||
manifests = app_dir / "manifests"
|
||||
app_dir.mkdir()
|
||||
deploy.render_site_manifests(
|
||||
"baseline.fritzlab.net", ROOT, app_dir, manifests, cfg, previous,
|
||||
)
|
||||
following = deploy.previous_route_contracts(app_dir)
|
||||
self.assertEqual(["foo/releases"], following["baseline-dist"]["immutable_paths"])
|
||||
html = root / "html"
|
||||
html.mkdir()
|
||||
first_filters = deploy.retired_immutable_filters(
|
||||
artifact, route, html, previous["baseline-dist"],
|
||||
)
|
||||
following_filters = deploy.retired_immutable_filters(
|
||||
artifact, route, html, following["baseline-dist"],
|
||||
)
|
||||
self.assertEqual(["--exclude", "foo/releases/*"], first_filters)
|
||||
self.assertEqual(first_filters, following_filters)
|
||||
|
||||
def test_later_route_immutable_failure_stops_all_mutable_publication(self):
|
||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
You move
/footo/while removing immutablereleases; this rebuilds retirement filters from new rules, sosync --deletesilently removesfoo/releases/*. Clients and rollbacks lose those URLs. Persist prior immutable paths or stop before S3.