Browse Source

feat(template): add OpenCode template

Thomas Zhang 2 months ago
parent
commit
8c392f0f45

+ 2 - 0
internal/controller/component_controller.go

@@ -175,6 +175,8 @@ func (r *ComponentReconciler) getComponentReconciler(comp *v1alpha1.Component, s
 		return reconciler.NewLocoContextReconciler(r.Client, r.Scheme, stack, comp)
 	case "loco-agents":
 		return reconciler.NewLocoAgentsReconciler(r.Client, r.Scheme, stack, comp)
+	case "opencode":
+		return reconciler.NewLocoOpenCodeAgentReconciler(r.Client, r.Scheme, stack, comp)
 	default:
 		return reconciler.NewDefaultComponentReconciler(r.Client, r.Scheme, stack, comp)
 	}

+ 206 - 0
internal/reconciler/opencode.go

@@ -0,0 +1,206 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package reconciler
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"maps"
+	"sort"
+	"strings"
+
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	"github.com/LocoStack/loco-operator/pkg/templates/litellm"
+	"github.com/LocoStack/loco-operator/pkg/templates/opencode"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+// LocoOpenCodeAgentReconciler reconciles the "opencode" template, running the OpenCode
+// CLI (https://opencode.ai) in headless server mode. Unlike loco-agents, OpenCode is
+// configured entirely through its own config file rather than a LocoStack-specific
+// shim, so this reconciler translates AgentComponents into OpenCode's native schema:
+// a custom "openai-compatible" provider for the model, native "mcp" entries for tools,
+// and native SKILL.md discovery for skills (via AddSkillsToTmpl).
+//
+// KnowledgeBases and Agents (subagents) dependencies are resolved by getDependencies
+// like any other agent template, but intentionally left unused here: OpenCode has no
+// native knowledge-base/RAG concept, and no native support for remote (A2A) subagents.
+type LocoOpenCodeAgentReconciler struct {
+	*DefaultComponentReconciler
+	*AgentReconciler
+	client    client.Client
+	scheme    *runtime.Scheme
+	stack     *v1alpha1.Stack
+	component *v1alpha1.Component
+}
+
+func NewLocoOpenCodeAgentReconciler(client client.Client, scheme *runtime.Scheme, stack *v1alpha1.Stack, component *v1alpha1.Component) *LocoOpenCodeAgentReconciler {
+	return &LocoOpenCodeAgentReconciler{
+		DefaultComponentReconciler: NewDefaultComponentReconciler(client, scheme, stack, component),
+		AgentReconciler:            NewAgentReconciler(client, scheme, stack, component),
+		client:                     client,
+		scheme:                     scheme,
+		stack:                      stack,
+		component:                  component,
+	}
+}
+
+func (r *LocoOpenCodeAgentReconciler) ReconcileComponent(ctx context.Context, tmpl *v1alpha1.Template, variables map[string]string) ([]client.Object, error) {
+	deps, comps, err := r.getDependencies(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to get dependencies for %s: %w", r.component.Name, err)
+	}
+
+	configHash, err := r.reconcileOpenCodeConfig(ctx, r.component.Spec.Variables["systemPrompt"], comps)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to reconcile config for %s: %w", r.component.Name, err)
+	}
+
+	skillsHash, err := r.AddSkillsToTmpl(ctx, comps, tmpl, opencode.OpenCodeSkillsRoot)
+	if err != nil {
+		return nil, fmt.Errorf("Failed to mount skills for %s: %w", r.component.Name, err)
+	}
+
+	if tmpl.Metadata.Annotations == nil {
+		tmpl.Metadata.Annotations = make(map[string]string)
+	}
+	// OpenCode discovers its skills at startup and the mounted ConfigMaps change under a
+	// running Pod, so the skill files count towards the Pod template just like the config
+	h := sha256.Sum256([]byte(configHash + skillsHash))
+	maps.Copy(tmpl.Metadata.Annotations, map[string]string{
+		"locostack.com/configHash": hex.EncodeToString(h[:]),
+	})
+
+	if _, err := r.DefaultComponentReconciler.ReconcileComponent(ctx, tmpl, variables); err != nil {
+		return nil, err
+	}
+	return deps, nil
+}
+
+type openCodeProviderModel struct {
+	Name string `json:"name,omitempty"`
+}
+
+type openCodeProviderOptions struct {
+	BaseURL string `json:"baseURL"`
+	APIKey  string `json:"apiKey,omitempty"`
+}
+
+type openCodeProvider struct {
+	NPM     string                           `json:"npm"`
+	Name    string                           `json:"name,omitempty"`
+	Options openCodeProviderOptions          `json:"options"`
+	Models  map[string]openCodeProviderModel `json:"models"`
+}
+
+type openCodeMCPServer struct {
+	Type    string            `json:"type"`
+	URL     string            `json:"url"`
+	Headers map[string]string `json:"headers,omitempty"`
+}
+
+type openCodeAgentConfig struct {
+	Prompt string `json:"prompt,omitempty"`
+}
+
+type openCodeConfig struct {
+	Schema   string                         `json:"$schema"`
+	Provider map[string]openCodeProvider    `json:"provider"`
+	Model    string                         `json:"model,omitempty"`
+	MCP      map[string]openCodeMCPServer   `json:"mcp,omitempty"`
+	Agent    map[string]openCodeAgentConfig `json:"agent,omitempty"`
+}
+
+const openCodeModelProviderID = "locostack"
+
+func (r *LocoOpenCodeAgentReconciler) reconcileOpenCodeConfig(ctx context.Context, systemPrompt string, comps *AgentComponents) (string, error) {
+	cfg := openCodeConfig{
+		Schema: "https://opencode.ai/config.json",
+		Provider: map[string]openCodeProvider{
+			openCodeModelProviderID: {
+				NPM:  "@ai-sdk/openai-compatible",
+				Name: "LocoStack",
+				Options: openCodeProviderOptions{
+					BaseURL: comps.Model.URL,
+					APIKey:  comps.GatewayKey,
+				},
+				Models: map[string]openCodeProviderModel{
+					comps.Model.Name: {},
+				},
+			},
+		},
+		Model: fmt.Sprintf("%s/%s", openCodeModelProviderID, comps.Model.Name),
+	}
+
+	if systemPrompt != "" {
+		cfg.Agent = map[string]openCodeAgentConfig{
+			"build": {Prompt: systemPrompt},
+		}
+	}
+
+	if len(comps.Tools) > 0 {
+		cfg.MCP = make(map[string]openCodeMCPServer)
+		if comps.Gateway.URL != "" {
+			toolNames := make([]string, 0, len(comps.Tools))
+			for _, tool := range comps.Tools {
+				toolNames = append(toolNames, tool.Name)
+			}
+			sort.Strings(toolNames)
+			cfg.MCP["litellm"] = openCodeMCPServer{
+				Type: "remote",
+				URL:  fmt.Sprintf("%s/%s/mcp", comps.Gateway.URL, strings.Join(toolNames, ",")),
+				Headers: map[string]string{
+					litellm.MCPAuthHeaderName: "Bearer " + comps.GatewayKey,
+				},
+			}
+		} else {
+			for _, tool := range comps.Tools {
+				cfg.MCP[tool.Name] = openCodeMCPServer{Type: "remote", URL: tool.URL}
+			}
+		}
+	}
+
+	jsonBytes, err := json.Marshal(cfg)
+	if err != nil {
+		return "", fmt.Errorf("marshaling opencode config: %w", err)
+	}
+	h := sha256.Sum256(jsonBytes)
+	configHash := hex.EncodeToString(h[:])
+	secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: r.ResourceName("config"), Namespace: r.component.GetNamespace()}}
+	_, err = controllerutil.CreateOrUpdate(ctx, r.client, secret, func() error {
+		if secret.Labels == nil {
+			secret.Labels = map[string]string{}
+		}
+		maps.Copy(secret.Labels, r.ResourceLabels())
+		if secret.Annotations == nil {
+			secret.Annotations = make(map[string]string)
+		}
+		secret.Annotations["hash"] = configHash
+		secret.Data = map[string][]byte{
+			"opencode.json": jsonBytes,
+		}
+		return controllerutil.SetControllerReference(r.component, secret, r.scheme)
+	})
+	return configHash, err
+}

+ 82 - 0
pkg/templates/opencode/opencode.go

@@ -0,0 +1,82 @@
+/*
+Copyright 2026 LocoStack.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package opencode
+
+import (
+	"github.com/LocoStack/loco-operator/api/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/utils/ptr"
+)
+
+const OpenCodeSkillsRoot = "/tmp/.config/opencode"
+
+const OpenCodeImage = "ghcr.io/anomalyco/opencode:1.18.26"
+
+var OpenCodeAgentTemplate = v1alpha1.Template{
+	Name: "opencode",
+	Spec: v1alpha1.TemplateSpec{
+		Runtime: v1alpha1.RuntimeSpec{
+			Name:  "opencode",
+			Image: OpenCodeImage,
+			Args: []string{
+				"serve",
+				"--port",
+				"$(spec.runtime.port)",
+				"--hostname",
+				"0.0.0.0",
+			},
+			Env: []corev1.EnvVar{
+				{
+					Name:  "HOME",
+					Value: "/tmp",
+				},
+				{
+					Name:  "OPENCODE_CONFIG",
+					Value: "/etc/opencode/opencode.json",
+				},
+			},
+			VolumeMounts: []corev1.VolumeMount{
+				{
+					Name:      "config",
+					MountPath: "/etc/opencode",
+					ReadOnly:  true,
+				},
+			},
+			Port: 4096,
+		},
+		SecurityContext: &corev1.PodSecurityContext{
+			RunAsNonRoot: ptr.To(true),
+			RunAsUser:    ptr.To[int64](10001),
+			RunAsGroup:   ptr.To[int64](10001),
+			FSGroup:      ptr.To[int64](10001),
+			SeccompProfile: &corev1.SeccompProfile{
+				Type: corev1.SeccompProfileTypeRuntimeDefault,
+			},
+			SupplementalGroups: []int64{},
+		},
+		Volumes: []corev1.Volume{
+			{
+				Name: "config",
+				VolumeSource: corev1.VolumeSource{
+					Secret: &corev1.SecretVolumeSource{
+						SecretName: "$(metadata.name)-config",
+					},
+				},
+			},
+		},
+	},
+}

+ 3 - 0
pkg/templates/templates.go

@@ -27,6 +27,7 @@ import (
 	"github.com/LocoStack/loco-operator/pkg/templates/locostack"
 	"github.com/LocoStack/loco-operator/pkg/templates/neo4j"
 	"github.com/LocoStack/loco-operator/pkg/templates/npx"
+	"github.com/LocoStack/loco-operator/pkg/templates/opencode"
 	"github.com/LocoStack/loco-operator/pkg/templates/phoenix"
 	"github.com/LocoStack/loco-operator/pkg/templates/postgresql"
 	"github.com/LocoStack/loco-operator/pkg/templates/qdrant"
@@ -274,4 +275,6 @@ func init() {
 	Manager.RegisterTemplate(locostack.LocoSimpleRAGEgestionTemplate)
 	Manager.RegisterTemplate(locostack.LocoAgentsTemplate)
 	Manager.RegisterRuntime(locostack.LocoAgentsTemplate.Spec.Runtime)
+	Manager.RegisterTemplate(opencode.OpenCodeAgentTemplate)
+	Manager.RegisterRuntime(opencode.OpenCodeAgentTemplate.Spec.Runtime)
 }