[bug-7acxk8rf0g6b] feat(site-publish): add split-surface publishing #2
@@ -128,7 +128,8 @@ cache policy, content type, and bytes. That content address makes concurrent
|
|||||||
writes identical even though Garage v2.2.0 has no conditional destination
|
writes identical even though Garage v2.2.0 has no conditional destination
|
||||||
write. An identical retry converges; a changed object, missing digest metadata,
|
write. An identical retry converges; a changed object, missing digest metadata,
|
||||||
wrong address, or nested policy under that immutable prefix fails publication.
|
wrong address, or nested policy under that immutable prefix fails publication.
|
||||||
Every immutable target is validated and published before mutable objects change.
|
Every immutable target across every artifact is validated and published before
|
||||||
|
any route's mutable objects change.
|
||||||
Mutable default and override partitions receive their final cache policy before
|
Mutable default and override partitions receive their final cache policy before
|
||||||
the matching prefix-scoped stale deletion, so publication never exposes a
|
the matching prefix-scoped stale deletion, so publication never exposes a
|
||||||
provisional cache policy or a pointer to a missing immutable target.
|
provisional cache policy or a pointer to a missing immutable target.
|
||||||
|
|||||||
+25
-9
@@ -149,9 +149,8 @@ def publish_immutable_rule(artifact, route, rule, html_dir, aws_env):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def s3_sync(artifact, route, site_dir, credential_env_names=None):
|
def publication_aws_env(artifact, credential_env_names=None):
|
||||||
endpoint = artifact["s3_endpoint"]
|
"""Build the route-scoped AWS environment without leaking other credentials."""
|
||||||
html_dir = site_dir / artifact["build_dir"]
|
|
||||||
access_key = env(artifact["credentials"]["access_key_env"])
|
access_key = env(artifact["credentials"]["access_key_env"])
|
||||||
secret_key = env(artifact["credentials"]["secret_key_env"])
|
secret_key = env(artifact["credentials"]["secret_key_env"])
|
||||||
aws_env = os.environ.copy()
|
aws_env = os.environ.copy()
|
||||||
@@ -165,6 +164,22 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None):
|
|||||||
"AWS_SECRET_ACCESS_KEY": secret_key,
|
"AWS_SECRET_ACCESS_KEY": secret_key,
|
||||||
"AWS_DEFAULT_REGION": os.environ.get("AWS_DEFAULT_REGION", "sjc001"),
|
"AWS_DEFAULT_REGION": os.environ.get("AWS_DEFAULT_REGION", "sjc001"),
|
||||||
})
|
})
|
||||||
|
return aws_env
|
||||||
|
|
||||||
|
|
||||||
|
def publish_route_immutables(artifact, route, site_dir, credential_env_names=None):
|
||||||
|
"""Publish one route's immutable partitions during the global preflight."""
|
||||||
|
html_dir = site_dir / artifact["build_dir"]
|
||||||
|
aws_env = publication_aws_env(artifact, credential_env_names)
|
||||||
|
for rule in artifact["cache_rules"]:
|
||||||
|
if _is_immutable(rule):
|
||||||
|
publish_immutable_rule(artifact, route, rule, html_dir, aws_env)
|
||||||
|
|
||||||
|
|
||||||
|
def s3_sync(artifact, route, site_dir, credential_env_names=None):
|
||||||
|
endpoint = artifact["s3_endpoint"]
|
||||||
|
html_dir = site_dir / artifact["build_dir"]
|
||||||
|
aws_env = publication_aws_env(artifact, credential_env_names)
|
||||||
bucket = artifact["bucket"]
|
bucket = artifact["bucket"]
|
||||||
object_prefix = route["path"].strip("/")
|
object_prefix = route["path"].strip("/")
|
||||||
destination = f"s3://{bucket}/{object_prefix + '/' if object_prefix else ''}"
|
destination = f"s3://{bucket}/{object_prefix + '/' if object_prefix else ''}"
|
||||||
@@ -176,12 +191,6 @@ def s3_sync(artifact, route, site_dir, credential_env_names=None):
|
|||||||
exclude_args = [arg for pattern in artifact["excludes"] for arg in ("--exclude", pattern)]
|
exclude_args = [arg for pattern in artifact["excludes"] for arg in ("--exclude", pattern)]
|
||||||
if artifact["excludes"]:
|
if artifact["excludes"]:
|
||||||
print(f"Excluding patterns: {artifact['excludes']}")
|
print(f"Excluding patterns: {artifact['excludes']}")
|
||||||
# Validate and publish every append-only target before a mutable channel can
|
|
||||||
# point at it. Partial immutable success is safe; partial mutable success is
|
|
||||||
# not.
|
|
||||||
for rule in artifact["cache_rules"]:
|
|
||||||
if _is_immutable(rule):
|
|
||||||
publish_immutable_rule(artifact, route, rule, html_dir, aws_env)
|
|
||||||
print(f"Syncing artifact {artifact['name']} → {destination} via {endpoint}")
|
print(f"Syncing artifact {artifact['name']} → {destination} via {endpoint}")
|
||||||
# Upload with the final cache policy before cleanup. Sync and deletion are
|
# Upload with the final cache policy before cleanup. Sync and deletion are
|
||||||
# scoped to the same current route prefix and cache partition. A route move
|
# scoped to the same current route prefix and cache partition. A route move
|
||||||
@@ -296,6 +305,13 @@ def deploy_static(site_name, site_dir, action_dir, token, cfg):
|
|||||||
validate_publication_environment(cfg)
|
validate_publication_environment(cfg)
|
||||||
for artifact in cfg["artifacts"]:
|
for artifact in cfg["artifacts"]:
|
||||||
validate_artifact_output(site_dir, artifact)
|
validate_artifact_output(site_dir, artifact)
|
||||||
|
# Complete immutable work across the whole publication before any route's
|
||||||
|
# mutable pointers can change. Partial immutable success is safe; mixing a
|
||||||
|
# new route with an old route after a later immutable failure is not.
|
||||||
|
for route in cfg["routes"]:
|
||||||
|
publish_route_immutables(
|
||||||
|
artifact_by_name[route["artifact"]], route, site_dir, credential_env_names,
|
||||||
|
)
|
||||||
for route in cfg["routes"]:
|
for route in cfg["routes"]:
|
||||||
s3_sync(artifact_by_name[route["artifact"]], route, site_dir, credential_env_names)
|
s3_sync(artifact_by_name[route["artifact"]], route, site_dir, credential_env_names)
|
||||||
if cfg["compatibility"]:
|
if cfg["compatibility"]:
|
||||||
|
|||||||
+11
-16
@@ -395,6 +395,7 @@ class PublishingTests(unittest.TestCase):
|
|||||||
}, clear=False), patch.object(deploy, "run", side_effect=capture), \
|
}, clear=False), patch.object(deploy, "run", side_effect=capture), \
|
||||||
patch.object(deploy, "publish_immutable_rule", side_effect=publish_immutable) as immutable_publish, \
|
patch.object(deploy, "publish_immutable_rule", side_effect=publish_immutable) as immutable_publish, \
|
||||||
redirect_stdout(output):
|
redirect_stdout(output):
|
||||||
|
deploy.publish_route_immutables(artifact, route, root)
|
||||||
deploy.s3_sync(artifact, route, root)
|
deploy.s3_sync(artifact, route, root)
|
||||||
|
|
||||||
self.assertTrue(all(secret not in " ".join(command) for command, _ in commands))
|
self.assertTrue(all(secret not in " ".join(command) for command, _ in commands))
|
||||||
@@ -417,28 +418,22 @@ class PublishingTests(unittest.TestCase):
|
|||||||
immutable_publish.call_args.args[2]["cache_control"],
|
immutable_publish.call_args.args[2]["cache_control"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_immutable_failure_stops_before_mutable_publication(self):
|
def test_later_route_immutable_failure_stops_all_mutable_publication(self):
|
||||||
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
cfg = normalize_site_config(fixture("split-site.yaml"), "baseline.fritzlab.net")
|
||||||
artifact = next(item for item in cfg["artifacts"] if item["name"] == "distributions")
|
|
||||||
route = next(item for item in cfg["routes"] if item["artifact"] == "distributions")
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
html = root / artifact["build_dir"]
|
with patch.object(deploy, "validate_publication_environment"), \
|
||||||
(html / "releases").mkdir(parents=True)
|
patch.object(deploy, "validate_artifact_output"), patch.object(
|
||||||
(html / "channels").mkdir()
|
deploy, "publish_route_immutables",
|
||||||
(html / "releases" / "1.0.js").write_text("release")
|
side_effect=[None, RuntimeError("immutable failed")],
|
||||||
(html / "channels" / "stable.json").write_text("channel")
|
) as immutable_publish, patch.object(deploy, "s3_sync") as mutable_sync, \
|
||||||
|
self.assertRaisesRegex(
|
||||||
with patch.dict(os.environ, {
|
|
||||||
"DIST_S3_ACCESS_KEY": "dist-key", "DIST_S3_SECRET_KEY": "dist-secret"
|
|
||||||
}, clear=False), patch.object(
|
|
||||||
deploy, "publish_immutable_rule", side_effect=RuntimeError("immutable failed")
|
|
||||||
), patch.object(deploy, "run") as mutable_run, self.assertRaisesRegex(
|
|
||||||
RuntimeError, "immutable failed"
|
RuntimeError, "immutable failed"
|
||||||
):
|
):
|
||||||
deploy.s3_sync(artifact, route, root)
|
deploy.deploy_static("baseline", root, root, "token", cfg)
|
||||||
|
|
||||||
mutable_run.assert_not_called()
|
self.assertEqual(2, immutable_publish.call_count)
|
||||||
|
mutable_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_absent_artifact_is_detected_before_publish(self):
|
def test_absent_artifact_is_detected_before_publish(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user