Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95e4eef250 |
@@ -92,10 +92,8 @@ routes:
|
||||
```
|
||||
|
||||
The caller supplies each declared credential name as an environment variable
|
||||
on the action step. Names must be matched `<NAME>_S3_ACCESS_KEY` and
|
||||
`<NAME>_S3_SECRET_KEY` pairs; arbitrary environment variables cannot become
|
||||
publication credentials. Values pass to `aws` only through its environment and
|
||||
never appear in a logged command or process argument.
|
||||
on the action step. Credential values are passed to `aws` only through its
|
||||
environment and never appear in a logged command or process argument.
|
||||
|
||||
```yaml
|
||||
- uses: https://code.fritzlab.net/action/site-publish@v1
|
||||
@@ -129,12 +127,6 @@ writes identical even though Garage v2.2.0 has no conditional destination
|
||||
write. An identical retry converges; a changed object, missing digest metadata,
|
||||
wrong address, or nested policy under that immutable prefix fails publication.
|
||||
|
||||
Artifact input directories must be pairwise disjoint after filesystem
|
||||
resolution. Publication stops before build or upload if one contains another or
|
||||
escapes the repository, preventing protected input from entering a public
|
||||
artifact. Split storage endpoints are pinned to Garage, and each website
|
||||
authority is derived from its bucket; a site cannot expose an arbitrary backend.
|
||||
|
||||
Each split route gets a bucket-specific `<bucket>.web.sjc001.fritzlab.net`
|
||||
ExternalName Service annotated to disable pass-host-header and a separate Ingress. Route
|
||||
Ingresses share the hostname's certificate Secret. The access middleware and
|
||||
|
||||
+1
-3
@@ -5,7 +5,7 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from utils import EXCLUDE_FILES, env, parse_site_yaml, run, validate_artifact_inputs
|
||||
from utils import EXCLUDE_FILES, env, parse_site_yaml, run
|
||||
|
||||
|
||||
def build_artifact(site_dir, artifact):
|
||||
@@ -64,7 +64,5 @@ def cmd_build():
|
||||
print("Site disabled — skipping build")
|
||||
return
|
||||
|
||||
validate_artifact_inputs(site_dir, cfg)
|
||||
|
||||
for artifact in cfg["artifacts"]:
|
||||
build_artifact(site_dir, artifact)
|
||||
|
||||
@@ -23,7 +23,6 @@ from utils import (
|
||||
parse_site_yaml,
|
||||
render_templates,
|
||||
run,
|
||||
validate_artifact_inputs,
|
||||
)
|
||||
|
||||
GARAGE_ADMIN_ENDPOINT = os.environ.get(
|
||||
@@ -351,5 +350,4 @@ def cmd_deploy():
|
||||
decommission(site_name, token, [artifact["bucket"] for artifact in cfg["artifacts"]])
|
||||
return
|
||||
|
||||
validate_artifact_inputs(site_dir, cfg)
|
||||
deploy_static(site_name, site_dir, action_dir, token, cfg)
|
||||
|
||||
+6
-32
@@ -26,7 +26,6 @@ EXCLUDE_FILES = {
|
||||
VALID_TYPES = {"static", "hugo", "mkdocs"}
|
||||
NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
ENV_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
||||
ACCESS_KEY_ENV_RE = re.compile(r"^([A-Z][A-Z0-9_]*)_S3_ACCESS_KEY$")
|
||||
BUCKET_RE = re.compile(r"^[a-z0-9](?:[a-z0-9.-]{1,61}[a-z0-9])?$")
|
||||
MIDDLEWARE_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9])?$")
|
||||
|
||||
@@ -239,7 +238,7 @@ def _artifact(item, index):
|
||||
if not isinstance(name, str) or not NAME_RE.fullmatch(name):
|
||||
raise ConfigError(f"{label}.name must be a DNS label")
|
||||
publish = _mapping(item.get("publish"), f"{label}.publish")
|
||||
_known_keys(publish, {"bucket", "credentials"}, f"{label}.publish")
|
||||
_known_keys(publish, {"bucket", "endpoint", "website_authority", "credentials"}, f"{label}.publish")
|
||||
bucket = publish.get("bucket")
|
||||
if not isinstance(bucket, str) or not BUCKET_RE.fullmatch(bucket):
|
||||
raise ConfigError(f"{label}.publish.bucket is not a valid bucket name")
|
||||
@@ -251,15 +250,6 @@ def _artifact(item, index):
|
||||
if not isinstance(value, str) or not ENV_RE.fullmatch(value):
|
||||
raise ConfigError(f"{label}.publish.credentials.{key} must name an environment variable")
|
||||
normalized_credentials[key] = value
|
||||
access_match = ACCESS_KEY_ENV_RE.fullmatch(normalized_credentials["access_key_env"])
|
||||
expected_secret = (
|
||||
f"{access_match.group(1)}_S3_SECRET_KEY" if access_match else None
|
||||
)
|
||||
if normalized_credentials["secret_key_env"] != expected_secret:
|
||||
raise ConfigError(
|
||||
f"{label}.publish.credentials must be a matched "
|
||||
"<NAME>_S3_ACCESS_KEY and <NAME>_S3_SECRET_KEY pair"
|
||||
)
|
||||
cache = _mapping(item.get("cache"), f"{label}.cache")
|
||||
_known_keys(cache, {"rules"}, f"{label}.cache")
|
||||
rules = _list(cache.get("rules"), f"{label}.cache.rules")
|
||||
@@ -288,7 +278,10 @@ def _artifact(item, index):
|
||||
for path in immutable_paths:
|
||||
if any(other.startswith(f"{path}/") for other in paths):
|
||||
raise ConfigError(f"{label}.cache.rules cannot nest another policy under immutable /{path}")
|
||||
authority = f"{bucket}.{DEFAULT_WEBSITE_SUFFIX}"
|
||||
authority = _hostname(
|
||||
publish.get("website_authority", f"{bucket}.{DEFAULT_WEBSITE_SUFFIX}"),
|
||||
f"{label}.publish.website_authority",
|
||||
)
|
||||
if not isinstance(item.get("tidy", True), bool):
|
||||
raise ConfigError(f"{label}.tidy must be a boolean")
|
||||
return {
|
||||
@@ -299,7 +292,7 @@ def _artifact(item, index):
|
||||
"excludes": _strings(item.get("excludes") or [], f"{label}.excludes"),
|
||||
"build_dir": f".site-publish/{name}/html",
|
||||
"bucket": bucket,
|
||||
"s3_endpoint": DEFAULT_S3_ENDPOINT,
|
||||
"s3_endpoint": _endpoint(publish.get("endpoint", DEFAULT_S3_ENDPOINT), f"{label}.publish.endpoint"),
|
||||
"website_authority": authority,
|
||||
"credentials": normalized_credentials,
|
||||
"cache_rules": cache_rules,
|
||||
@@ -425,25 +418,6 @@ def normalize_site_config(raw, site_name):
|
||||
return cfg
|
||||
|
||||
|
||||
def validate_artifact_inputs(site_dir, cfg):
|
||||
"""Reject source containment before a public or protected build starts."""
|
||||
root = Path(site_dir).resolve()
|
||||
sources = []
|
||||
for artifact in cfg["artifacts"]:
|
||||
source = (root / artifact["content_dir"]).resolve()
|
||||
if source != root and root not in source.parents:
|
||||
raise ConfigError(
|
||||
f"artifact {artifact['name']} content_dir resolves outside the repository"
|
||||
)
|
||||
sources.append((artifact["name"], source))
|
||||
for index, (name, source) in enumerate(sources):
|
||||
for other_name, other_source in sources[index + 1:]:
|
||||
if source == other_source or source in other_source.parents or other_source in source.parents:
|
||||
raise ConfigError(
|
||||
f"artifact build inputs overlap after resolution: {name} and {other_name}"
|
||||
)
|
||||
|
||||
|
||||
def parse_site_yaml(site_dir, site_name=None):
|
||||
path = Path(site_dir) / "site.yaml"
|
||||
if not path.exists():
|
||||
|
||||
+7
-35
@@ -24,7 +24,7 @@ sys.path.insert(0, str(ROOT / "scripts"))
|
||||
import deploy
|
||||
import build
|
||||
import utils
|
||||
from utils import ConfigError, normalize_site_config, validate_artifact_inputs
|
||||
from utils import ConfigError, normalize_site_config
|
||||
|
||||
|
||||
def fixture(name):
|
||||
@@ -151,11 +151,12 @@ class ConfigContractTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_publication_credentials_cannot_be_reused(self):
|
||||
def mutate(raw):
|
||||
raw["artifacts"][0]["publish"]["credentials"] = copy.deepcopy(
|
||||
raw["artifacts"][1]["publish"]["credentials"]
|
||||
)
|
||||
self.assert_invalid(mutate, "publication credential DIST_S3_ACCESS_KEY is reused")
|
||||
self.assert_invalid(
|
||||
lambda raw: raw["artifacts"][0]["publish"]["credentials"].__setitem__(
|
||||
"access_key_env", "DIST_S3_ACCESS_KEY"
|
||||
),
|
||||
"publication credential DIST_S3_ACCESS_KEY is reused",
|
||||
)
|
||||
|
||||
def test_cache_directive_contradiction_is_rejected(self):
|
||||
self.assert_invalid(
|
||||
@@ -190,35 +191,6 @@ class ConfigContractTests(unittest.TestCase):
|
||||
"unknown fields: storage_bucket",
|
||||
)
|
||||
|
||||
def test_backend_authority_and_endpoint_are_derived(self):
|
||||
for field, value in (
|
||||
("endpoint", "https://attacker.example"),
|
||||
("website_authority", "internal-api.default.svc.k8s.sjc001.fritzlab.net"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
self.assert_invalid(
|
||||
lambda raw, field=field, value=value: raw["artifacts"][0]["publish"].__setitem__(field, value),
|
||||
f"unknown fields: {field}",
|
||||
)
|
||||
|
||||
def test_publication_credentials_use_dedicated_matched_names(self):
|
||||
self.assert_invalid(
|
||||
lambda raw: raw["artifacts"][0]["publish"]["credentials"].__setitem__(
|
||||
"access_key_env", "CI_BOT_TOKEN"
|
||||
),
|
||||
"must be a matched <NAME>_S3_ACCESS_KEY",
|
||||
)
|
||||
|
||||
def test_resolved_build_inputs_must_be_pairwise_disjoint(self):
|
||||
raw = copy.deepcopy(self.raw)
|
||||
raw["artifacts"][1]["content_dir"] = ""
|
||||
cfg = normalize_site_config(raw, "baseline.fritzlab.net")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "portal" / "build").mkdir(parents=True)
|
||||
with self.assertRaisesRegex(ConfigError, "build inputs overlap after resolution"):
|
||||
validate_artifact_inputs(root, cfg)
|
||||
|
||||
|
||||
class GenerationTests(unittest.TestCase):
|
||||
def render(self, raw):
|
||||
|
||||
Reference in New Issue
Block a user