Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/159-domain-routes-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- A Domain can declare `spec.routes`, path routes that each either proxy to an `upstream` (`address`, `port`) or serve a component's release files (`static`: `component`, `directory`, optional `spaFallback`). `cloudfall config validate` refuses a Domain with both `proxy.upstream` and `routes` (`domain_route_source_invalid`), a path that doesn't start with `/` or uses characters outside letters, digits and `._~/-`, or a static path without a trailing `/` (`domain_route_path_invalid`), a path declared twice (`domain_route_path_duplicate`), a path under `/.well-known/acme-challenge` (`domain_route_path_reserved`), a missing static component (`resource_reference_missing`) or one not deployed on the Domain's `proxy.server` (`domain_route_component_not_on_proxy`), and a `directory` or `spaFallback` that is absolute, climbs out with `..`, or, for `directory`, names the release root. Validation only: the domains playbook doesn't render routes yet and still refuses a Domain without `proxy.upstream` before any change. Domains with only `proxy.upstream` validate and render as before (#159)
116 changes: 92 additions & 24 deletions config/schemas/v1/domain.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,90 @@
"$id": "https://cloudfall.internal/schemas/v1/domain.schema.json",
"title": "Cloudfall public domain route",
"x-cloudfall-kind": "Domain",
"$defs": {
"upstream": {
"type": "object",
"properties": {
"address": {
"type": "string",
"anyOf": [
{
"format": "ipv4"
},
{
"format": "ipv6"
}
]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": [
"address",
"port"
],
"additionalProperties": false
},
"releasePath": {
"type": "string",
"pattern": "^(?!/)(?!(?:.*/)?\\.\\.(?:/|$))[A-Za-z0-9._/-]+$",
"not": {
"pattern": "[\\u0000-\\u001f\\u007f]"
}
},
"route": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"upstream": {
"$ref": "#/$defs/upstream"
},
"static": {
"type": "object",
"properties": {
"component": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"directory": {
"$ref": "#/$defs/releasePath",
"not": {
"pattern": "^\\.(?:/+\\.)*/*$"
}
},
"spaFallback": {
"$ref": "#/$defs/releasePath"
}
},
"required": [
"component",
"directory"
],
"additionalProperties": false
}
},
"required": [
"path"
],
"oneOf": [
{
"required": [
"upstream"
]
},
{
"required": [
"static"
]
}
],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"apiVersion": {
Expand Down Expand Up @@ -42,30 +126,7 @@
"$ref": "common.schema.json#/$defs/serviceName"
},
"upstream": {
"type": "object",
"properties": {
"address": {
"type": "string",
"anyOf": [
{
"format": "ipv4"
},
{
"format": "ipv6"
}
]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": [
"address",
"port"
],
"additionalProperties": false
"$ref": "#/$defs/upstream"
}
},
"required": [
Expand All @@ -75,6 +136,13 @@
],
"additionalProperties": false
},
"routes": {
"type": "array",
"items": {
"$ref": "#/$defs/route"
},
"minItems": 1
},
"origin": {
"type": "object",
"properties": {
Expand Down
70 changes: 70 additions & 0 deletions sdk/src/cloudfall/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,76 @@ def from_boundary(cls, value: object) -> RelativeDirectory:
return cls(_required_string(value, "relative directory"))


_ROUTE_PATH_PATTERN = re.compile(r"/[A-Za-z0-9._~/-]*")
_ACME_CHALLENGE_PATH = "/.well-known/acme-challenge"


@dataclass(frozen=True, slots=True)
class DomainRoutePath:
"""URL path prefix of a Domain route, safe to render as an nginx location."""

value: str

def __post_init__(self) -> None:
"""Accept only ``/`` then letters, digits and ``._~/-``."""
if not _ROUTE_PATH_PATTERN.fullmatch(self.value):
message = (
f"invalid route path: {self.value!r} must start with '/' and use "
"only letters, digits and '._~/-'"
)
raise ValueError(message)

@property
def is_directory(self) -> bool:
"""Return whether the path ends with ``/``, as a static route needs."""
return self.value.endswith("/")

@property
def is_reserved(self) -> bool:
"""Return whether certificate issuance owns the path."""
return self.value == _ACME_CHALLENGE_PATH or self.value.startswith(
f"{_ACME_CHALLENGE_PATH}/"
)

@classmethod
def from_boundary(cls, value: object) -> DomainRoutePath:
"""Coerce a boundary value into a route path."""
return cls(_required_string(value, "route path"))


_RELEASE_PATH_PATTERN = re.compile(r"[A-Za-z0-9._/-]+")


@dataclass(frozen=True, slots=True)
class ReleaseRelativePath:
"""Path inside a component release that nginx may serve files from."""

value: str

def __post_init__(self) -> None:
"""Reject absolute paths, ``..`` segments and characters outside ``._-/``."""
if (
not _RELEASE_PATH_PATTERN.fullmatch(self.value)
or self.value.startswith("/")
or ".." in self.value.split("/")
):
message = (
f"invalid release path: {self.value!r} must be relative, use only "
"letters, digits and '._-/', and have no '..' segment"
)
raise ValueError(message)

@property
def is_release_root(self) -> bool:
"""Return whether the path names the release root itself, such as ``.``."""
return PurePosixPath(self.value) == PurePosixPath(".")

@classmethod
def from_boundary(cls, value: object) -> ReleaseRelativePath:
"""Coerce a boundary value into a release path."""
return cls(_required_string(value, "release path"))


_RELEASE_TIMEOUT_MINIMUM = 1
_RELEASE_TIMEOUT_MAXIMUM = 1800
_RELEASE_TIMEOUT_DEFAULT = 300
Expand Down
99 changes: 98 additions & 1 deletion sdk/src/cloudfall/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
DayTime,
DeploymentApproval,
DnsMode,
DomainRoutePath,
EmailAddress,
FileMode,
FilesystemName,
Expand Down Expand Up @@ -54,6 +55,7 @@
RelativeDirectory,
RelativeRepositoryPath,
ReleaseCommandTimeout,
ReleaseRelativePath,
RepositoryUrl,
RequiredServiceState,
RequiredServiceStatus,
Expand Down Expand Up @@ -670,6 +672,57 @@ class DomainHealthCheck:
timeout_seconds: PositiveCount


@dataclass(frozen=True, slots=True)
class DomainProxyRoute:
"""A path a Domain proxies to an upstream, keeping the full request path."""

path: DomainRoutePath
upstream_address: IpAddress
upstream_port: TcpPort

def as_dict(self) -> dict[str, object]:
"""Serialize the route in its config shape."""
return {
"path": self.path.value,
"upstream": {
"address": self.upstream_address.value,
"port": self.upstream_port.value,
},
}


@dataclass(frozen=True, slots=True)
class DomainStaticRoute:
"""A path a Domain serves as files from a component's current release."""

path: DomainRoutePath
component_id: ResourceId
directory: ReleaseRelativePath
spa_fallback: ReleaseRelativePath | None = None

def __post_init__(self) -> None:
"""Keep the alias-safe trailing slash and the release root out."""
if not self.path.is_directory:
message = f"static route path must end with '/': {self.path.value!r}"
raise ValueError(message)
if self.directory.is_release_root:
message = "static route directory must not be the release root"
raise ValueError(message)

def as_dict(self) -> dict[str, object]:
"""Serialize the route in its config shape."""
static: dict[str, object] = {
"component": self.component_id.value,
"directory": self.directory.value,
}
if self.spa_fallback is not None:
static["spaFallback"] = self.spa_fallback.value
return {"path": self.path.value, "static": static}


type DomainRoute = DomainProxyRoute | DomainStaticRoute


@dataclass(frozen=True, slots=True)
class DomainInventory:
"""Typed desired state for one public domain route."""
Expand All @@ -682,6 +735,13 @@ class DomainInventory:
edge: DomainEdge
tls_mode: TlsMode
health_check: DomainHealthCheck
routes: tuple[DomainRoute, ...] = ()

def __post_init__(self) -> None:
"""Hold at most one route source: ``proxy.upstream`` or ``routes``."""
if self.routes and self.proxy.upstream_address is not None:
message = "a Domain declares proxy.upstream or routes, not both"
raise ValueError(message)

def as_dict(self) -> dict[str, object]:
"""Serialize public routing intent without secret material."""
Expand All @@ -698,7 +758,7 @@ def as_dict(self) -> dict[str, object]:
"address": self.proxy.upstream_address.value,
"port": self.proxy.upstream_port.value,
}
return {
result: dict[str, object] = {
"id": self.resource_id.value,
"primaryName": self.primary_name.value,
"aliases": [alias.value for alias in self.aliases],
Expand All @@ -725,6 +785,9 @@ def as_dict(self) -> dict[str, object]:
"timeoutSeconds": self.health_check.timeout_seconds.value,
},
}
if self.routes:
result["routes"] = [route.as_dict() for route in self.routes]
return result


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -1815,6 +1878,40 @@ def _domain_inventory(document: ResourceDocument) -> DomainInventory:
),
timeout_seconds=PositiveCount.from_boundary(health.get("timeoutSeconds")),
),
routes=(
()
if spec.get("routes") is None
else tuple(
_domain_route(item)
for item in _mapping_list(spec.get("routes"), "domain routes")
)
),
)


def _domain_route(route: Mapping[str, object]) -> DomainRoute:
path = DomainRoutePath.from_boundary(route.get("path"))
if route.get("static") is None:
upstream = _mapping(route, "upstream")
return DomainProxyRoute(
path=path,
upstream_address=IpAddress.from_boundary(upstream.get("address")),
upstream_port=TcpPort.from_boundary(upstream.get("port")),
)
if route.get("upstream") is not None:
message = f"route {path.value!r} declares both upstream and static"
raise ValueError(message)
static = _mapping(route, "static")
raw_spa_fallback = static.get("spaFallback")
return DomainStaticRoute(
path=path,
component_id=ResourceId.from_boundary(static.get("component")),
directory=ReleaseRelativePath.from_boundary(static.get("directory")),
spa_fallback=(
None
if raw_spa_fallback is None
else ReleaseRelativePath.from_boundary(raw_spa_fallback)
),
)


Expand Down
Loading
Loading