cli/pkg/container/templates.go
Snider 7741360bd5 Migrate pkg/container to io.Medium abstraction (#292)
* chore(io): migrate pkg/container to Medium abstraction

Migrated State, Templates, and LinuxKitManager in pkg/container to use
the io.Medium abstraction for storage operations.

- Introduced TemplateManager struct to handle template logic with injected medium.
- Updated State struct to use injected medium for persistence.
- Updated LinuxKitManager to hold and use an io.Medium instance.
- Updated all internal callers in internal/cmd/vm and pkg/devops to use new APIs.
- Adapted and maintained comprehensive test coverage in linuxkit_test.go.
- Fixed naming collision with standard io package by aliasing it as goio.

* chore(io): migrate pkg/container to Medium abstraction (v2)

- Migrated State, Templates, and LinuxKitManager in pkg/container to use io.Medium.
- Introduced TemplateManager struct for dependency injection.
- Updated all call sites in internal/cmd/vm and pkg/devops.
- Restored and adapted comprehensive test suite in linuxkit_test.go.
- Fixed naming collisions and followed project test naming conventions.

* chore(io): address PR feedback for container Medium migration

- Added Open method to io.Medium interface to support log streaming.
- Implemented Open in local.Medium and MockMedium.
- Fixed extension inconsistency in GetTemplate (.yml vs .yaml).
- Refactored TemplateManager to use configurable WorkingDir and HomeDir.
- Reused TemplateManager instance in cmd_templates.go.
- Updated LinuxKitManager to use medium.Open for log access.
- Maintained and updated all tests to verify these improvements.
2026-02-04 15:33:22 +00:00

332 lines
8.8 KiB
Go

package container
import (
"embed"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/host-uk/core/pkg/io"
)
//go:embed templates/*.yml
var embeddedTemplates embed.FS
// Template represents a LinuxKit YAML template.
type Template struct {
// Name is the template identifier (e.g., "core-dev", "server-php").
Name string
// Description is a human-readable description of the template.
Description string
// Path is the file path to the template (relative or absolute).
Path string
}
// builtinTemplates defines the metadata for embedded templates.
var builtinTemplates = []Template{
{
Name: "core-dev",
Description: "Development environment with Go, Node.js, PHP, Docker-in-LinuxKit, and SSH access",
Path: "templates/core-dev.yml",
},
{
Name: "server-php",
Description: "Production PHP server with FrankenPHP, Caddy reverse proxy, and health checks",
Path: "templates/server-php.yml",
},
}
// TemplateManager manages LinuxKit templates using a storage medium.
type TemplateManager struct {
medium io.Medium
workingDir string
homeDir string
}
// NewTemplateManager creates a new TemplateManager instance.
func NewTemplateManager(m io.Medium) *TemplateManager {
tm := &TemplateManager{medium: m}
// Default working and home directories from local system
// These can be overridden if needed.
if wd, err := os.Getwd(); err == nil {
tm.workingDir = wd
}
if home, err := os.UserHomeDir(); err == nil {
tm.homeDir = home
}
return tm
}
// WithWorkingDir sets the working directory for user template discovery.
func (tm *TemplateManager) WithWorkingDir(wd string) *TemplateManager {
tm.workingDir = wd
return tm
}
// WithHomeDir sets the home directory for user template discovery.
func (tm *TemplateManager) WithHomeDir(home string) *TemplateManager {
tm.homeDir = home
return tm
}
// ListTemplates returns all available LinuxKit templates.
// It combines embedded templates with any templates found in the user's
// .core/linuxkit directory.
func (tm *TemplateManager) ListTemplates() []Template {
templates := make([]Template, len(builtinTemplates))
copy(templates, builtinTemplates)
// Check for user templates in .core/linuxkit/
userTemplatesDir := tm.getUserTemplatesDir()
if userTemplatesDir != "" {
userTemplates := tm.scanUserTemplates(userTemplatesDir)
templates = append(templates, userTemplates...)
}
return templates
}
// GetTemplate returns the content of a template by name.
// It first checks embedded templates, then user templates.
func (tm *TemplateManager) GetTemplate(name string) (string, error) {
// Check embedded templates first
for _, t := range builtinTemplates {
if t.Name == name {
content, err := embeddedTemplates.ReadFile(t.Path)
if err != nil {
return "", fmt.Errorf("failed to read embedded template %s: %w", name, err)
}
return string(content), nil
}
}
// Check user templates
userTemplatesDir := tm.getUserTemplatesDir()
if userTemplatesDir != "" {
// Check both .yml and .yaml extensions
for _, ext := range []string{".yml", ".yaml"} {
templatePath := filepath.Join(userTemplatesDir, name+ext)
if tm.medium.IsFile(templatePath) {
content, err := tm.medium.Read(templatePath)
if err != nil {
return "", fmt.Errorf("failed to read user template %s: %w", name, err)
}
return content, nil
}
}
}
return "", fmt.Errorf("template not found: %s", name)
}
// ApplyTemplate applies variable substitution to a template.
func (tm *TemplateManager) ApplyTemplate(name string, vars map[string]string) (string, error) {
content, err := tm.GetTemplate(name)
if err != nil {
return "", err
}
return ApplyVariables(content, vars)
}
// ApplyVariables applies variable substitution to content string.
// It supports two syntaxes:
// - ${VAR} - required variable, returns error if not provided
// - ${VAR:-default} - variable with default value
func ApplyVariables(content string, vars map[string]string) (string, error) {
// Pattern for ${VAR:-default} syntax
defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`)
// Pattern for ${VAR} syntax (no default)
requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// Track missing required variables
var missingVars []string
// First pass: replace variables with defaults
result := defaultPattern.ReplaceAllStringFunc(content, func(match string) string {
submatch := defaultPattern.FindStringSubmatch(match)
if len(submatch) != 3 {
return match
}
varName := submatch[1]
defaultVal := submatch[2]
if val, ok := vars[varName]; ok {
return val
}
return defaultVal
})
// Second pass: replace required variables and track missing ones
result = requiredPattern.ReplaceAllStringFunc(result, func(match string) string {
submatch := requiredPattern.FindStringSubmatch(match)
if len(submatch) != 2 {
return match
}
varName := submatch[1]
if val, ok := vars[varName]; ok {
return val
}
missingVars = append(missingVars, varName)
return match // Keep original if missing
})
if len(missingVars) > 0 {
return "", fmt.Errorf("missing required variables: %s", strings.Join(missingVars, ", "))
}
return result, nil
}
// ExtractVariables extracts all variable names from a template.
// Returns two slices: required variables and optional variables (with defaults).
func ExtractVariables(content string) (required []string, optional map[string]string) {
optional = make(map[string]string)
requiredSet := make(map[string]bool)
// Pattern for ${VAR:-default} syntax
defaultPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}`)
// Pattern for ${VAR} syntax (no default)
requiredPattern := regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
// Find optional variables with defaults
matches := defaultPattern.FindAllStringSubmatch(content, -1)
for _, match := range matches {
if len(match) == 3 {
optional[match[1]] = match[2]
}
}
// Find required variables
matches = requiredPattern.FindAllStringSubmatch(content, -1)
for _, match := range matches {
if len(match) == 2 {
varName := match[1]
// Only add if not already in optional (with default)
if _, hasDefault := optional[varName]; !hasDefault {
requiredSet[varName] = true
}
}
}
// Convert set to slice
for v := range requiredSet {
required = append(required, v)
}
return required, optional
}
// getUserTemplatesDir returns the path to user templates directory.
// Returns empty string if the directory doesn't exist.
func (tm *TemplateManager) getUserTemplatesDir() string {
// Try workspace-relative .core/linuxkit first
if tm.workingDir != "" {
wsDir := filepath.Join(tm.workingDir, ".core", "linuxkit")
if tm.medium.IsDir(wsDir) {
return wsDir
}
}
// Try home directory
if tm.homeDir != "" {
homeDir := filepath.Join(tm.homeDir, ".core", "linuxkit")
if tm.medium.IsDir(homeDir) {
return homeDir
}
}
return ""
}
// scanUserTemplates scans a directory for .yml template files.
func (tm *TemplateManager) scanUserTemplates(dir string) []Template {
var templates []Template
entries, err := tm.medium.List(dir)
if err != nil {
return templates
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".yml") && !strings.HasSuffix(name, ".yaml") {
continue
}
// Extract template name from filename
templateName := strings.TrimSuffix(strings.TrimSuffix(name, ".yml"), ".yaml")
// Skip if this is a builtin template name (embedded takes precedence)
isBuiltin := false
for _, bt := range builtinTemplates {
if bt.Name == templateName {
isBuiltin = true
break
}
}
if isBuiltin {
continue
}
// Read file to extract description from comments
description := tm.extractTemplateDescription(filepath.Join(dir, name))
if description == "" {
description = "User-defined template"
}
templates = append(templates, Template{
Name: templateName,
Description: description,
Path: filepath.Join(dir, name),
})
}
return templates
}
// extractTemplateDescription reads the first comment block from a YAML file
// to use as a description.
func (tm *TemplateManager) extractTemplateDescription(path string) string {
content, err := tm.medium.Read(path)
if err != nil {
return ""
}
lines := strings.Split(content, "\n")
var descLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
// Remove the # and trim
comment := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
if comment != "" {
descLines = append(descLines, comment)
// Only take the first meaningful comment line as description
if len(descLines) == 1 {
return comment
}
}
} else if trimmed != "" {
// Hit non-comment content, stop
break
}
}
if len(descLines) > 0 {
return descLines[0]
}
return ""
}