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/113-component-build-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- A Component can declare `spec.buildTools` (tool name to minimum version), `spec.build` (ordered steps, each a `workingDirectory` inside the packaged root, an argv `command` and an optional `timeoutSeconds` from 1 to 3600, default 900) and `spec.artifact.include` (relative paths the build produces); `cloudfall config validate` refuses a step whose `command[0]` is not in `buildTools` (`component_build_tool_undeclared`), an absolute or `..` path, and `buildTools` or `artifact` without `build`. Validation only: `artifact build` does not run the steps yet. Components without `build` validate as before (#113)
85 changes: 85 additions & 0 deletions config/schemas/v1/component.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,70 @@
"$ref": "common.schema.json#/$defs/secretReference"
},
"uniqueItems": true
},
"buildTools": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/controlCharacterFree"
},
"patternProperties": {
"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$": {
"type": "string",
"pattern": "^[0-9]{1,9}(?:\\.[0-9]{1,9}){0,7}$",
"$ref": "#/$defs/controlCharacterFree"
}
},
"additionalProperties": false
},
"build": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"workingDirectory": {
"$ref": "#/$defs/relativePath"
},
"command": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"pattern": "^[^\\u0000]+$"
},
"minItems": 1
},
"timeoutSeconds": {
"type": "integer",
"minimum": 1,
"maximum": 3600,
"default": 900
}
},
"required": [
"workingDirectory",
"command"
],
"additionalProperties": false
}
},
"artifact": {
"type": "object",
"properties": {
"include": {
"type": "array",
"items": {
"$ref": "#/$defs/relativePath"
},
"minItems": 1,
"uniqueItems": true
}
},
"required": [
"include"
],
"additionalProperties": false
}
},
"required": [
Expand All @@ -221,9 +285,30 @@
"service",
"healthCheck"
],
"dependentRequired": {
"buildTools": [
"build"
],
"artifact": [
"build"
]
},
"additionalProperties": false
}
},
"$defs": {
"relativePath": {
"type": "string",
"minLength": 1,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$",
"$ref": "#/$defs/controlCharacterFree"
},
"controlCharacterFree": {
"not": {
"pattern": "[\\u0000-\\u001f\\u007f]"
}
}
},
"required": [
"apiVersion",
"kind",
Expand Down
123 changes: 123 additions & 0 deletions sdk/src/cloudfall/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@
_PACKAGE_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9+.-]{0,199}$")
_SERVICE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.service$")
_SYSTEMD_UNIT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9@_.:-]+\.(?:service|timer)$")
_BUILD_TOOL_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,63}")
_BUILD_TOOL_VERSION_PATTERN = re.compile(r"[0-9]{1,9}(?:\.[0-9]{1,9}){0,7}")
_CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
BUILD_STEP_DEFAULT_TIMEOUT_SECONDS = 900
BUILD_STEP_MAXIMUM_TIMEOUT_SECONDS = 3600
_FILE_MODE_PATTERN = re.compile(r"^0[0-7]{3}$")
_SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
_MAX_HOSTNAME_LENGTH = 253
Expand Down Expand Up @@ -675,6 +680,124 @@ def from_boundary(cls, value: object) -> ServiceCommand:
return cls(tuple(value))


@dataclass(frozen=True, slots=True)
class RelativeRepositoryPath:
"""Path inside a component's packaged root: relative, never climbing out."""

value: str

def __post_init__(self) -> None:
"""Reject absolute paths, ``..`` segments, and control characters."""
if (
not self.value
or _CONTROL_CHARACTER_PATTERN.search(self.value)
or self.value.startswith("/")
or ".." in self.value.split("/")
):
message = f"invalid relative repository path: {self.value!r}"
raise ValueError(message)

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


@dataclass(frozen=True, slots=True)
class BuildToolName:
"""Executable a component build step runs, looked up on ``PATH``."""

value: str

def __post_init__(self) -> None:
"""Accept a bare executable name only, never a path."""
if not _BUILD_TOOL_NAME_PATTERN.fullmatch(self.value):
message = f"invalid build tool name: {self.value!r}"
raise ValueError(message)

@classmethod
def from_boundary(cls, value: object) -> BuildToolName:
"""Coerce a boundary value into a build tool name."""
return cls(_required_string(value, "build tool name"))


@dataclass(frozen=True, slots=True)
class BuildToolVersion:
"""Minimum dotted numeric version of a build tool."""

value: str

def __post_init__(self) -> None:
"""Accept one to eight dotted numeric parts."""
if not _BUILD_TOOL_VERSION_PATTERN.fullmatch(self.value):
message = f"invalid build tool version: {self.value!r}"
raise ValueError(message)

@classmethod
def from_boundary(cls, value: object) -> BuildToolVersion:
"""Coerce a boundary value into a build tool version."""
return cls(_required_string(value, "build tool version"))


@dataclass(frozen=True, slots=True)
class BuildCommand:
"""Argv list of one component build step, never a shell string."""

value: tuple[str, ...]

def __post_init__(self) -> None:
"""Reject empty argv lists, empty arguments, and NUL bytes."""
if not self.value:
message = "build command must not be empty"
raise ValueError(message)
for argument in self.value:
if not argument or "\x00" in argument:
message = f"invalid build command argument: {argument!r}"
raise ValueError(message)

@property
def executable(self) -> BuildToolName:
"""The tool the step runs: ``command[0]``."""
return BuildToolName(self.value[0])

@classmethod
def from_boundary(cls, value: object) -> BuildCommand:
"""Coerce a boundary argv list into a build command."""
if not isinstance(value, list) or not all(
isinstance(item, str) for item in value
):
message = "build command must be a list of strings"
raise TypeError(message)
return cls(tuple(value))


@dataclass(frozen=True, slots=True)
class BuildTimeoutSeconds:
"""Time limit of one build step: 1 to 3600 seconds, 900 by default."""

value: int = BUILD_STEP_DEFAULT_TIMEOUT_SECONDS

def __post_init__(self) -> None:
"""Reject booleans and values outside 1..3600."""
if (
isinstance(self.value, bool)
or not isinstance(self.value, int)
or not 1 <= self.value <= BUILD_STEP_MAXIMUM_TIMEOUT_SECONDS
):
message = f"invalid build step timeout: {self.value!r}"
raise ValueError(message)

@classmethod
def from_boundary(cls, value: object) -> BuildTimeoutSeconds:
"""Coerce a boundary value, defaulting when it is absent."""
if value is None:
return cls()
if not isinstance(value, int) or isinstance(value, bool):
message = "build step timeout must be an integer"
raise TypeError(message)
return cls(value)


@dataclass(frozen=True, slots=True)
class PostgresDatabaseName:
"""Validated PostgreSQL database name safe for quoted identifiers."""
Expand Down
115 changes: 115 additions & 0 deletions sdk/src/cloudfall/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
AlertDuration,
AlertSeverity,
AlertSummary,
BuildCommand,
BuildTimeoutSeconds,
BuildToolName,
BuildToolVersion,
ByteSize,
ConfigCapture,
ConnectionAddress,
Expand Down Expand Up @@ -43,6 +47,7 @@
PromqlExpression,
ProviderServerId,
RaidLevel,
RelativeRepositoryPath,
RepositoryUrl,
RequiredServiceState,
RequiredServiceStatus,
Expand Down Expand Up @@ -480,6 +485,73 @@ def as_dict(self) -> dict[str, object]:
return result


@dataclass(frozen=True, slots=True)
class ComponentBuildTool:
"""One tool a build step runs and its minimum version."""

name: BuildToolName
minimum_version: BuildToolVersion


@dataclass(frozen=True, slots=True)
class ComponentBuildStep:
"""One build step: an argv list run in a directory of the packaged root."""

working_directory: RelativeRepositoryPath
command: BuildCommand
timeout_seconds: BuildTimeoutSeconds

def as_dict(self) -> dict[str, object]:
"""Serialize the step contract."""
return {
"workingDirectory": self.working_directory.value,
"command": list(self.command.value),
"timeoutSeconds": self.timeout_seconds.value,
}


@dataclass(frozen=True, slots=True)
class ComponentBuild:
"""Controller-side build steps that run before a component is packaged."""

tools: tuple[ComponentBuildTool, ...]
steps: tuple[ComponentBuildStep, ...]
include: tuple[RelativeRepositoryPath, ...] = ()

def __post_init__(self) -> None:
"""Require steps, unique tools and includes, and declared executables."""
if not self.steps:
message = "component build requires at least one step"
raise ValueError(message)
tool_names = [tool.name for tool in self.tools]
if len(set(tool_names)) != len(tool_names):
message = "component build tools must be unique"
raise ValueError(message)
if len(set(self.include)) != len(self.include):
message = "component artifact include paths must be unique"
raise ValueError(message)
declared = frozenset(tool_names)
for index, step in enumerate(self.steps):
if step.command.executable not in declared:
message = (
f"build step {index} runs {step.command.executable.value!r}, "
"which spec.buildTools does not declare"
)
raise ValueError(message)

def as_dict(self) -> dict[str, object]:
"""Serialize the build contract in the Component spec's shape."""
result: dict[str, object] = {
"buildTools": {
tool.name.value: tool.minimum_version.value for tool in self.tools
},
"build": [step.as_dict() for step in self.steps],
}
if self.include:
result["artifact"] = {"include": [path.value for path in self.include]}
return result


@dataclass(frozen=True, slots=True)
class ComponentInventory:
"""Placement and deployment contract for one deployable component."""
Expand All @@ -495,6 +567,7 @@ class ComponentInventory:
health_check: ComponentHealthCheck
secret_refs: tuple[SecretReference, ...] = ()
environment: tuple[tuple[str, str], ...] = ()
build: ComponentBuild | None = None

def as_dict(self) -> dict[str, object]:
"""Serialize the component for inventory consumers."""
Expand All @@ -511,6 +584,8 @@ def as_dict(self) -> dict[str, object]:
}
if self.environment:
result["environment"] = dict(self.environment)
if self.build is not None:
result.update(self.build.as_dict())
return result


Expand Down Expand Up @@ -1470,6 +1545,46 @@ def _component_inventory(document: ResourceDocument) -> ComponentInventory:
health_check=_component_health_check(health),
secret_refs=_secret_references(spec.get("secretRefs")),
environment=_declared_environment(spec.get("environment")),
build=_component_build(spec),
)


def _component_build(spec: Mapping[str, object]) -> ComponentBuild | None:
if spec.get("build") is None:
if spec.get("buildTools") is not None or spec.get("artifact") is not None:
message = "validated component has buildTools or artifact without build"
raise ValueError(message)
return None
raw_tools = spec.get("buildTools")
tools = () if raw_tools is None else tuple(
ComponentBuildTool(
name=BuildToolName.from_boundary(name),
minimum_version=BuildToolVersion.from_boundary(version),
)
for name, version in _boundary_mapping(raw_tools, "build tools").items()
)
raw_artifact = spec.get("artifact")
include = () if raw_artifact is None else tuple(
RelativeRepositoryPath.from_boundary(item)
for item in _list(
_mapping(spec, "artifact").get("include"), "artifact include paths"
)
)
return ComponentBuild(
tools=tools,
steps=tuple(
ComponentBuildStep(
working_directory=RelativeRepositoryPath.from_boundary(
step.get("workingDirectory")
),
command=BuildCommand.from_boundary(step.get("command")),
timeout_seconds=BuildTimeoutSeconds.from_boundary(
step.get("timeoutSeconds")
),
)
for step in _mapping_list(spec.get("build"), "build steps")
),
include=include,
)


Expand Down
Loading
Loading