Compare commits

...
Author SHA1 Message Date
KilianandCopilot 2111c0f9d6 Add workflow condition contracts and shared catalog packaging (#180)
* Add workflow condition contracts and shared catalog packaging

Define versioned contract and catalog schemas, Start and custom-stage examples, and opt-in bundled/external ConfigMap packaging. Extract classification defaults without changing the rendered default manifests or existing overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump Hub chart to 0.149.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Omit optional classification catalog override by default

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 16:40:48 +02:00
KilianandCopilot eef273bf3c Support explicit IDs for deployment-defined workflows (#179)
Render and validate optional stable workflow IDs, retain legacy name-derived identities, pin documented examples, and exercise identity consistency across API and engine manifests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-29 16:15:02 +02:00
Cédric Verstraeten dc6c79df2e Merge pull request #178 from kerberos-io/feature/extend-workflow-designer
feature/extend-workflow-designer
2026-09-28 17:17:44 +02:00
20 changed files with 929 additions and 62 deletions
@@ -1,15 +1,17 @@
name: Workflows queue consistency
name: Workflows queue and identity consistency
# Fails the build if the analysis producer, the workflows engine and the stage
# workers would render onto different WORKFLOWS_QUEUE names — the silent
# producer/consumer queue-name drift that leaves runs piling up with no
# consumer. Pure `helm template` render check, no cluster required.
# consumer. Also validates stable workflow identities and API/engine definition
# consistency. Pure `helm template` render checks, no cluster required.
on:
workflow_dispatch:
pull_request:
paths:
- 'charts/hub/**'
- 'scripts/check-workflows-queue-consistency.sh'
- 'scripts/check-workflow-configuration.sh'
- '.github/workflows/workflows-queue-consistency.yaml'
permissions:
@@ -27,5 +29,8 @@ jobs:
with:
version: v3.16.2
- name: Check WORKFLOWS_QUEUE consistency
- name: Check workflow queue and identity consistency
run: ./scripts/check-workflows-queue-consistency.sh charts/hub
- name: Check workflow configuration and classification catalog
run: bash scripts/check-workflow-configuration.sh charts/hub
+1 -1
View File
@@ -16,7 +16,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.148.0
version: 0.149.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
+218 -2
View File
@@ -91,6 +91,66 @@ Waves are not a substitute for message compatibility, readiness checks, or
stopping old RabbitMQ consumers. This example does not add health probes or
change the Deployment rolling-update strategy.
### Deployment-global workflow identity
`kerberoshub.workflows.definitions` is a map keyed by workflow name. Enabled
definitions are passed as the same `WORKFLOW_DEFINITIONS` JSON to Hub API and the
workflow engine. These deployment-global, ops-managed workflows are read-only
through the API; manage them in Helm values. There are no per-project on/off
overrides.
Supply an explicit, stable `id` for new definitions:
```yaml
kerberoshub:
workflows:
enabled: true
definitions:
tracking-workflow:
id: "ec7e72bc4cedd32018e1f92a"
enabled: true
stages:
- operation: objecttracking
```
The worker and its queue must also be configured under
`kerberoshub.services.objecttracking`. As before, omitted triggers default to a
single automatic trigger and disabled definitions are not sent to either service.
Identity is separate from mutable names and content versions. Keep an explicit
ID unchanged when renaming or revising a workflow; changing it creates an
independent identity, not a new version of the existing workflow. The chart
does not generate random IDs or introduce a version field.
For compatibility, an omitted `id` remains omitted from the rendered JSON. The
backend derives the effective ID as the first 24 hexadecimal characters of
SHA-256 over the **exact map key**, including case and whitespace. Before
renaming an existing ID-less definition, pin its current derived ID explicitly
to preserve references and history. For example:
```sh
printf '%s' 'tracking-workflow' | sha256sum | cut -c1-24
# ec7e72bc4cedd32018e1f92a
```
The chart ships an empty definitions map. Its commented `tracking-workflow` and
`vlm-workflow` examples pin their current name-derived IDs for compatibility.
Explicit IDs must be quoted, non-zero, 24-character hexadecimal strings (Mongo
ObjectIDs); uppercase is accepted and normalized to lowercase in JSON. Empty,
null, all-zero, malformed, and non-string IDs fail Helm rendering with the
definition name and validation requirement. Duplicate effective IDs also fail,
naming both definitions and the colliding ID, whether explicit/explicit or
explicit/name-derived. Validation includes disabled definitions and runs even
when the engine is disabled, so identities remain reserved. Omit the `id` key
entirely to use the legacy fallback; invalid explicit IDs never fall back.
Run the render-only queue and identity checks (Helm and Python 3 required):
```sh
./scripts/check-workflows-queue-consistency.sh charts/hub
```
### Parameters
Below all configuration options and parameters are listed.
@@ -599,8 +659,10 @@ As mentioned during the Post installation step, you'll import some `.nosql` file
Within the Kerberos Hub front-end you'll see the option to filter through classifications. This filtered is stored in the `settings` collection. By changing the entries of the `classifications` object, you can add, edit or remove items from the filters.
New deployments should define the shared classification list through
`classificationCatalog`. Each entry contains the stable classifier output
The default shared classification list lives in
[`configuration/catalogs/classifications.json`](configuration/catalogs/classifications.json),
independently of workflow contracts. Set `classificationCatalog` to a list to
replace those defaults; unset or `null` uses the bundled file. Each entry contains the stable classifier output
`key`, the user-facing `label`, and an `icon` key. Hub API exposes this catalog
to alerts and filters. When the mounted catalog is unavailable, compatible Hub
API versions fall back to the legacy `settings` document and then the built-in
@@ -615,6 +677,160 @@ classificationCatalog:
icon: vehicle
```
### Workflow condition contracts (preparatory)
This chart defines the initial v1 metadata format and opt-in file packaging.
**Hub API and the frontend do not yet consume these contracts.** Enabling the
packaging only mounts files; it neither enables an editor feature nor registers
workers or changes execution. No new loader environment variable is set.
The earlier device-bound workflow configurations were development fixtures, not
a recommended deployment model. Charts describe capabilities, not selected
devices, project IDs, concrete workflow edges, or user-authored conditions.
Device filtering is optional. Saved selections belong in individual workflows.
The configuration is split into independent shared catalogs and their consumers:
```text
configuration/
catalogs/
classifications.json
workflow-contracts/
start.yaml
schemas/
catalog.schema.json
workflow-contract.schema.json
```
- [Start contract](configuration/workflow-contracts/start.yaml): current device,
site, group and initial-classification presets, plus schedule/condition controls.
- [Custom pose example](examples/workflow-contracts/pose.yaml): hypothetical
worker output, demonstrating a file catalog, inline choices and a numeric field.
This example does not deploy a worker or claim a universal pose-model format.
- [Contract schema](schemas/workflow-contract.schema.json): format, source-name
documentation and YAML editor completion via the `$schema` comment.
- [Catalog schema](schemas/catalog.schema.json): shared JSON lists of
`{key, label, icon?}`, compatible with the current classification file format.
Each contract defines a `stage` type, a `schemaVersion`, a `contractVersion` and
its own `fields`. A field declares `id`, `label`, `path`, scalar `type` and
supported `operators`; `optional`, numeric bounds and `choices` are optional.
The `type` describes each wildcard-selected candidate, not the containing array.
IDs must be unique within a contract. Increment `contractVersion` when changing
its meaning/output shape; the future runtime must bind workflows to compatible
versions rather than silently reinterpreting saved conditions.
#### Choice origins
Use exactly one of these, or omit `choices` for typed user input:
```yaml
choices:
source: project.devices
```
```yaml
choices:
file: ../catalogs/classifications.json
```
```yaml
choices:
values: [standing, sitting, lying]
```
The supported system-resource vocabulary for v1 is:
| `choices.source` | User-facing labels | Stored comparison values |
| --- | --- | --- |
| `project.devices` | Authorized device names | Stable device keys |
| `project.sites` | Authorized site names | Stable site IDs |
| `project.groups` | Authorized group names | Stable group IDs |
These are direct built-in API resolver identifiers, not database paths or
deployer-selected authorization scopes. They still require implementation in the
future loader. Resolvers must reuse existing project/resource authorization and
legacy-owner compatibility; metadata cannot grant access.
File references resolve relative to the mounted contract, within the shared
configuration root. V1 uses `../catalogs/<name>.json`; no URLs, arbitrary file
access or recursive catalog references. Catalog keys are stable strings, with
labels for display; inline lists may contain strings, numbers or booleans matching
the field type. Catalogs are reusable by filters, alerts and other consumers,
not owned by workflows. A library may ship a catalog without implementing an API.
#### Bundled and external packaging
`workflowConfiguration.enabled` defaults to `false`. With it enabled and
`existingConfigMap` empty, the chart mounts the bundled Start contract and the
**effective** classification list under `/etc/kerberos/configuration`, using
the directory layout above. The projected volume reuses the existing
classification ConfigMap, including `classificationCatalog` overrides, rather
than storing another copy in the workflow ConfigMap.
The existing classification ConfigMap, mount path, `CLASSIFICATION_CATALOG_FILE`
and API endpoint remain unchanged. `classificationCatalog: []` remains an
explicit empty list, not a request for chart defaults; the current API falls
back to settings/built-in values for an empty/invalid catalog. The preparatory
bundle does not reproduce that runtime fallback and must not be treated as a
resolved API catalog. The future loader must share the existing classification
loader. Bundled packaging requires `classificationCatalogEnabled: true`; an
external bundle can be packaged independently.
Custom deployers can maintain files in their own repository, create a ConfigMap
in the release namespace, and use the unchanged chart. For example, from this
chart directory (replace the namespace):
```sh
kubectl -n YOUR_NAMESPACE create configmap custom-workflow-configuration \
--from-file=pose.yaml=examples/workflow-contracts/pose.yaml \
--from-file=pose-keypoints.json=examples/catalogs/pose-keypoints.json
```
Then use [the example values overlay](examples/workflow-configuration-values.yaml).
`existingConfigMap` replaces the entire bundled configuration, not individual
files. Include Start and its referenced catalog too if the custom bundle needs
them. `items` explicitly maps ConfigMap keys to files in `workflow-contracts/`
and `catalogs/`; files are mounted read-only, without `subPath`. ConfigMap size
limits apply. Mount the same shared catalog ConfigMap into other consumers as
needed; do not maintain divergent copies of its contents.
The chart checks item paths and required options but cannot inspect an existing
ConfigMap at render time. Validate its files against the supplied schemas.
Bundled changes update the pod checksum. For externally managed ConfigMaps,
manage rollout/reload in your deployment tooling; the chart cannot checksum
their contents.
Helm validates `values.schema.json`, **not** arbitrary bundled YAML/JSON files.
The standalone schemas support authoring validation. The future loader must
also reject duplicate field/catalog keys, unknown sources, missing/invalid
files, incompatible bounds, unsupported contract versions and unsafe paths.
Validation failures must be surfaced, not silently replaced with empty choices.
#### Runtime semantics to preserve when wiring consumers
Start paths refer to sanitized initial context, never credentials. Other stage
paths are relative to that node instance's result. Conditions may use Start and
all available upstream node outputs, not just the immediately preceding stage;
future or unrelated parallel results are unavailable. Node instances remain
distinct. Conditional branches and missing outputs require explicit availability
handling, even for a field not marked `optional`.
Wildcard positive comparisons match any candidate. Independent wildcard
conditions can match different objects; use the existing same-element `anyMatch`
semantics when predicates must match one detection/keypoint.
The existing Start controls retain their behavior: alternatives within a scope
category are OR; populated device/site/group/classification categories, schedule
and advanced conditions are AND. `conditionMode` only combines the advanced
conditions. Empty selections/schedules are unrestricted. Multiple automatic
Start edges are OR for creating one run; only matched entry branches dispatch.
Manual runs are created before continuation conditions are evaluated, and their
automatic trigger settings remain dormant. Schedule uses recording time and an
IANA timezone, not an invented output field. The advanced editor retains logical
groups and existence checks; neither scheduling nor logical groups are scalar
output fields. Contracts do not redefine the saved workflow wire format.
### Indexing
Following indexes should be executed on the MongoDB database (Kerberos) to improve future performance. Within Kerberos Hub several queries are executed, following indexes will improve the loading times. If not applied you might experience application timeouts or reduced performance when storing lots of data.
@@ -0,0 +1,11 @@
[
{"key": "animal", "label": "Animal", "icon": "animal"},
{"key": "pedestrian", "label": "Pedestrian", "icon": "pedestrian"},
{"key": "cyclist", "label": "Cyclist", "icon": "cyclist"},
{"key": "motorbike", "label": "Motorbike", "icon": "motorbike"},
{"key": "lorry", "label": "Lorry", "icon": "lorry"},
{"key": "car", "label": "Car", "icon": "car"},
{"key": "handbag", "label": "Handbag", "icon": "handbag"},
{"key": "suitcase", "label": "Suitcase", "icon": "suitcase"},
{"key": "cell phone", "label": "Cell phone", "icon": "cell phone"}
]
@@ -0,0 +1,44 @@
# yaml-language-server: $schema=../../schemas/workflow-contract.schema.json
# Metadata only; no current API loader consumes this contract.
schemaVersion: 1
stage: start
contractVersion: 1
# Paths refer to the safe initial context, not the full event or credentials.
fields:
- id: device
label: Device
path: device.deviceKey
type: string
operators: [eq, in]
choices:
source: project.devices
- id: sites
label: Sites
path: device.siteIds.*
type: string
operators: [in]
choices:
source: project.sites
- id: groups
label: Groups
path: device.groupIds.*
type: string
operators: [in]
choices:
source: project.groups
- id: classifications
label: Initial classifications
path: inputs.classify.details.*.classified
type: string
operators: [in]
optional: true
choices:
file: ../catalogs/classifications.json
# Existing structured controls, not fields produced by Start.
controls:
- id: weeklySchedule
label: Weekly schedule
- id: conditions
label: Advanced conditions
@@ -0,0 +1,5 @@
[
{"key": "nose", "label": "Nose"},
{"key": "left_shoulder", "label": "Left shoulder"},
{"key": "right_shoulder", "label": "Right shoulder"}
]
@@ -0,0 +1,9 @@
# Packaging only; does not activate contract loading or deploy a pose worker.
workflowConfiguration:
enabled: true
existingConfigMap: custom-workflow-configuration
items:
- key: pose.yaml
path: workflow-contracts/pose.yaml
- key: pose-keypoints.json
path: catalogs/pose-keypoints.json
@@ -0,0 +1,27 @@
# yaml-language-server: $schema=../../schemas/workflow-contract.schema.json
# Hypothetical custom worker output; not an installed stage or universal pose schema.
schemaVersion: 1
stage: pose
contractVersion: 1
fields:
- id: keypoint
label: Keypoint
path: people.*.keypoints.*.name
type: string
operators: [in]
choices:
file: ../catalogs/pose-keypoints.json
- id: posture
label: Posture
path: people.*.posture
type: string
operators: [in]
choices:
values: [standing, sitting, lying]
- id: confidence
label: Keypoint confidence
path: people.*.keypoints.*.confidence
type: number
operators: [gt, gte, lt, lte]
minimum: 0
maximum: 1
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Hub shared choice catalog v1",
"description": "Compatible with the existing classification JSON format. Keys must be unique; no references to other catalogs.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "label"],
"properties": {
"key": {"type": "string", "minLength": 1},
"label": {"type": "string", "minLength": 1},
"icon": {"type": "string", "minLength": 1}
}
}
}
@@ -0,0 +1,142 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Hub workflow condition contract v1 (preparatory)",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "stage", "contractVersion", "fields"],
"properties": {
"schemaVersion": {"const": 1},
"stage": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Stage type, not a node instance. start describes initial context."
},
"contractVersion": {"type": "integer", "minimum": 1},
"fields": {
"type": "array",
"minItems": 1,
"items": {"$ref": "#/definitions/field"}
},
"controls": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label"],
"properties": {
"id": {"enum": ["weeklySchedule", "conditions"]},
"label": {"type": "string", "minLength": 1}
}
}
}
},
"definitions": {
"choices": {
"type": "object",
"additionalProperties": false,
"properties": {
"source": {
"type": "string",
"description": "Built-in authorized project-resource resolver; not a database path.",
"oneOf": [
{"const": "project.devices", "description": "Device keys as values, device names as labels."},
{"const": "project.sites", "description": "Site IDs as values, site names as labels."},
{"const": "project.groups", "description": "Group IDs as values, group names as labels."}
]
},
"file": {
"type": "string",
"pattern": "^\\.\\./catalogs/[A-Za-z0-9_-][A-Za-z0-9._-]*\\.json$",
"description": "Direct shared catalog file relative to the contract; no URLs or nested references."
},
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"type": ["string", "number", "boolean"]}
}
},
"oneOf": [
{"required": ["source"]},
{"required": ["file"]},
{"required": ["values"]}
]
},
"field": {
"type": "object",
"additionalProperties": false,
"required": ["id", "label", "path", "type", "operators"],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_-]*$",
"description": "Stable identifier, unique within this contract."
},
"label": {"type": "string", "minLength": 1},
"path": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_-]*(\\.(\\*|[A-Za-z_][A-Za-z0-9_-]*))*$",
"description": "Safe Start-context path, or relative path inside this node instance's output. Wildcards select scalar candidates."
},
"type": {"enum": ["string", "integer", "number", "boolean"]},
"operators": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {"enum": ["eq", "ne", "in", "exists", "contains", "matches", "gt", "gte", "lt", "lte"]}
},
"optional": {
"type": "boolean",
"default": false,
"description": "Field may be absent even when the node completes; branch availability is determined separately."
},
"minimum": {"type": "number"},
"maximum": {"type": "number"},
"choices": {"$ref": "#/definitions/choices"}
},
"allOf": [
{
"if": {"properties": {"type": {"const": "string"}}},
"then": {
"properties": {
"operators": {"items": {"enum": ["eq", "ne", "in", "exists", "contains", "matches"]}},
"choices": {"properties": {"values": {"items": {"type": "string"}}}}
},
"not": {"anyOf": [{"required": ["minimum"]}, {"required": ["maximum"]}]}
}
},
{
"if": {"properties": {"type": {"const": "boolean"}}},
"then": {
"properties": {
"operators": {"items": {"enum": ["eq", "ne", "in", "exists"]}},
"choices": {"properties": {"values": {"items": {"type": "boolean"}}}}
},
"not": {"anyOf": [{"required": ["minimum"]}, {"required": ["maximum"]}]}
}
},
{
"if": {"properties": {"type": {"enum": ["integer", "number"]}}},
"then": {
"properties": {
"operators": {"items": {"enum": ["eq", "ne", "in", "exists", "gt", "gte", "lt", "lte"]}},
"choices": {"properties": {"values": {"items": {"type": "number"}}}}
}
}
},
{
"if": {"properties": {"type": {"const": "integer"}}},
"then": {"properties": {"choices": {"properties": {"values": {"items": {"type": "integer"}}}}}}
},
{
"if": {"properties": {"type": {"enum": ["integer", "number", "boolean"]}}},
"then": {
"properties": {
"choices": {"not": {"anyOf": [{"required": ["file"]}, {"required": ["source"]}]}}
}
}
}
]
}
}
}
+41
View File
@@ -0,0 +1,41 @@
{{/* Keep the legacy endpoint and the preparatory bundle on the same chart data. */}}
{{- define "hub.classificationCatalog" -}}
{{- if kindIs "invalid" .Values.classificationCatalog -}}
{{- .Files.Get "configuration/catalogs/classifications.json" | mustFromJson | toPrettyJson -}}
{{- else if kindIs "slice" .Values.classificationCatalog -}}
{{- .Values.classificationCatalog | toPrettyJson -}}
{{- else -}}
{{- fail "classificationCatalog must be null (bundled defaults) or a list" -}}
{{- end -}}
{{- end -}}
{{- define "hub.workflowConfiguration.validate" -}}
{{- $config := .Values.workflowConfiguration | default dict -}}
{{- if $config.enabled -}}
{{- if $config.existingConfigMap -}}
{{- if not $config.items -}}
{{- fail "workflowConfiguration.items is required with existingConfigMap" -}}
{{- end -}}
{{- $paths := dict -}}
{{- range $config.items -}}
{{- if not (regexMatch "^[A-Za-z0-9._-]+$" .key) -}}
{{- fail "workflowConfiguration.items keys must be valid ConfigMap keys" -}}
{{- end -}}
{{- if or (not (regexMatch "^(workflow-contracts|catalogs)/[A-Za-z0-9._/-]+$" .path)) (ne (clean .path) .path) -}}
{{- fail "workflowConfiguration.items paths must be normalized relative files under workflow-contracts/ or catalogs/" -}}
{{- end -}}
{{- if hasKey $paths .path -}}
{{- fail (printf "duplicate workflowConfiguration.items path %q" .path) -}}
{{- end -}}
{{- $_ := set $paths .path true -}}
{{- end -}}
{{- else -}}
{{- if $config.items -}}
{{- fail "workflowConfiguration.items requires existingConfigMap" -}}
{{- end -}}
{{- if not .Values.classificationCatalogEnabled -}}
{{- fail "bundled workflowConfiguration requires classificationCatalogEnabled; use an existingConfigMap for a custom bundle" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -6,5 +6,5 @@ metadata:
namespace: {{ .Release.Namespace }}
data:
classifications.json: |-
{{- toPrettyJson .Values.classificationCatalog | nindent 4 }}
{{- include "hub.classificationCatalog" . | nindent 4 }}
{{- end }}
@@ -0,0 +1,12 @@
{{- include "hub.workflowConfiguration.validate" . -}}
{{- $config := .Values.workflowConfiguration | default dict -}}
{{- if and $config.enabled (not $config.existingConfigMap) (or (eq .Values.mode "all") (eq .Values.mode "ui")) }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ printf "%s-workflow-configuration" .Release.Name | trunc 63 | trimSuffix "-" }}
namespace: {{ .Release.Namespace }}
data:
start.yaml: |-
{{- .Files.Get "configuration/workflow-contracts/start.yaml" | nindent 4 }}
{{- end }}
+33 -2
View File
@@ -1,4 +1,5 @@
{{- if or (eq .Values.mode "all") (eq .Values.mode "ui") -}}
{{- $workflowConfiguration := .Values.workflowConfiguration | default dict -}}
{{- if .Values.kerberoshub.api.serviceEnabled }}
apiVersion: v1
kind: Service
@@ -123,6 +124,9 @@ spec:
{{- if .Values.classificationCatalogEnabled }}
checksum/classification-catalog: {{ include (print $.Template.BasePath "/configmap-classification-catalog.yaml") . | sha256sum }}
{{- end }}
{{- if and $workflowConfiguration.enabled (not $workflowConfiguration.existingConfigMap) }}
checksum/workflow-configuration: {{ include (print $.Template.BasePath "/configmap-workflow-configuration.yaml") . | sha256sum }}
{{- end }}
labels:
app: hub-api
spec:
@@ -136,8 +140,30 @@ spec:
{{- $serverTLS := .Values.kerberoshub.api.serverTLS }}
{{- $mongodbTLS := .Values.mongodb.tls }}
{{- $classificationCatalogEnabled := .Values.classificationCatalogEnabled }}
{{- if or $classificationCatalogEnabled .Values.kerberoshub.api.volumes (and $serverTLS.enabled $serverTLS.secretName) (and $mongodbTLS.enabled $mongodbTLS.existingSecret) }}
{{- if or $workflowConfiguration.enabled $classificationCatalogEnabled .Values.kerberoshub.api.volumes (and $serverTLS.enabled $serverTLS.secretName) (and $mongodbTLS.enabled $mongodbTLS.existingSecret) }}
volumes:
{{- if $workflowConfiguration.enabled }}
- name: workflow-configuration
{{- if $workflowConfiguration.existingConfigMap }}
configMap:
name: {{ $workflowConfiguration.existingConfigMap | quote }}
items:
{{- toYaml $workflowConfiguration.items | nindent 14 }}
{{- else }}
projected:
sources:
- configMap:
name: {{ printf "%s-workflow-configuration" .Release.Name | trunc 63 | trimSuffix "-" }}
items:
- key: start.yaml
path: workflow-contracts/start.yaml
- configMap:
name: classification-catalog
items:
- key: classifications.json
path: catalogs/classifications.json
{{- end }}
{{- end }}
{{- if $classificationCatalogEnabled }}
- name: classification-catalog
configMap:
@@ -170,8 +196,13 @@ spec:
ports:
- containerPort: 80
name: http
{{- if or $classificationCatalogEnabled .Values.kerberoshub.api.volumeMounts (and $serverTLS.enabled $serverTLS.secretName) (and $mongodbTLS.enabled $mongodbTLS.existingSecret) }}
{{- if or $workflowConfiguration.enabled $classificationCatalogEnabled .Values.kerberoshub.api.volumeMounts (and $serverTLS.enabled $serverTLS.secretName) (and $mongodbTLS.enabled $mongodbTLS.existingSecret) }}
volumeMounts:
{{- if $workflowConfiguration.enabled }}
- name: workflow-configuration
mountPath: /etc/kerberos/configuration
readOnly: true
{{- end }}
{{- if $classificationCatalogEnabled }}
- name: classification-catalog
mountPath: /etc/kerberos/classifications
@@ -1,3 +1,28 @@
{{/*
Reserve every effective identity, including disabled definitions, so enabling a
workflow later cannot silently alias another workflow's runs or references.
*/}}
{{- define "kerberoshub.workflows.validateDefinitions" -}}
{{- $identities := dict -}}
{{- range $name, $wf := .Values.kerberoshub.workflows.definitions -}}
{{- $id := sha256sum $name | trunc 24 -}}
{{- if hasKey $wf "id" -}}
{{- $invalid := printf "kerberoshub.workflows.definitions[%q].id must be a non-zero 24-character hexadecimal string (Mongo ObjectID); omit id for name-derived compatibility" $name -}}
{{- if not (kindIs "string" $wf.id) -}}
{{- fail $invalid -}}
{{- end -}}
{{- if or (not (regexMatch "^[0-9a-fA-F]{24}$" $wf.id)) (eq $wf.id "000000000000000000000000") -}}
{{- fail $invalid -}}
{{- end -}}
{{- $id = lower $wf.id -}}
{{- end -}}
{{- if hasKey $identities $id -}}
{{- fail (printf "kerberoshub.workflows.definitions: duplicate effective workflow id %q for %q and %q (including disabled definitions)" $id (index $identities $id) $name) -}}
{{- end -}}
{{- $_ := set $identities $id $name -}}
{{- end -}}
{{- end -}}
{{/*
Assemble the deployment-global workflow definitions (WORKFLOW_DEFINITIONS) as a
JSON array from every *enabled* workflow under kerberoshub.workflows.definitions.
@@ -7,9 +32,9 @@ opens its own run and dispatches only its own stages. Organisation-scoped
database workflows are discovered separately at runtime.
Each enabled definition contributes one workflow object:
name the map key (the workflow's human-readable name; also its identity —
the engine derives a stable id from it when the definition carries
no explicit id).
id optional explicit stable identity, normalized to lowercase. Omitted
for legacy definitions; their effective id is SHA256(name)[:24].
name the map key (mutable display name when an explicit id is supplied).
enabled always true here (a disabled definition is skipped entirely).
source "config" — provenance marking a Helm-defined, deployment-global,
ops-managed workflow (read-only in the API, no owning organisation).
@@ -28,6 +53,7 @@ Each enabled definition contributes one workflow object:
needsMode conditional stages: "any" (default) | "all".
*/}}
{{- define "kerberoshub.workflows.workflowDefinitions" -}}
{{- include "kerberoshub.workflows.validateDefinitions" . -}}
{{- $defs := list -}}
{{- $services := .Values.kerberoshub.services | default dict -}}
{{- range $name, $wf := .Values.kerberoshub.workflows.definitions -}}
@@ -43,6 +69,7 @@ Each enabled definition contributes one workflow object:
{{- $stages = append $stages $entry -}}
{{- end -}}
{{- $def := dict "name" $name "enabled" true "source" "config" "triggers" (default (list (dict "type" "automatic")) $wf.triggers) "stages" $stages -}}
{{- if hasKey $wf "id" }}{{- $_ := set $def "id" (lower $wf.id) -}}{{- end }}
{{- $defs = append $defs $def -}}
{{- end -}}
{{- end -}}
@@ -1,3 +1,4 @@
{{- include "kerberoshub.workflows.validateDefinitions" . -}}
{{- if and (or (eq .Values.mode "all") (eq .Values.mode "pipeline")) .Values.kerberoshub.workflows.enabled -}}
apiVersion: apps/v1
kind: Deployment
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"classificationCatalog": {"type": ["array", "null"]},
"workflowConfiguration": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "existingConfigMap", "items"],
"properties": {
"enabled": {"type": "boolean"},
"existingConfigMap": {
"type": "string",
"maxLength": 253,
"pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"
},
"items": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["key", "path"],
"properties": {
"key": {"type": "string", "pattern": "^[A-Za-z0-9._-]+$"},
"path": {"type": "string", "pattern": "^(workflow-contracts|catalogs)/[A-Za-z0-9_-][A-Za-z0-9._-]*\\.(yaml|json)$"}
}
}
}
}
}
}
}
+46 -49
View File
@@ -74,34 +74,20 @@ mongodb:
# downstream processing. Classifier output keys must match these values.
# Disable this to use the legacy classification catalog stored in MongoDB.
classificationCatalogEnabled: true
classificationCatalog:
- key: animal
label: Animal
icon: animal
- key: pedestrian
label: Pedestrian
icon: pedestrian
- key: cyclist
label: Cyclist
icon: cyclist
- key: motorbike
label: Motorbike
icon: motorbike
- key: lorry
label: Lorry
icon: lorry
- key: car
label: Car
icon: car
- key: handbag
label: Handbag
icon: handbag
- key: suitcase
label: Suitcase
icon: suitcase
- key: cell phone
label: Cell phone
icon: cell phone
# classificationCatalog is optional and omitted by default, which uses
# configuration/catalogs/classifications.json. Set it to a list to override.
# Explicit null also uses defaults; [] retains the API fallback behavior.
# Preparatory configuration packaging only: the API does not yet load workflow
# contracts. Enabling this mounts files but does not activate a new editor/runtime.
workflowConfiguration:
enabled: false
# Empty uses the bundled Start contract and effective classification catalog.
# Otherwise mount this existing ConfigMap instead; it must be in this namespace.
existingConfigMap: ""
# Required only with existingConfigMap: map ConfigMap keys to relative files
# under /etc/kerberos/configuration (workflow-contracts/... or catalogs/...).
items: []
###################################################
# MQTT configuration (bi-directional communication)
###################################################
@@ -791,6 +777,13 @@ kerberoshub:
# more config workflows.
#
# Each definition:
# id optional stable identity: quote a non-zero 24-hex Mongo ObjectID.
# Recommended for new definitions; rendered lowercase. Omit for
# legacy SHA256(exact map key)[:24] identity. Pin that old ID before
# renaming an existing workflow; changing id creates a new identity.
# Invalid IDs and duplicate effective IDs fail Helm rendering,
# including disabled definitions. Name/version are not identity
# when id is explicit. No per-project on/off overrides are provided.
# enabled include this workflow (soft-delete toggle).
# source always rendered as "config" (a Helm-defined, ops-managed,
# deployment-global workflow — read-only in the API).
@@ -815,27 +808,31 @@ kerberoshub:
# kerberoshub.services.<operation> (deploy the objecttracking / loitering
# workers below).
definitions: {}
#tracking-workflow:
# enabled: true
# triggers:
# - type: automatic
# stages:
# - operation: objecttracking
# dispatch: always
# - operation: loitering
# dispatch: conditional
# # Fire loitering once objecttracking has resolved — a readiness join
# # (no condition ⇒ gate on the upstream's presence, not a value).
# needs:
# - operation: objecttracking
#vlm-workflow:
# # Describe every recording with the hub-vlm worker (services.vlm below).
# enabled: true
# triggers:
# - type: automatic
# stages:
# - operation: vlm
# dispatch: always
# Replace {} above with the indented examples below to enable them. Their IDs
# are pinned to the existing name-derived identities; do not regenerate them.
# tracking-workflow:
# id: "ec7e72bc4cedd32018e1f92a"
# enabled: true
# triggers:
# - type: automatic
# stages:
# - operation: objecttracking
# dispatch: always
# - operation: loitering
# dispatch: conditional
# # Fire loitering once objecttracking has resolved — a readiness join
# # (no condition ⇒ gate on the upstream's presence, not a value).
# needs:
# - operation: objecttracking
# vlm-workflow:
# id: "9e3666c56854b2befdb1f645"
# # Describe every recording with the hub-vlm worker (services.vlm below).
# enabled: true
# triggers:
# - type: automatic
# stages:
# - operation: vlm
# dispatch: always
# Stages tenants may place on the workflow editor canvas next to the device
# node. Each entry needs a deployed worker (services.<operation>.queue);
# entries without one are hidden. Empty means the editor offers no stages.
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Chart-only checks; no cluster or application loader required.
set -euo pipefail
CHART_DIR="${1:-charts/hub}"
assert_contains() {
if ! grep -Fq -- "$2" <<<"$1"; then
printf 'FAIL: expected rendered output to contain %s\n' "$2" >&2
exit 1
fi
}
assert_absent() {
if grep -Fq -- "$2" <<<"$1"; then
printf 'FAIL: unexpected rendered output contains %s\n' "$2" >&2
exit 1
fi
}
expect_failure() {
local expected="$1"
shift
local output
if output="$(helm template hub "$CHART_DIR" "$@" 2>&1)"; then
printf 'FAIL: render unexpectedly succeeded: %s\n' "$*" >&2
exit 1
fi
assert_contains "$output" "$expected"
}
if grep -q '^classificationCatalog:' "$CHART_DIR/values.yaml"; then
echo "FAIL: classificationCatalog should be omitted from default values" >&2
exit 1
fi
default_out="$(helm template hub "$CHART_DIR")"
assert_absent "$default_out" "name: workflow-configuration"
assert_absent "$default_out" "checksum/workflow-configuration"
assert_contains "$default_out" "value: /etc/kerberos/classifications/classifications.json"
legacy_out="$(helm template hub "$CHART_DIR" --set workflowConfiguration=null)"
if [ "$default_out" != "$legacy_out" ]; then
echo "FAIL: absent workflowConfiguration changed the default render" >&2
exit 1
fi
null_out="$(helm template hub "$CHART_DIR" --set classificationCatalog=null)"
if [ "$default_out" != "$null_out" ]; then
echo "FAIL: omitted and null classificationCatalog render differently" >&2
exit 1
fi
# A file-backed default must render identically to the previous inline-list form.
explicit_out="$(helm template hub "$CHART_DIR" \
--set-json "classificationCatalog=$(tr -d '\n' <"$CHART_DIR/configuration/catalogs/classifications.json")")"
if [ "$default_out" != "$explicit_out" ]; then
echo "FAIL: bundled defaults and explicit classification list render differently" >&2
exit 1
fi
override_flags=(--set-json 'classificationCatalog=[{"key":"forklift","label":"Forklift","icon":"vehicle"}]')
override_out="$(helm template hub "$CHART_DIR" "${override_flags[@]}")"
assert_contains "$override_out" '"key": "forklift"'
assert_absent "$override_out" '"key": "pedestrian"'
empty_out="$(helm template hub "$CHART_DIR" --set-json 'classificationCatalog=[]')"
assert_contains "$empty_out" " []"
assert_absent "$empty_out" '"key": "pedestrian"'
disabled_out="$(helm template hub "$CHART_DIR" --set classificationCatalogEnabled=false)"
assert_absent "$disabled_out" "name: classification-catalog"
assert_absent "$disabled_out" "CLASSIFICATION_CATALOG_FILE"
bundle_out="$(helm template hub "$CHART_DIR" --set workflowConfiguration.enabled=true)"
assert_contains "$bundle_out" "checksum/workflow-configuration:"
assert_contains "$bundle_out" "mountPath: /etc/kerberos/configuration"
assert_contains "$bundle_out" "path: workflow-contracts/start.yaml"
assert_contains "$bundle_out" "path: catalogs/classifications.json"
assert_contains "$bundle_out" "source: project.devices"
assert_contains "$bundle_out" "file: ../catalogs/classifications.json"
assert_absent "$bundle_out" "WORKFLOW_CONTRACTS"
bundle_config="$(helm template hub "$CHART_DIR" --set workflowConfiguration.enabled=true \
--show-only templates/configmap-workflow-configuration.yaml)"
assert_absent "$bundle_config" "classifications.json: |-"
override_bundle="$(helm template hub "$CHART_DIR" --set workflowConfiguration.enabled=true "${override_flags[@]}")"
assert_contains "$override_bundle" '"key": "forklift"'
assert_absent "$override_bundle" '"key": "pedestrian"'
external_flags=(-f "$CHART_DIR/examples/workflow-configuration-values.yaml")
external_out="$(helm template hub "$CHART_DIR" "${external_flags[@]}" --set classificationCatalogEnabled=false)"
assert_contains "$external_out" 'name: "custom-workflow-configuration"'
assert_contains "$external_out" "path: workflow-contracts/pose.yaml"
assert_contains "$external_out" "path: catalogs/pose-keypoints.json"
assert_absent "$external_out" "name: hub-workflow-configuration"
assert_absent "$external_out" "checksum/workflow-configuration"
ui_out="$(helm template hub "$CHART_DIR" --set mode=ui --set workflowConfiguration.enabled=true)"
assert_contains "$ui_out" "name: hub-workflow-configuration"
pipeline_out="$(helm template hub "$CHART_DIR" --set mode=pipeline --set workflowConfiguration.enabled=true)"
assert_absent "$pipeline_out" "name: hub-workflow-configuration"
assert_absent "$pipeline_out" "name: workflow-configuration"
expect_failure "requires classificationCatalogEnabled" \
--set workflowConfiguration.enabled=true --set classificationCatalogEnabled=false
expect_failure "items is required" \
--set workflowConfiguration.enabled=true --set workflowConfiguration.existingConfigMap=custom
expect_failure "items requires existingConfigMap" "${external_flags[@]}" --set workflowConfiguration.existingConfigMap=
expect_failure "duplicate workflowConfiguration.items path" "${external_flags[@]}" \
--set 'workflowConfiguration.items[1].path=workflow-contracts/pose.yaml'
expect_failure "workflowConfiguration" "${external_flags[@]}" \
--set 'workflowConfiguration.items[0].path=../../secret.yaml'
expect_failure "workflowConfiguration" "${external_flags[@]}" \
--set 'workflowConfiguration.items[0].path=/etc/secret.yaml'
expect_failure "classificationCatalog" --set classificationCatalog=not-a-list
echo "All workflow configuration and classification catalog checks passed."
+137 -1
View File
@@ -12,6 +12,9 @@
# producer and consumer silently drift onto different queue names and messages
# pile up with no consumer. This check fails the build before that can ship.
#
# Also checks workflow identity validation and identical WORKFLOW_DEFINITIONS
# in the API and engine. Requires Helm and Python 3 (standard library only).
#
# Usage: scripts/check-workflows-queue-consistency.sh [chart-dir]
# (chart-dir defaults to charts/hub, relative to the repo root)
@@ -94,4 +97,137 @@ probe_out="$(helm template hub "${CHART_DIR}" "${RENDER_FLAGS[@]}" \
mapfile -t probe_vals < <(printf '%s\n' "${probe_out}" | extract_workflows_queue)
assert_all_equal "${PROBE}" "override probe" "${probe_vals[@]}" || exit 1
echo "All WORKFLOWS_QUEUE consistency checks passed."
assert_definitions() {
local rendered="$1" expected="$2" label="$3"
printf '%s\n' "$rendered" | python3 -c '
import json
import re
import sys
expected = json.loads(sys.argv[1])
label = sys.argv[2]
actual = {}
for document in sys.stdin.read().split("\n---"):
match = re.search(r"(?m)^[ \t]*- name: WORKFLOW_DEFINITIONS[ \t]*\n[ \t]*value: (\".*\")$", document)
if not match:
continue
name = re.search(r"(?m)^metadata:\n name: (.+)$", document).group(1)
assert name not in actual, f"duplicate WORKFLOW_DEFINITIONS in {name}"
actual[name] = json.loads(json.loads(match.group(1)))
assert set(actual) == {"hub-api", "hub-workflows"}, f"unexpected deployments: {set(actual)}"
for name, definitions in actual.items():
assert definitions == expected, f"{label}: {name}: {definitions!r} != {expected!r}"
print(f"OK ({label}): API and engine receive identical expected workflow definitions")
' "$expected" "$label"
}
assert_render_failure() {
local label="$1" expected="$2"; shift 2
local output
if output="$(helm template hub "${CHART_DIR}" "${RENDER_FLAGS[@]}" "$@" 2>&1)"; then
echo "FAIL (${label}): Helm accepted invalid workflow identities" >&2
return 1
fi
if [[ "$output" != *"$expected"* ]]; then
printf 'FAIL (%s): expected diagnostic %s, got:\n%s\n' "$label" "$expected" "$output" >&2
return 1
fi
echo "OK (${label}): Helm rejected invalid workflow identities"
}
assert_definitions "$default_out" '[]' "empty defaults"
echo "== Checking explicit and legacy workflow definitions =="
identity_fixture='{
" Legacy Workflow ": {
"enabled": true,
"stages": [
{"operation": "queuecheck"},
{"operation": "external", "dispatch": "conditional", "needs": [{"operation": "queuecheck"}], "needsMode": "all"}
]
},
"explicit": {
"id": "ABCDEF0123456789ABCDEF01",
"enabled": true,
"triggers": [{"type": "automatic", "devices": [{"key": "fixture-device"}]}],
"stages": []
},
"disabled": {"id": "abcdef0123456789abcdef02", "enabled": false}
}'
expected_definitions='[
{
"name": " Legacy Workflow ", "enabled": true, "source": "config",
"triggers": [{"type": "automatic"}],
"stages": [
{"operation": "queuecheck", "dispatch": "always", "queue": "queuecheck-fixture-queue"},
{"operation": "external", "dispatch": "conditional", "needs": [{"operation": "queuecheck"}], "needsMode": "all"}
]
},
{
"name": "explicit", "id": "abcdef0123456789abcdef01", "enabled": true, "source": "config",
"triggers": [{"type": "automatic", "devices": [{"key": "fixture-device"}]}],
"stages": []
}
]'
identity_out="$(helm template hub "${CHART_DIR}" "${RENDER_FLAGS[@]}" \
--set-json "kerberoshub.workflows.definitions=$identity_fixture")"
assert_definitions "$identity_out" "$expected_definitions" "explicit ID lowercase; legacy omission; disabled filtering; triggers and stages unchanged"
renamed_out="$(helm template hub "${CHART_DIR}" "${RENDER_FLAGS[@]}" \
--set-json "kerberoshub.workflows.definitions=${identity_fixture/\"explicit\"/\"renamed\"}")"
assert_definitions "$renamed_out" "${expected_definitions/\"explicit\"/\"renamed\"}" "explicit identity survives rename"
legacy_only_out="$(helm template hub "${CHART_DIR}" "${RENDER_FLAGS[@]}" \
--set-json 'kerberoshub.workflows.definitions={"legacy":{"enabled":true}}')"
assert_definitions "$legacy_only_out" \
'[{"name":"legacy","enabled":true,"source":"config","triggers":[{"type":"automatic"}],"stages":[]}]' \
"legacy-only values unchanged"
echo "== Checking bundled example identity pins =="
python3 - "$CHART_DIR/values.yaml" <<'PY'
import hashlib
import pathlib
import re
import sys
values = pathlib.Path(sys.argv[1]).read_text()
for name in ("tracking-workflow", "vlm-workflow"):
expected = hashlib.sha256(name.encode()).hexdigest()[:24]
match = re.search(r"(?m)^ # " + re.escape(name) + r':\n # id: "([^"]+)"$', values)
assert match and match.group(1) == expected, f"{name} must preserve its name-derived ID {expected}"
print(f"OK ({name}): example pins existing identity {expected}")
PY
echo "== Rejecting invalid explicit IDs, including disabled definitions =="
invalid_ids=('""' 'null' '"000000000000000000000000"' '0' '123' 'true' 'false' '[]' '{}'
'"abc"' '"zzzzzzzzzzzzzzzzzzzzzzzz"' '"abcdef0123456789abcdef01 "'
'"abcdef0123456789abcdef0"' '"abcdef0123456789abcdef012"')
invalid_diagnostic='kerberoshub.workflows.definitions["invalid"].id must be a non-zero 24-character hexadecimal string'
for enabled in true false; do
for id in "${invalid_ids[@]}"; do
assert_render_failure "id=$id, enabled=$enabled" "$invalid_diagnostic" \
--set-json "kerberoshub.workflows.definitions={\"invalid\":{\"enabled\":$enabled,\"id\":$id}}"
done
done
# Validation must not depend on either consumer being rendered.
for mode in all ui pipeline; do
assert_render_failure "engine disabled, mode=$mode" "$invalid_diagnostic" \
--set "mode=$mode" --set kerberoshub.workflows.enabled=false \
--set-json 'kerberoshub.workflows.definitions={"invalid":{"enabled":false,"id":null}}'
done
echo "== Rejecting duplicate effective IDs =="
legacy_id="$(python3 -c 'import hashlib; print(hashlib.sha256(b" Legacy Workflow ").hexdigest()[:24])')"
for first_enabled in true false; do
for second_enabled in true false; do
assert_render_failure "explicit collision, enabled=$first_enabled/$second_enabled" \
'duplicate effective workflow id "abcdef0123456789abcdef01" for "first" and "second"' \
--set-json "kerberoshub.workflows.definitions={\"first\":{\"enabled\":$first_enabled,\"id\":\"ABCDEF0123456789ABCDEF01\"},\"second\":{\"enabled\":$second_enabled,\"id\":\"abcdef0123456789abcdef01\"}}"
assert_render_failure "explicit/derived collision, enabled=$first_enabled/$second_enabled" \
"duplicate effective workflow id \"$legacy_id\" for \" Legacy Workflow \" and \"explicit\"" \
--set-json "kerberoshub.workflows.definitions={\" Legacy Workflow \":{\"enabled\":$first_enabled},\"explicit\":{\"enabled\":$second_enabled,\"id\":\"${legacy_id^^}\"}}"
done
done
echo "All workflow queue and identity checks passed."