Metadata-Version: 2.4
Name: scapps
Version: 26.6.0
Summary: StratusCore application manager. Requires Docker and Docker Compose.
Requires-Python: >=3.11
Requires-Dist: click>=8.0
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: typer>=0.9.0
Description-Content-Type: text/markdown

# `scapps`: The StratusCore Application Manager

The StratusCore Apps CLI tool for creating and running applications.

## Requirements

- [Docker](https://docs.docker.com/get-docker/) with the Compose plugin (`docker compose`)

## Quick Start

```bash
# create a new application
scapps new my-app

# run the application
scapps run my-app

# run all apps in a directory
scapps run apps/managed

# check running applications
scapps info

# attach to application logs
scapps attach my-app

# stop the application
scapps down my-app

# stop all running scapps apps
scapps down

# build/pull images and package into a tar
scapps configure my-app
```

## Template Option Flags

When creating a new app with `scapps new`, you can use **template option flags** to scaffold an application for a commonly used application type. These flags populate your application with pre-configured directories, Docker configurations, and `scapps.yaml` files needed for that application type.

In the generated `scapps.yaml`, the template option flags can create entries in the default service section for **runtime features**. These features provide additional Docker compose configuration at runtime for the specified application type. For example, using the `--ros2` template option flag adds the `ros2: true` runtime feature to the `scapps.yaml`, which does the following when the application is ran:

- share memory with the host
- share network with the host
- set the container to privileged
- set `ROS_DOMAIN_ID`
- set `FASTRTPS_DEAFULT_PROFILE_FILE`
- attach `dds_profile.xml` volume

The corresponding `scapps.yaml` and `compose.yaml` (called `compose.<app-name>.yaml` and generated by `scapps run`) files for a `--ros2` template option flag application are shown below.

**`scapps.yaml`**

```yaml
name: my-ros2-app
services:
  ros2-node:
    build:
      context: .
      dockerfile: Dockerfile
      network: host

    ros2: true
```

**`compose.my-ros2-app.yaml`**

```yaml
# generated when `scapps run` is called
name: my-ros2-app
services:
  ros2-node:
    # copied over from scapps.yaml
    build:
      context: .
      dockerfile: Dockerfile
      network: host

    # generated by ros2:true runtime feature
    ipc: host
    network_mode: host
    privileged: true
    environment:
    - ROS_DOMAIN_ID=0
    - FASTRTPS_DEFAULT_PROFILES_FILE=/dds_profile.xml
    volumes:
    - ./dds_profile.xml:/dds_profile.xml:ro
```

### Available Flags

| Flag           | Template Used     | Adds Runtime Feature | Description                                                            |
| -------------- | ----------------- | -------------------- | ---------------------------------------------------------------------- |
| `--ros2`       | ROS2Template      | `ros2: true`         | creates ROS2 workspace, ROS2 Dockerfile, and adds ROS2 runtime feature |

### Available Runtime Features

These runtime features can be added using a template options flag in `scapps new` or manually added to the `scapps.yaml` after creation. The entries listed below show the format of the runtime feature inside of `scapps.yaml`. For example, the `ros2: true` runtime feature can be seen in context above in the `scapps.yaml` excerpt.

| Runtime Feature    | Description                                                                                        |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| `ros2: true`       | enables ROS2 networking with host, shared memory, ROS_DOMAIN_ID, and the DDS profile configuration |
| `nvidia-gpu: true` | enables NVIDIA GPU device access, driver exposure, and GPU environment configuration               |
| `ui: {port: <port>[, path: <path>]}` | registers the frontend or backend service with the Traefik reverse proxy systemmd service, making it accessible on the host at `/<path>` |

> **Note:** The `ui` runtime feature takes a configuration dict rather than a boolean. `port` is required and specifies the service's port in the container. `path` is optional and defaults to the app name; it defines the URL path prefix to where the frontend or backend of your UI is served.

#### Docker Compose Additions
When a runtime feature is added, the generated `compose.<app-name>.yaml` file used by Docker is modified with the following for each specified feature:

**ROS2 Runtime Feature**

`scapps.yaml`

```yaml
# added to the service definition
ros2: true
```
`compose.<app-name>.yaml`

```yaml
# added to the service definition
network_mode: host
ipc: host
privileged: true
environment:
  - ROS_DOMAIN_ID=0
  - FASTRTPS_DEFAULT_PROFILES_FILE=/dds_profile.xml
volumes:
  - ./dds_profile.xml:/dds_profile.xml:ro
```

**NVIDIA GPU Runtime Feature**

`scapps.yaml`

```yaml
# added to the service definition
nvidia-gpu: true
```

`compose.<app-name>.yaml`

```yaml
# added to the service definition
deploy:
  resources:
    reservations:
      devices:
        - driver: nvidia
          count: all
          capabilities: [gpu]
environment:
  - NVIDIA_VISIBLE_DEVICES=all
  - NVIDIA_DRIVER_CAPABILITIES=all
```
**UI Runtime Feature**

`scapps.yaml`

```yaml
# added to the service definition
ui:
  - port
  - path # optional
```

`compose.<app-name>.yaml`

```yaml
# added to the service definition
  networks:
    - reverse-proxy-network
  labels:
    - "traefik.enable=true"
    - "traefik.http.routers.<app-name>-<service-name>.rule=PathPrefix(`/<path>`)"
    - "traefik.http.routers.<app-name>-<service-name>.middlewares=<app-name>-<service-name>-strip@docker"
    - "traefik.http.middlewares.<app-name>-<service-name>-strip.stripprefix.prefixes=/<path>"
    - "traefik.http.services.<app-name>-<service-name>.loadbalancer.server.port=<port>"

# added to the top-level networks section
networks:
  reverse-proxy-network:
    name: reverse-proxy-network
    external: true
```

Where `<path>` defaults to the app name if not specified in the `ui` config block.

## App Dependencies

An application can declare other StratusCore apps it depends on by adding a top-level `dependencies` key to its `scapps.yaml`. Each entry is the **app name** (the `name` field in the target app's `scapps.yaml`), not its directory name.

```yaml
name: my-app
dependencies:
  - my-app-dependency
services:
  my-service:
    build:
      context: .
      dockerfile: Dockerfile
```

### Behavior

#### `scapps run`

Before starting an app, `scapps run` checks whether every declared dependency is already running. For each dependency that is **not** running:

1. It searches **only within the same parent folder** (sibling apps) for the dependency.
2. If found in the same folder, it automatically starts the dependency in detached mode first.
3. If the dependency is **not in the same folder**, it will not be auto-started. `scapps run` searches the broader directory tree, **shows the location** with a manual start hint, and **exits with an error**.
4. If the dependency cannot be found anywhere on disk, an error is shown and the command exits.

In all cases where a dependency is not running and is outside the same directory, **the app will not start**.

#### `scapps info --verbose`

The verbose output shows a **Dependencies** section for each app that declares dependencies, with a live ✓/✗ indicator showing whether each dependency is currently running.

## Commands

### `scapps new <app-dir> [options]`

Create a new application from a template with the specified directory structure and configuration files.

The `new` command creates:

- `Dockerfile` and `entrypoint.sh`
- `scapps.yaml` configuration file (with runtime feature flags if template enables them)
- Application directory structure
- Template-specific files (e.g., DDS profiles for ROS2)

**Options:**

- `--ros2` - Use ROS2 template (creates workspace, DDS profile, ROS2 `Dockerfile`, and adds ROS2 runtime feature)

**Examples:**

```bash
scapps new my-app                             # basic Ubuntu image app
scapps new my-ros-app --ros2                  # ROS2 app
```

### `scapps run <app-dir> [options]`

Generate a Docker compose file from the `scapps.yaml` configuration and run the application containers. This command reads runtime feature flags from `scapps.yaml`, applies the appropriate runtime feature configurations, generates a compose file, and starts the containers.

The `run` command automatically builds images if they don't exist, making it the primary command for starting your application.

Pass a single app directory, multiple app directories, or a parent directory containing multiple apps. When multiple apps are resolved, they are started in order; if any app fails to start, all previously started apps (and the failed one) are stopped automatically.

If the `scapps.yaml` contains a `dependencies` list, `run` checks each dependency before starting the app. Any dependency that is not already running will be located on disk and started automatically in detached mode if in the same directory. If a dependency cannot be found, the command exits with an error.

**Options:**

- `-d, --detach` - Run containers in detached mode (background). This is the default behavior.
- `-a, --attach` - Attach to container output to see logs in real-time.
- `-r, --rebuild` - Force rebuild of images before starting containers.
- `--pause-on-failure` - When an app fails to start while running multiple at once, pause for inspection before stopping all started apps.

**Examples:**

```bash
scapps run my-app                    # run single app in background
scapps run my-app -a                 # run with output
scapps run my-app -r                 # rebuild and run
scapps run apps/managed              # run all apps in a directory
scapps run app1 app2                 # run multiple specific apps
scapps run apps/managed --pause-on-failure  # pause before teardown on failure
```

### `scapps down [app-name-or-dir ...]`

Stop and remove containers for one or more applications. Accepts app names, app directory paths, or a parent directory containing multiple apps. Omit all arguments to stop every running scapps-managed app.

Containers and networks are removed, but volumes and images are preserved.

**Arguments:**

- `[app-name-or-dir ...]` - Zero or more project names, app directory paths, or a parent directory. Omit to stop all running scapps apps.

**Examples:**

```bash
scapps down                     # stop all running scapps apps
scapps down my-app              # stop by name
scapps down /path/to/my-app     # stop by path
scapps down apps/managed        # stop all apps in a directory
scapps down app1 app2           # stop multiple apps
```

### `scapps info [options]`

Display information about running StratusCore Apps.

**Options:**

- `-v, --verbose` - Show detailed container information including status, networks, volumes, and resource limits. Also shows declared `dependencies` with a live running/stopped indicator for each.

**Examples:**

```bash
scapps info                      # list running apps (simple)
scapps info -v                   # list running apps with detailed container info
scapps info --verbose            # list running apps with detailed container info
```

### `scapps attach <app-name-or-dir> [options]`

Attach to the live logs of a running application. Accepts either the app name or a path to the app directory.

**Arguments:**

- `<app-name-or-dir>` - App name (e.g., `my-app`) or path to the app directory

**Options:**

- `-n <lines>` - Show the last N lines of logs without following. When omitted, logs are streamed live.

**Examples:**

```bash
scapps attach my-app             # stream live logs
scapps attach my-app -n 100      # show last 100 lines and exit
scapps attach /path/to/my-app    # attach using directory path
```



### `scapps install <app-dir> [tar-file]`

Install pre-built application images for offline deployment. This command is designed for air-gapped environments where Docker build or image pull operations are not available.

**Arguments:**

- `<app-dir>` - Path to the app directory containing the image tar file(s)
- `[tar-file]` - Optional: specific tar file to load. If omitted, all `.tar` files in the directory will be loaded

**Note:** This command is optional and only needed for offline scenarios. The `run` command handles image building and pulling automatically when internet access is available.

**Use cases:**

- deploy applications in air-gapped environments
- install from a local registry or image archive
- set up applications without internet access
- bulk load multiple container images

**Examples:**

```bash
scapps install my-app                    # load all .tar files in my-app directory
scapps install my-app my-app.tar         # load specific tar file
scapps install /path/to/app frontend.tar # load specific tar from path
```

### `scapps configure <app-dir ...> [options]`

Build and/or pull all Docker images for one or more applications, then package them into a `<app-name>-images.tar` file per app. Pass a single app directory, multiple app directories, or a parent directory containing multiple app subdirectories.

For each service in `scapps.yaml` (processed in declaration order):
1. **Services with a `build:` context** — built together via `docker compose build`.
2. **Image-only services** — pulled from the registry.

This makes `configure` the primary command for preparing images for air-gapped or offline deployment.

**Arguments:**

- `<app-dir ...>` - One or more app directories (each containing `scapps.yaml`), or a parent directory containing multiple app subdirectories.

**Options:**

- `--no-cache` - Build without using Docker layer cache.
- `--build-only` - Skip pulling entirely; all services must have a `build:` context.
- `--platform <platform>` - Target platform for builds and pulls (e.g., `linux/amd64`, `linux/arm64`).

**`pull_policy` support:**

The `pull_policy` field on a service in `scapps.yaml` is respected:

| `pull_policy` | Behavior |
|---|---|
| _(unset)_ | Pull if image reference exists, build as fallback |
| `never` | Build from Dockerfile; error if no build context |
| `always` | Always pull from registry |
| `build` | Always build from Dockerfile; error if no build context |

**Use cases:**

- pre-build and package images for air-gapped or offline deployment
- test that all images build/pull successfully without running them
- produce a distributable tar for transport to another machine

**Examples:**

```bash
scapps configure my-app                        # build/pull and save to my-app-images.tar
scapps configure my-app --no-cache             # build without cache
scapps configure my-app --build-only           # build all services, skip pulling
scapps configure my-app --platform linux/amd64 # cross-platform build/pull
scapps configure apps/managed                  # configure all apps in a directory
scapps configure app1 app2                     # configure multiple specific apps
```

## Testing

### Unit tests (no Docker required)

These cover command logic, compose generation, runtime features, and error
handling using mocks. They run in CI and require no Docker daemon.

```bash
cd scapps
uv run pytest
```

### Docker integration tests (local only)

`tests/test_docker_integration.py` exercises `scapps run` and `scapps down`
against real containers built from `apps/managed/example-app`. Excluded from
CI and from the default test run. Requires:

- A running Docker daemon
- Two external networks created during StratusCore host setup:
  ```bash
  docker network create ros2-internal
  docker network create reverse-proxy-network
  ```

Run them with:

```bash
uv run pytest -m docker
```

The first run pulls `ros:humble-ros-base-jammy` and compiles the workspace
(~5–10 min, ~1 GB). Subsequent runs reuse Docker's build cache. Images are
left behind; containers are torn down after the test.

### Run everything

```bash
uv run pytest --override-ini='addopts='
```

## Contributing: Creating a Template

Templates use file-based approach with actual template files stored in directories.

### Directory Structure

```
scapps/templates/
├── generic/              # generic template files
│   ├── `Dockerfile`
│   └── `entrypoint.sh`
├── ros2/                 # ROS2 template files
│   ├── `Dockerfile`.template
│   ├── `entrypoint.sh`.template
│   └── dds_profile.xml
├── base.py              # base template class
├── generic.py           # generic template logic
└── ros2.py              # ROS2 template logic
```

### Step 1: Create Template Directory

```bash
mkdir scapps/templates/python
```

Create template files (e.g., `Dockerfile`, `entrypoint.sh`, etc.)

### Step 2: Create Template Class

```python
# templates/python.py
from pathlib import Path
from typing import Dict
from .base import AppTemplate

class PythonTemplate(AppTemplate): # you can also inherit from GenericTemplate for runtime feature-only templates
    def get_scapps_config(self) -> Dict:
        return {
            "name": self.app_name,
            "services": {
                f"{self.app_name}-service": {
                    "image": f"{self.app_name}:latest",
                }
            },
        }

    def get_dockerfile(self) -> str:
        template_dir = Path(__file__).parent / "python"
        with open(template_dir / "`Dockerfile`") as f:
            return f.read()

    def get_entrypoint(self) -> str:
        template_dir = Path(__file__).parent / "python"
        with open(template_dir / "`entrypoint.sh`") as f:
            return f.read()

    def get_additional_files(self) -> Dict[Path, str]:
        template_dir = Path(__file__).parent / "python"
        files = {}
        for filename in ["requirements.txt", "main.py"]:
            with open(template_dir / filename) as f:
                files[self.app_dir / filename] = f.read()
        return files
```

### Step 3: Register Template

Add to `templates/__init__.py`:

```python
from .python import PythonTemplate
__all__ = [..., "PythonTemplate"]
```

### Step 4: Add CLI Flag

Update `commands/new.py`:

```python
def new(
    app_dir: Path = typer.Argument(...),
    ros2: bool = typer.Option(False, "--ros2"),
    python: bool = typer.Option(False, "--python"),
):

    TEMPLATE_PRIORITY = ["generic", "ros2", "python"] # earlier in list trumps later in list

    if ros2:
        selected_templates["ros2"] = ROS2Template

    if python:
      selected_templates["python"] = PythonTemplate

```

### Template Files

For templates requiring files with variable substitution, use `.template` extension. For example, in the `scapps/templates/ros2/`, the `Dockerfile.template` looks a little like so:

```dockerfile
# templates/ros2/`Dockerfile`.template
FROM ros:humble-ros-base-jammy
COPY {workspace_name}/ /workspaces/{workspace_name}/
```

The corresponding Python code looks like the following:

```python
def get_dockerfile(self) -> str:
    template_dir = Path(__file__).parent / "ros2"
    with open(template_dir / "`Dockerfile`.template") as f:
        return f.read().format(workspace_name=self.workspace_name)
```

## Contributing: Creating a Runtime Feature

Runtime features inject Docker Compose configuration (service settings, environment variables, volumes, networks, etc.) into the generated compose file at the moment `scapps run` is executed. Each runtime feature is a class that inherits from `RuntimeFeatureConfig`, located in `scapps/runtime_features/`.

### Directory Structure

```
scapps/runtime_features/
├── base.py          # base class – RuntimeFeatureConfig
├── ros2.py          # ROS2 feature
├── nvidia_gpu.py    # NVIDIA GPU feature
├── ui.py            # UI / reverse-proxy feature
└── __init__.py      # exports
```

### Step 1: Create the Feature Class

Create a new file, e.g. `scapps/runtime_features/my_feature.py`:

```python
# runtime_features/my_feature.py
from typing import Any, Dict
from .base import RuntimeFeatureConfig


class MyRuntimeFeature(RuntimeFeatureConfig):

    @property
    def name(self) -> str:
        # this is the key that users write in scapps.yaml, e.g. "my-feature: true"
        return "my-feature"

    def get_service_config(self, **kwargs) -> Dict[str, Any]:
        """Return Docker Compose keys merged into each service that enables this feature."""
        return {
            "environment": [
                "MY_ENV_VAR=value",
            ],
            # add any other service-level compose keys here
            # e.g. volumes, devices, cap_add, ulimits, etc.
        }

    def get_network_config(self, **kwargs) -> Dict[str, Any]:
        """Return top-level Docker Compose network definitions needed by this feature.
        Return an empty dict if no custom networks are required (the default)."""
        return {}
```

`get_service_config` returns a dict that is deep-merged into every service that has the feature flag set. `get_network_config` returns entries that are merged into the top-level `networks:` section of the compose file.

Both methods receive `**kwargs` with at minimum `app_name`, `service_name`, and `user_defined_feature` (the value written in `scapps.yaml` – could be `True` for simple flags or a dict for configurable features like `ui`).

### Step 2: Register the Feature

Add the new class to `runtime_features/__init__.py`:

```python
from .my_feature import MyRuntimeFeature

__all__ = [
    "RuntimeFeatureConfig",
    "NvidiaGpuRuntimeFeature",
    "Ros2RuntimeFeature",
    "UiRuntimeFeature",
    "MyRuntimeFeature",   # ← add this
]
```

### Step 3: Register with the Generator

Add the feature to `_runtime_features` inside `DockerComposeGenerator.__init__` in `commands/utils.py`:

```python
from runtime_features import ..., MyRuntimeFeature

# inside DockerComposeGenerator.__init__:
self._runtime_features = [
    Ros2RuntimeFeature(),
    NvidiaGpuRuntimeFeature(),
    UiRuntimeFeature(),
    MyRuntimeFeature(),   # ← add this
]
```

### Step 4: Use It in `scapps.yaml`

Users can now enable the feature in their `scapps.yaml`:

```yaml
# simple boolean flag
name: my-app
services:
  my-service:
    image: my-app:latest
    my-feature: true
```

Or, for a configurable feature that reads from a dict:

```yaml
name: my-app
services:
  my-service:
    image: my-app:latest
    my-feature:
      option-a: foo
      option-b: 42
```

The dict (or `True`) is passed to `get_service_config` and `get_network_config` as `user_defined_feature`.
