Add signed runtime distribution pipeline
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create runtime SBOM, provenance, and an unsigned distribution descriptor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
|
||||
|
||||
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
DIGEST_IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--composition", type=Path, required=True)
|
||||
parser.add_argument("--api-metadata", type=Path, required=True)
|
||||
parser.add_argument("--web-metadata", type=Path, required=True)
|
||||
parser.add_argument("--deployer", type=Path, required=True)
|
||||
parser.add_argument("--deployer-url", required=True)
|
||||
parser.add_argument("--artifact-base-url", required=True)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--channel", default="stable")
|
||||
parser.add_argument("--sequence", type=int, required=True)
|
||||
parser.add_argument("--expires-days", type=int, default=90)
|
||||
parser.add_argument(
|
||||
"--dependency",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=IMAGE@SHA256",
|
||||
)
|
||||
parser.add_argument("--output-directory", type=Path, required=True)
|
||||
parser.add_argument("--descriptor", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def finalize(args: argparse.Namespace) -> dict[str, Any]:
|
||||
composition = _json_object(args.composition)
|
||||
api = _image_metadata(_json_object(args.api_metadata), "api")
|
||||
web = _image_metadata(_json_object(args.web_metadata), "web")
|
||||
dependencies = dict(_dependency(value) for value in args.dependency)
|
||||
if not dependencies:
|
||||
raise ValueError("at least one --dependency is required")
|
||||
_https_url(args.deployer_url, "deployer URL")
|
||||
artifact_base = _https_url(args.artifact_base_url, "artifact base URL").rstrip("/")
|
||||
if args.sequence < 1 or not 1 <= args.expires_days <= 365:
|
||||
raise ValueError("sequence and expiry window are out of bounds")
|
||||
output = args.output_directory.expanduser().resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
api_sbom = _api_sbom(composition, version=args.version)
|
||||
web_sbom = _web_sbom(composition, version=args.version)
|
||||
api_provenance = _provenance(
|
||||
subject=api["index"],
|
||||
source_commit=args.source_commit,
|
||||
composition=composition,
|
||||
)
|
||||
web_provenance = _provenance(
|
||||
subject=web["index"],
|
||||
source_commit=args.source_commit,
|
||||
composition=composition,
|
||||
)
|
||||
artifact_values = {
|
||||
"api-sbom.cdx.json": api_sbom,
|
||||
"web-sbom.cdx.json": web_sbom,
|
||||
"api-provenance.json": api_provenance,
|
||||
"web-provenance.json": web_provenance,
|
||||
}
|
||||
artifacts: dict[str, dict[str, str]] = {}
|
||||
for filename, value in artifact_values.items():
|
||||
path = output / filename
|
||||
encoded = _canonical_json(value)
|
||||
path.write_bytes(encoded)
|
||||
artifacts[filename] = {
|
||||
"url": f"{artifact_base}/{filename}",
|
||||
"sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
composition_encoded = _canonical_json(composition)
|
||||
packages = _manifest_packages(composition)
|
||||
issued = datetime.now(UTC).replace(microsecond=0)
|
||||
descriptor: dict[str, Any] = {
|
||||
"schema_version": "1",
|
||||
"channel": args.channel,
|
||||
"sequence": args.sequence,
|
||||
"version": args.version,
|
||||
"issued_at": issued.isoformat(),
|
||||
"expires_at": (issued + timedelta(days=args.expires_days)).isoformat(),
|
||||
"revoked": False,
|
||||
"deployer": {
|
||||
"url": args.deployer_url,
|
||||
"sha256": _sha256_file(args.deployer),
|
||||
},
|
||||
"images": {
|
||||
"api": {
|
||||
**api,
|
||||
"sbom": artifacts["api-sbom.cdx.json"],
|
||||
"provenance": artifacts["api-provenance.json"],
|
||||
},
|
||||
"web": {
|
||||
**web,
|
||||
"sbom": artifacts["web-sbom.cdx.json"],
|
||||
"provenance": artifacts["web-provenance.json"],
|
||||
},
|
||||
},
|
||||
"dependencies": dict(sorted(dependencies.items())),
|
||||
"composition": {
|
||||
"sha256": hashlib.sha256(composition_encoded).hexdigest(),
|
||||
"module_ids": list(composition["python"]["module_ids"]),
|
||||
"packages": packages,
|
||||
},
|
||||
}
|
||||
args.descriptor.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.descriptor.write_bytes(_canonical_json(descriptor))
|
||||
return descriptor
|
||||
|
||||
|
||||
def _api_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
|
||||
components = []
|
||||
for package in composition["python"]["packages"]:
|
||||
components.append(
|
||||
{
|
||||
"type": "library",
|
||||
"name": package["package"],
|
||||
"version": package["version"],
|
||||
"hashes": [{"alg": "SHA-256", "content": package["sha256"]}],
|
||||
"purl": f"pkg:pypi/{package['package']}@{package['version']}",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-api:' + version)}",
|
||||
"version": 1,
|
||||
"metadata": {"component": {"type": "application", "name": "govoplan-api", "version": version}},
|
||||
"components": components,
|
||||
}
|
||||
|
||||
|
||||
def _web_sbom(composition: dict[str, Any], *, version: str) -> dict[str, Any]:
|
||||
return {
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"serialNumber": f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, 'govoplan-web:' + version)}",
|
||||
"version": 1,
|
||||
"metadata": {"component": {"type": "application", "name": "govoplan-web", "version": version}},
|
||||
"components": [
|
||||
{
|
||||
"type": "file",
|
||||
"name": "govoplan-web-dist",
|
||||
"version": version,
|
||||
"hashes": [
|
||||
{
|
||||
"alg": "SHA-256",
|
||||
"content": composition["web"]["sha256"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _provenance(
|
||||
*,
|
||||
subject: str,
|
||||
source_commit: str,
|
||||
composition: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
digest = subject.rsplit("@sha256:", 1)[1]
|
||||
return {
|
||||
"_type": "https://in-toto.io/Statement/v1",
|
||||
"subject": [{"name": subject.split("@", 1)[0], "digest": {"sha256": digest}}],
|
||||
"predicateType": "https://slsa.dev/provenance/v1",
|
||||
"predicate": {
|
||||
"buildDefinition": {
|
||||
"buildType": "https://govoplan.add-ideas.de/build/runtime-oci/v1",
|
||||
"externalParameters": {
|
||||
"source_commit": source_commit,
|
||||
"network_free_image_assembly": True,
|
||||
},
|
||||
"resolvedDependencies": [
|
||||
{
|
||||
"uri": "govoplan:runtime-composition",
|
||||
"digest": {
|
||||
"sha256": hashlib.sha256(_canonical_json(composition)).hexdigest()
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"runDetails": {
|
||||
"builder": {"id": "https://git.add-ideas.de/GovOPlaN/govoplan/actions"},
|
||||
"metadata": {"invocationId": source_commit},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _manifest_packages(composition: dict[str, Any]) -> list[dict[str, str]]:
|
||||
values = []
|
||||
for package in composition["python"]["packages"]:
|
||||
values.append(
|
||||
{
|
||||
"name": str(package["package"]),
|
||||
"version": str(package["version"]),
|
||||
"wheel_sha256": str(package["sha256"]),
|
||||
}
|
||||
)
|
||||
return sorted(values, key=lambda item: item["name"])
|
||||
|
||||
|
||||
def _image_metadata(value: dict[str, Any], label: str) -> dict[str, Any]:
|
||||
if set(value) != {"index", "platforms"}:
|
||||
raise ValueError(f"{label} image metadata has invalid fields")
|
||||
if not isinstance(value["index"], str) or DIGEST_IMAGE.fullmatch(value["index"]) is None:
|
||||
raise ValueError(f"{label} index is not digest-pinned")
|
||||
platforms = value["platforms"]
|
||||
if not isinstance(platforms, dict) or set(platforms) != {"linux/amd64", "linux/arm64"}:
|
||||
raise ValueError(f"{label} image does not cover amd64 and arm64")
|
||||
if any(not isinstance(item, str) or DIGEST_IMAGE.fullmatch(item) is None for item in platforms.values()):
|
||||
raise ValueError(f"{label} platform image is not digest-pinned")
|
||||
return {"index": value["index"], "platforms": dict(sorted(platforms.items()))}
|
||||
|
||||
|
||||
def _dependency(value: str) -> tuple[str, str]:
|
||||
if "=" not in value:
|
||||
raise ValueError("--dependency must use NAME=IMAGE@SHA256")
|
||||
name, reference = value.split("=", 1)
|
||||
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", name) is None:
|
||||
raise ValueError(f"invalid dependency name: {name!r}")
|
||||
if DIGEST_IMAGE.fullmatch(reference) is None:
|
||||
raise ValueError(f"dependency {name!r} is not digest-pinned")
|
||||
return name, reference
|
||||
|
||||
|
||||
def _json_object(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON root must be an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _https_url(value: str, label: str) -> str:
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
|
||||
raise ValueError(f"{label} must be an HTTPS URL without credentials")
|
||||
return value
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
finalize(args)
|
||||
except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Runtime release evidence written below {args.output_directory}")
|
||||
print(f"Unsigned descriptor written to {args.descriptor}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate and sign a GovOPlaN OCI runtime distribution manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(META_ROOT / "tools" / "deployment"))
|
||||
|
||||
from govoplan_deploy.distribution import ( # noqa: E402
|
||||
DistributionError,
|
||||
canonical_json,
|
||||
canonical_signed_payload,
|
||||
validate_manifest,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--descriptor", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--signing-key",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="KEY_ID=PRIVATE_PEM",
|
||||
required=True,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def sign_manifest(
|
||||
descriptor: dict[str, object],
|
||||
signing_keys: tuple[tuple[str, Path], ...],
|
||||
) -> dict[str, object]:
|
||||
payload = dict(descriptor)
|
||||
payload["signatures"] = [
|
||||
_signature(payload, key_id=key_id, path=path)
|
||||
for key_id, path in signing_keys
|
||||
]
|
||||
validate_manifest(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _signature(
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
key_id: str,
|
||||
path: Path,
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
key = serialization.load_pem_private_key(path.read_bytes(), password=None)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
raise DistributionError(f"cannot load signing key {key_id!r}") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise DistributionError(f"signing key {key_id!r} is not Ed25519")
|
||||
return {
|
||||
"key_id": key_id,
|
||||
"algorithm": "ed25519",
|
||||
"value": base64.b64encode(key.sign(canonical_signed_payload(payload))).decode(
|
||||
"ascii"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _parse_signing_key(value: str) -> tuple[str, Path]:
|
||||
if "=" not in value:
|
||||
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
|
||||
key_id, raw_path = value.split("=", 1)
|
||||
if not key_id or not raw_path:
|
||||
raise DistributionError("--signing-key must use KEY_ID=PRIVATE_PEM")
|
||||
return key_id, Path(raw_path).expanduser().resolve()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
descriptor = json.loads(args.descriptor.read_text(encoding="utf-8"))
|
||||
if not isinstance(descriptor, dict):
|
||||
raise DistributionError("descriptor root must be an object")
|
||||
if "signatures" in descriptor:
|
||||
raise DistributionError("descriptor must not contain signatures")
|
||||
payload = sign_manifest(
|
||||
descriptor,
|
||||
tuple(_parse_signing_key(value) for value in args.signing_key),
|
||||
)
|
||||
encoded = canonical_json(payload)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = args.output.with_suffix(args.output.suffix + ".tmp")
|
||||
temporary.write_bytes(encoded)
|
||||
temporary.chmod(0o644)
|
||||
temporary.replace(args.output)
|
||||
except (DistributionError, OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Runtime distribution manifest written to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assemble a deterministic, network-free GovOPlaN OCI build context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
from email.parser import BytesParser
|
||||
from email.policy import compat32
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
|
||||
NORMALIZED_PACKAGE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
|
||||
MAX_WHEELS = 512
|
||||
MAX_WHEEL_BYTES = 512 * 1024 * 1024
|
||||
MAX_WEB_FILES = 100_000
|
||||
MAX_WEB_BYTES = 2 * 1024 * 1024 * 1024
|
||||
|
||||
|
||||
class ContextError(ValueError):
|
||||
"""The release inputs cannot form an immutable runtime context."""
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--wheelhouse", type=Path, required=True)
|
||||
parser.add_argument("--web-dist", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--required-module", action="append", default=[])
|
||||
parser.add_argument(
|
||||
"--source-date-epoch",
|
||||
type=int,
|
||||
default=int(os.environ.get("SOURCE_DATE_EPOCH", "0") or 0),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def prepare_context(
|
||||
*,
|
||||
wheelhouse: Path,
|
||||
web_dist: Path,
|
||||
output: Path,
|
||||
required_modules: tuple[str, ...] = (),
|
||||
source_date_epoch: int = 0,
|
||||
) -> dict[str, object]:
|
||||
source_wheels = _regular_files(wheelhouse, suffix=".whl", maximum=MAX_WHEELS)
|
||||
if not source_wheels:
|
||||
raise ContextError("wheelhouse contains no wheel artifacts")
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ContextError("output directory must be absent or empty")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
target_wheels = output / "wheelhouse"
|
||||
target_web = output / "web-dist"
|
||||
target_wheels.mkdir(mode=0o755)
|
||||
|
||||
packages: list[dict[str, object]] = []
|
||||
govoplan_wheel_rows: list[dict[str, object]] = []
|
||||
roots: list[tuple[str, str]] = []
|
||||
module_ids: set[str] = set()
|
||||
seen_packages: set[str] = set()
|
||||
wheel_rows: list[dict[str, object]] = []
|
||||
for source in source_wheels:
|
||||
if source.stat().st_size > MAX_WHEEL_BYTES:
|
||||
raise ContextError(f"wheel exceeds size limit: {source.name}")
|
||||
identity = inspect_wheel(source)
|
||||
package_name = str(identity["package"])
|
||||
if package_name in seen_packages:
|
||||
raise ContextError(f"duplicate wheel distribution: {package_name}")
|
||||
seen_packages.add(package_name)
|
||||
target = target_wheels / source.name
|
||||
_copy_regular(source, target, source_date_epoch=source_date_epoch)
|
||||
row = {
|
||||
"filename": source.name,
|
||||
"sha256": _sha256_file(target),
|
||||
"size": target.stat().st_size,
|
||||
}
|
||||
wheel_rows.append(row)
|
||||
if package_name.startswith("govoplan-"):
|
||||
package_modules = tuple(str(item) for item in identity["module_ids"])
|
||||
module_ids.update(package_modules)
|
||||
package = {
|
||||
**row,
|
||||
"package": package_name,
|
||||
"version": identity["version"],
|
||||
"module_ids": list(package_modules),
|
||||
}
|
||||
packages.append(package)
|
||||
govoplan_wheel_rows.append(row)
|
||||
root = (
|
||||
f"{package_name}[server]"
|
||||
if package_name == "govoplan-core"
|
||||
else package_name
|
||||
)
|
||||
roots.append((root, str(identity["version"])))
|
||||
|
||||
if not any(package["package"] == "govoplan-core" for package in packages):
|
||||
raise ContextError("wheelhouse does not contain govoplan-core")
|
||||
missing_modules = sorted(set(required_modules) - module_ids)
|
||||
if missing_modules:
|
||||
raise ContextError(
|
||||
"runtime composition is missing required modules: "
|
||||
+ ", ".join(missing_modules)
|
||||
)
|
||||
requirements = "".join(
|
||||
f"{package}=={version}\n" for package, version in sorted(roots)
|
||||
)
|
||||
_write_regular(
|
||||
output / "requirements-runtime.txt",
|
||||
requirements.encode("utf-8"),
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
|
||||
web_rows = _copy_web_tree(
|
||||
web_dist,
|
||||
target_web,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
composition: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"python": {
|
||||
"packages": sorted(packages, key=lambda item: str(item["package"])),
|
||||
"module_ids": sorted(module_ids),
|
||||
"wheelhouse_sha256": _rows_digest(govoplan_wheel_rows),
|
||||
"wheel_count": len(govoplan_wheel_rows),
|
||||
},
|
||||
"web": {
|
||||
"sha256": _rows_digest(web_rows),
|
||||
"file_count": len(web_rows),
|
||||
},
|
||||
}
|
||||
encoded = (json.dumps(composition, indent=2, sort_keys=True) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
_write_regular(
|
||||
output / "composition.json",
|
||||
encoded,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
_write_regular(
|
||||
target_web / ".well-known" / "govoplan-composition.json",
|
||||
encoded,
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
_copy_regular(
|
||||
Path(__file__).resolve().parent / "runtime" / "nginx.conf",
|
||||
output / "nginx.conf",
|
||||
source_date_epoch=source_date_epoch,
|
||||
)
|
||||
return composition
|
||||
|
||||
|
||||
def inspect_wheel(path: Path) -> dict[str, object]:
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as exc:
|
||||
raise ContextError(f"wheel cannot be opened safely: {path.name}") from exc
|
||||
try:
|
||||
opened = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(opened.st_mode):
|
||||
raise ContextError(f"wheel is not a regular file: {path.name}")
|
||||
with os.fdopen(os.dup(descriptor), "rb") as handle:
|
||||
with zipfile.ZipFile(handle) as archive:
|
||||
metadata = [
|
||||
member
|
||||
for member in archive.infolist()
|
||||
if PurePosixPath(member.filename).name == "METADATA"
|
||||
and PurePosixPath(member.filename).parent.name.endswith(
|
||||
".dist-info"
|
||||
)
|
||||
]
|
||||
if len(metadata) != 1:
|
||||
raise ContextError(
|
||||
f"wheel must contain one METADATA file: {path.name}"
|
||||
)
|
||||
parsed = BytesParser(policy=compat32).parsebytes(
|
||||
archive.read(metadata[0])
|
||||
)
|
||||
package = _normalize_package(str(parsed.get("Name") or ""))
|
||||
version = str(parsed.get("Version") or "").strip()
|
||||
if VERSION.fullmatch(version) is None:
|
||||
raise ContextError(f"wheel has invalid version: {path.name}")
|
||||
entry_points_name = (
|
||||
PurePosixPath(metadata[0].filename).parent / "entry_points.txt"
|
||||
).as_posix()
|
||||
module_ids: tuple[str, ...] = ()
|
||||
if entry_points_name in archive.namelist():
|
||||
module_ids = _module_entry_points(
|
||||
archive.read(entry_points_name).decode("utf-8")
|
||||
)
|
||||
final = os.fstat(descriptor)
|
||||
if (opened.st_dev, opened.st_ino, opened.st_size, opened.st_mtime_ns) != (
|
||||
final.st_dev,
|
||||
final.st_ino,
|
||||
final.st_size,
|
||||
final.st_mtime_ns,
|
||||
):
|
||||
raise ContextError(f"wheel changed while inspected: {path.name}")
|
||||
except (OSError, RuntimeError, zipfile.BadZipFile) as exc:
|
||||
if isinstance(exc, ContextError):
|
||||
raise
|
||||
raise ContextError(f"wheel is not a readable archive: {path.name}") from exc
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
return {"package": package, "version": version, "module_ids": module_ids}
|
||||
|
||||
|
||||
def _module_entry_points(value: str) -> tuple[str, ...]:
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=True)
|
||||
try:
|
||||
parser.read_string(value)
|
||||
except configparser.Error as exc:
|
||||
raise ContextError("wheel entry_points.txt is malformed") from exc
|
||||
if not parser.has_section("govoplan.modules"):
|
||||
return ()
|
||||
values = tuple(sorted(parser.options("govoplan.modules")))
|
||||
for item in values:
|
||||
if re.fullmatch(r"[a-z][a-z0-9_]{1,63}", item) is None:
|
||||
raise ContextError(f"wheel has invalid module entry point: {item!r}")
|
||||
return values
|
||||
|
||||
|
||||
def _normalize_package(value: str) -> str:
|
||||
normalized = re.sub(r"[-_.]+", "-", value.strip().lower())
|
||||
if NORMALIZED_PACKAGE.fullmatch(normalized) is None:
|
||||
raise ContextError("wheel has invalid package name")
|
||||
return normalized
|
||||
|
||||
|
||||
def _regular_files(root: Path, *, suffix: str, maximum: int) -> list[Path]:
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise ContextError(f"input directory is not a real directory: {root}")
|
||||
values = sorted(path for path in root.iterdir() if path.name.endswith(suffix))
|
||||
if len(values) > maximum:
|
||||
raise ContextError(f"input directory exceeds {maximum} files")
|
||||
for path in values:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ContextError(f"input artifact is not a regular file: {path.name}")
|
||||
return values
|
||||
|
||||
|
||||
def _copy_web_tree(
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
source_date_epoch: int,
|
||||
) -> list[dict[str, object]]:
|
||||
if source.is_symlink() or not source.is_dir():
|
||||
raise ContextError("WebUI dist must be a real directory")
|
||||
rows: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for path in sorted(source.rglob("*")):
|
||||
relative = path.relative_to(source)
|
||||
if path.is_symlink():
|
||||
raise ContextError(f"WebUI dist contains a symlink: {relative}")
|
||||
if path.is_dir():
|
||||
continue
|
||||
if not path.is_file():
|
||||
raise ContextError(f"WebUI dist contains a special file: {relative}")
|
||||
if len(rows) >= MAX_WEB_FILES:
|
||||
raise ContextError("WebUI dist exceeds its file-count limit")
|
||||
total += path.stat().st_size
|
||||
if total > MAX_WEB_BYTES:
|
||||
raise ContextError("WebUI dist exceeds its total-size limit")
|
||||
destination = target / relative
|
||||
_copy_regular(path, destination, source_date_epoch=source_date_epoch)
|
||||
rows.append(
|
||||
{
|
||||
"path": relative.as_posix(),
|
||||
"sha256": _sha256_file(destination),
|
||||
"size": destination.stat().st_size,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise ContextError("WebUI dist contains no files")
|
||||
return rows
|
||||
|
||||
|
||||
def _copy_regular(source: Path, target: Path, *, source_date_epoch: int) -> None:
|
||||
target.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with source.open("rb") as source_handle, target.open("xb") as target_handle:
|
||||
shutil.copyfileobj(source_handle, target_handle)
|
||||
target_handle.flush()
|
||||
os.fsync(target_handle.fileno())
|
||||
target.chmod(0o644)
|
||||
os.utime(target, (source_date_epoch, source_date_epoch))
|
||||
|
||||
|
||||
def _write_regular(path: Path, value: bytes, *, source_date_epoch: int) -> None:
|
||||
path.parent.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
path.write_bytes(value)
|
||||
path.chmod(0o644)
|
||||
os.utime(path, (source_date_epoch, source_date_epoch))
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _rows_digest(rows: list[dict[str, object]]) -> str:
|
||||
encoded = json.dumps(rows, separators=(",", ":"), sort_keys=True).encode(
|
||||
"utf-8"
|
||||
)
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
composition = prepare_context(
|
||||
wheelhouse=args.wheelhouse.expanduser().resolve(),
|
||||
web_dist=args.web_dist.expanduser().resolve(),
|
||||
output=args.output.expanduser().resolve(),
|
||||
required_modules=tuple(args.required_module),
|
||||
source_date_epoch=args.source_date_epoch,
|
||||
)
|
||||
except (ContextError, OSError) as exc:
|
||||
print(f"error: {exc}", file=os.sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(composition, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish immutable GovOPlaN runtime evidence as Gitea release assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sys
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
MAX_ASSET_BYTES = 256 * 1024 * 1024
|
||||
|
||||
|
||||
class PublishError(RuntimeError):
|
||||
"""A release asset cannot be published immutably."""
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", default="https://git.add-ideas.de")
|
||||
parser.add_argument("--owner", default="GovOPlaN")
|
||||
parser.add_argument("--repo", default="govoplan")
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument("--title", required=True)
|
||||
parser.add_argument("--body", default="Signed GovOPlaN runtime distribution.")
|
||||
parser.add_argument("--asset", type=Path, action="append", default=[], required=True)
|
||||
parser.add_argument("--token-env", default="GITEA_RELEASE_TOKEN")
|
||||
return parser
|
||||
|
||||
|
||||
class GiteaReleasePublisher:
|
||||
def __init__(self, *, base_url: str, owner: str, repo: str, token: str) -> None:
|
||||
if not base_url.startswith("https://"):
|
||||
raise PublishError("Gitea release publication requires HTTPS")
|
||||
if not token:
|
||||
raise PublishError("Gitea release token is empty")
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.token = token
|
||||
|
||||
def release(self, *, tag: str, title: str, body: str) -> dict[str, Any]:
|
||||
path = self._repo_path(f"/releases/tags/{quote(tag, safe='')}")
|
||||
try:
|
||||
return self._json("GET", path)
|
||||
except HTTPError as exc:
|
||||
if exc.code != 404:
|
||||
raise
|
||||
return self._json(
|
||||
"POST",
|
||||
self._repo_path("/releases"),
|
||||
payload={
|
||||
"tag_name": tag,
|
||||
"name": title,
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
},
|
||||
expected=201,
|
||||
)
|
||||
|
||||
def upload_assets(self, release: dict[str, Any], assets: tuple[Path, ...]) -> None:
|
||||
release_id = release.get("id")
|
||||
if isinstance(release_id, bool) or not isinstance(release_id, int):
|
||||
raise PublishError("Gitea release response has no numeric id")
|
||||
existing = self._json(
|
||||
"GET",
|
||||
self._repo_path(f"/releases/{release_id}/assets"),
|
||||
)
|
||||
if not isinstance(existing, list):
|
||||
raise PublishError("Gitea release assets response is invalid")
|
||||
existing_by_name = {
|
||||
str(item.get("name")): item for item in existing if isinstance(item, dict)
|
||||
}
|
||||
for asset in assets:
|
||||
path = asset.expanduser().resolve()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise PublishError(f"release asset is not a regular file: {path}")
|
||||
size = path.stat().st_size
|
||||
if size > MAX_ASSET_BYTES:
|
||||
raise PublishError(f"release asset exceeds size limit: {path.name}")
|
||||
prior = existing_by_name.get(path.name)
|
||||
if prior is not None:
|
||||
self._require_same_existing_asset(prior, path)
|
||||
continue
|
||||
self._upload(release_id, path)
|
||||
|
||||
def _require_same_existing_asset(self, prior: dict[str, Any], path: Path) -> None:
|
||||
url = prior.get("browser_download_url")
|
||||
size = prior.get("size")
|
||||
if not isinstance(url, str) or not url.startswith("https://") or size != path.stat().st_size:
|
||||
raise PublishError(f"release asset already exists with another identity: {path.name}")
|
||||
request = Request(url, headers=self._headers())
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
with urlopen(request, timeout=30) as response: # noqa: S310
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_ASSET_BYTES:
|
||||
raise PublishError("existing release asset exceeds size limit")
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != _sha256_file(path):
|
||||
raise PublishError(f"release asset already exists with another digest: {path.name}")
|
||||
|
||||
def _upload(self, release_id: int, path: Path) -> None:
|
||||
boundary = "govoplan-" + secrets.token_hex(16)
|
||||
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||
prefix = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="attachment"; filename="{path.name}"\r\n'
|
||||
f"Content-Type: {content_type}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
suffix = f"\r\n--{boundary}--\r\n".encode("ascii")
|
||||
data = prefix + path.read_bytes() + suffix
|
||||
query = urlencode({"name": path.name})
|
||||
request = Request(
|
||||
self._repo_path(f"/releases/{release_id}/assets") + "?" + query,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
**self._headers(),
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=120) as response: # noqa: S310
|
||||
if response.status != 201:
|
||||
raise PublishError(
|
||||
f"Gitea asset upload returned HTTP {response.status}"
|
||||
)
|
||||
except HTTPError as exc:
|
||||
raise PublishError(f"Gitea asset upload failed with HTTP {exc.code}") from exc
|
||||
|
||||
def _json(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
payload: dict[str, Any] | None = None,
|
||||
expected: int = 200,
|
||||
) -> Any:
|
||||
data = None
|
||||
headers = self._headers()
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(url, data=data, method=method, headers=headers)
|
||||
with urlopen(request, timeout=30) as response: # noqa: S310
|
||||
if response.status != expected:
|
||||
raise PublishError(f"Gitea API returned HTTP {response.status}")
|
||||
return json.load(response)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"token {self.token}", "Accept": "application/json"}
|
||||
|
||||
def _repo_path(self, suffix: str) -> str:
|
||||
return (
|
||||
f"{self.base_url}/api/v1/repos/{quote(self.owner, safe='')}/"
|
||||
f"{quote(self.repo, safe='')}{suffix}"
|
||||
)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
publisher = GiteaReleasePublisher(
|
||||
base_url=args.base_url,
|
||||
owner=args.owner,
|
||||
repo=args.repo,
|
||||
token=os.environ.get(args.token_env, ""),
|
||||
)
|
||||
release = publisher.release(tag=args.tag, title=args.title, body=args.body)
|
||||
publisher.upload_assets(release, tuple(args.asset))
|
||||
except (HTTPError, OSError, PublishError, ValueError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Published {len(args.asset)} immutable asset(s) to {args.owner}/{args.repo} {args.tag}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve amd64/arm64 child digests from an OCI image index."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def resolve_platforms(
|
||||
payload: object,
|
||||
*,
|
||||
repository: str,
|
||||
index_digest: str,
|
||||
) -> dict[str, object]:
|
||||
if not repository or "@" in repository or any(value.isspace() for value in repository):
|
||||
raise ValueError("repository must be an unpinned OCI repository name")
|
||||
if DIGEST.fullmatch(index_digest) is None:
|
||||
raise ValueError("index digest must be sha256:<hex>")
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get("manifests"), list):
|
||||
raise ValueError("OCI index must contain manifests")
|
||||
platforms: dict[str, str] = {}
|
||||
for item in payload["manifests"]:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("platform"), dict):
|
||||
continue
|
||||
platform = item["platform"]
|
||||
key = f"{platform.get('os')}/{platform.get('architecture')}"
|
||||
if key not in {"linux/amd64", "linux/arm64"}:
|
||||
continue
|
||||
digest = item.get("digest")
|
||||
if not isinstance(digest, str) or DIGEST.fullmatch(digest) is None:
|
||||
raise ValueError(f"OCI index has an invalid {key} digest")
|
||||
if key in platforms:
|
||||
raise ValueError(f"OCI index has duplicate {key} manifests")
|
||||
platforms[key] = f"{repository}@{digest}"
|
||||
if set(platforms) != {"linux/amd64", "linux/arm64"}:
|
||||
raise ValueError("OCI index must contain linux/amd64 and linux/arm64")
|
||||
return {
|
||||
"index": f"{repository}@{index_digest}",
|
||||
"platforms": dict(sorted(platforms.items())),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--index-digest", required=True)
|
||||
parser.add_argument("--index", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
payload = json.loads(args.index.read_text(encoding="utf-8"))
|
||||
result = resolve_platforms(
|
||||
payload,
|
||||
repository=args.repository,
|
||||
index_digest=args.index_digest,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(result, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG PYTHON_IMAGE
|
||||
FROM ${PYTHON_IMAGE}
|
||||
|
||||
ARG GOVOPLAN_RELEASE_VERSION
|
||||
ARG GOVOPLAN_COMPOSITION_SHA256
|
||||
LABEL org.opencontainers.image.title="GovOPlaN API runtime" \
|
||||
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
|
||||
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONPATH=/opt/govoplan/runtime \
|
||||
PATH=/opt/govoplan/runtime/bin:${PATH} \
|
||||
HOME=/var/lib/govoplan
|
||||
|
||||
COPY wheelhouse/ /opt/govoplan/wheels/
|
||||
COPY requirements-runtime.txt composition.json /opt/govoplan/
|
||||
RUN python -m pip install --disable-pip-version-check --no-cache-dir \
|
||||
--no-index --find-links=/opt/govoplan/wheels \
|
||||
--target=/opt/govoplan/runtime \
|
||||
--requirement=/opt/govoplan/requirements-runtime.txt \
|
||||
&& rm -rf /opt/govoplan/wheels \
|
||||
&& groupadd --gid 10001 govoplan \
|
||||
&& useradd --uid 10001 --gid 10001 --home-dir /var/lib/govoplan \
|
||||
--create-home --shell /usr/sbin/nologin govoplan \
|
||||
&& mkdir -p /var/lib/govoplan /tmp/govoplan \
|
||||
&& chown -R 10001:10001 /var/lib/govoplan /tmp/govoplan \
|
||||
&& chmod -R a-w /opt/govoplan
|
||||
|
||||
USER 10001:10001
|
||||
WORKDIR /var/lib/govoplan
|
||||
EXPOSE 8000
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)"]
|
||||
CMD ["python", "-m", "uvicorn", "govoplan_core.server.app:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG NGINX_IMAGE
|
||||
FROM ${NGINX_IMAGE}
|
||||
|
||||
ARG GOVOPLAN_RELEASE_VERSION
|
||||
ARG GOVOPLAN_COMPOSITION_SHA256
|
||||
LABEL org.opencontainers.image.title="GovOPlaN WebUI runtime" \
|
||||
org.opencontainers.image.version="${GOVOPLAN_RELEASE_VERSION}" \
|
||||
org.govoplan.composition.sha256="${GOVOPLAN_COMPOSITION_SHA256}"
|
||||
|
||||
USER 0
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/*
|
||||
COPY web-dist/ /usr/share/nginx/html/
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
RUN chown -R 101:101 /usr/share/nginx/html \
|
||||
&& chmod -R a-w /usr/share/nginx/html /etc/nginx/nginx.conf
|
||||
|
||||
USER 101:101
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT []
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,41 @@
|
||||
pid /tmp/nginx.pid;
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location = /health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://load-balancer:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user