Developer Guide

Test your Robotic Software with RoboVAST

RoboVAST is designed to facilitate testing and validation of robotic software systems by generating diverse scenarios and executing them in simulation environments. This guide provides an overview of how to utilize RoboVAST for testing your robotic applications.

1. Containerize your Software

As RoboVAST relies on containerization to ensure consistent and reproducible environments, the first step is to create a Docker container for your robotic software.

There are some requirements your container image must fulfill to be compatible with RoboVAST: - the image must contain scenario-execution package installed in /ws/install (which currently is available for ROS2 jazzy) - the image must be accessible by Kubernetes, e.g. by pushing it to a container registry.

2. Define a Test Scenario

Use the examples and the documentation of scenario-execution to create a scenario that tests your robotic software.

Keep in mind, that variations are currently supported for all overwritable scenario parameters as described here.

To test your scenario locally, you can run:

ros2 run scenario_execution_ros scenario_execution_ros <scenario-file> -t -d

3. Create Initial RoboVAST Configuration

Create a RoboVAST configuration file, based on the existing examples in the configs/ directory. Do not set any configuration, as this will be done in the next step.

vast exec local prepare-run --config config1 ./test_run

Afterwards you can verify the scenario, the RoboVAST-configuration and the docker image.

# execute a basic run
./test_run/run.sh

# use different container image
./test_run/run.sh --image <your-container-image>

# analyze issues by using an interactive shell
./test_run/run.sh --shell

# analyze network traffic, by using host network mode
./test_run/run.sh --network-host

# check that a standalone non-GUI environment (like in Kubernetes) works
./test_run/run.sh --no-gui

# enable extra scenario-execution output: live py-tree (-t) and debug log (-d)
./test_run/run.sh -t -d

To enable GUI visualization (e.g. RViz) for local runs while keeping cluster runs headless, add execution.local.parameter_overrides in your .vast file (see Configuration).

Next, it is important to verify that the output (e.g. ROS bag) is stored correctly.

vast exec local run --config config1 ./test_out

# check that output is created in ./test_out/<campaign-name>-<timestamp>/<config-name>/<run_number>
ls -l ./test_out/*-*/config1/0/

Once you are satisfied that the scenario and configuration work as expected, you can proceed to the next step.

4. Define Configurations

Define configurations in your .vast file. A good procedure is to add configurations one-by-one and analyze the result.

# 1. add configuration in config file

# 2. list created configurations
vast config list

# 3. try local execution with one of the created configurations
vast exec local run --config <config-name> --runs 1 ./test_out

5. Execute in Cluster

Once you have defined your configurations and verified local execution, you can run the tests in a Kubernetes cluster.

A good practice is, to first run a single configuration to verify that everything works as expected.

# 1. run single configuration in cluster, once
vast exec cluster run --config config1 --runs 1

# 2. upload results to share service (or use download-cleanup to just remove S3 buckets)
vast exec cluster upload-to-share
# Results can then be retrieved with: vast results download
# Files are organized as: <results-dir>/<campaign-name>-<timestamp>/<config-name>/<run_number>/

vast exec cluster run is fire-and-forget: it launches an in-cluster controller pod that drives the campaign and returns immediately. The campaign runs in the background in the cluster:

# Monitor job status (shows progress per run when multiple runs are active)
vast exec cluster monitor

# Clean up after jobs complete (all campaigns, or use --campaign for a specific campaign)
vast exec cluster run-cleanup

By default, a new run does not clean up previous runs, so you can run multiple runs in parallel. Use --cleanup to remove previous runs before starting (e.g. vast exec cluster run --cleanup).

Running local container images in minikube

To test local container images in a minikube cluster, you can load the image into minikube’s Docker environment.

# first terminal
docker run --rm -it --network=host alpine ash -c "apk add socat && socat TCP-LISTEN:5000,reuseaddr,fork TCP:$(minikube ip):5000"

# second terminal
./container/build.sh --push

# specify the image in your RoboVAST configuration file

6. Analysis

RoboVAST provides a GUI for analyzing run results, which is based on user-provided Jupyter notebooks.

To develop the notebooks, it is recommended to use e.g. VSCode. For the RoboVAST GUI to work, it is expected to contain a DATA_DIR definition. The RoboVAST GUI will replace this line with the actual path to the results directory. During development you can set this variable manually to point to your results directory.

# for single-run (specific run of a configuration)
DATA_DIR = '<path-to-your-results-directory>/<campaign-name>-<timestamp>/<config-name>/<run_number>'
# for configuration (all configurations)
DATA_DIR = '<path-to-your-results-directory>/<campaign-name>-<timestamp>/<config-name>'
# for complete run
DATA_DIR = '<path-to-your-results-directory>/<campaign-name>-<timestamp>'

In case you are using ROS bags as output format, it is recommended to postprocess the results before analysis. This can be done with the postprocessing commands defined in the configuration file. RoboVAST provides several conversion scripts for common use-cases.

Postprocessing is cached based on the results directory hash. To bypass the cache and force postprocessing (e.g., after updating postprocessing scripts), use the --force or -f flag:

Afterwards you can start the GUI:

vast results postprocess
# or, to force postprocessing even if results are unchanged:
vast results postprocess --force
vast evaluation gui

Note

The GUI discovers campaigns exclusively from a per-campaign ``campaign.db`` store — it does not walk the results filesystem. Search campaigns write this store live; batch campaigns are indexed post-hoc from their results tree. vast evaluation gui indexes any missing batch stores automatically before launching, but you can also (re)build them explicitly:

vast evaluation index            # build/refresh campaign stores
vast evaluation index --force    # rebuild even if up to date

The store also carries the campaign mode (batch/search), so the GUI renders the search batch level and resolves the evaluation.visualization notebooks from the recorded config_dir. See Campaign Store and Results Indexing for the schema and internals.

Container Image Compatibility Version

RoboVAST enforces a compatibility version between the host Python code and the Docker container image. This prevents cryptic runtime failures when the two sides are out of sync (e.g. after updating one without the other).

How it works

A single integer COMPAT_VERSION is defined in src/robovast/common/execution.py. The same value is baked into the container image as the file /etc/robovast_compat_version.

Before any container starts, the version is checked by reading /etc/robovast_compat_version from inside the container:

  • Local execution: the generated run.sh script checks the file before docker-compose up.

  • Cluster execution: a Kubernetes init container reads the file and compares it to the expected value.

  • Postprocessing: docker_exec.sh checks the file before docker run.

If the versions do not match (or the file is missing), execution fails immediately with a clear error message.

When to bump the version

Bump COMPAT_VERSION when the contract between host scripts and the container changes:

  • A new Python or system package is required inside the container

  • The ROS distribution changes

  • The interface of mounted scripts changes (e.g. ros2_exec.sh, entrypoint.sh)

  • A postprocessing script requires a new ROS package

How to bump the version

  1. Increment COMPAT_VERSION in src/robovast/common/execution.py

  2. Update the LABEL and RUN echo lines in container/robovast/Dockerfile to match

  3. Rebuild and push the container image

The CI workflow (image.yml) validates that all three values are in sync before building the image.

Extending RoboVAST

Add Variation Plugin

Provide your custom variation type by creating a class that inherits from robovast.common.variation.Variation.

To your pyproject.toml, add an entry under [tool.poetry.plugins.”robovast.variation_types”] to register your variation type. The key is the name used in the RoboVAST configuration file, and the value is the import path to your variation class.

[tool.poetry.plugins."robovast.variation_types"]
"YourVariation" = "robovast_<yourplugin>.your_variation:YourVariation"

A variation can also be loaded from a local file relative to the .vast without packaging it — reference it as <path>.py:<Class> wherever a variation name is expected (in a configuration[].variations list or a search.variations template). This is the same ./path.py:Class convention used by search strategies, extractors and postprocessing plugins:

variations:
- variations/wind.py:WindFieldVariation:
    wind_speed: 5.0

See configs/examples/quadrotor_landing/variations/wind.py for a runnable example (a wind model that derives the simulator’s wind_strength), wired into the quadrotor search vasts.

Note

Packaging a variation plugin as its own distribution. If your variation types live in a separate installable package (as robovast-nav does for FloorplanVariation, PathVariation*, ObstacleVariation*), it must be installed everywhere scenario variations get composed — not just where scenarios run. For local and host-driven cluster runs that’s the host venv; for vast execution cluster run (search/batch) composition happens inside the in-cluster controller pod, so the controller image (container/controller/Dockerfile) must install the plugin package too, or composing a config that references your variation type will fail there with Unknown variation class.

Two pitfalls when exposing the package as a poetry extra (e.g. nav = ["robovast-nav"]):

  • The extra’s package name must also be declared as an optional dependency in [tool.poetry.dependencies] (e.g. robovast-nav = {path = "src/robovast_nav", optional = true} for an in-repo sibling package) — poetry check catches the mismatch if not.

  • The controller’s dev-iteration fast path (controller_launcher.build_dev_wheels) builds a wheel of the current robovast source for quick redeploys; if your plugin lives in a separate poetry project under src/, it needs its own wheel built and shipped alongside (as robovast_nav does) or dev changes to it won’t reach the controller pod.

If your plugin’s package pulls in a dependency that itself needs system shared libraries (e.g. robovast-nav hard-depends on pyside6-essentials, whose bundled Qt6 libs need libGL.so.1 and friends to even import, regardless of whether any GUI is ever shown), the controller image needs those apt packages too — see the container/controller/Dockerfile apt-get block for the list verified against robovast-nav. A missing system lib shows up the same way as a missing extra: the plugin’s entry point fails to load and the variation type is reported as unknown.

Add Command-line Plugin

To create a plugin for the vast CLI:

  1. Create a Click group or command in your package

  2. Register it in your pyproject.toml under [tool.poetry.plugins.”robovast.cli_plugins”]

  3. The plugin will be automatically discovered and added to the vast command

Example plugin registration:

[tool.poetry.plugins."vast.plugins"]
variation = "variation_utils.cli:variation"

Add Metadata Processing Plugin

Metadata processing plugins run after the generic and variation-plugin metadata phases and can modify the metadata.yaml produced for each campaign. They are configured in the .vast file under results_processing.metadata_processing:

results_processing:
  metadata_processing:
    - my_metadata_plugin
    - my_metadata_plugin:
        param1: value1
        param2: value2

Each plugin must subclass robovast.common.metadata.MetadataProcessor and implement the process_metadata method:

from pathlib import Path
from robovast.common.metadata import MetadataProcessor

class MyMetadataPlugin(MetadataProcessor):

    def process_metadata(self, metadata: dict, campaign_dir: Path) -> dict:
        # Modify metadata as needed
        metadata["custom_field"] = "custom_value"
        return metadata

Register the plugin in your package’s pyproject.toml:

[tool.poetry.plugins."robovast.metadata_processing"]
my_metadata_plugin = "my_package.metadata:MyMetadataPlugin"

Add Variation Plugin Metadata Hook

The Variation base class defines an overridable classmethod that returns an empty dict by default. Subclasses implement it to attach domain-specific metadata to each configuration entry in metadata.yaml:

from pathlib import Path
import yaml
from robovast.common.variation import Variation

class MyVariation(Variation):

    @classmethod
    def collect_config_metadata(cls, config_entry, config_dir: Path,
                                 campaign_dir: Path) -> dict:
        """Load extra metadata from a YAML sidecar in _config/."""
        data_file = config_dir / "_config" / "my_data.yaml"
        if data_file.exists():
            with open(data_file) as f:
                return {"my_data": yaml.safe_load(f)}
        return {}

collect_config_metadata is called once per configuration that used the variation and returns a dictionary that is merged into the configuration’s metadata entry.

Add PROV-O Provenance Hook to a Variation Plugin

Variation plugins can contribute domain-specific nodes to the campaign’s PROV-O provenance graph by overriding collect_prov_metadata on the Variation base class. The default implementation returns None (no contribution).

This hook is the right place for provenance that is tightly coupled to a specific variation — for example, a floorplan generation variation knows which map and mesh files it produced and can declare their lineage in the graph.

Return type: ProvContribution (or None to contribute nothing):

from robovast.common.variation import Variation, ProvContribution

class MyVariation(Variation):

    @classmethod
    def collect_prov_metadata(
        cls,
        config_entry: dict,
        campaign_namespace,   # rdflib.Namespace for the campaign
        config_namespace,     # rdflib.Namespace for this config
        gen_activity_id: str, # IRI of the config-generation activity
        vast_id: str, # IRI of the vast file that contains it
    ):
        """Contribute domain-specific PROV-O nodes."""
        from rdflib import PROV, Namespace

        _ID, _TYPE = "@id", "@type"
        MY_NS = Namespace("https://example.org/metamodels/")

        config_cfg = config_entry.get("config", {})
        my_file = config_cfg.get("my_output_file", "")
        if not my_file:
            return None

        file_iri = config_namespace[my_file]

        return ProvContribution(
            # Extra graph nodes (entities, activities) appended to @graph
            graph_nodes=[{
                _ID: file_iri,
                _TYPE: PROV["Entity"],
                "wasGeneratedBy": gen_activity_id,
                MY_NS["someProperty"]: "value",
            }],
            # Properties merged onto the concrete scenario node
            scenario_properties={MY_NS["outputCount"]: 1},
            # IRIs that each run activity should declare as "used"
            run_used_iris=[file_iri],
        )

ProvContribution fields:

graph_nodes

List of JSON-LD node dictionaries appended to the PROV @graph. Use rdflib.PROV, rdflib.DCTERMS, or your own Namespace objects as keys/values.

scenario_properties

Dict merged onto the concrete scenario entity node for this configuration. Useful for adding counts or classification properties (e.g. number of goals, number of obstacles).

run_used_iris

List of IRIs that every run activity in this configuration will declare as prov:used. Typically the IRIs of entities generated by this variation that are consumed at runtime (e.g. a map file, a mesh file).

Note

collect_prov_metadata receives rdflib.Namespace objects (campaign_namespace, config_namespace) so you can construct campaign-relative IRIs with campaign_namespace["some/path"]. rdflib is a required dependency of the core robovast package.

Add Postprocessing Command Plugin

Postprocessing plugins are Python functions that process run result directories (e.g., convert rosbag data to CSV). They are registered as entry points and executed before analysis.

Return value: A plugin must return (success: bool, message: str). It may optionally return a third value, a list of provenance entries, so that each produced file is recorded (e.g. which CSV was created from which rosbag). Each entry is a dict with keys: output (path relative to results_dir), sources (list of paths), plugin (plugin name), params (optional dict). If returned, these entries are merged and written into postprocessing.yaml in each run folder (<campaign-name>-<timestamp>/<config>/<run-number>/).

Provenance for container scripts: Plugins that run scripts inside Docker (e.g. via docker_exec.sh) cannot return data directly. The orchestrator passes a provenance file path to each plugin (optional kwarg provenance_file). Container-invoking plugins must pass this to docker_exec.sh as --provenance-file HOST_PATH; docker_exec.sh mounts the directory at /provenance in the container and the script receives --provenance-file /provenance/<basename>. The script should write a JSON file at that path with format {"entries": [{"output": "...", "sources": [...], "plugin": "...", "params": {}}]} (paths relative to the results/input directory). Use the helper write_provenance_entry from rosbags_common (same directory as the scripts, so it works in the container) to append entries; the script gets the path from --provenance-file and uses its own plugin name when calling the helper.

Creating a Postprocessing Plugin:

from typing import Tuple, Optional, List

def my_postprocessing_command(
    results_dir: str,
    config_dir: str,
    custom_param: Optional[str] = None,
    provenance_file: Optional[str] = None,
) -> Tuple[bool, str]:
    """Convert custom data to CSV.

    Args:
        results_dir: Path to the <campaign-name>-<timestamp> run directory to process
        config_dir: Config file directory (for resolving relative paths)
        custom_param: Optional custom parameter
        provenance_file: Optional path for provenance JSON (for container scripts)

    Returns:
        Tuple of (success, message) or (success, message, provenance_entries)
    """
    import subprocess
    import os

    script = os.path.join(config_dir, "tools/script.sh")
    cmd = [script, results_dir]
    if custom_param:
        cmd.extend(["--param", custom_param])

    result = subprocess.run(cmd, capture_output=True, text=True)

    if result.returncode != 0:
        return False, f"Failed: {result.stderr}"
    return True, "Success"

Register in pyproject.toml:

[tool.poetry.plugins."robovast.postprocessing_commands"]
my_postprocessing_command = "your_package.postprocessing_plugins:my_postprocessing_command"

Usage in .vast config:

analysis:
  postprocessing:
    - my_postprocessing_command:
        custom_param: value

Add Publication Plugin

Publication plugins package or distribute the results directory after postprocessing. They are plain callables (functions or class instances) that operate on the full results directory.

Return value: A plugin must return (success: bool, message: str).

Creating a Publication Plugin:

from typing import Optional, Tuple

def my_publication_plugin(
    results_dir: str,
    config_dir: str,
    destination: Optional[str] = None,
) -> Tuple[bool, str]:
    """Upload results to a remote storage location.

    Args:
        results_dir: Path to the results directory (parent of campaign directories).
        config_dir: Directory containing the .vast config file; relative
            paths should be resolved from here.
        destination: Remote destination URL or path.

    Returns:
        Tuple of (success, message).
    """
    import subprocess
    dest = destination or "s3://my-bucket/results/"

    result = subprocess.run(
        ["aws", "s3", "sync", results_dir, dest],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        return False, f"Upload failed: {result.stderr}"
    return True, f"Uploaded results to {dest}"

Register in pyproject.toml:

[tool.poetry.plugins."robovast.publication_plugins"]
my_publication_plugin = "your_package.publication_plugins:my_publication_plugin"

Usage in .vast config:

results_processing:
  publication:
    - my_publication_plugin:
        destination: s3://my-bucket/results/

Add Cluster Config Plugin

To add a new cluster configuration option for RoboVAST, create a class that inherits from robovast.execution.cluster_config.base.BaseConfig. Register your cluster config in your pyproject.toml under [tool.poetry.plugins.”robovast.cluster_configs”]. The key is the name used to select the configuration, and the value is the import path to your configuration class.

[tool.poetry.plugins."robovast.cluster_configs"]
"YourClusterConfig" = "robovast_<yourplugin>.your_cluster_config:YourClusterConfig"

To test your cluster configuration, you can use:

vast exec cluster prepare-setup --cluster-config YourClusterConfig ./setup_output

The output directory will contain all necessary files and instructions to manually execute the setup steps for your cluster configuration and execution.

Add Share Provider Plugin

Share providers are discovered as entry-point plugins under the robovast.share_providers group. They determine where a finished campaign is uploaded (see Sharing Results). To add a new provider:

  1. Create a provider class that inherits from BaseShareProvider and implements the three abstract methods:

    import os
    
    from robovast.execution.cluster_execution.share_providers.base import (
        BaseShareProvider,
        UploadProgressReader,
    )
    
    class MyShareProvider(BaseShareProvider):
        SHARE_TYPE = "myshare"
    
        def required_env_vars(self) -> dict[str, str]:
            return {
                "ROBOVAST_SHARE_URL": "URL of the target folder",
                "MY_SHARE_TOKEN":     "API token for the share service",
            }
    
        def build_pod_env(self) -> dict[str, str]:
            return {
                "MY_SHARE_URL":   os.environ["ROBOVAST_SHARE_URL"],
                "MY_SHARE_TOKEN": os.environ["MY_SHARE_TOKEN"],
            }
    
        def upload_archive(self, archive_path, object_name, progress_callback=None):
            total = os.path.getsize(archive_path)
            with open(archive_path, "rb") as fh:
                body = UploadProgressReader(
                    fh, total, progress_callback=progress_callback)
                ...  # PUT/stream `body` to the share, raising on failure
    
  2. Implement upload_archive(). It runs in-process in the controller pod (no sidecar, no subprocess), reads credentials from os.environ (populated by build_pod_env()), and uploads the local archive_path. Wrap the request body in UploadProgressReader so the (bytes_sent, total_bytes) progress_callback drives the live upload bar in vast exec cluster monitor.

    Optionally override verify_access() with a cheap authenticated check so a bad configuration fails the pre-flight credential check before any batches run.

  3. Register the provider in your package’s pyproject.toml:

    [tool.poetry.plugins."robovast.share_providers"]
    myshare = "mypackage.myshare:MyShareProvider"
    
  4. Re-install the package (pip install -e .) so the entry point is registered.

After that, ROBOVAST_SHARE_TYPE=myshare in .env will select your provider automatically.

Share provider API reference

class robovast.execution.cluster_execution.share_providers.base.BaseShareProvider

Base class for all share providers.

A share provider encapsulates everything needed to upload a tar.gz archive to a remote storage service (Nextcloud, Google Drive, …) and to download it back. Both directions run in-process in the controller pod (no archiver sidecar, no kubectl exec): see robovast.execution.cluster_execution.in_pod_upload.

Subclasses must:

  • Set SHARE_TYPE to the provider name (matching the entry-point key).

  • Declare all required environment variables in required_env_vars().

  • Implement upload_archive() (the in-process transfer).

  • Provide environment variables for the controller pod via build_pod_env() (the launcher resolves host credentials — e.g. a key file into inline JSON/PEM — and injects them into the pod environment).

The constructor automatically validates that all required env vars are present; it raises click.UsageError if any are missing. Values are read from os.environ (which is already populated by python-dotenv before the provider is instantiated).

To add a new provider:

  1. Create a new file in this package (e.g. myshare.py).

  2. Subclass BaseShareProvider, fill in the abstract members.

  3. Register the provider in pyproject.toml under [tool.poetry.plugins."robovast.share_providers"].

SHARE_TYPE: str = ''

Short identifier for the provider, e.g. "nextcloud" .

archive_exists_on_share(object_name: str) bool

Return True if object_name already exists on the share.

Used by cluster upload-to-share to skip uploads when the archive is already present (unless --force is given). Only meaningful for providers that support remote listing or HTTP HEAD checks.

The default implementation always returns False (no skip), so providers that do not override this method will always re-upload.

Parameters:

object_name – Filename of the archive on the server (e.g. campaign-2025-02-27-123456.tar.gz).

Returns:

True if the archive already exists on the share, False otherwise.

abstract build_pod_env() dict[str, str]

Return environment variables to inject into the pod exec call.

These variables will be set for the upload script executed inside the archiver container. Include everything the script needs: URLs, tokens, credentials, etc.

The return value is merged into the pod’s environment via the --env flag of kubectl exec.

Returns:

Mapping of variable name to value.

Return type:

dict[str, str]

download_archive(object_name: str, dest_path: str, progress_callback=None, resume_offset: int = 0) None

Download object_name from the share to the local dest_path.

Parameters:
  • object_name – The object/file name on the share (as returned by list_campaign_archives()).

  • dest_path – Absolute local path to write the downloaded file to.

  • progress_callback – Optional callable (bytes_received, total_bytes) called periodically during the download.

  • resume_offset – Byte offset to resume downloading from. When non-zero the provider should skip the first resume_offset bytes and append to dest_path.

Raise NotImplementedError if the provider does not support downloading (default).

list_campaign_archives() list[str]

Return a list of campaign *.tar.gz object names on the share.

Archives whose base name (without .tar.gz) matches the campaign naming convention (<campaign-name>-YYYY-MM-DD-HHMMSS) are returned.

Raise NotImplementedError if the provider does not support downloading (default). Implementations should return bare object names (keys), not full URLs.

The default implementation delegates to list_campaign_archives_with_size() and discards the size. Override list_campaign_archives_with_size() to provide sizes.

list_campaign_archives_with_size() list[tuple[str, int]]

Return a list of (object_name, size_in_bytes) for each campaign-*.tar.gz object on the share.

size_in_bytes is -1 when the provider cannot determine the file size. Raise NotImplementedError if the provider does not support listing at all (default).

Implementations should return bare object names (keys), not full URLs.

remove_archive(object_name: str) None

Remove object_name from the share.

Parameters:

object_name – The object/file name on the share (as returned by list_campaign_archives()).

Raise NotImplementedError if the provider does not support removal (default).

abstract required_env_vars() dict[str, str]

Return a mapping of environment-variable name → human-readable description.

All listed variables must be non-empty strings in the environment when the provider is instantiated. The base class validates them automatically and raises click.UsageError if any are missing.

Example:

return {
    "ROBOVAST_SHARE_URL": "Public share URL of the target folder",
}
abstract upload_archive(archive_path: str, object_name: str, progress_callback=None) None

Upload the local archive_path to the share as object_name.

Runs in-process in the controller pod. Credentials and target settings are read from os.environ (populated by build_pod_env() at launch). Implementations should be resumable where the backend allows it.

Parameters:
  • archive_path – Absolute path to the local <campaign>.tar.gz.

  • object_name – Destination object/file name on the share (the archive basename, optionally with a provider prefix).

  • progress_callback – Optional (bytes_sent, total_bytes) callable, invoked periodically during the transfer — the same shape as the download_archive() callback. Use UploadProgressReader to drive it from a streamed request body.

Raise on failure (the caller treats any exception as a failed upload and keeps the controller alive for a retrigger).

verify_access() None

Verify the share is reachable with the configured credentials.

Called by the in-cluster controller before any batches start, so a campaign that could never be delivered fails fast instead of wasting compute. Implementations should perform the cheapest authenticated operation that proves write access (a HEAD/PROPFIND, a token exchange, an SFTP stat) and raise on failure.

The default is a non-blocking warning: providers that cannot cheaply check access do not gate the campaign.

class robovast.execution.cluster_execution.share_providers.nextcloud.NextcloudShareProvider

Upload/download campaign archives to a public Nextcloud share (WebDAV).

The share must be a public link that allows file uploads without a password. In the Nextcloud web UI, create a share with “Allow upload and editing” enabled and copy the link.

Required .env variables:

Variable

Description

ROBOVAST_SHARE_TYPE

Must be nextcloud

ROBOVAST_SHARE_URL

Public share URL (e.g. https://cloud.example.com/s/AbCdEfGhIjKlMn)

SHARE_TYPE: str = 'nextcloud'

Short identifier for the provider, e.g. "nextcloud" .

build_pod_env() dict[str, str]

Return environment variables to inject into the pod exec call.

These variables will be set for the upload script executed inside the archiver container. Include everything the script needs: URLs, tokens, credentials, etc.

The return value is merged into the pod’s environment via the --env flag of kubectl exec.

Returns:

Mapping of variable name to value.

Return type:

dict[str, str]

download_archive(object_name: str, dest_path: str, progress_callback: Callable[[int, int], None] | None = None, resume_offset: int = 0) None

Download object_name from the Nextcloud share to dest_path.

Parameters:
  • object_name – Filename of the archive on the share.

  • dest_path – Local destination path.

  • progress_callback – Optional (bytes_received, total_bytes) callable.

  • resume_offset – Byte offset to resume downloading from.

list_campaign_archives() list[str]

Return a list of campaign *.tar.gz filenames on the share.

list_campaign_archives_with_size() list[tuple[str, int]]

Return (filename, size_in_bytes) for each campaign *.tar.gz on the share.

Uses WebDAV PROPFIND Depth: 1 against the Nextcloud public.php/webdav/ endpoint, authenticated with the share token as the HTTP Basic Auth username. size_in_bytes is -1 when the server does not return a getcontentlength value.

remove_archive(object_name: str) None

Delete object_name from the Nextcloud share via WebDAV DELETE.

Parameters:

object_name – Filename of the archive on the share (as returned by list_campaign_archives()).

required_env_vars() dict[str, str]

Return a mapping of environment-variable name → human-readable description.

All listed variables must be non-empty strings in the environment when the provider is instantiated. The base class validates them automatically and raises click.UsageError if any are missing.

Example:

return {
    "ROBOVAST_SHARE_URL": "Public share URL of the target folder",
}
upload_archive(archive_path: str, object_name: str, progress_callback=None) None

Upload archive_path to the public Nextcloud share via WebDAV PUT.

verify_access() None

Confirm the public Nextcloud share accepts authenticated WebDAV access.

Issues a PROPFIND Depth: 0 against the share’s public.php/webdav/ collection; 207 means the share token resolves and uploads are permitted.

class robovast.execution.cluster_execution.share_providers.gcs.GcsShareProvider

Upload campaign archives to a Google Cloud Storage bucket.

Authentication uses a service-account key file. Create a service account with the Storage Object Creator role on the target bucket, generate a JSON key, download the file, and point ROBOVAST_GCS_KEY_FILE at it.

Required .env variables:

Variable

Description

ROBOVAST_SHARE_TYPE

Must be gcs

ROBOVAST_GCS_BUCKET

Target GCS bucket name (e.g. my-robovast-results)

ROBOVAST_GCS_KEY_FILE

Path to the service-account JSON key file (required for cluster upload-to-share only; not needed for results download on public buckets)

Optional .env variables:

Variable

Description

ROBOVAST_GCS_PREFIX

Object-name prefix inside the bucket (e.g. results/). Defaults to the bucket root.

SHARE_TYPE: str = 'gcs'

Short identifier for the provider, e.g. "nextcloud" .

build_pod_env() dict[str, str]

Return environment variables to inject into the pod exec call.

These variables will be set for the upload script executed inside the archiver container. Include everything the script needs: URLs, tokens, credentials, etc.

The return value is merged into the pod’s environment via the --env flag of kubectl exec.

Returns:

Mapping of variable name to value.

Return type:

dict[str, str]

download_archive(object_name: str, dest_path: str, progress_callback: Callable[[int, int], None] | None = None, resume_offset: int = 0) None

Stream object_name from the public GCS bucket to dest_path.

Uses chunked streaming so that archives of any size (including 100 GB+) are written incrementally without loading the file into memory.

Parameters:
  • object_name – GCS object key (as returned by list_campaign_archives()).

  • dest_path – Local file path to write the downloaded content to.

  • progress_callback – Optional (bytes_received, total_bytes) callable called after each chunk. total_bytes is 0 if unknown.

  • resume_offset – Byte offset to resume downloading from.

list_campaign_archives_with_size() list[str]

List all campaign *.tar.gz objects in the configured GCS bucket.

Recognizes archives whose base name (without .tar.gz) matches the campaign naming convention (<campaign-name>-YYYY-MM-DD-HHMMSS). Uses the public GCS XML API (no credentials required for public buckets). Handles GCS list pagination via the NextContinuationToken marker.

Returns:

List of (object_name, size_in_bytes) tuples.

remove_archive(object_name: str) None

Delete object_name from the GCS bucket.

Requires ROBOVAST_GCS_KEY_FILE to be set to a service-account key file with at least Storage Object Admin (or Storage Object Viewer + Storage Object Creator + delete permission) on the bucket.

Uses the GCS JSON API DELETE endpoint with a Bearer token.

Parameters:

object_name – GCS object key (as returned by list_campaign_archives()).

required_env_vars() dict[str, str]

Return a mapping of environment-variable name → human-readable description.

All listed variables must be non-empty strings in the environment when the provider is instantiated. The base class validates them automatically and raises click.UsageError if any are missing.

Example:

return {
    "ROBOVAST_SHARE_URL": "Public share URL of the target folder",
}
upload_archive(archive_path: str, object_name: str, progress_callback=None) None

Upload archive_path to the bucket as <prefix><object_name>.

Uses a GCS resumable upload so an interrupted transfer can continue: the session URI is persisted next to the archive and, on a later attempt, GCS is asked how many bytes it already holds so the upload resumes from there. Expired sessions (HTTP 404) restart cleanly.

verify_access() None

Confirm the service-account credentials can reach the target bucket.

Exchanges the service-account key for an access token (proving the key is valid) and GETs the bucket metadata over the GCS JSON API (proving the account can see the bucket). Accepts the key as inline JSON (ROBOVAST_GCS_KEY_JSON, as injected into the controller) or, on the host, via ROBOVAST_GCS_KEY_FILE.

Compress + upload a campaign to a share, in-process in the controller pod.

This replaces the host-driven upload-to-share flow that used to kubectl exec into an archiver sidecar. The controller pod already reaches the campaign storage in-cluster, so it compresses and uploads itself — no second pod, no kubectl. Compression is cluster-specific and owned by the cluster config (compress_campaign()); this module stays generic and just orchestrates compress → upload → retry.

Share credentials are injected into the controller pod at launch (resolved from the host .env by controller_launcher), so they are already present in os.environ here. load_provider_from_env() reads them (with optional overrides supplied by a retrigger command).

robovast.execution.cluster_execution.in_pod_upload.load_provider_from_env(overrides: dict | None = None)

Instantiate the configured share provider from the environment.

Parameters:

overrides – Optional {ENV_VAR: value} applied to os.environ before the provider is built — used by the retrigger command to supply corrected credentials without relaunching the controller.

Returns:

An instantiated BaseShareProvider, or None when ROBOVAST_SHARE_TYPE is unset.

Raises:
  • ValueError – when the configured share type has no registered provider.

  • click.UsageError – when required provider env vars are missing (raised by the provider constructor).

robovast.execution.cluster_execution.in_pod_upload.share_type_configured() bool

Return True if a share provider is configured in the environment.

robovast.execution.cluster_execution.in_pod_upload.upload_campaign(cluster_config, campaign_id: str, provider, progress_cb=None) bool

Compress campaign_id from storage and upload it via provider.

  1. Compress: cluster_config.compress_campaign (storage-specific — S3 vs GCS lives in the cluster config) writes $ROBOVAST_ARCHIVE_DIR/<campaign>.tar.gz.

  2. Upload: call the provider’s in-process upload_archive. The provider’s resolved env (URLs, tokens, key JSON/PEM) is already in os.environ — injected at launch (and possibly overridden by a retrigger).

  3. Remove the local archive on success.

Parameters:

progress_cb – Optional (bytes_sent, total_bytes) callable forwarded to the provider so the controller can publish upload progress.

Returns True on success; logs and returns False on any failure (so the controller can keep the pod alive for a retrigger).

robovast.execution.cluster_execution.in_pod_upload.verify_share_access(provider) None

Run the provider’s pre-flight credential check (raises on failure).

Add a MCP Plugin

Create a class with a name property and a register(mcp) method:

# my_package/mcp_plugin.py
from fastmcp import FastMCP

class MyMCPPlugin:
    @property
    def name(self) -> str:
        return "my_plugin"

    def register(self, mcp: FastMCP) -> None:
        @mcp.tool()
        def my_tool() -> str:
            """A custom tool."""
            return "hello"

Then register the class as an entry point in pyproject.toml:

[tool.poetry.plugins."robovast.mcp_plugins"]
my_plugin = "my_package.mcp_plugin:MyMCPPlugin"

The plugin is picked up automatically the next time the server starts.

Add Search Strategy Plugin

A search strategy drives the closed-loop search (see Iterative Search): it proposes parameter sets, is told their evaluations, and produces a final report. Strategies are algorithm-agnostic and share one config schema; only the per-strategy strategy_parameters block differs.

Subclass robovast.search.strategy.SearchStrategy and implement the four abstract methods. Optionally set PARAMS_MODEL to a Pydantic model — the framework validates search.strategy_parameters against it and passes the parsed object as params:

from pydantic import BaseModel
from robovast.search.strategy import SearchStrategy
from robovast.search.types import ParamSet, SearchReport

class MyParams(BaseModel):
    step: float = 0.1

class MyStrategy(SearchStrategy):
    PARAMS_MODEL = MyParams  # optional; omit (None) for no parameters

    def ask(self, n: int) -> list[ParamSet]:
        """Propose n parameter sets (keys match search_space dims)."""
        ...

    def tell(self, evaluations) -> None:
        """Ingest the evaluations of the batch just run."""
        ...

    def is_done(self) -> bool:
        """True when the budget is exhausted / converged."""
        ...

    def report(self) -> SearchReport:
        """Return the deliverable (ranked best, archive, Pareto front)."""
        ...

self.search_space, self.objectives and the validated self.params are available on the instance. For single-objective strategies, self.objective_value(ev) returns the sole objective sign-oriented so that higher is always better.

Register the class under robovast.search_strategies; the key is the search.strategy name:

[tool.poetry.plugins."robovast.search_strategies"]
my_strategy = "your_package.strategies:MyStrategy"

A strategy can also be loaded from a local file relative to the .vast without packaging, using strategy: ./search/my_strategy.py:MyStrategy (the same load_ref mechanism used for extractors and search postprocessing).

Add Extractor Plugin

The extractor is the single, SUT-specific scoring step: it reads a parameter set’s per-config result directory and returns named objectives (optimized) and measures (quality-diversity behavior axes; ignored by non-QD strategies). This is the one place system-under-test logic lives.

Subclass robovast.search.extractor.Extractor. It is constructed with the extract.params from the .vast (so thresholds / column names can be swept without editing code), and aggregation over the config’s runs is its responsibility:

from pathlib import Path
from robovast.search.extractor import (Extractor, ExtractResult,
                                       completed_run_dirs)

class MyExtract(Extractor):
    # __init__(self, **params) is inherited; params land on self.params

    def extract(self, config_dir: Path) -> ExtractResult:
        runs = completed_run_dirs(config_dir)        # helper: finished runs
        failures = sum(1 for r in runs if _failed(r))
        return ExtractResult(
            objectives={"failure_rate": failures / max(len(runs), 1)},
            measures={},                              # {} when unused
        )

objectives and measures are named dicts, so single- and multi-objective use the same shape. The framework records how many runs backed each result.

Register under robovast.extractors (referenced by search.extract.plugin), or load from a local file with extract.plugin: ./search/extract.py:MyExtract:

[tool.poetry.plugins."robovast.extractors"]
my_extract = "your_package.extractors:MyExtract"

The extractor reads what a postprocessing plugin produced (e.g. per-run metrics.csv) — it no longer computes raw metrics itself. Pair it with a postprocessing plugin (below) that writes metrics.csv from raw artifacts: list that plugin in search.postprocessing (run before extract) and/or in results_processing.postprocessing (analysis), so search and the analysis notebooks read the same metrics. Postprocessing plugins load identically in both lists — by entry-point name or a local ./path.py:Class file reference.

Campaign Store and Results Indexing

Every campaign — batch or search — is described by a single sqlite store, campaign.db (robovast.common.store.STORE_FILENAME), written at the root of the campaign directory. It is the single source of truth the results GUI reads, and the seam an in-cluster controller or web UI can later read/stream.

A campaign runs one or more batches: a batch-mode campaign (no search: block) has exactly one batch of the enumerated configs; a search campaign has one batch per ask/tell round.

Schema

robovast.common.store.CampaignStore is a thin wrapper over three tables:

campaign (1) --< batch (1) --< unit (one per param set / config)
  • campaignmode (batch/search), config_dir (base directory against which evaluation.visualization notebooks resolve), config_json (the full config), and an opaque strategy_state blob for resumable strategies.

  • batch — one ask/tell round (search), or the single batch (idx=0) of a batch-mode campaign.

  • unit — one evaluated parameter set (search) or one configuration (batch): the sampled params, objectives/measures (JSON; {} for batch), n_samples, an aggregate status and the result_dir.

Who writes it

  • The controller (robovast.execution.controller.CampaignController) writes the store live for both modes as each batch is evaluated, so progress is queryable while a campaign runs. It owns the campaign id, the flat results layout (<campaign>/<config>/<run>/) and the batch loop; an ExecutionBackend (DockerBackend locally) only dispatches one batch’s jobs.

  • The post-hoc indexer robovast.common.campaign_index.build_campaign_store(campaign_dir) reconstructs the same store by scanning a finished results tree (reusing the campaign_data readers). It is used for campaign dirs not produced by the controller — e.g. cluster results downloaded from S3 — and is idempotent (mtime-guarded; force=True to rebuild), invoked by vast evaluation index and automatically on vast evaluation gui launch. Controller-written stores are left untouched.

Store-driven GUI

The results GUI (RunResultsAnalyzer) discovers campaigns by scanning <results_dir>/*/campaign.db — there is no filesystem-walk or depth-based heuristic. It reads the campaign/batch/unit rows to build the tree (campaign → batch, search only → config), resolves notebook workloads from config_json against config_dir, and enumerates only the run-level leaves from each unit’s result_dir.

Controller Control Interface

Every cluster campaign is driven by a fire-and-forget controller pod. While it runs (and after it finishes) the host talks to it through a small in-pod HTTP/JSON control channel — the live seam for monitoring and for issuing commands such as a graceful stop or an upload retry.

Server

robovast.execution.control_server runs a FastAPI + uvicorn server on a daemon thread beside the synchronous controller loop (default port 8099, ROBOVAST_CONTROL_PORT). Startup is best-effort: if it fails (e.g. uvicorn missing) the campaign continues and the monitor falls back to its Kubernetes-only view. FastAPI also serves the live OpenAPI schema at /docs, so the same contract serves the CLI now and a web UI later.

Endpoints

  • GET /status — the controller’s live Status (loop phase, current batch, budget/run progress, history). The CLI monitor polls this; phase is the authoritative “done” signal.

  • POST /command — an extensible RPC: a JSON body {name, args} is dispatched through the handler registry and returns a CommandResult ({ok, result, error}). An unknown command returns HTTP 400.

  • GET /healthz — liveness ({"ok": true}).

Status: phase and stage

phase is an open string advanced through a documented vocabulary, with stage carrying finer markers so new states slot in without a schema change:

phase

Meaning

startingrunning

Campaign created; batch loop executing.

finishing

Search stop condition met (or a stop was requested); winding down.

uploading

Campaign published to storage; compressing/uploading to the share. The stage refines this: upload-to-share (in progress), upload-failed (waiting for a retrigger), uploaded (done).

finished

Done (stage=uploaded) — the controller process exits 0.

failed

Aborted. stage says why for the new pre-flight gates: share-config-error (no/invalid share configured) or share-verify-failed (credentials rejected before any batch ran).

Commands

Handlers are registered in the HANDLERS registry via the @register("name") decorator and receive (state, **args). Built-in commands:

  • stop — cooperative graceful stop. During the batch loop it ends the search after the current batch; while the controller is parked waiting to retry a failed upload it abandons that wait and terminates.

  • upload-to-share — (re)run the post-campaign upload. args may carry credential overrides (e.g. a corrected password); with no args the launch-time credentials are reused. The handler only signals — the actual upload runs on the controller’s main thread (see below) — so callers poll GET /status for the stage transition.

State ownership and the retrigger handshake

ControllerState is the thread-safe holder the controller writes and the server reads. The controller calls update / set_phase at each batch boundary; the server thread reads a consistent snapshot. Long-running work never executes on a request thread: upload-to-share calls request_upload(overrides) (and stop calls request_stop()), which wake the controller’s main thread blocked in wait_for_retrigger() — it returns ("retrigger", overrides) to retry or ("abandon", {}) when a stop was requested.

Host-side client

robovast.execution.cluster_execution.control_client reaches the server over kubectl port-forward (the same transport the launcher uses, so it needs only the user’s kubeconfig — no extra RBAC): find_controller_pod locates the pod by label, port_forward opens the tunnel, and get_status / send_command call the endpoints. The CLI monitor, stop and upload-to-share are thin wrappers over these.

The upload-to-share command may carry credential overrides, and because they populate os.environ before the provider is loaded they can also switch the share type (e.g. retry a failed gcs upload to sftp). The active share type is reported in Status.share_provider and shown by monitor while uploading.

Warning

Trust model. The control server is unauthenticated — any principal that can kubectl port-forward to the controller pod (RBAC verb pods/portforward in the namespace) can issue commands. Because a retrigger can switch the upload destination, such a principal can redirect a campaign’s results to an arbitrary server (data exfiltration). A principal with pods/exec could already read the data directly, so this only widens exposure for port-forward-only principals. On shared/multi-tenant clusters, restrict pods/portforward (and pods/exec) on the robovast namespace accordingly. Adding a bearer-token check on POST /command is a possible future hardening but is out of scope today.

API reference

In-controller HTTP/JSON control channel (state + RPC).

Every cluster campaign is driven by a fire-and-forget controller pod (see robovast.execution.cluster_execution.controller_launcher). The controller loop deletes each batch’s Kubernetes Jobs once their results are downloaded, so a client that reconstructs progress purely from live Jobs cannot tell the gap between search generations from the real end of the campaign, nor see the loop-level state (current batch, search budget, run-level progress).

This module gives the controller a tiny FastAPI + uvicorn server, run on a daemon thread beside the synchronous controller loop:

  • GET  /status — the controller’s live Status (loop phase, current batch, budget progress, per-batch run progress, history). The CLI monitor polls this; the phase field is the authoritative “done” signal.

  • POST /command — an extensible RPC: dispatch {name, args} through the HANDLERS registry. Ships one handler (stop); register more later.

  • GET  /healthz — liveness.

FastAPI auto-emits an OpenAPI schema (/docs), so the same contract serves the CLI now and a web UI later (reached via kubectl port-forward now, a Service / Ingress later — no code change).

fastapi / uvicorn are imported lazily (only build_app() / serve_in_thread() need them) so the models and ControllerState import cleanly anywhere; pydantic is a core dependency.

class robovast.execution.control_server.Command(*, name: str, args: dict = <factory>)

An RPC request: a registered handler name plus its keyword args.

args: dict
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

name: str
class robovast.execution.control_server.CommandResult(*, ok: bool, result: Any = None, error: str | None = None)
error: str | None
model_config: ClassVar[ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

ok: bool
result: Any
class robovast.execution.control_server.ControllerState(**initial)

Thread-safe holder the controller writes and the server reads.

The controller calls update() / set_phase() at each batch boundary (and update() for run-level progress within a batch); the server thread reads a consistent snapshot(). request_stop() / stop_requested back the cooperative stop command.

request_stop() None
request_upload(overrides: dict | None = None) None

Ask the controller’s main thread to (re)run upload-to-share.

overrides are optional {ENV_VAR: value} credential corrections applied before the next attempt (the manual retrigger usually needs no args — the launch-time credentials are still in the pod).

set_phase(phase: str, stage: str | None = None) None
snapshot() Status
property stop_requested: bool
update(**fields) None
wait_for_retrigger() tuple[str, dict]

Block until an upload retrigger or a stop is requested.

Returns ("retrigger", overrides) to retry the upload, or ("abandon", {}) when a stop was requested (give up, terminate).

class robovast.execution.control_server.Status(*, phase: str = 'starting', stage: str | None = None, mode: str | None = None, campaign_id: str | None = None, batch: int = 0, batches_done: int = 0, budget: list[~robovast.execution.control_server.BudgetItem] = <factory>, runs: ~robovast.execution.control_server.RunProgress = <factory>, best_objective: float | None = None, batch_history: list[dict] = <factory>, stop: dict | None = None, share_provider: str | None = None, extra: dict = <factory>, updated_at: float = <factory>)

The controller’s live state, served by GET /status.

phase is an open string the controller advances through a documented vocabulary (startingrunningfinishingfinished / failed); stage and extra exist so future markers (e.g. "upload-to-share-done") slot in without a schema change. share_provider names the share type of the current upload attempt; it can change across retriggers (a failed upload may be retried to a different provider).

batch: int
batch_history: list[dict]
batches_done: int
best_objective: float | None
budget: list[BudgetItem]
campaign_id: str | None
extra: dict
mode: str | None
model_config: ClassVar[ConfigDict] = {'validate_assignment': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

phase: str
runs: RunProgress
share_provider: str | None
stage: str | None
stop: dict | None
updated_at: float
robovast.execution.control_server.dispatch(state: ControllerState, command: Command) CommandResult
robovast.execution.control_server.register(name: str) Callable[[Callable], Callable]

Host-side client for the in-controller control channel.

The CLI monitor (and future tools) reach the controller pod’s HTTP server (see robovast.execution.control_server) through kubectl port-forward — the same kubectl transport the launcher and archiver use, so it relies only on the user’s kubeconfig (no extra RBAC). Helpers here locate the controller pod, open a port-forward, and call GET /status / POST /command.

robovast.execution.cluster_execution.control_client.find_controller_pod(namespace='default', kube_context=None, campaign=None)

Return (pod_name, phase) of the controller pod, or (None, None).

Prefers a Running pod (the live controller); otherwise returns the most recent terminal pod so the monitor can report a campaign that has already finished. With campaign given, restricts to that campaign’s controller (campaign-id=<label-safe>).

robovast.execution.cluster_execution.control_client.get_status(base_url, timeout=5.0) dict

GET /status -> parsed JSON dict.

robovast.execution.cluster_execution.control_client.port_forward(pod, namespace='default', kube_context=None, remote_port=8099, timeout=15.0)

Open a kubectl port-forward to pod and yield the localhost base URL.

kubectl picks a free local port (:<remote>); we parse it from kubectl’s output. The forward is torn down on exit.

robovast.execution.cluster_execution.control_client.send_command(base_url, name, timeout=10.0, **args) dict

POST /command with {name, args} -> parsed JSON CommandResult.

Per-Cluster Resource Resolution

The resolution logic for per-cluster resources (the {context-name: value} mappings documented under Per-Cluster Resource Limits in Cluster Execution) lives in robovast.common.cluster_context:

Kubernetes context awareness and per-cluster resource resolution.

Resource values in the .vast config file may be given as a per-cluster list keyed by the real Kubernetes context name instead of a scalar:

resources:
  cpu:
    - gke_my-project_us-central1_my-cluster: 4
    - minikube: 8
  memory:
    - gke_my-project_us-central1_my-cluster: 10Gi
    - minikube: 20Gi

Scalars always work and are the recommended default when a single cluster is used. Pass the matching context name via --context/-x when running commands against a specific cluster.

robovast.common.cluster_context.get_active_kube_context() str | None

Return the name of the currently active Kubernetes context.

Reads the active context from the local kubeconfig (~/.kube/config or KUBECONFIG). Returns None when the context cannot be determined (e.g. kubeconfig is absent).

robovast.common.cluster_context.get_config_context_names(config_path: str) set[str]

Extract all context names used in per-cluster resource lists.

Scans a .vast YAML config for any field that uses the per-cluster list syntax ([{context-name: value}, …]) and returns the union of all keys.

Parameters:

config_path – Absolute path to a .vast YAML config file.

Returns:

Set of context name strings. Empty when no per-cluster lists are found.

robovast.common.cluster_context.list_all_contexts() list[tuple[str, str]]

List all available (label, kube_context_name) pairs from the kubeconfig.

Returns:

List of (label, kube_context_name) tuples sorted by name. Returns an empty list when no kubeconfig is available.

robovast.common.cluster_context.require_context_for_multi_cluster(kube_context: str | None) None

Raise ValueError when a multi-cluster config is used without --context.

Discovers the project .vast config file, scans it for per-cluster resource lists, and raises an informative error when more than one context name is present and no kube_context was specified.

This is a no-op when:

  • kube_context is already set (the user supplied --context).

  • No project config can be found.

  • The config uses only a single context name (or only plain scalars).

Parameters:

kube_context – The Kubernetes context name (None when the user did not pass --context).

Raises:

ValueError – When multiple context names are found and kube_context is None.

robovast.common.cluster_context.resolve_resource_value(value: Any, context: str | None) Any

Resolve a resource value for the active Kubernetes context.

Handles two forms:

  • Scalar (int, float, or str): returned as-is.

  • Per-cluster list ([{context-name: value}, …]): the entry whose key matches context is returned.

Raises:

ValueError – When the value is a per-cluster list but context is None, or when the context has no entry in the list.

Parameters:
  • value – Raw resource value (scalar or per-cluster list).

  • context – Active Kubernetes context name, or None.

Returns:

Resolved scalar value, or None when value is None.

robovast.common.cluster_context.resolve_resources(resources: dict, context: str | None) dict

Resolve all resource fields in a resources dict for the active cluster.

Calls resolve_resource_value() for every key in resources and returns a new dict with all per-cluster lists replaced by their resolved scalar values.

Raises:

ValueError – Propagated from resolve_resource_value() when a per-cluster list has no entry for context.

Parameters:
  • resources – Raw resources dict (e.g. {'cpu': 15} or {'cpu': [{'gke_my-project_…_cluster': 4}, {'minikube': 8}]}).

  • context – Active Kubernetes context name, or None.

Returns:

New dict with resolved scalar values (None entries removed).

Querying RoboVAST campaigns

Using [rdflib](https://rdflib.readthedocs.io/), you can query the generated metadata graph using [SPARQL](https://www.w3.org/TR/sparql11-query/).

Load the metadata graph

from rdflib import Graph

g = Graph()
g.parse("metadata.prov.json)

Loading SPARQL queries

To do so, you can load any of the queries below as text, and use the query method for any graph g:

with open("query-file.rq", "r") as f:
  query_string = f.read()

qres = g.query(query_string)
for row in qres:
  # Process your results
  print(row)

Below are a few example queries demonstrating the PROV relationships in the metadata graph.

Scenario inputs

FloorPlan models:

 SELECT ?floorplan ?creator ?date
 WHERE {
      ?floorplan rdf:type env:FloorPlanModel .
      OPTIONAL {?floorplan prov:wasAttributedTo ?creator .}
      OPTIONAL {?floorplan dcterms:modified ?date .}
}

Vast file:

SELECT ?vast_file ?creator ?date ?abstract_scenario
WHERE {
    ?vast_file rdf:type robovast:VastConfiguration .
    ?vast_file dcterms:references ?abstract_scenario .
    ?abstract_scenario rdf:type scenarios:AbstractScenario .
    OPTIONAL {?vast_file prov:wasAttributedTo ?creator .}
    OPTIONAL {?vast_file dcterms:modified ?date .}
}

Generation

FloorPlan Model-to-Model Transformation:

SELECT ?floorplan ?activity ?jsonld_file ?agent
WHERE {
    ?floorplan rdf:type env:FloorPlanModel .
    ?activity rdf:type robovast:FloorPlanTransformation .
    ?activity prov:used ?floorplan .
    ?jsonld_file prov:wasGeneratedBy ?activity .
    OPTIONAL {?activity prov:wasAssociatedWith ?agent .}
}

FloorPlan Artefact Generation:

SELECT ?source_files ?activity ?gen_file ?agent
WHERE {
    ?activity rdf:type robovast:FloorPlanGeneration .
    ?activity prov:used ?source_files .
    ?gen_file prov:wasGeneratedBy ?activity .
    OPTIONAL {?activity prov:wasAssociatedWith ?agent .}
}

Generation of Concrete Scenario

SELECT ?vast_file ?ref_file ?activity ?agent ?gen_file
WHERE {
    ?vast_file rdf:type robovast:VastConfiguration .
    ?activity prov:used ?vast_file .
    ?vast_file dcterms:references ?ref_file .
    ?gen_file prov:wasGeneratedBy ?activity .
    OPTIONAL {?activity prov:wasAssociatedWith ?agent .}

Test Execution

Test results generated from a test run:

SELECT ?scenario ?config_files ?activity ?agent ?gen_file ?start_time ?end_time
WHERE {
    ?scenario rdf:type smm:ConcreteScenario .
    ?activity prov:used ?scenario .
    ?activity prov:used ?config_files .
    OPTIONAL{?activity prov:startedAtTime ?start_time .}
    OPTIONAL{ ?activity prov:endedAtTime ?end_time . }
    ?gen_file prov:wasGeneratedBy ?activity .
    OPTIONAL {?activity prov:wasAssociatedWith ?agent .}

Postprocessing

Postprocessing of a bagfile:

SELECT ?bag_file ?activity ?agent ?gen_file ?start_time ?end_time
WHERE {
    ?bag_file rdf:type robovast:ROSBag .
    ?activity prov:used ?bag_file .
    ?gen_file prov:wasGeneratedBy ?activity .
    OPTIONAL {?activity prov:wasAssociatedWith ?agent .}
    OPTIONAL{?activity prov:startedAtTime ?start_time .}
    OPTIONAL{ ?activity prov:endedAtTime ?end_time . }
}

Metadata and Graph postprocessing:

SELECT ?metadata_file ?graph_file ?md_activity ?graph_activity ?agent ?start_time ?end_time
WHERE {
    ?md_activity rdf:type robovast:PostprocessingMetadata .
    ?graph_activity rdf:type robovast:PostprocessingGraph .
    ?metadata_file prov:wasGeneratedBy ?md_activity .
    ?graph_file prov:wasGeneratedBy ?graph_activity .
    ?graph_activity prov:used ?metadata_file
    OPTIONAL {?md_activity prov:wasAssociatedWith ?agent .
    ?graph_activity prov:wasAssociatedWith ?agent .}
    OPTIONAL{?md_activity prov:startedAtTime ?start_time .}
    OPTIONAL{ ?md_activity prov:endedAtTime ?end_time . }
}

Analysis

Identifying which variation types were used on each config:

SELECT ?config ?variation_type
WHERE {
    ?config rdf:type smm:ConcreteScenario .
    ?config robovast:variations/rdf:rest*/rdf:first ?variation .
    ?variation rdf:type ?variation_type .
    FILTER (?variation_type != prov:Activity)
    FILTER (?variation_type != robovast:Variation)
}

Getting the failure rate by environment:

SELECT  ?env_model (SUM(?failures)/COUNT (?activity) * 100 AS ?result) (COUNT (?activity) AS ?total)
WHERE {
    ?conf rdf:type smm:ConcreteScenario .
    ?activity prov:used ?conf .
    ?activity rdf:type robovast:TestExecution .
    ?activity robovast:success ?success .
    BIND(IF(?success=true, 0, 1) AS ?failures) .
    ?conf dcterms:references ?env_model .
    ?env_model rdf:type env:FloorPlanModel .
} GROUP BY ?env_model