The Extension system is the core architectural pattern in Halo CMS that enables flexible, Kubernetes-inspired data modeling and persistence. Extensions allow developers to define custom resource types that are automatically managed by the system.
What is an Extension?
An Extension is a structured data type that represents a resource in Halo. Similar to Kubernetes Custom Resources, Extensions provide:
- Schema-based data modeling with automatic validation
- Metadata management including labels, annotations, and versioning
- CRUD operations through a unified client interface
- Watch and event notifications for reactive programming
- Indexing and querying for efficient data retrieval
Core Concepts
Extension Interface
All custom types must implement the Extension interface:
public interface Extension extends ExtensionOperator, Comparable<Extension> {
@Override
default int compareTo(Extension another) {
if (another == null || another.getMetadata() == null) {
return 1;
}
if (getMetadata() == null) {
return -1;
}
return Objects.compare(getMetadata().getName(),
another.getMetadata().getName(),
Comparator.naturalOrder());
}
}
For convenience, you typically extend AbstractExtension which provides basic structure:
@Data
public abstract class AbstractExtension implements Extension {
private String apiVersion;
private String kind;
private MetadataOperator metadata;
}
Group, Version, Kind (GVK)
Every Extension must be annotated with @GVK to define its identity:
@GVK(
group = "plugin.halo.run",
version = "v1alpha1",
kind = "Plugin",
plural = "plugins",
singular = "plugin"
)
public class Plugin extends AbstractExtension {
private PluginSpec spec;
private PluginStatus status;
}
GVK Components:
- group: API group name (e.g.,
plugin.halo.run, content.halo.run)
- version: API version (e.g.,
v1alpha1, v1beta1, v1)
- kind: Resource type name (singular, CamelCase)
- plural: Plural form for REST endpoints
- singular: Singular form for display
The combination of group, version, and kind must be globally unique within your Halo instance.
Every Extension has metadata that provides common fields:
@Data
public class Metadata implements MetadataOperator {
private String name; // Unique identifier
private String generateName; // Auto-generate name prefix
private Map<String, String> labels; // Key-value labels for selection
private Map<String, String> annotations; // Key-value annotations for metadata
private Long version; // Optimistic locking version
private Instant creationTimestamp; // Creation time
private Instant deletionTimestamp; // Deletion time (soft delete)
private Set<String> finalizers; // Prevent deletion until cleared
}
Key Metadata Features:
- name: Globally unique identifier for the resource
- labels: Used for querying and filtering (indexed by default)
- annotations: Store additional non-identifying metadata
- version: Optimistic locking to prevent concurrent update conflicts
- finalizers: Enable cleanup logic before deletion
Scheme
The Scheme represents the complete definition of an Extension type:
public record Scheme(
Class<? extends Extension> type,
GroupVersionKind groupVersionKind,
String plural,
String singular,
ObjectNode openApiSchema
) {
public static Scheme buildFromType(Class<? extends Extension> type) {
var gvk = getGvkFromType(type);
// Generate OpenAPI schema from class
var resolvedSchema = ModelConverters.getInstance()
.readAllAsResolvedSchema(type);
// ...
return new Scheme(type, gvk, plural, singular, schema);
}
}
Schemes are automatically registered and managed by the SchemeManager.
Extension Lifecycle
Working with Extensions
Creating an Extension
@Component
public class MyService {
private final ReactiveExtensionClient client;
public Mono<Plugin> createPlugin(Plugin plugin) {
// Set metadata
plugin.setMetadata(new Metadata());
plugin.getMetadata().setName("my-plugin");
// Create in database
return client.create(plugin);
}
}
Updating an Extension
public Mono<Plugin> updatePlugin(String name) {
return client.fetch(Plugin.class, name)
.flatMap(plugin -> {
// Modify the plugin
plugin.getSpec().setEnabled(true);
// Update (version is checked automatically)
return client.update(plugin);
});
}
Always fetch the latest version before updating to avoid optimistic locking failures.
Deleting an Extension
public Mono<Plugin> deletePlugin(String name) {
return client.fetch(Plugin.class, name)
.flatMap(client::delete);
}
Listing Extensions
public Flux<Plugin> listPlugins() {
return client.list(Plugin.class,
plugin -> true, // predicate
Comparators.compareCreationTimestamp(false)); // comparator
}
Extension Patterns
Spec-Status Pattern
Follow the Kubernetes pattern of separating desired state (spec) from observed state (status):
@GVK(group = "example.halo.run", version = "v1alpha1",
kind = "MyResource", plural = "myresources", singular = "myresource")
public class MyResource extends AbstractExtension {
private MyResourceSpec spec; // Desired state (user input)
private MyResourceStatus status; // Observed state (system output)
}
@Data
public class MyResourceSpec {
private String desiredValue;
private boolean enabled;
}
@Data
public class MyResourceStatus {
private String currentValue;
private Phase phase;
private ConditionList conditions;
}
Never modify status in user-facing APIs. Status should only be updated by controllers/reconcilers.
Finalizers
Use finalizers to perform cleanup before deletion:
public Mono<Void> ensureFinalizer(Plugin plugin) {
var finalizers = plugin.getMetadata().getFinalizers();
if (finalizers == null) {
finalizers = new HashSet<>();
plugin.getMetadata().setFinalizers(finalizers);
}
if (!finalizers.contains("my-plugin.halo.run/cleanup")) {
finalizers.add("my-plugin.halo.run/cleanup");
return client.update(plugin).then();
}
return Mono.empty();
}
public Mono<Void> cleanup(Plugin plugin) {
// Perform cleanup logic
return doCleanup(plugin)
.then(Mono.defer(() -> {
// Remove finalizer
plugin.getMetadata().getFinalizers()
.remove("my-plugin.halo.run/cleanup");
return client.update(plugin).then();
}));
}
Best Practices
- Use meaningful GVK names: Group names should be reverse-DNS format (e.g.,
plugin.halo.run)
- Version your APIs: Start with
v1alpha1, progress to v1beta1, then v1
- Follow spec-status pattern: Keep desired and observed state separate
- Add validation: Use
@Schema annotations to define constraints
- Document your fields: Use Javadoc and schema descriptions
- Use labels wisely: Index frequently queried fields as labels
- Handle optimistic locking: Retry on version conflicts
- Clean up with finalizers: Ensure resources are properly cleaned up
Next Steps