Custom Module Development
Forkspacer supports custom modules that allow you to extend the operator with custom resource management logic. Custom modules enable you to integrate third-party tools, implement custom deployment strategies, or handle specialized workloads beyond the built-in Helm support.
What are Custom Modules?
Section titled “What are Custom Modules?”Custom modules are containerized HTTP services that implement a REST API for lifecycle management. They run as separate Docker containers that communicate with the operator via HTTP endpoints.
Custom Module Capabilities
Section titled “Custom Module Capabilities”A custom module must implement four core lifecycle endpoints:
- Install (
POST /install): Deploy and configure resources - Uninstall (
POST /uninstall): Remove resources and clean up - Sleep (
POST /sleep): Hibernate or scale down resources - Resume (
POST /resume): Wake up or scale up resources - Health (
GET /health): Health check endpoint
Custom Module Architecture
Section titled “Custom Module Architecture”HTTP API Interface
Section titled “HTTP API Interface”Custom modules expose an HTTP server that the operator calls to manage Module lifecycle. Each endpoint receives metadata as JSON in the request body and returns appropriate HTTP status codes.
Required Endpoints
Section titled “Required Endpoints”Health Check:
GET /healthResponse: 200 OK{ "timestamp": "2025-10-14T10:30:00Z", "uptime": 123.45, "message": "Service is running", "healthy": true}Install:
POST /installContent-Type: application/jsonRequest Body: {"key": "value", ...} // Module configurationResponse: 201 Created (on success) or 400 Bad Request (on failure)Uninstall:
POST /uninstallContent-Type: application/jsonRequest Body: {"key": "value", ...} // Module configurationResponse: 204 No Content (on success) or 400 Bad Request (on failure)Sleep:
POST /sleepContent-Type: application/jsonRequest Body: {"key": "value", ...} // Module configurationResponse: 200 OK (on success) or 400 Bad Request (on failure)Resume:
POST /resumeContent-Type: application/jsonRequest Body: {"key": "value", ...} // Module configurationResponse: 200 OK (on success) or 400 Bad Request (on failure)Creating a Custom Module
Section titled “Creating a Custom Module”Step 1: Create Module Directory
Section titled “Step 1: Create Module Directory”Create a directory for your custom module under modules/custom/:
mkdir -p modules/custom/my-modulecd modules/custom/my-moduleStep 2: Initialize Go Module
Section titled “Step 2: Initialize Go Module”Create a go.mod file:
go mod init my-moduleExample go.mod:
module my-module
go 1.25.0
require go.uber.org/zap v1.27.0
require go.uber.org/multierr v1.10.0 // indirectStep 3: Write HTTP Server Code
Section titled “Step 3: Write HTTP Server Code”Create main.go with an HTTP server that listens on a configurable port:
package main
import ( "context" "fmt" "net/http" "os" "os/signal" "strconv" "syscall" "time"
"go.uber.org/zap")
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() go listenForTermination(func() { cancel() })
logger := initLogger() mux := setupRoutes(logger) port := getPort(logger)
httpServer := &http.Server{ Addr: ":" + port, Handler: mux, }
logger.Info("Starting manager server", zap.String("port", port)) runServer(ctx, httpServer, logger, port) logger.Info("Manager server stopped", zap.String("port", port))}
func listenForTermination(do func()) { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan do()}
func initLogger() *zap.Logger { logger, err := zap.NewProduction() if err != nil { panic(err) } return logger}
func setupRoutes(logger *zap.Logger) *http.ServeMux { mux := http.NewServeMux()
manager := NewManager(logger) handler := NewHandler(logger, manager)
mux.HandleFunc("GET /health", handler.Health) mux.HandleFunc("POST /install", handler.Install) mux.HandleFunc("POST /uninstall", handler.Uninstall) mux.HandleFunc("POST /sleep", handler.Sleep) mux.HandleFunc("POST /resume", handler.Resume)
return mux}
func getPort(logger *zap.Logger) string { port := os.Getenv("PORT") if port == "" { port = "8080" }
_, err := strconv.ParseUint(port, 10, 16) if err != nil { logger.Fatal(fmt.Sprintf("failed to parse port '%s' as uint16", port), zap.Error(err)) }
return port}
func runServer(ctx context.Context, server *http.Server, logger *zap.Logger, port string) { listenerErrChan := make(chan error) go func() { listenerErrChan <- server.ListenAndServe() }()
select { case err := <-listenerErrChan: if err != nil && err != http.ErrServerClosed { logger.Fatal("error while serving http", zap.String("port", port), zap.Error(err)) } case <-ctx.Done(): shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second*10) defer shutdownCancel() if err := server.Shutdown(shutdownCtx); err != nil { logger.Fatal("error while shutting down http server", zap.String("port", port), zap.Error(err)) } }}Step 4: Create HTTP Handler
Section titled “Step 4: Create HTTP Handler”Create handler.go to handle HTTP requests:
package main
import ( "encoding/json" "fmt" "io" "net/http" "time"
"go.uber.org/zap")
var startTime time.Time
func init() { startTime = time.Now()}
type Handler struct { logger *zap.Logger manager *Manager}
func NewHandler(logger *zap.Logger, manager *Manager) *Handler { return &Handler{logger, manager}}
func (h *Handler) Install(w http.ResponseWriter, r *http.Request) { metaData, err := h.parseMetaDataBodyOrErr(w, r) if err != nil { return }
if err = h.manager.Install(r.Context(), metaData); err != nil { http.Error(w, err.Error(), 400) return }
h.responseMetaData(w, 201, metaData)}
func (h *Handler) Uninstall(w http.ResponseWriter, r *http.Request) { metaData, err := h.parseMetaDataBodyOrErr(w, r) if err != nil { return }
if err = h.manager.Uninstall(r.Context(), metaData); err != nil { http.Error(w, err.Error(), 400) return }
w.WriteHeader(204)}
func (h *Handler) Sleep(w http.ResponseWriter, r *http.Request) { metaData, err := h.parseMetaDataBodyOrErr(w, r) if err != nil { return }
if err = h.manager.Sleep(r.Context(), metaData); err != nil { http.Error(w, err.Error(), 400) return }
h.responseMetaData(w, 200, metaData)}
func (h *Handler) Resume(w http.ResponseWriter, r *http.Request) { metaData, err := h.parseMetaDataBodyOrErr(w, r) if err != nil { return }
if err = h.manager.Resume(r.Context(), metaData); err != nil { http.Error(w, err.Error(), 400) return }
h.responseMetaData(w, 200, metaData)}
type HealthResponse struct { Timestamp string `json:"timestamp"` Uptime float64 `json:"uptime"` Message string `json:"message"` Healthy bool `json:"healthy"`}
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { uptime := time.Since(startTime).Seconds() response := HealthResponse{ Timestamp: time.Now().UTC().Format(time.RFC3339), Uptime: uptime, Message: "Service is running", Healthy: true, }
w.Header().Set("Content-Type", "application/json") w.WriteHeader(200)
if err := json.NewEncoder(w).Encode(response); err != nil { h.logger.Error("Failed to encode health response", zap.Error(err), zap.Any("data", response)) }}
func (h *Handler) parseMetaDataBody(r *http.Request) (map[string]any, error) { body, err := io.ReadAll(r.Body) if err != nil { return nil, fmt.Errorf("failed to read request body: %w", err) } defer func() { _ = r.Body.Close() }()
data := make(map[string]any) if len(body) == 0 { return data, nil }
err = json.Unmarshal(body, &data) if err != nil { return nil, fmt.Errorf("failed to unmarshal request body: %w", err) }
return data, nil}
func (h *Handler) parseMetaDataBodyOrErr(w http.ResponseWriter, r *http.Request) (map[string]any, error) { metaData, err := h.parseMetaDataBody(r) if err != nil { http.Error(w, err.Error(), 400) return nil, err }
return metaData, nil}
func (h *Handler) responseMetaData(w http.ResponseWriter, statusCode int, metaData map[string]any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode)
if err := json.NewEncoder(w).Encode(metaData); err != nil { h.logger.Error("Failed to encode metaData response", zap.Error(err), zap.Any("metaData", metaData)) }}Step 5: Implement Business Logic
Section titled “Step 5: Implement Business Logic”Create manager.go with your custom lifecycle logic:
package main
import ( "context"
"go.uber.org/zap")
type Manager struct { logger *zap.Logger}
func NewManager(logger *zap.Logger) *Manager { return &Manager{logger}}
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { m.logger.Info("Installing...", zap.Any("metaData", metaData))
// TODO: Implement your installation logic here // - Create Kubernetes resources // - Configure applications // - Store state in metaData if needed
return nil}
func (m *Manager) Uninstall(ctx context.Context, metaData map[string]any) error { m.logger.Info("Uninstalling...", zap.Any("metaData", metaData))
// TODO: Implement your uninstallation logic here // - Delete created resources // - Clean up external resources
return nil}
func (m *Manager) Sleep(ctx context.Context, metaData map[string]any) error { m.logger.Info("Sleeping...", zap.Any("metaData", metaData))
// TODO: Implement your hibernation logic here // - Scale down deployments to 0 // - Store current state in metaData
return nil}
func (m *Manager) Resume(ctx context.Context, metaData map[string]any) error { m.logger.Info("Resuming...", zap.Any("metaData", metaData))
// TODO: Implement your resume logic here // - Restore state from metaData // - Scale up deployments
return nil}Step 6: Create Dockerfile
Section titled “Step 6: Create Dockerfile”Create a Dockerfile to containerize your module:
FROM golang:1.25 AS builder
WORKDIR /workspace
COPY go.mod go.modCOPY go.sum go.sum
RUN go mod download
COPY ./ ./
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o plugin
FROM alpine:3.22.2
WORKDIR /output
COPY --from=builder /workspace/plugin .
ENV PORT=8080EXPOSE ${PORT}
ENTRYPOINT ["./plugin"]Step 7: Build the Docker Image
Section titled “Step 7: Build the Docker Image”Build your custom module image:
docker build -t my-registry/my-module:v1.0.0 .Push the image to a container registry:
docker push my-registry/my-module:v1.0.0Using Your Custom Module
Section titled “Using Your Custom Module”Deploy the Module
Section titled “Deploy the Module”Create a Module CRD with your custom module configuration:
apiVersion: batch.forkspacer.com/v1kind: Modulemetadata: name: my-app namespace: default
config: - name: "Namespace" alias: "namespace" string: required: true default: "default" editable: true
- name: "Application Name" alias: "appName" string: required: true editable: true
- name: "Replicas" alias: "replicas" integer: required: true default: 1 min: 1 max: 10 editable: true
spec: custom: image: my-registry/my-module:v1.0.0 permissions: - workspace
workspace: name: dev-workspace namespace: default
config: namespace: my-namespace appName: my-application replicas: 3Key points:
- The
configarray at the top level defines the configuration schema with validation - The
spec.customsection specifies your custom module’s Docker image and permissions - The
spec.configsection provides the actual configuration values - Configuration is validated against the schema and passed to your HTTP endpoints
Configuration and Metadata
Section titled “Configuration and Metadata”Configuration Values
Section titled “Configuration Values”The validated configuration values from spec.config are passed to your custom module endpoints as JSON in the request body. The configuration is validated against the schema defined in the config array before being sent to your module.
Example request to /install:
{ "namespace": "my-namespace", "appName": "my-application", "replicas": 3}Your custom module receives these values and can use them to configure the installation.
Working with Metadata
Section titled “Working with Metadata”The metadata parameter in each endpoint is a JSON object that persists across lifecycle operations. Use it to store state information.
Storing Data
Section titled “Storing Data”In your Manager’s Install method:
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { // Store deployment name for later use deploymentName := "my-app-deployment" metaData["deploymentName"] = deploymentName
// Store complex data metaData["installedResources"] = map[string]string{ "deployment": deploymentName, "service": "my-app-service", }
m.logger.Info("Installation completed", zap.String("deployment", deploymentName)) return nil}Retrieving Data
Section titled “Retrieving Data”In your Manager’s Uninstall method:
func (m *Manager) Uninstall(ctx context.Context, metaData map[string]any) error { // Retrieve stored data deploymentName, ok := metaData["deploymentName"].(string) if !ok { return fmt.Errorf("deploymentName not found in metadata") }
m.logger.Info("Deleting deployment", zap.String("name", deploymentName))
// Your deletion logic here
return nil}Best Practices
Section titled “Best Practices”1. Error Handling
Section titled “1. Error Handling”Always return descriptive errors:
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { deployment, err := m.createDeployment(ctx, metaData) if err != nil { return fmt.Errorf("failed to create deployment: %w", err) }
metaData["deploymentName"] = deployment.Name return nil}2. Idempotency
Section titled “2. Idempotency”Make all operations idempotent:
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { // Check if already installed if deploymentName, ok := metaData["deploymentName"].(string); ok && deploymentName != "" { m.logger.Info("Deployment already exists", zap.String("name", deploymentName)) return nil }
// Create deployment // ...}3. Context Awareness
Section titled “3. Context Awareness”Respect context cancellation:
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { select { case <-ctx.Done(): return ctx.Err() default: // Proceed with installation }
// For long-running operations, check context periodically}4. Structured Logging
Section titled “4. Structured Logging”Use structured logging throughout:
func (m *Manager) Install(ctx context.Context, metaData map[string]any) error { m.logger.Info("Starting installation", zap.Any("config", metaData), )
// ... implementation ...
m.logger.Info("Installation completed", zap.String("deploymentName", deploymentName), )
return nil}5. Resource Cleanup
Section titled “5. Resource Cleanup”Always clean up resources in Uninstall:
func (m *Manager) Uninstall(ctx context.Context, metaData map[string]any) error { // Retrieve all resources created during Install resources, ok := metaData["installedResources"].(map[string]any) if !ok { m.logger.Warn("No installed resources found in metadata") return nil }
// Delete in reverse order of creation if svcName, ok := resources["service"].(string); ok { if err := m.deleteService(ctx, svcName); err != nil { return fmt.Errorf("failed to delete service: %w", err) } }
if depName, ok := resources["deployment"].(string); ok { if err := m.deleteDeployment(ctx, depName); err != nil { return fmt.Errorf("failed to delete deployment: %w", err) } }
return nil}6. Health Checks
Section titled “6. Health Checks”Implement a robust health check:
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { // Check dependencies (database, external APIs, etc.) healthy := h.manager.CheckHealth(r.Context())
response := HealthResponse{ Timestamp: time.Now().UTC().Format(time.RFC3339), Uptime: time.Since(startTime).Seconds(), Message: "Service is running", Healthy: healthy, }
statusCode := 200 if !healthy { statusCode = 503 }
w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(response)}Complete Example
Section titled “Complete Example”See the complete example custom module in the repository:
Next Steps
Section titled “Next Steps”- Development Overview - Learn about operator development
- Module CRD Reference - Understand Module CRD specification with custom module support
Resources
Section titled “Resources”- Go HTTP Package - Go HTTP server documentation
- Docker Documentation - Container build and deployment
- Kubernetes Client-Go - Kubernetes Go client library
- Zap Logger - Structured logging library