* 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.
332 lines
8.8 KiB
Go
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 ""
|
|
}
|