Russell Brooks
05/10/2026, 8:10 AMMarvin
05/10/2026, 8:10 AMMarvin
05/10/2026, 8:14 AM(block_type, block_name).
"""
return block_type.strip(), block_name.strip()
def _build_expected_set(
expected_blocks: Iterable[dict[str, str]],
) -> set[tuple[str, str]]:
"""Convert the expected block input into a comparable set.
Each expected item must provide:
- block_type
- block_name
Args:
expected_blocks: Iterable of dictionaries describing expected blocks.
Returns:
A set of (block_type, block_name) tuples.
Raises:
ValueError: If any item is missing required keys or contains blanks.
TypeError: If any item is not a dictionary.
"""
normalized: set[tuple[str, str]] = set()
for index, item in enumerate(expected_blocks):
if not isinstance(item, dict):
msg = (
f"Each expected block must be a dictionary. "
f"Item at index {index} has type {type(item)!r}."
)
raise TypeError(msg)
block_type = item.get("block_type")
block_name = item.get("block_name")
if not block_type or not block_name:
msg = (
"Each expected block must include non-empty 'block_type' and "
f"'block_name'. Invalid item at index {index}: {item!r}"
)
raise ValueError(msg)
normalized.add(_normalize_block_key(block_type, block_name))
return normalized
async def _fetch_saved_blocks() -> set[tuple[str, str]]:
"""Fetch all non-anonymous saved blocks from the Prefect API.
This uses offset-based pagination via read_block_documents().
Returns:
A set of (block_type, block_name) tuples for all saved blocks.
"""
saved_blocks: set[tuple[str, str]] = set()
offset = 0
limit = 200
async with get_client() as client:
while True:
block_documents = await client.read_block_documents(
offset=offset,
limit=limit,
include_secrets=False,
)
if not block_documents:
break
for block_document in block_documents:
if not block_document.block_type_name or not block_document.name:
continue
saved_blocks.add(
_normalize_block_key(
block_type=block_document.block_type_name,
block_name=block_document.name,
)
)
offset += len(block_documents)
return saved_blocks
def _emit_gap_event(
*,
event_name: str,
gap_direction: str,
block_type: str,
block_name: str,
message: str,
) -> None:
"""Emit a Prefect event describing a block inventory gap.
Args:Marvin
05/10/2026, 8:14 AMmissing_expected or unexpected_actual.
block_type: The Prefect block type name.
block_name: The saved block document name.
message: Human-readable explanation of the gap.
"""
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_type": block_type,
"block_name": block_name,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_type": block_type,
"block_name": block_name,
"gap_direction": gap_direction,
},
)
@flow(name="audit-prefect-blocks")
async def audit_prefect_blocks(
expected_blocks: list[dict[str, str]],
) -> dict[str, Any]:
"""Audit saved Prefect blocks against an expected inventory.
This flow compares the current saved blocks in a self-hosted Prefect server
against a caller-provided expected list.
It emits events for:
- expected blocks that are missing from Prefect server
- blocks that exist in Prefect server but are not in the expected list
Input format:
expected_blocks = [
{"block_type": "S3 Bucket", "block_name": "raw-data"},
{"block_type": "Secret", "block_name": "db-password"},
{"block_type": "Webhook", "block_name": "alerts"},
]
Notes:
- A block_name may appear under multiple block types.
- Comparison is performed on the pair (block_type, block_name).
- Saved block discovery uses read_block_documents(), which returns
non-anonymous block documents by default.
Args:
expected_blocks: List of expected block definitions.
Returns:
A summary dictionary containing expected, actual, missing, and
unexpected block inventories.
"""
logger = get_run_logger()
expected_set = _build_expected_set(expected_blocks)
actual_set = await _fetch_saved_blocks()
missing_blocks = sorted(expected_set - actual_set)
unexpected_blocks = sorted(actual_set - expected_set)
logger.info("Expected blocks: %d", len(expected_set))
logger.info("Actual saved blocks: %d", len(actual_set))
logger.info("Missing expected blocks: %d", len(missing_blocks))
logger.info("Unexpected actual blocks: %d", len(unexpected_blocks))
for block_type, block_name in missing_blocks:
message = (
"Expected block was not found in Prefect server: "
f"block_type={block_type!r}, block_name={block_name!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.expected-block-missing",
gap_direction="missing_expected",
block_type=block_type,
block_name=block_name,
message=message,
)
for block_type, block_name in unexpected_blocks:
message = (
"Prefect server contains a block that was not found in the expected "
f"inventory: block_type={block_type!r}, block_name={block_name!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.unexpected-block-found",
gap_direction="unexpected_actual",
block_type=block_type,
block_name=block_name,
message=message,
)
summary = {
"expected_blocks": [
{"block_type": block_type, "block_name": block_name}
for block_type, block_name in sorted(expected_set)
],
"actual_blocks": [
{"block_type": block_type, "block_name": block_name}
for block_type, block_name in sorted(actual_set)
],
"missing_blocks": [
{"block_type": block_type, "block_name": block_name}
for block_type, block_name in missing_blocks
],
"unexpected_blocks": [Marvin
05/10/2026, 8:14 AM(block_type, block_name), so the same block_name can appear under different block_type values.
- It uses block_document.block_type_name, which is the block type name returned by the API.
- If you want to compare against block type slug instead of type name, I can rewrite it to do that too. Slugs are often more stable than display names.
Relevant APIs
- Prefect client: get_client() and block document listing via read_block_documents()
- Event emission: emit_event()
If you want, I can also provide:
- a version using a pydantic model for the expected_blocks parameter
- a deployment-ready prefect deploy setup
- an automation example that reacts to these emitted eventsRussell Brooks
05/10/2026, 8:26 AMMarvin
05/10/2026, 8:27 AMMarvin
05/10/2026, 8:29 AMexpected_blocks
- a separate automation example that sends a notification when one of the custom block-audit events is emitted
I’ve also made the event payload a bit richer so the automation notification has useful fields to reference.
```python
from future import annotations
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field, field_validator
from prefect import flow, get_run_logger
from prefect.client.orchestration import get_client
from prefect.events import emit_event
EVENT_PREFIX = "custom.prefect.block-audit"
RESOURCE_ID = "custom.prefect.block-audit"
class ExpectedBlock(BaseModel):
"""Represents one expected Prefect block document.
The comparison key is the pair (block_type_slug, block_name), which allows
the same block name to appear under multiple block types.
Attributes:
block_type_slug: The Prefect block type slug, such as secret,
s3-bucket, or webhook.
block_name: The saved block document name.
"""
block_type_slug: str = Field(
...,
description="The Prefect block type slug.",
examples=["secret", "s3-bucket", "webhook"],
)
block_name: str = Field(
...,
description="The saved Prefect block document name.",
examples=["db-password", "raw-data", "alerts"],
)
@field_validator("block_type_slug", "block_name")
@classmethod
def validate_not_blank(cls, value: str) -> str:
"""Validate that a field contains a non-blank string.
Args:
value: The string field value.
Returns:
The stripped string.
Raises:
ValueError: If the value is blank after stripping whitespace.
"""
normalized = value.strip()
if not normalized:
msg = "Value must not be blank."
raise ValueError(msg)
return normalized
def _normalize_block_key(block_type_slug: str, block_name: str) -> tuple[str, str]:
"""Normalize a block identity for comparisons.
Args:
block_type_slug: The block type slug.
block_name: The block document name.
Returns:
A normalized (block_type_slug, block_name) tuple.
"""
return block_type_slug.strip(), block_name.strip()
def _build_expected_set(
expected_blocks: Iterable[ExpectedBlock],
) -> set[tuple[str, str]]:
"""Convert expected block models into a set of comparable keys.
Args:
expected_blocks: Expected block definitions.
Returns:
A set of (block_type_slug, block_name) tuples.
"""
return {
_normalize_block_key(
block.block_type_slug,
block.block_name,
)
for block in expected_blocks
}
async def _fetch_saved_blocks() -> set[tuple[str, str]]:
"""Fetch all saved non-anonymous blocks from the Prefect API.
This function paginates through read_block_documents() and extracts the
(block_type_slug, block_name) pair for each block. If a returned block
document does not include a type slug, it is skipped.
Returns:
A set of (block_type_slug, block_name) tuples.
"""
saved_blocks: set[tuple[str, str]] = set()
offset = 0
limit = 200
async with get_client() as client:
while True:
block_documents = await client.read_block_documents(
offset=offset,
limit=limit,
include_secrets=False,
)
if not block_documents:
break
for block_document in block_documents:
if (
not block_document.name
or not block_document.block_type
or not block_document.block_type.slug
):
continueMarvin
05/10/2026, 8:29 AMmissing_expected or
unexpected_actual.
block_type_slug: The Prefect block type slug.
block_name: The saved block document name.
message: Human-readable explanation of the gap.
"""
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
)
@flow(name="audit-prefect-blocks")
async def audit_prefect_blocks(
expected_blocks: list[ExpectedBlock],
) -> dict[str, Any]:
"""Audit saved Prefect blocks against an expected inventory.
This flow compares the current saved blocks in a self-hosted Prefect server
against an expected list provided as a flow parameter.
The comparison uses:
- block_type_slug
- block_name
It emits events for:
- expected blocks that are missing from Prefect server
- blocks present in Prefect server that are not in the expected inventory
Args:
expected_blocks: Expected block definitions.
Returns:
A summary dictionary describing the expected, actual, missing, and
unexpected block inventories.
"""
logger = get_run_logger()
expected_set = _build_expected_set(expected_blocks)
actual_set = await _fetch_saved_blocks()
missing_blocks = sorted(expected_set - actual_set)
unexpected_blocks = sorted(actual_set - expected_set)
logger.info("Expected blocks: %d", len(expected_set))
logger.info("Actual saved blocks: %d", len(actual_set))
logger.info("Missing expected blocks: %d", len(missing_blocks))
logger.info("Unexpected actual blocks: %d", len(unexpected_blocks))
for block_type_slug, block_name in missing_blocks:
message = (
"Expected block was not found in Prefect server: "
f"block_type_slug={block_type_slug!r}, block_name={block_name!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.expected-block-missing",
gap_direction="missing_expected",
block_type_slug=block_type_slug,
block_name=block_name,
message=message,
)
for block_type_slug, block_name in unexpected_blocks:
message = (
"Prefect server contains a block that was not found in the expected "
"inventory: "
f"block_type_slug={block_type_slug!r}, block_name={block_name!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.unexpected-block-found",
gap_direction="unexpected_actual",
block_type_slug=block_type_slug,
block_name=block_name,
message=message,
)
summary = {
"expected_blocks": [
{
"block_type_slug": block_type_slug,
"block_name": block_name,
}
for block_type_slug, block_name in sorted(expected_set)
],
"actual_blocks": [
{
"block_type_slug": block_type_slug,Marvin
05/10/2026, 8:29 AM*Example automation that sends a notification*
This example creates a Prefect Automation that listens for either:
- `custom.prefect.block-audit.expected-block-missing`
- `custom.prefect.block-audit.unexpected-block-found`
and sends a notification using a saved *notification block*.
```python
from __future__ import annotations
from datetime import timedelta
from uuid import UUID
from prefect.automations import Automation, EventTrigger, Posture, SendNotification
def create_block_audit_notification_automation(
notification_block_document_id: UUID,
) -> Automation:
"""Create an automation for block audit gap events.
This automation reacts to block audit events emitted by the
`audit_prefect_blocks` flow and sends a notification through the provided
notification block.
Args:
notification_block_document_id: The block document ID of a saved
notification block, such as a Slack webhook notification block.
Returns:
The created automation object.
"""
automation = Automation(
name="Notify on Prefect block audit gaps",
description=(
"Send a notification whenever the block audit flow detects a "
"missing expected block or an unexpected saved block."
),
enabled=True,
trigger=EventTrigger(
expect={
"custom.prefect.block-audit.expected-block-missing",
"custom.prefect.block-audit.unexpected-block-found",
},
match={
"prefect.resource.id": "custom.prefect.block-audit",
},
posture=Posture.Reactive,
threshold=1,
within=timedelta(seconds=0),
),
actions=[
SendNotification(
block_document_id=notification_block_document_id,
subject="Prefect block audit detected a gap",
body=(
"A Prefect block audit event was emitted.\n\n"
"Event: {{ event.event }}\n"
"Message: {{ event.payload['message'] }}\n"
"Block type slug: {{ event.payload['block_type_slug'] }}\n"
"Block name: {{ event.payload['block_name'] }}\n"
"Gap direction: {{ event.payload['gap_direction'] }}\n"
),
)
],
)
return automation.create()
Example usage
```python
from uuid import UUID
automation = create_block_audit_notification_automation(
notification_block_document_id=UUID("11111111-2222-3333-4444-555555555555")
)Marvin
05/10/2026, 8:29 AMnotification_block_document_id must be the UUID of a saved notification block document.
- For example, you might use a Slack webhook notification block.
- The event matching is based on:
- exact event names in expect
- prefect.resource.id == "custom.prefect.block-audit"
Why slugs are better here
Using block_type.slug is usually preferable to block_type_name because:
- slugs are more stable than display names
- they are better for automation and code-based comparisons
- they avoid formatting inconsistencies like spaces/capitalization
If you want, I can next give you either:
1. a deployment-ready version with prefect deploy, or
2. a version that fails the flow run when gaps are found, in addition to emitting events.Russell Brooks
05/10/2026, 11:08 AMMarvin
05/10/2026, 11:08 AMMarvin
05/10/2026, 11:10 AMprefect.yaml.
You can then create it with:
bash
prefect automation create --from-file automation.yaml
Verified CLI:
- prefect automation create --from-file ...
Here is the YAML equivalent of the Python automation script from above:
yaml
name: Notify on Prefect block audit gaps
description: Send a notification whenever the block audit flow detects a missing expected block or an unexpected saved block.
enabled: true
trigger:
type: event
posture: Reactive
expect:
- custom.prefect.block-audit.expected-block-missing
- custom.prefect.block-audit.unexpected-block-found
match:
prefect.resource.id: custom.prefect.block-audit
threshold: 1
within: 0
actions:
- type: send-notification
block_document_id: "11111111-2222-3333-4444-555555555555"
subject: "Prefect block audit detected a gap"
body: |
A Prefect block audit event was emitted.
Event: {{ event.event }}
Message: {{ event.payload['message'] }}
Block type slug: {{ event.payload['block_type_slug'] }}
Block name: {{ event.payload['block_name'] }}
Gap direction: {{ event.payload['gap_direction'] }}
Create it with:
bash
prefect automation create --from-file automation.yaml
If you want multiple automations in one file, wrap them in an automations: list:
yaml
automations:
- name: Notify on Prefect block audit gaps
description: Send a notification whenever the block audit flow detects a missing expected block or an unexpected saved block.
enabled: true
trigger:
type: event
posture: Reactive
expect:
- custom.prefect.block-audit.expected-block-missing
- custom.prefect.block-audit.unexpected-block-found
match:
prefect.resource.id: custom.prefect.block-audit
threshold: 1
within: 0
actions:
- type: send-notification
block_document_id: "11111111-2222-3333-4444-555555555555"
subject: "Prefect block audit detected a gap"
body: |
A Prefect block audit event was emitted.
Event: {{ event.event }}
Message: {{ event.payload['message'] }}
Block type slug: {{ event.payload['block_type_slug'] }}
Block name: {{ event.payload['block_name'] }}
Gap direction: {{ event.payload['gap_direction'] }}
A few important notes:
- block_document_id must be the UUID of your saved notification block document.
- This automation YAML is not part of prefect.yaml.
- prefect.yaml supports deployment triggers, but not general automations like send-notification.
Relevant command:
- prefect automation create --from-file automation.yaml
If you want, I can also convert this into:
- a Slack notification-specific example, or
- a full package with the flow Python file plus the automation YAML side-by-side.Russell Brooks
05/11/2026, 2:40 PMMarvin
05/11/2026, 2:40 PMMarvin
05/11/2026, 2:41 PMblock_type_slug/block_name form is a nice simplification.
Below is a rewritten version of audit_prefect_blocks that:
- uses a single block_slug field instead of separate block_type_slug and block_name
- compares expected vs actual blocks using the full slug shown by prefect block ls
- still emits events for:
- missing expected blocks
- unexpected actual blocks
- keeps the same general structure, docstrings, and lint-friendly style
```python
from future import annotations
from collections.abc import Iterable
from typing import Any
from pydantic import BaseModel, Field, field_validator
from prefect import flow, get_run_logger
from prefect.client.orchestration import get_client
from prefect.events import emit_event
EVENT_PREFIX = "custom.prefect.block-audit"
RESOURCE_ID = "custom.prefect.block-audit"
class ExpectedBlock(BaseModel):
"""Represents one expected Prefect block using its full block slug.
The block_slug must match the format shown by prefect block ls,
combining the block type slug and block name as:
<block_type_slug>/<block_name>
Example:
- secret/db-password
- s3-bucket/raw-data
- webhook/alerts
"""
block_slug: str = Field(
...,
description=(
"The full Prefect block slug in the form "
"'<block_type_slug>/<block_name>'."
),
examples=["secret/db-password", "s3-bucket/raw-data", "webhook/alerts"],
)
@field_validator("block_slug")
@classmethod
def validate_block_slug(cls, value: str) -> str:
"""Validate and normalize a full block slug.
Args:
value: A block slug in the form <block_type_slug>/<block_name>.
Returns:
The stripped block slug.
Raises:
ValueError: If the block slug is blank or does not contain exactly
one / separator.
"""
normalized = value.strip()
if not normalized:
msg = "block_slug must not be blank."
raise ValueError(msg)
parts = normalized.split("/", maxsplit=1)
if len(parts) != 2 or not parts[0].strip() or not parts[1].strip():
msg = (
"block_slug must be in the form "
"'<block_type_slug>/<block_name>'."
)
raise ValueError(msg)
return normalized
def _build_expected_set(
expected_blocks: Iterable[ExpectedBlock],
) -> set[str]:
"""Convert expected block models into a set of comparable full block slugs.
Args:
expected_blocks: Expected block definitions.
Returns:
A set of full block slugs.
"""
return {block.block_slug for block in expected_blocks}
def _split_block_slug(block_slug: str) -> tuple[str, str]:
"""Split a full block slug into its block type slug and block name.
Args:
block_slug: Full block slug in the form
<block_type_slug>/<block_name>.
Returns:
A tuple of (block_type_slug, block_name).
"""
return tuple(block_slug.split("/", maxsplit=1)) # type: ignore[return-value]
async def _fetch_saved_blocks() -> set[str]:
"""Fetch all saved non-anonymous blocks from the Prefect API.
This function paginates through read_block_documents() and extracts the
full block slug for each block as:
<block_type_slug>/<block_name>
Returns:
A set of full block slugs.
"""
saved_blocks: set[str] = set()
offset = 0
limit = 200
async with get_client() as client:
while True:
block_documents = await client.read_block_documents(
offset=offset,
limit=limit,
include_secrets=False,
)
if not block_documents:
break
for block_document in block_documents:
if (
not block_document.nameMarvin
05/11/2026, 2:41 PMmissing_expected or
unexpected_actual.
block_slug: Full block slug in the form
<block_type_slug>/<block_name>.
message: Human-readable explanation of the gap.
"""
block_type_slug, block_name = _split_block_slug(block_slug)
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_slug": block_slug,
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_slug": block_slug,
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
)
@flow(name="audit-prefect-blocks")
async def audit_prefect_blocks(
expected_blocks: list[ExpectedBlock],
) -> dict[str, Any]:
"""Audit saved Prefect blocks against an expected inventory.
This flow compares the current saved blocks in a self-hosted Prefect server
against an expected list provided as a flow parameter.
The comparison uses the full block slug shown by `prefect block ls`:
<block_type_slug>/<block_name>
It emits events for:
- expected blocks that are missing from Prefect server
- blocks present in Prefect server that are not in the expected inventory
Args:
expected_blocks: Expected block definitions using full block slugs.
Returns:
A summary dictionary describing the expected, actual, missing, and
unexpected block inventories.
"""
logger = get_run_logger()
expected_set = _build_expected_set(expected_blocks)
actual_set = await _fetch_saved_blocks()
missing_blocks = sorted(expected_set - actual_set)
unexpected_blocks = sorted(actual_set - expected_set)
logger.info("Expected blocks: %d", len(expected_set))
logger.info("Actual saved blocks: %d", len(actual_set))
logger.info("Missing expected blocks: %d", len(missing_blocks))
logger.info("Unexpected actual blocks: %d", len(unexpected_blocks))
for block_slug in missing_blocks:
message = (
"Expected block was not found in Prefect server: "
f"block_slug={block_slug!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.expected-block-missing",
gap_direction="missing_expected",
block_slug=block_slug,
message=message,
)
for block_slug in unexpected_blocks:
message = (
"Prefect server contains a block that was not found in the expected "
f"inventory: block_slug={block_slug!r}."
)
_emit_gap_event(
event_name=f"{EVENT_PREFIX}.unexpected-block-found",
gap_direction="unexpected_actual",
block_slug=block_slug,
message=message,
)
summary = {
"expected_blocks": [
{"block_slug": block_slug}
for block_slug in sorted(expected_set)
],
"actual_blocks": [
{"block_slug": block_slug}
for block_slug in sorted(actual_set)
],
"missing_blocks": [
{"block_slug": block_slug}Marvin
05/11/2026, 2:41 PMblock_type_slug
- block_name
with just:
- block_slug
- Comparison is now just set math on strings like:
- secret/db-password
- s3-bucket/raw-data
- The event payload still includes:
- block_slug
- block_type_slug
- block_name
so downstream automations and notifications can still reference either the full slug or the separated parts.
One small note
I kept _split_block_slug() so emitted events still contain both the full slug and its components. That makes the flow simpler without losing detail in event payloads.
If you want, I can also rewrite the automation YAML to reference just block_slug in the message body.Russell Brooks
05/11/2026, 2:48 PMMarvin
05/11/2026, 2:48 PMMarvin
05/11/2026, 2:48 PM_emit_gap_event() still exist now that you’re using block_slug?
If so: it’s mostly for encapsulation and consistency.
It centralizes:
- the emit_event(...) call
- the resource structure
- the payload structure
- splitting block_slug into:
- block_type_slug
- block_name
So instead of repeating this in both loops, you do it once.
Without _emit_gap_event(), you’d repeat something like this twice:
python
block_type_slug, block_name = _split_block_slug(block_slug)
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_slug": block_slug,
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_slug": block_slug,
"block_type_slug": block_type_slug,
"block_name": block_name,
"gap_direction": gap_direction,
},
)
So _emit_gap_event() helps keep the flow body cleaner.
If your real question is instead why does _emit_gap_event() split the slug at all, the answer is:
- it doesn’t need to for the comparison
- it only does that so the emitted event includes both:
- the full slug
- the individual components
That can be useful for:
- notification templates
- automation filters
- downstream consumers
If you want the simplest possible version, you can remove _split_block_slug() entirely and keep only block_slug in the event:
python
def _emit_gap_event(
*,
event_name: str,
gap_direction: str,
block_slug: str,
message: str,
) -> None:
"""Emit a custom event describing a block inventory gap."""
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_slug": block_slug,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_slug": block_slug,
"gap_direction": gap_direction,
},
)
If you want, I can rewrite the whole flow into that minimal block_slug-only version.Russell Brooks
05/11/2026, 2:49 PMMarvin
05/11/2026, 2:49 PMMarvin
05/11/2026, 2:49 PM* in this function:
python
def _emit_gap_event(
*,
event_name: str,
gap_direction: str,
block_slug: str,
message: str,
) -> None:
means: all following arguments must be passed by keyword, not position.
So this is allowed:
python
_emit_gap_event(
event_name="custom.prefect.block-audit.expected-block-missing",
gap_direction="missing_expected",
block_slug="secret/db-password",
message="Expected block was not found.",
)
and this is not:
python
_emit_gap_event(
"custom.prefect.block-audit.expected-block-missing",
"missing_expected",
"secret/db-password",
"Expected block was not found.",
)
Why people use it
It’s often used when:
- several parameters are the same type, especially multiple `str`s
- positional ordering would be easy to mix up
- readability at call sites matters
In your case, all four parameters are strings, so keyword-only arguments do help prevent mistakes.
Is it required?
No — it is not required.
Is it bad practice?
Also no — keyword-only arguments are a legitimate Python feature and often considered good API design.
That said, for a small private helper like _emit_gap_event(), I agree it may be more ceremony than necessary. If you want a more precise and cleaner approach, I’d suggest one of these:
*Option 1: remove the * entirely*
This is the simplest approach.
python
def _emit_gap_event(
event_name: str,
gap_direction: str,
block_slug: str,
message: str,
) -> None:
"""Emit a custom event describing a block inventory gap."""
emit_event(
event=event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_slug": block_slug,
"gap_direction": gap_direction,
},
payload={
"message": message,
"block_slug": block_slug,
"gap_direction": gap_direction,
},
)
This works fine, but it makes it easier to accidentally swap string arguments.
Option 2: use a typed model for the event payload
This is the more precise approach if you want stronger structure without relying on keyword-only args.
Example:
python
from pydantic import BaseModel
class BlockGapEvent(BaseModel):
"""Represents one block inventory gap event."""
event_name: str
gap_direction: str
block_slug: str
message: str
def _emit_gap_event(event: BlockGapEvent) -> None:
"""Emit a custom event describing a block inventory gap."""
emit_event(
event=event.event_name,
resource={
"prefect.resource.id": RESOURCE_ID,
"prefect.resource.name": "prefect-block-audit",
"block_slug": event.block_slug,
"gap_direction": event.gap_direction,
},
payload={
"message": event.message,
"block_slug": event.block_slug,
"gap_direction": event.gap_direction,
},
)
Call it like:
python
_emit_gap_event(
BlockGapEvent(
event_name=f"{EVENT_PREFIX}.expected-block-missing",
gap_direction="missing_expected",
block_slug=block_slug,
message=message,
)
)
I think this is the most precise option if your goal is correctness and maintainability.
My recommendation
For this specific helper, I’d choose one of these:
- simplest: remove the *
- most precise: pass a BlockGapEvent model
Since you asked for a more precise approach, I’d lean toward the model approach.
Here’s a compact replacement:
```python
class BlockGapEvent(BaseModel):
"""Represents one block inventory gap event."""
event_name: str
gap_direction: str
block_slug: str
message: str
def _emit_gap_event(event: BlockGapEvent) -> None:
"""Emit a custom event describing a block inventory gap."""
emit_event(
event=event.event_name,
resource={Marvin
05/11/2026, 2:49 PMRussell Brooks
05/11/2026, 2:51 PMMarvin
05/11/2026, 2:51 PMMarvin
05/11/2026, 2:51 PM* is one of those small Python features that feels odd until you see why people use it.
If you want, I can do a final cleanup pass on the flow and make it:
- simpler
- more idiomatic
- strictly slug-based
- model-driven where it actually helps
and trim out any unnecessary cleverness.Russell Brooks
05/11/2026, 3:01 PMMarvin
05/11/2026, 3:01 PMMarvin
05/11/2026, 3:01 PMbash
prefect block ls --output json
If you want just the slugs, pipe it to `jq`:
bash
prefect block ls --output json | jq -r '.[].slug'
If jq is not available, in Python:
bash
prefect block ls --output json | python -c "import sys, json; print('\n'.join(item['slug'] for item in json.load(sys.stdin)))"
So the most practical answer is:
bash
prefect block ls --output json | jq -r '.[].slug'
There isn’t a built-in CLI flag on prefect block ls to output only slugs directly.
Relevant command:
- prefect block ls --helpRussell Brooks
05/11/2026, 3:05 PMMarvin
05/11/2026, 3:05 PMMarvin
05/11/2026, 3:05 PMprefect block ls --output json does not return a top-level slug field. The slug is nested, and the full block slug needs to be constructed from:
- .block_schema.fields.block_type_slug
- .name
So to get the same full slug style you see in prefect block ls, use:
bash
prefect block ls --output json | jq -r '.[] | "\(.block_schema.fields.block_type_slug)/\(.name)"'
A slightly safer variant is:
bash
prefect block ls --output json | jq -r '.[] | select(.block_schema.fields.block_type_slug and .name) | "\(.block_schema.fields.block_type_slug)/\(.name)"'
If you prefer using the nested block type object instead, this should also work when present:
bash
prefect block ls --output json | jq -r '.[] | "\(.block_type.slug)/\(.name)"'
But based on the output you shared, the most reliable path looks like:
bash
prefect block ls --output json | jq -r '.[] | "\(.block_schema.fields.block_type_slug)/\(.name)"'
So the reason you got null is exactly what you suspected: slug is not a top-level key.Russell Brooks
05/12/2026, 6:54 AMMarvin
05/12/2026, 6:54 AMMarvin
05/12/2026, 6:56 AMprefect-server Helm chart, the answers are:
1. How to check the size of the Redis app created by the subchart
There are a few different “size” questions you might mean:
- configured CPU/memory requests and limits
- actual runtime CPU/memory usage
- persistent volume size
- data currently stored in Redis memory
Here’s how to check each.
Find the Redis resources first
bash
kubectl get pods -n <namespace> | grep redis
kubectl get statefulset -n <namespace> | grep redis
kubectl get svc -n <namespace> | grep redis
The Redis subchart is Bitnami Redis, and it is typically deployed as a StatefulSet.
Check configured CPU / memory requests and limits
bash
kubectl get statefulset -n <namespace> <redis-statefulset-name> -o yaml
Then look under:
yaml
spec:
template:
spec:
containers:
- name: redis
resources:
requests:
limits:
A quicker JSONPath view:
bash
kubectl get statefulset -n <namespace> <redis-statefulset-name> \
-o jsonpath='{.spec.template.spec.containers[0].resources}'
Check actual runtime usage
If metrics-server is installed:
bash
kubectl top pod -n <namespace> <redis-pod-name>
Check PVC size
bash
kubectl get pvc -n <namespace> | grep redis
kubectl describe pvc -n <namespace> <redis-pvc-name>
Check actual Redis memory usage
Exec into the Redis pod:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- redis-cli INFO memory
Or just the key fields:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- redis-cli INFO memory | grep used_memory
Useful fields include:
- used_memory
- used_memory_human
- used_memory_peak
- maxmemory
Check the Helm values actually applied
This is often the best way to confirm the configured size from the chart:
bash
helm get values <release-name> -n <namespace>
or all computed values:
bash
helm get values <release-name> -n <namespace> --all
Then inspect the redis: section.
Prefect’s chart uses the Bitnami Redis subchart, so Redis sizing is controlled through that subchart’s values.
2. Which Redis database number is used?
For the Prefect Helm chart defaults, the Redis DB for messaging is:
text
0
The chart sets:
yaml
backgroundServices:
messaging:
redis:
db: 0
and this becomes the environment variable:
text
PREFECT_REDIS_MESSAGING_DB=0
So for Prefect background services messaging, the default Redis DB is 0.
Important nuance about Docket
There is also separate Docket configuration in the chart, and if you set a Docket Redis URL yourself, that URL can use a different DB, for example /1.
Example from chart patterns:
text
<redis://host:6379/1>
So:
- Prefect messaging Redis DB default: 0
- Docket Redis DB: whatever is in backgroundServices.messaging.docket.url
- if unset, Docket may not use Redis at all
How to confirm in your running cluster
Check the env vars on the background services pod:
bash
kubectl get pod -n <namespace> <background-services-pod> -o yaml
Look for:
text
PREFECT_REDIS_MESSAGING_DB
PREFECT_SERVER_DOCKET_URL
Or directly:
bash
kubectl exec -n <namespace> <background-services-pod> -- env | grep PREFECT_REDIS_MESSAGING_DB
kubectl exec -n <namespace> <background-services-pod> -- env | grep PREFECT_SERVER_DOCKET_URL
Summary
- Redis subchart “size” can be checked via:
- kubectl get statefulset ... -o yaml
- kubectl top pod ...
- kubectl describe pvc ...
- redis-cli INFO memory
- Prefect messaging uses Redis DB 0 by default
- Docket may use a different Redis DB if configured separately in its URL
If you want, I can give you a single copy-paste command set tailored to your namespace and release name.Russell Brooks
05/12/2026, 7:05 AMMarvin
05/12/2026, 7:05 AMMarvin
05/12/2026, 7:06 AMbash
kubectl get pvc -n <namespace> | grep redis
Then inspect it:
bash
kubectl describe pvc -n <namespace> <redis-pvc-name>
That shows the requested/allocated size, for example 8Gi, but not the used bytes inside the filesystem.
2. Check actual disk usage inside the Redis pod
The most direct way is to exec into the Redis pod and run df on the Redis data directory.
First find the pod:
bash
kubectl get pods -n <namespace> | grep redis
Then run:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- df -h /bitnami/redis/data
That should show something like:
text
Filesystem Size Used Avail Use% Mounted on
/dev/... 8G 2.1G 5.9G 27% /bitnami/redis/data
That is usually the most useful answer to “how close am I to the allocated size?”
You can also inspect directory size:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- du -sh /bitnami/redis/data
3. Compare allocated size vs used size
- allocated size: from the PVC
- used size: from df -h /bitnami/redis/data
So your quick check is:
bash
kubectl describe pvc -n <namespace> <redis-pvc-name>
kubectl exec -it -n <namespace> <redis-pod-name> -- df -h /bitnami/redis/data
4. If you want Redis logical memory usage too
This is separate from disk persistence, but often useful:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- redis-cli INFO memory
That tells you how much data Redis is holding in memory, which is not the same as PVC usage.
Important caveat
If Redis persistence is disabled, there may be no PVC at all. In that case:
- there is no persistent storage allocation to compare against
- Redis data is just in the container filesystem / ephemeral volume
- the monitoring approach changes
You can check whether persistence is enabled via:
bash
helm get values <release-name> -n <namespace> --all
and inspect the redis.persistence section.
Recommended monitoring target
If your goal is “alert me before Redis storage fills up”, the best signal is:
- filesystem usage percentage on /bitnami/redis/data
For example, alert at:
- 70%
- 80%
- 90%
Most useful one-liner
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- df -h /bitnami/redis/data
If you want, I can also help you turn this into:
- a Prometheus metric / alert
- a small Prefect flow to check Redis disk usage periodically
- a Kubernetes command sequence that auto-discovers the Redis pod and PVC namesRussell Brooks
05/12/2026, 7:24 AMMarvin
05/12/2026, 7:24 AMMarvin
05/12/2026, 7:24 AMbash
kubectl exec -it -n <namespace> <redis-pod-name> -- df -h /bitnami/redis/data
runs df inside the container filesystem. It is a Kubernetes exec into the pod, not a Redis client connection, so Redis authentication is not involved.
You only need the Redis password when using redis-cli, for example:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- redis-cli -a '<password>' INFO memory
or better, with an env var to avoid putting the password directly in shell history:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -- sh -c 'redis-cli -a "$REDIS_PASSWORD" INFO memory'
But for df and du, no Redis password is needed.
If kubectl exec itself is failing, that would usually be an RBAC or pod/container issue, not a Redis password issue.
For example, if the pod has multiple containers, specify the container:
bash
kubectl exec -it -n <namespace> <redis-pod-name> -c redis -- df -h /bitnami/redis/data
So the short answer is:
- df / `du`: no password needed
- `redis-cli`: password may be neededRussell Brooks
05/12/2026, 7:28 AMMarvin
05/12/2026, 7:28 AMMarvin
05/12/2026, 7:29 AMnamespace, redis_pod_name, and threshold_percent as parameters
- runs:
bash
kubectl exec -n <namespace> <redis-pod-name> -c redis -- df -h /bitnami/redis/data
- parses the filesystem usage percentage
- emits an event if usage exceeds the threshold
I removed -it because interactive TTY flags are usually not appropriate for subprocess execution from automation code.
```python
from future import annotations
import re
import subprocess
from typing import Any
from pydantic import BaseModel, Field, field_validator
from prefect import flow, get_run_logger
from prefect.events import emit_event
EVENT_NAME = "custom.redis.storage-threshold-exceeded"
RESOURCE_PREFIX = "custom.redis.storage"
class RedisStorageCheckParameters(BaseModel):
"""Parameters for checking Redis persistent storage usage."""
namespace: str = Field(
...,
description="Kubernetes namespace containing the Redis pod.",
)
redis_pod_name: str = Field(
...,
description="Name of the Redis pod to inspect.",
)
threshold_percent: float = Field(
...,
description="Usage percentage threshold that triggers an event.",
ge=0.0,
le=100.0,
)
@field_validator("namespace", "redis_pod_name")
@classmethod
def validate_not_blank(cls, value: str) -> str:
"""Validate that string parameters are non-empty.
Args:
value: Input string value.
Returns:
The stripped value.
Raises:
ValueError: If the value is blank.
"""
normalized = value.strip()
if not normalized:
msg = "Value must not be blank."
raise ValueError(msg)
return normalized
def _run_df_command(namespace: str, redis_pod_name: str) -> str:
"""Run df -h in the Redis container and return stdout.
Args:
namespace: Kubernetes namespace containing the Redis pod.
redis_pod_name: Redis pod name.
Returns:
The command stdout.
Raises:
RuntimeError: If the command fails.
"""
command = [
"kubectl",
"exec",
"-n",
namespace,
redis_pod_name,
"-c",
"redis",
"--",
"df",
"-h",
"/bitnami/redis/data",
]
completed_process = subprocess.run(
command,
capture_output=True,
text=True,
check=False,
timeout=60,
)
if completed_process.returncode != 0:
msg = (
"Failed to run kubectl exec for Redis storage check. "
f"returncode={completed_process.returncode}, "
f"stderr={completed_process.stderr.strip()!r}"
)
raise RuntimeError(msg)
return completed_process.stdout
def _parse_df_output(df_output: str) -> dict[str, Any]:
"""Parse df -h output and extract storage usage details.
Expected output resembles:
Filesystem Size Used Avail Use% Mounted on
/dev/... 8G 2.1G 5.9G 27% /bitnami/redis/data
Args:
df_output: Raw stdout from df -h.
Returns:
A dictionary containing parsed fields.
Raises:
ValueError: If the output cannot be parsed.
"""
lines = [line.strip() for line in df_output.splitlines() if line.strip()]
if len(lines) < 2:
msg = f"Unexpected df output: {df_output!r}"
raise ValueError(msg)
data_line = lines[-1]
columns = data_line.split()
if len(columns) < 6:
msg = f"Unable to parse df data line: {data_line!r}"
raise ValueError(msg)
filesystem = columns[0]
size = columns[1]
used = columns[2]
available = columns[3]
use_percent_raw = columns[4]
mounted_on = columns[5]
match = re.fullmatch(r"(\d+)%", use_percent_raw)
if not match:
msg = f"Unable to parse usage percentage: {use_percent_raw!r}"Marvin
05/12/2026, 7:29 AMdf -h /bitnami/redis/data inside the Redis container,
parses the reported usage, and emits a custom Prefect event when the usage
percentage is greater than the configured threshold.
Args:
namespace: Kubernetes namespace containing the Redis pod.
redis_pod_name: Name of the Redis pod.
threshold_percent: Usage percentage threshold for event emission.
Returns:
A dictionary containing the parsed storage details and threshold status.
"""
logger = get_run_logger()
params = RedisStorageCheckParameters(
namespace=namespace,
redis_pod_name=redis_pod_name,
threshold_percent=threshold_percent,
)
logger.info(
"Checking Redis storage usage for pod %s in namespace %s",
params.redis_pod_name,
params.namespace,
)
df_output = _run_df_command(
namespace=params.namespace,
redis_pod_name=params.redis_pod_name,
)
storage_details = _parse_df_output(df_output)
used_percent = storage_details["used_percent"]
threshold_exceeded = used_percent > params.threshold_percent
logger.info(
"Redis storage usage is %.1f%% (threshold %.1f%%)",
used_percent,
params.threshold_percent,
)
if threshold_exceeded:
logger.warning(
"Redis storage usage exceeded threshold: %.1f%% > %.1f%%",
used_percent,
params.threshold_percent,
)
_emit_threshold_event(
namespace=params.namespace,
redis_pod_name=params.redis_pod_name,
threshold_percent=params.threshold_percent,
storage_details=storage_details,
)
return {
"namespace": params.namespace,Marvin
05/12/2026, 7:29 AM*Notes*
- I used `subprocess.run(...)` with a timeout of 60 seconds.
- I intentionally did *not* use `-it` because TTY allocation is usually wrong in non-interactive process execution.
- The flow emits this custom event:
```text
custom.redis.storage-threshold-exceeded
- The event payload includes:
- threshold_percent
- used_percent
- size
- used
- available
- namespace
- redis_pod_name
If you want, I can also provide:
- a matching automation YAML that sends a notification when this event is emitted
- a version that uses df -P for slightly more stable parsing
- an async version using Prefect process utilities instead of subprocess.run()Russell Brooks
05/13/2026, 8:23 AMMarvin
05/13/2026, 8:23 AMMarvin
05/13/2026, 8:23 AMkubectl at all.
A few alternatives:
1. Query Redis directly
If by “Redis usage” you mean memory / keys / stats, connect to Redis and use `INFO`:
import redis
r = redis.Redis(host="your-redis-host", port=6379, decode_responses=True)
info = r.info()
print(info["used_memory_human"])
print(info["connected_clients"])
print(info["db0"]) # if present
Useful fields from INFO include:
- used_memory
- used_memory_human
- connected_clients
- total_commands_processed
- keyspace_hits
- keyspace_misses
If you want per-DB key counts, look at sections like db0, db1, etc.
2. Use the Kubernetes Python client instead of shelling out to kubectl
If you were using kubectl just to find the Redis pod or inspect pod/container metrics, you can call the Kubernetes API directly:
from kubernetes import client, config
config.load_incluster_config() # or config.load_kube_config()
v1 = client.CoreV1Api()
pods = v1.list_namespaced_pod(
namespace="default",
label_selector="app=redis",
)
for pod in pods.items:
print(pod.metadata.name)
print(pod.status.phase)
This is generally better than subprocess + kubectl in automation.
3. Read Prometheus metrics
If Redis is exporting metrics via redis-exporter, scrape/query Prometheus instead of touching Kubernetes directly. That’s usually the best option for “usage” over time.
Typical metrics include things like:
- redis_memory_used_bytes
- redis_connected_clients
- redis_commands_processed_total
4. If you only need container resource usage
If you meant CPU/memory usage of the Redis pod/container rather than Redis internals, then kubectl top is just one frontend for the Kubernetes metrics API. You can query that API directly, though it’s a bit more involved than using the core K8s client.
So the best alternative depends on what “usage” means here:
- Redis internals → use Redis INFO
- Pod/container status → Kubernetes Python client
- Historical monitoring / dashboards → Prometheus
- CPU/memory consumption of pod → metrics API / Prometheus
If you want, paste the exact script from yesterday (or the part that uses kubectl) and I can rewrite it to use either:
- the Redis Python client, or
- the Kubernetes Python client
without shelling out.Russell Brooks
05/13/2026, 1:57 PMMarvin
05/13/2026, 1:58 PMMarvin
05/13/2026, 2:01 PMprefecthq/prefect, not a prefect-server image, for flow run jobs
2. System package installs and update-ca-certificates at pod startup are possible, but they’re usually slower and more fragile than a small custom image
So I’ll give you:
- a modified base job template pattern
- a recommended better approach
- caveats so you know what will and won’t work well
Important image note
For Prefect flow runs on Kubernetes, you generally want the public Prefect image from Docker Hub, e.g.:
prefecthq/prefect:3.7.0
Not a “prefect-server job image”. The server image is for running Prefect services, not your flow-run execution environment.
You can see Prefect deploy docs here:
Prefect deployment docs
And Kubernetes worker docs here:
Prefect Kubernetes integration docs
---
Best practical recommendation
If your goal is:
- add internal CA certs
- install OS packages
- install uv
- install Python deps from pyproject.toml
then the best long-term pattern is usually:
Option A — Better / more reliable
Build a very small bootstrap image based on prefecthq/prefect:3.7.0 and publish it somewhere accessible.
Why this is better:
- much faster pod startup
- fewer runtime failures from apt-get
- no dependency on outbound network during every flow run
- cert handling is cleaner
- easier to audit/reproduce
Option B — Works without custom image
Use the base job template to:
- add an initContainer that clones certs into a shared volume
- mount that volume into the main Prefect container
- override the main container command to run a bootstrap shell script that:
- installs apt packages
- installs uv if needed
- installs Python deps
- then `exec`s the original Prefect command
This is doable, but a bit hacky.
---
Important constraint with certificates
If you mount certs into /usr/local/share/ca-certificates and run update-ca-certificates, that updates the certificate bundle inside that container filesystem. An initContainer cannot directly mutate the main container’s root filesystem.
So for certs, the workable runtime patterns are:
Pattern 1: Shared volume + env vars
- init container clones/copies cert files into a shared volume
- main container mounts the volume
- main container runs update-ca-certificates itself before starting Prefect, or points tools at the cert bundle with env vars like:
- SSL_CERT_FILE
- REQUESTS_CA_BUNDLE
- PIP_CERT
- GIT_SSL_CAINFO
Pattern 2: Main container bootstrap script
- mount cert files into main container
- main container startup script copies them into /usr/local/share/ca-certificates
- run update-ca-certificates
- continue to Prefect engine
That’s the closest to what you asked for.
---
Example base job template
This example assumes:
- Kubernetes work pool
- base image prefecthq/prefect:3.7.0
- cert repo is cloned by an init container
- main container does final setup
- dependencies are installed at runtime
- code comes from your deployment as usual
You would paste/adapt this as your work pool’s base job template.
```json
{
"variables": {
"properties": {
"namespace": {
"title": "Namespace",
"type": "string",
"default": "default"
},
"image": {
"title": "Image",
"type": "string",
"default": "prefecthq/prefect:3.7.0"
},
"service_account_name": {
"title": "Service Account Name",
"type": "string",
"default": "default"
},
"git_cert_repo": {
"title": "Git repo containing CA certs",
"type": "string",
"default": "https://git.example.com/platform/internal-certs.git"
},
"git_cert_branch": {
"title": "Git branch for cert repo",
"type": "string",Marvin
05/13/2026, 2:01 PMMarvin
05/13/2026, 2:01 PM---
*Very important note about `{{ command }}`*
That line is the key idea, but depending on your work pool template/version, the exact injected variable names may differ from how your current base job template is structured.
So before saving this, I strongly recommend:
1. open your current Kubernetes work pool base job template
2. preserve the existing Prefect-provided fields for:
- image
- env
- labels / annotations
- namespace
- command / args
Then only add:
- `initContainers`
- `volumes`
- `volumeMounts`
- bootstrap shell logic
If you paste me your current base job template, I can rewrite it *exactly* instead of giving you a pattern.
---
*What this template does*
- Uses public image `prefecthq/prefect:3.7.0`
- Clones certs from internal git repo in an init container
- Shares those cert files through an `emptyDir`
- Main container:
- runs `apt-get update`
- installs requested system packages
- copies certs into `/usr/local/share/ca-certificates/extra`
- runs `update-ca-certificates`
- sets common CA env vars
- installs `uv` if missing
- optionally clones another repo containing `pyproject.toml`
- runs `uv pip install --system .`
- optionally installs extra packages
- cleans apt and temp files
- finally executes the Prefect flow-run command
---
*How to handle Python deps from `pyproject.toml`*
There are a few variants.
*Simplest*
If the same repo that Prefect pulls flow code from already contains `pyproject.toml`, it may be better to install from the checked-out source before starting the flow.
But this depends on how your deployment pulls code.
If you’re using `flow.from_source(...)`, Prefect handles code retrieval for the flow itself. Your runtime bootstrap script may not know where that source is available before flow execution starts.
So the reliable runtime-only choices are:
1. *Clone the dependency repo separately in the bootstrap step*
2. *Pass an explicit package list via variable*
3. *Use Prefect’s `EXTRA_PIP_PACKAGES` env var for Python-only additions*
The public Prefect image already supports runtime Python package installation via `EXTRA_PIP_PACKAGES`, which is much simpler if you only need Python packages.
Example deployment/job variables:
```json
{
"env": {
"EXTRA_PIP_PACKAGES": "pandas==2.2.2 pyarrow sqlalchemy"
}
}
That won’t help with:
- apt packages
- CA certs
- git-based bootstrap
but it does simplify Python package installation.
---
Better alternative than job template bootstrapping
If you want something cleaner without maintaining a big in-house Dockerfile, here are better patterns:
Option 1: Tiny derived image
Just create a tiny Dockerfile like:
dockerfile
FROM prefecthq/prefect:3.7.0
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf <https://astral.sh/uv/install.sh> | sh
ENV PATH="/root/.local/bin:${PATH}"
Then at runtime, only:
- mount/clone certs
- update-ca-certificates
- install project deps if needed
That is dramatically more reliable.
Option 2: ConfigMap/Secret for certs instead of git clone
If internal CA certs don’t change often, store them as:
- Kubernetes ConfigMap
- or Secret
Then mount directly into the pod.Marvin
05/13/2026, 2:01 PMpyproject.toml / EXTRA_PIP_PACKAGES
This reduces runtime fragility a lot.
---
What I would recommend in order
For production use, I’d rank the approaches:
1. Best: small custom image derived from prefecthq/prefect:3.7.0
2. Next best: public image + mounted ConfigMap/Secret certs + EXTRA_PIP_PACKAGES
3. Works but less ideal: public image + initContainer git clone + runtime apt-get + runtime uv pip install
Reason: every runtime install step increases:
- startup time
- network dependence
- failure surface
- non-reproducibility
---
If you want to avoid a custom image entirely
Then I’d suggest this refinement of your plan:
- Do not clone certs from git on every run if avoidable
- prefer ConfigMap/Secret
- Do not dynamically discover dependencies from arbitrary pyproject.toml unless necessary
- prefer explicit package list, or a pinned lock file
- use public prefecthq/prefect:3.7.0
- use a bootstrap shell wrapper in the job template
- keep runtime apt installs minimal
---
One more practical note
If you install Python packages at runtime and some have native extensions, you may also need OS build deps like:
- build-essential
- python3-dev
- pkg-config
- library headers such as libpq-dev, gcc, etc.
That’s another reason a prebuilt image is usually preferable.
---
If you want, send me your current Kubernetes base job template JSON and I’ll rewrite it into a concrete version that preserves Prefect’s exact required placeholders and injects:
- cert bootstrap
- apt packages
- uv install
- pyproject-based dependency install