Design best practices¶
When crafting SDKs for Workshop, publishers face design decisions that affect how their SDKs install, integrate, and work inside workshops. Understanding the best practices outlined below helps publishers create more maintainable, reliable, and user-friendly SDKs that better align with Workshop’s architecture and ideology.
This explanation covers key design considerations and provides rationale for common patterns found in a number of SDKs available in the Workshop ecosystem.
System services¶
System services within SDKs should be designed to integrate smoothly with the workshop’s lifecycle and other SDK components.
Consider the approach used by the ollama SDK:
it implements a setup-project hook
that configures and starts the systemd service
by including a service file:
parts:
user-service:
plugin: dump
source: ollama.service
source-type: file
The file provides appropriate service configuration:
[Unit]
Description=Ollama Service
After=network.target
[Service]
ExecStart=/bin/bash -lc "ollama serve"
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
And gets installed during the setup-project phase:
install -D --mode=644 --target-directory ~/.config/systemd/user "$SDK/ollama.service"
systemctl --user daemon-reload
systemctl --user enable --now ollama
This design ensures that the service starts automatically when the workshop is launched, and stops cleanly when the workshop is terminated.
Parts decomposition¶
The parts mechanism, shared by Workshop with projects such as Snapcraft, enables modularity by separating different aspects of an SDK into discrete, manageable components. Effective decomposition strategies depend on the SDK’s complexity and the independence of its components.
Consider the go SDK, which uses a single part
because the Go toolchain can be distributed as a cohesive unit:
parts:
go:
plugin: dump
source: https://go.dev/dl/go$CRAFT_PROJECT_VERSION.linux-$CRAFT_ARCH_BUILD_FOR.tar.gz
source-type: tar
In contrast, the ollama SDK is built with multiple parts
for the runtime and service configuration,
allowing selective updates and reducing build times:
parts:
ollama:
plugin: dump
source: https://github.com/ollama/ollama/releases/download/v0.9.6/ollama-linux-amd64.tgz
source-type: tar
user-service:
plugin: dump
source: ollama.service
source-type: file
Parts should be organized around functional boundaries:
Component Type |
Description |
|---|---|
Runtime components |
Core binaries and libraries that change infrequently |
Configuration |
Settings and templates that may need customization |
Data assets |
Large files like models or datasets that update independently |
Tools |
Auxiliary utilities that complement the main functionality |
However, parts are not mandatory: the minimal viable option is to forgo them entirely and install everything in the hooks.
Interface layout¶
Interfaces define how SDKs interact with the host system and other SDKs. The layout of interfaces ultimately impacts an SDK’s usability and security. Publishers should select interfaces based on the resources their SDK requires (via plugs) or exposes (via slots).
In particular,
the mount interface plugs are frequently used
because they specifically address data persistence and sharing needs.
The uv SDK demonstrates this by mounting /home/workshop/.cache/uv
to preserve package caches across workshop life-cycle,
improving performance for workshop refresh:
plugs:
cache:
interface: mount
workshop-target: /home/workshop/.cache/uv
This configuration means that the /home/workshop/.cache/uv/ directory
inside the workshop maps to a persistent storage location on the host system.
This setup allows the uv SDK to retain its cache between refreshes.
Rather than a plug, a slot provides resources to the workshop;
for instance, the ollama SDK uses the tunnel interface slot
to expose its server functionality on a specific port,
enabling external access to its services:
slots:
ollama-server:
interface: tunnel
endpoint: 11434
The most obvious interface choices are as follows:
Use
mountfor persistent data and cachesUse
gpuwhen GPU acceleration is requiredUse
tunnelfor network services that need to be accessible externallyUse
sshfor authentication with remote services
For a complete list, see Interfaces; for a discussion of interface capabilities, see Interface concepts.
Environment variables¶
Environment variables provide a clean way to configure SDK behavior and integrate with workshops. SDKs should use standard POSIX-compatible shell mechanisms to add variables to the workshop.
For system-wide variables that affect all users,
SDKs should place configuration files in /etc/profile.d/.
The uv SDK demonstrates this approach
by setting system-wide PATH modifications
in its setup-base hook:
cat <<EOF >> /etc/profile.d/uv.sh
PATH="$SDK/bin:\$PATH"
EOF
For user-specific variables that only make sense for the workshop user,
SDKs should modify ~/.profile.
Again, the uv SDK illustrates this pattern
by setting UV_LINK_MODE=copy in its setup-project hook
to address interaction between SDK behavior and workshop architecture:
cat <<EOF >> ~/.profile
# SDK uses 'mount' interface to preserve
# uv cache across refreshes, thus, hardlinking is
# not available on 'uv sync'.
export UV_LINK_MODE=copy
EOF
Publishers should avoid shell-specific configuration files,
such as ~/.bash_profile or ~/.bashrc,
because Workshop supports multiple shell interpreters
and these files may not be sourced consistently
across different shell sessions.
Some guidelines for environment variables:
Use clear names that indicate origin and purpose; prefix them with the SDK name to avoid conflicts
Include comments explaining why specific values are chosen
Choose between
/etc/profile.d/for system-wide settings and~/.profilefor user-specific configuration
Parts or hooks?¶
The decision between shipping prebuilt content and, alternatively, installing it dynamically at runtime through hooks affects SDK size, startup time, and flexibility. Different content types have different optimal strategies.
For instance, Debian packages are best installed in hooks,
particularly in setup-base,
because they integrate with the system package manager
and can leverage apt’s local cache.
Installing packages during SDK build
would bypass distribution security updates
and create larger SDK artifacts.
The ros2 SDK exemplifies this approach:
apt-get update
apt-get install ros-dev-tools
apt-get install python3-colcon-argcomplete python3-colcon-alias python3-colcon-clean python3-colcon-mixin
# ...
In general, binary artifacts are best shipped as parts when you need to:
Pin specific versions regardless of what’s available in package repositories,
Distribute custom builds with specialized compilation flags,
Provide tools that aren’t available through the system package manager.
This approach ensures consistent environments and avoids eventual dependency conflicts.
The uv SDK shows this approach by shipping prebuilt Rust binaries:
parts:
uv:
plugin: rust
source: https://github.com/astral-sh/uv
source-tag: $CRAFT_PROJECT_VERSION
source-type: git
organize:
uv: bin/uv
uvx: bin/uvx
prime:
- bin/uv
- bin/uvx
setup-base or setup-project?¶
The choice between setup-base and setup-project hooks
fundamentally affects when and how SDK initialization occurs.
This decision impacts performance, caching behavior,
and the SDK’s presence in workshop snapshots.
First of all,
note that both setup-base and setup-project
should configure the workshop for running,
but normally don’t directly control service startup or other runtime behavior.
For instance, they can configure container shutdown or startup,
but they shouldn’t start services directly
unless there’s a specific reason to do so.
The setup-base hook runs once when the SDK is installed
at launch or refresh time,
making it ideal for system-wide configuration
that doesn’t change between projects.
Operations in setup-base become part of
workshop snapshots,
improving startup performance at subsequent refreshes.
For instance,
the uv SDK uses setup-base for system-wide configuration
that includes shell completion, PATH updates,
and system package manager integration:
sudo -u workshop mkdir -p /home/workshop/uv-venv
cat <<EOF >> /etc/profile.d/uv.sh
PATH="$SDK/bin:\$PATH"
EOF
"$SDK"/bin/uv generate-shell-completion bash > /etc/bash_completion.d/uv.sh
"$SDK"/bin/uvx --generate-shell-completion bash > /etc/bash_completion.d/uvx.sh
mkdir -p /usr/local/libexec/alternatives
cat << 'EOF' > /usr/local/libexec/alternatives/uv-pip
#!/bin/bash
exec uv pip "$@"
EOF
chmod +x /usr/local/libexec/alternatives/uv-pip
update-alternatives --install /usr/bin/pip pip /usr/local/libexec/alternatives/uv-pip 50
The setup-project hook runs as the workshop user
after setup-base,
when interfaces are connected and the workshop is fully operational.
This makes it suitable for project-specific initialization
that might vary depending on the actual project files.
For instance, the comfy SDK uses setup-project
to detect the available GPU type,
configuring the appropriate PyTorch variant accordingly:
GPU_TYPE="none"
if command -v lspci >/dev/null 2>&1; then
if lspci | grep -i 'NVIDIA' >/dev/null 2>&1; then
GPU_TYPE="nvidia"
elif lspci | grep -i 'AMD/ATI' >/dev/null 2>&1; then
GPU_TYPE="amd"
elif lspci | grep -i 'Intel.*Graphics' >/dev/null 2>&1; then
GPU_TYPE="intel"
fi
fi
echo "Detected GPU: $GPU_TYPE"
The need to use setup-project for this purpose arises
from the fact that the GPU is accessed via an auto-connected interface,
so its availability can only be determined
after the workshop has launched and interfaces are connected.
However, the choice of packages to install depends on the GPU type,
necessitating dynamic configuration at project setup time:
case "$GPU_TYPE" in
nvidia)
pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu129
;;
amd)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.4
;;
*)
pip install torch torchvision torchaudio
;;
esac
In general, you use setup-base for:
System package installation
Global environment configuration
One-time setup operations
Content that should be part of snapshots (e.g., infrequently updated or unlikely to change)
Choose setup-project for:
Project-specific configuration that depends on the project context
Operations requiring auto-connected interfaces
Content that shouldn’t be part of snapshots (e.g., frequently updated or extra large)
Health checks¶
Health check scripts provide essential feedback about SDK operational status and help users diagnose problems quickly. Well-designed health checks go beyond simple binary success or failure, reporting extra details to provide actionable diagnostic information.
The ollama SDK demonstrates comprehensive health checking
by testing actual service functionality and channeling its error output:
if ! output=$(sudo -u workshop --login ollama list 2>&1); then
workshopctl set-health error "$output"
exit
fi
workshopctl set-health okay
When the workshop is launched with --wait-on-error,
the workshop info output will contain these details.
In general, health checks should:
Test each of the relevant features, not just the sheer fact of installation
Provide specific error codes for different failure modes
Include helpful error messages that guide troubleshooting with workshop changes
Run quickly to avoid slowing workshop operations down
Handle edge cases gracefully
SDK dependencies¶
The mount interface enables sophisticated collaboration patterns
between SDKs within a workshop while avoiding explicit dependency management.
Rather than having each SDK prepare and maintain its own resources,
SDKs can expose capabilities they provide through slots
and consume them through plugs,
creating efficient resource utilization patterns.
Consider Python-based SDKs that need to install packages from PyPI.
Instead of each SDK maintaining its own virtual environment,
one SDK can provide a shared environment that others consume.
The uv SDK demonstrates this by exposing a virtual environment slot:
slots:
venv:
interface: mount
workshop-source: /home/workshop/uv-venv
Here, workshop-source says that the resource is inside the workshop,
rather than on the host.
Other Python-based SDKs can then connect to this shared environment
through corresponding plugs.
The jupyter SDK shows this pattern:
plugs:
venv:
interface: mount
workshop-target: $SDK/venv
Workshop users wire the two together
through a connections: block in their workshop definition;
when no compatible slot is available,
the consuming SDK falls back to the host directory
that Workshop automatically provides for the plug,
so a Python-based SDK still works on its own.
For the details of how this works,
see How to manage Python environments with the uv SDK.
This pattern extends beyond Python or its virtual environments to encompass various shared resources, including common libraries and runtime environments, shared data directories and caches, and development tools and utilities. It offers several advantages:
Eliminates duplication of large tool chains or environments
Maintains separation between SDK responsibilities
Allows workshop users to mix and match compatible SDKs
Avoids the complexity of dependency management with a fallback mechanism
Both examples above assume SDK publishers ship the required plugs and slots. When they don’t, the workshop user can graft the missing elements in the workshop definition, extending an SDK’s capabilities without the publisher’s involvement. This makes plug and slot management a shared effort: SDK authors define the standard capabilities, and users augment them to fit their project’s needs.
See also¶
Explanation:
Reference: