From b4e1b1423dec17f355fea8c64b0f3341742f144d Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 18:59:45 +0000 Subject: [PATCH] feat(container): add LinuxKit YAML templates with variable substitution Templates: - core-dev: Development environment (Go, Node, PHP, Docker-in-LinuxKit) - server-php: Production FrankenPHP server with Caddy Features: - Variable substitution: ${VAR} (required), ${VAR:-default} (optional) - Template listing, viewing, and variable extraction - Run directly from template: core run --template CLI commands: - core templates - list available templates - core templates show - display template - core templates vars - show variables - core run --template --var KEY=value Co-Authored-By: Claude Opus 4.5 --- .core/linuxkit/core-dev.yml | 121 ++++++++ .core/linuxkit/server-php.yml | 142 +++++++++ cmd/core/cmd/container.go | 39 ++- cmd/core/cmd/root.go | 1 + cmd/core/cmd/templates.go | 318 ++++++++++++++++++++ pkg/container/templates.go | 299 +++++++++++++++++++ pkg/container/templates/core-dev.yml | 121 ++++++++ pkg/container/templates/server-php.yml | 142 +++++++++ pkg/container/templates_test.go | 385 +++++++++++++++++++++++++ 9 files changed, 1560 insertions(+), 8 deletions(-) create mode 100644 .core/linuxkit/core-dev.yml create mode 100644 .core/linuxkit/server-php.yml create mode 100644 cmd/core/cmd/templates.go create mode 100644 pkg/container/templates.go create mode 100644 pkg/container/templates/core-dev.yml create mode 100644 pkg/container/templates/server-php.yml create mode 100644 pkg/container/templates_test.go diff --git a/.core/linuxkit/core-dev.yml b/.core/linuxkit/core-dev.yml new file mode 100644 index 00000000..712e43e7 --- /dev/null +++ b/.core/linuxkit/core-dev.yml @@ -0,0 +1,121 @@ +# Core Development Environment Template +# A full-featured development environment with multiple runtimes +# +# Variables: +# ${SSH_KEY} - SSH public key for access (required) +# ${MEMORY:-2048} - Memory in MB (default: 2048) +# ${CPUS:-2} - Number of CPUs (default: 2) +# ${HOSTNAME:-core-dev} - Hostname for the VM +# ${DATA_SIZE:-10G} - Size of persistent /data volume + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: format + image: linuxkit/format:v1.0.0 + - name: mount + image: linuxkit/mount:v1.0.0 + command: ["/usr/bin/mountie", "/dev/sda1", "/data"] + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +onshutdown: + - name: shutdown + image: busybox:latest + command: ["/bin/echo", "Shutting down..."] + +services: + - name: getty + image: linuxkit/getty:v1.0.0 + env: + - INSECURE=true + + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: docker + image: docker:24.0-dind + capabilities: + - all + net: host + pid: host + binds: + - /var/run:/var/run + - /data/docker:/var/lib/docker + rootfsPropagation: shared + + - name: dev-tools + image: alpine:3.19 + capabilities: + - all + net: host + binds: + - /data:/data + command: + - /bin/sh + - -c + - | + # Install development tools + apk add --no-cache \ + git curl wget vim nano htop tmux \ + build-base gcc musl-dev linux-headers \ + openssh-client jq yq + + # Install Go 1.22.0 + wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz + tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz + rm go1.22.0.linux-amd64.tar.gz + echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile + + # Install Node.js + apk add --no-cache nodejs npm + + # Install PHP + apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \ + php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \ + php82-session php82-tokenizer php82-xml php82-zip composer + + # Keep container running + tail -f /dev/null + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-core-dev}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/profile.d/dev.sh + contents: | + export PATH=$PATH:/usr/local/go/bin + export GOPATH=/data/go + export PATH=$PATH:$GOPATH/bin + cd /data + mode: "0755" + - path: /etc/motd + contents: | + ================================================ + Core Development Environment + + Runtimes: Go, Node.js, PHP + Tools: git, curl, vim, docker + + Data directory: /data (persistent) + ================================================ + +trust: + org: + - linuxkit + - library diff --git a/.core/linuxkit/server-php.yml b/.core/linuxkit/server-php.yml new file mode 100644 index 00000000..9db9f74b --- /dev/null +++ b/.core/linuxkit/server-php.yml @@ -0,0 +1,142 @@ +# PHP/FrankenPHP Server Template +# A minimal production-ready PHP server with FrankenPHP and Caddy +# +# Variables: +# ${SSH_KEY} - SSH public key for management access (required) +# ${MEMORY:-512} - Memory in MB (default: 512) +# ${CPUS:-1} - Number of CPUs (default: 1) +# ${HOSTNAME:-php-server} - Hostname for the VM +# ${APP_NAME:-app} - Application name +# ${DOMAIN:-localhost} - Domain for SSL certificates +# ${PHP_MEMORY:-128M} - PHP memory limit + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +services: + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: frankenphp + image: dunglas/frankenphp:latest + capabilities: + - CAP_NET_BIND_SERVICE + net: host + binds: + - /app:/app + - /data:/data + - /etc/caddy/Caddyfile:/etc/caddy/Caddyfile + env: + - SERVER_NAME=${DOMAIN:-localhost} + - FRANKENPHP_CONFIG=/etc/caddy/Caddyfile + command: + - frankenphp + - run + - --config + - /etc/caddy/Caddyfile + + - name: healthcheck + image: alpine:3.19 + net: host + command: + - /bin/sh + - -c + - | + apk add --no-cache curl + while true; do + sleep 30 + curl -sf http://localhost/health || echo "Health check failed" + done + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-php-server}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/caddy/Caddyfile + contents: | + { + frankenphp + order php_server before file_server + } + + ${DOMAIN:-localhost} { + root * /app/public + + # Health check endpoint + handle /health { + respond "OK" 200 + } + + # PHP handling + php_server + + # Encode responses + encode zstd gzip + + # Security headers + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + } + + # Logging + log { + output file /data/logs/access.log + format json + } + } + mode: "0644" + - path: /app/public/index.php + contents: | + 'healthy', + 'app' => '${APP_NAME:-app}', + 'timestamp' => date('c'), + 'php_version' => PHP_VERSION, + ]); + mode: "0644" + - path: /etc/php/php.ini + contents: | + memory_limit = ${PHP_MEMORY:-128M} + max_execution_time = 30 + upload_max_filesize = 64M + post_max_size = 64M + display_errors = Off + log_errors = On + error_log = /data/logs/php_errors.log + mode: "0644" + - path: /data/logs/.gitkeep + contents: "" + +trust: + org: + - linuxkit + - library + - dunglas diff --git a/cmd/core/cmd/container.go b/cmd/core/cmd/container.go index 8aaec3d6..50de7ba3 100644 --- a/cmd/core/cmd/container.go +++ b/cmd/core/cmd/container.go @@ -25,31 +25,54 @@ func AddContainerCommands(parent *clir.Cli) { // AddRunCommand adds the 'run' command. func AddRunCommand(parent *clir.Cli) { var ( - name string - detach bool - memory int - cpus int - sshPort int + name string + detach bool + memory int + cpus int + sshPort int + templateName string + varFlags []string ) - runCmd := parent.NewSubCommand("run", "Run a LinuxKit image") + runCmd := parent.NewSubCommand("run", "Run a LinuxKit image or template") runCmd.LongDescription("Runs a LinuxKit image as a VM using the available hypervisor.\n\n" + "Supported image formats: .iso, .qcow2, .vmdk, .raw\n\n" + + "You can also run from a template using --template, which will build and run\n" + + "the image automatically. Use --var to set template variables.\n\n" + "Examples:\n" + " core run image.iso\n" + " core run -d image.qcow2\n" + - " core run --name myvm --memory 2048 --cpus 4 image.iso") + " core run --name myvm --memory 2048 --cpus 4 image.iso\n" + + " core run --template core-dev --var SSH_KEY=\"ssh-rsa AAAA...\"\n" + + " core run --template server-php --var SSH_KEY=\"...\" --var DOMAIN=example.com") runCmd.StringFlag("name", "Name for the container", &name) runCmd.BoolFlag("d", "Run in detached mode (background)", &detach) runCmd.IntFlag("memory", "Memory in MB (default: 1024)", &memory) runCmd.IntFlag("cpus", "Number of CPUs (default: 1)", &cpus) runCmd.IntFlag("ssh-port", "SSH port for exec commands (default: 2222)", &sshPort) + runCmd.StringFlag("template", "Run from a LinuxKit template (build + run)", &templateName) + runCmd.StringsFlag("var", "Template variable in KEY=VALUE format (can be repeated)", &varFlags) runCmd.Action(func() error { + opts := container.RunOptions{ + Name: name, + Detach: detach, + Memory: memory, + CPUs: cpus, + SSHPort: sshPort, + } + + // If template is specified, build and run from template + if templateName != "" { + vars := ParseVarFlags(varFlags) + return RunFromTemplate(templateName, vars, opts) + } + + // Otherwise, require an image path args := runCmd.OtherArgs() if len(args) == 0 { - return fmt.Errorf("image path is required") + return fmt.Errorf("image path is required (or use --template)") } image := args[0] diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 6d60c558..7717873a 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -86,6 +86,7 @@ func Execute() error { AddInstallCommand(app) AddReleaseCommand(app) AddContainerCommands(app) + AddTemplatesCommand(app) // Run the application return app.Run() } diff --git a/cmd/core/cmd/templates.go b/cmd/core/cmd/templates.go new file mode 100644 index 00000000..7a33ee39 --- /dev/null +++ b/cmd/core/cmd/templates.go @@ -0,0 +1,318 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/container" + "github.com/leaanthony/clir" +) + +var ( + varStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f59e0b")) + defaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Italic(true) +) + +// AddTemplatesCommand adds the 'templates' command and subcommands. +func AddTemplatesCommand(parent *clir.Cli) { + templatesCmd := parent.NewSubCommand("templates", "Manage LinuxKit templates") + templatesCmd.LongDescription("Manage LinuxKit YAML templates for building VMs.\n\n" + + "Templates provide pre-configured LinuxKit configurations for common use cases.\n" + + "They support variable substitution with ${VAR} and ${VAR:-default} syntax.\n\n" + + "Examples:\n" + + " core templates # List available templates\n" + + " core templates show core-dev # Show template content\n" + + " core templates vars server-php # Show template variables") + + // Default action: list templates + templatesCmd.Action(func() error { + return listTemplates() + }) + + // Add subcommands + addTemplatesShowCommand(templatesCmd) + addTemplatesVarsCommand(templatesCmd) +} + +// addTemplatesShowCommand adds the 'templates show' subcommand. +func addTemplatesShowCommand(parent *clir.Command) { + showCmd := parent.NewSubCommand("show", "Display template content") + showCmd.LongDescription("Display the content of a LinuxKit template.\n\n" + + "Examples:\n" + + " core templates show core-dev\n" + + " core templates show server-php") + + showCmd.Action(func() error { + args := showCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("template name is required") + } + return showTemplate(args[0]) + }) +} + +// addTemplatesVarsCommand adds the 'templates vars' subcommand. +func addTemplatesVarsCommand(parent *clir.Command) { + varsCmd := parent.NewSubCommand("vars", "Show template variables") + varsCmd.LongDescription("Display all variables used in a template.\n\n" + + "Shows required variables (no default) and optional variables (with defaults).\n\n" + + "Examples:\n" + + " core templates vars core-dev\n" + + " core templates vars server-php") + + varsCmd.Action(func() error { + args := varsCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("template name is required") + } + return showTemplateVars(args[0]) + }) +} + +func listTemplates() error { + templates := container.ListTemplates() + + if len(templates) == 0 { + fmt.Println("No templates available.") + return nil + } + + fmt.Printf("%s\n\n", repoNameStyle.Render("Available LinuxKit Templates")) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION") + fmt.Fprintln(w, "----\t-----------") + + for _, tmpl := range templates { + desc := tmpl.Description + if len(desc) > 60 { + desc = desc[:57] + "..." + } + fmt.Fprintf(w, "%s\t%s\n", repoNameStyle.Render(tmpl.Name), desc) + } + w.Flush() + + fmt.Println() + fmt.Printf("Show template: %s\n", dimStyle.Render("core templates show ")) + fmt.Printf("Show variables: %s\n", dimStyle.Render("core templates vars ")) + fmt.Printf("Run from template: %s\n", dimStyle.Render("core run --template --var SSH_KEY=\"...\"")) + + return nil +} + +func showTemplate(name string) error { + content, err := container.GetTemplate(name) + if err != nil { + return err + } + + fmt.Printf("%s %s\n\n", dimStyle.Render("Template:"), repoNameStyle.Render(name)) + fmt.Println(content) + + return nil +} + +func showTemplateVars(name string) error { + content, err := container.GetTemplate(name) + if err != nil { + return err + } + + required, optional := container.ExtractVariables(content) + + fmt.Printf("%s %s\n\n", dimStyle.Render("Template:"), repoNameStyle.Render(name)) + + if len(required) > 0 { + fmt.Printf("%s\n", errorStyle.Render("Required Variables (no default):")) + for _, v := range required { + fmt.Printf(" %s\n", varStyle.Render("${"+v+"}")) + } + fmt.Println() + } + + if len(optional) > 0 { + fmt.Printf("%s\n", successStyle.Render("Optional Variables (with defaults):")) + for v, def := range optional { + fmt.Printf(" %s = %s\n", + varStyle.Render("${"+v+"}"), + defaultStyle.Render(def)) + } + fmt.Println() + } + + if len(required) == 0 && len(optional) == 0 { + fmt.Println("No variables in this template.") + } + + return nil +} + +// RunFromTemplate builds and runs a LinuxKit image from a template. +func RunFromTemplate(templateName string, vars map[string]string, runOpts container.RunOptions) error { + // Apply template with variables + content, err := container.ApplyTemplate(templateName, vars) + if err != nil { + return fmt.Errorf("failed to apply template: %w", err) + } + + // Create a temporary directory for the build + tmpDir, err := os.MkdirTemp("", "core-linuxkit-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Write the YAML file + yamlPath := filepath.Join(tmpDir, templateName+".yml") + if err := os.WriteFile(yamlPath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to write template: %w", err) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Template:"), repoNameStyle.Render(templateName)) + fmt.Printf("%s %s\n", dimStyle.Render("Building:"), yamlPath) + + // Build the image using linuxkit + outputPath := filepath.Join(tmpDir, templateName) + if err := buildLinuxKitImage(yamlPath, outputPath); err != nil { + return fmt.Errorf("failed to build image: %w", err) + } + + // Find the built image (linuxkit creates .iso or other format) + imagePath := findBuiltImage(outputPath) + if imagePath == "" { + return fmt.Errorf("no image found after build") + } + + fmt.Printf("%s %s\n", dimStyle.Render("Image:"), imagePath) + fmt.Println() + + // Run the image + manager, err := container.NewLinuxKitManager() + if err != nil { + return fmt.Errorf("failed to initialize container manager: %w", err) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Hypervisor:"), manager.Hypervisor().Name()) + fmt.Println() + + ctx := context.Background() + c, err := manager.Run(ctx, imagePath, runOpts) + if err != nil { + return fmt.Errorf("failed to run container: %w", err) + } + + if runOpts.Detach { + fmt.Printf("%s %s\n", successStyle.Render("Started:"), c.ID) + fmt.Printf("%s %d\n", dimStyle.Render("PID:"), c.PID) + fmt.Println() + fmt.Printf("Use 'core logs %s' to view output\n", c.ID[:8]) + fmt.Printf("Use 'core stop %s' to stop\n", c.ID[:8]) + } else { + fmt.Printf("\n%s %s\n", dimStyle.Render("Container stopped:"), c.ID) + } + + return nil +} + +// buildLinuxKitImage builds a LinuxKit image from a YAML file. +func buildLinuxKitImage(yamlPath, outputPath string) error { + // Check if linuxkit is available + lkPath, err := lookupLinuxKit() + if err != nil { + return err + } + + // Build the image + // linuxkit build -format iso-bios -name + cmd := exec.Command(lkPath, "build", + "-format", "iso-bios", + "-name", outputPath, + yamlPath) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// findBuiltImage finds the built image file. +func findBuiltImage(basePath string) string { + // LinuxKit can create different formats + extensions := []string{".iso", "-bios.iso", ".qcow2", ".raw", ".vmdk"} + + for _, ext := range extensions { + path := basePath + ext + if _, err := os.Stat(path); err == nil { + return path + } + } + + // Check directory for any image file + dir := filepath.Dir(basePath) + base := filepath.Base(basePath) + + entries, err := os.ReadDir(dir) + if err != nil { + return "" + } + + for _, entry := range entries { + name := entry.Name() + if strings.HasPrefix(name, base) { + for _, ext := range []string{".iso", ".qcow2", ".raw", ".vmdk"} { + if strings.HasSuffix(name, ext) { + return filepath.Join(dir, name) + } + } + } + } + + return "" +} + +// lookupLinuxKit finds the linuxkit binary. +func lookupLinuxKit() (string, error) { + // Check PATH first + if path, err := exec.LookPath("linuxkit"); err == nil { + return path, nil + } + + // Check common locations + paths := []string{ + "/usr/local/bin/linuxkit", + "/opt/homebrew/bin/linuxkit", + } + + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + + return "", fmt.Errorf("linuxkit not found. Install with: brew install linuxkit (macOS) or see https://github.com/linuxkit/linuxkit") +} + +// ParseVarFlags parses --var flags into a map. +// Format: --var KEY=VALUE or --var KEY="VALUE" +func ParseVarFlags(varFlags []string) map[string]string { + vars := make(map[string]string) + + for _, v := range varFlags { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Remove surrounding quotes if present + value = strings.Trim(value, "\"'") + vars[key] = value + } + } + + return vars +} diff --git a/pkg/container/templates.go b/pkg/container/templates.go new file mode 100644 index 00000000..b0068a00 --- /dev/null +++ b/pkg/container/templates.go @@ -0,0 +1,299 @@ +package container + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +//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", + }, +} + +// ListTemplates returns all available LinuxKit templates. +// It combines embedded templates with any templates found in the user's +// .core/linuxkit directory. +func ListTemplates() []Template { + templates := make([]Template, len(builtinTemplates)) + copy(templates, builtinTemplates) + + // Check for user templates in .core/linuxkit/ + userTemplatesDir := getUserTemplatesDir() + if userTemplatesDir != "" { + userTemplates := 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 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 := getUserTemplatesDir() + if userTemplatesDir != "" { + templatePath := filepath.Join(userTemplatesDir, name+".yml") + if _, err := os.Stat(templatePath); err == nil { + content, err := os.ReadFile(templatePath) + if err != nil { + return "", fmt.Errorf("failed to read user template %s: %w", name, err) + } + return string(content), nil + } + } + + return "", fmt.Errorf("template not found: %s", name) +} + +// ApplyTemplate applies variable substitution to a template. +// It supports two syntaxes: +// - ${VAR} - required variable, returns error if not provided +// - ${VAR:-default} - variable with default value +func ApplyTemplate(name string, vars map[string]string) (string, error) { + content, err := 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 getUserTemplatesDir() string { + // Try workspace-relative .core/linuxkit first + cwd, err := os.Getwd() + if err == nil { + wsDir := filepath.Join(cwd, ".core", "linuxkit") + if info, err := os.Stat(wsDir); err == nil && info.IsDir() { + return wsDir + } + } + + // Try home directory + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + homeDir := filepath.Join(home, ".core", "linuxkit") + if info, err := os.Stat(homeDir); err == nil && info.IsDir() { + return homeDir + } + + return "" +} + +// scanUserTemplates scans a directory for .yml template files. +func scanUserTemplates(dir string) []Template { + var templates []Template + + entries, err := os.ReadDir(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 := 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 extractTemplateDescription(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + + lines := strings.Split(string(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 "" +} diff --git a/pkg/container/templates/core-dev.yml b/pkg/container/templates/core-dev.yml new file mode 100644 index 00000000..712e43e7 --- /dev/null +++ b/pkg/container/templates/core-dev.yml @@ -0,0 +1,121 @@ +# Core Development Environment Template +# A full-featured development environment with multiple runtimes +# +# Variables: +# ${SSH_KEY} - SSH public key for access (required) +# ${MEMORY:-2048} - Memory in MB (default: 2048) +# ${CPUS:-2} - Number of CPUs (default: 2) +# ${HOSTNAME:-core-dev} - Hostname for the VM +# ${DATA_SIZE:-10G} - Size of persistent /data volume + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: format + image: linuxkit/format:v1.0.0 + - name: mount + image: linuxkit/mount:v1.0.0 + command: ["/usr/bin/mountie", "/dev/sda1", "/data"] + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +onshutdown: + - name: shutdown + image: busybox:latest + command: ["/bin/echo", "Shutting down..."] + +services: + - name: getty + image: linuxkit/getty:v1.0.0 + env: + - INSECURE=true + + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: docker + image: docker:24.0-dind + capabilities: + - all + net: host + pid: host + binds: + - /var/run:/var/run + - /data/docker:/var/lib/docker + rootfsPropagation: shared + + - name: dev-tools + image: alpine:3.19 + capabilities: + - all + net: host + binds: + - /data:/data + command: + - /bin/sh + - -c + - | + # Install development tools + apk add --no-cache \ + git curl wget vim nano htop tmux \ + build-base gcc musl-dev linux-headers \ + openssh-client jq yq + + # Install Go 1.22.0 + wget -q https://go.dev/dl/go1.22.0.linux-amd64.tar.gz + tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz + rm go1.22.0.linux-amd64.tar.gz + echo 'export PATH=/usr/local/go/bin:$PATH' >> /etc/profile + + # Install Node.js + apk add --no-cache nodejs npm + + # Install PHP + apk add --no-cache php82 php82-cli php82-curl php82-json php82-mbstring \ + php82-openssl php82-pdo php82-pdo_mysql php82-pdo_pgsql php82-phar \ + php82-session php82-tokenizer php82-xml php82-zip composer + + # Keep container running + tail -f /dev/null + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-core-dev}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/profile.d/dev.sh + contents: | + export PATH=$PATH:/usr/local/go/bin + export GOPATH=/data/go + export PATH=$PATH:$GOPATH/bin + cd /data + mode: "0755" + - path: /etc/motd + contents: | + ================================================ + Core Development Environment + + Runtimes: Go, Node.js, PHP + Tools: git, curl, vim, docker + + Data directory: /data (persistent) + ================================================ + +trust: + org: + - linuxkit + - library diff --git a/pkg/container/templates/server-php.yml b/pkg/container/templates/server-php.yml new file mode 100644 index 00000000..9db9f74b --- /dev/null +++ b/pkg/container/templates/server-php.yml @@ -0,0 +1,142 @@ +# PHP/FrankenPHP Server Template +# A minimal production-ready PHP server with FrankenPHP and Caddy +# +# Variables: +# ${SSH_KEY} - SSH public key for management access (required) +# ${MEMORY:-512} - Memory in MB (default: 512) +# ${CPUS:-1} - Number of CPUs (default: 1) +# ${HOSTNAME:-php-server} - Hostname for the VM +# ${APP_NAME:-app} - Application name +# ${DOMAIN:-localhost} - Domain for SSL certificates +# ${PHP_MEMORY:-128M} - PHP memory limit + +kernel: + image: linuxkit/kernel:6.6.13 + cmdline: "console=tty0 console=ttyS0" + +init: + - linuxkit/init:v1.2.0 + - linuxkit/runc:v1.1.12 + - linuxkit/containerd:v1.7.13 + - linuxkit/ca-certificates:v1.0.0 + +onboot: + - name: sysctl + image: linuxkit/sysctl:v1.0.0 + - name: dhcpcd + image: linuxkit/dhcpcd:v1.0.0 + command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"] + +services: + - name: sshd + image: linuxkit/sshd:v1.2.0 + binds: + - /etc/ssh/authorized_keys:/root/.ssh/authorized_keys + + - name: frankenphp + image: dunglas/frankenphp:latest + capabilities: + - CAP_NET_BIND_SERVICE + net: host + binds: + - /app:/app + - /data:/data + - /etc/caddy/Caddyfile:/etc/caddy/Caddyfile + env: + - SERVER_NAME=${DOMAIN:-localhost} + - FRANKENPHP_CONFIG=/etc/caddy/Caddyfile + command: + - frankenphp + - run + - --config + - /etc/caddy/Caddyfile + + - name: healthcheck + image: alpine:3.19 + net: host + command: + - /bin/sh + - -c + - | + apk add --no-cache curl + while true; do + sleep 30 + curl -sf http://localhost/health || echo "Health check failed" + done + +files: + - path: /etc/hostname + contents: "${HOSTNAME:-php-server}" + - path: /etc/ssh/authorized_keys + contents: "${SSH_KEY}" + mode: "0600" + - path: /etc/caddy/Caddyfile + contents: | + { + frankenphp + order php_server before file_server + } + + ${DOMAIN:-localhost} { + root * /app/public + + # Health check endpoint + handle /health { + respond "OK" 200 + } + + # PHP handling + php_server + + # Encode responses + encode zstd gzip + + # Security headers + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + } + + # Logging + log { + output file /data/logs/access.log + format json + } + } + mode: "0644" + - path: /app/public/index.php + contents: | + 'healthy', + 'app' => '${APP_NAME:-app}', + 'timestamp' => date('c'), + 'php_version' => PHP_VERSION, + ]); + mode: "0644" + - path: /etc/php/php.ini + contents: | + memory_limit = ${PHP_MEMORY:-128M} + max_execution_time = 30 + upload_max_filesize = 64M + post_max_size = 64M + display_errors = Off + log_errors = On + error_log = /data/logs/php_errors.log + mode: "0644" + - path: /data/logs/.gitkeep + contents: "" + +trust: + org: + - linuxkit + - library + - dunglas diff --git a/pkg/container/templates_test.go b/pkg/container/templates_test.go new file mode 100644 index 00000000..614cbc16 --- /dev/null +++ b/pkg/container/templates_test.go @@ -0,0 +1,385 @@ +package container + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListTemplates_Good(t *testing.T) { + templates := ListTemplates() + + // Should have at least the builtin templates + assert.GreaterOrEqual(t, len(templates), 2) + + // Find the core-dev template + var found bool + for _, tmpl := range templates { + if tmpl.Name == "core-dev" { + found = true + assert.NotEmpty(t, tmpl.Description) + assert.NotEmpty(t, tmpl.Path) + break + } + } + assert.True(t, found, "core-dev template should exist") + + // Find the server-php template + found = false + for _, tmpl := range templates { + if tmpl.Name == "server-php" { + found = true + assert.NotEmpty(t, tmpl.Description) + assert.NotEmpty(t, tmpl.Path) + break + } + } + assert.True(t, found, "server-php template should exist") +} + +func TestGetTemplate_Good_CoreDev(t *testing.T) { + content, err := GetTemplate("core-dev") + + require.NoError(t, err) + assert.NotEmpty(t, content) + assert.Contains(t, content, "kernel:") + assert.Contains(t, content, "linuxkit/kernel") + assert.Contains(t, content, "${SSH_KEY}") + assert.Contains(t, content, "services:") +} + +func TestGetTemplate_Good_ServerPhp(t *testing.T) { + content, err := GetTemplate("server-php") + + require.NoError(t, err) + assert.NotEmpty(t, content) + assert.Contains(t, content, "kernel:") + assert.Contains(t, content, "frankenphp") + assert.Contains(t, content, "${SSH_KEY}") + assert.Contains(t, content, "${DOMAIN:-localhost}") +} + +func TestGetTemplate_Bad_NotFound(t *testing.T) { + _, err := GetTemplate("nonexistent-template") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestApplyVariables_Good_SimpleSubstitution(t *testing.T) { + content := "Hello ${NAME}, welcome to ${PLACE}!" + vars := map[string]string{ + "NAME": "World", + "PLACE": "Core", + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "Hello World, welcome to Core!", result) +} + +func TestApplyVariables_Good_WithDefaults(t *testing.T) { + content := "Memory: ${MEMORY:-1024}MB, CPUs: ${CPUS:-2}" + vars := map[string]string{ + "MEMORY": "2048", + // CPUS not provided, should use default + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "Memory: 2048MB, CPUs: 2", result) +} + +func TestApplyVariables_Good_AllDefaults(t *testing.T) { + content := "${HOST:-localhost}:${PORT:-8080}" + vars := map[string]string{} // No vars provided + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "localhost:8080", result) +} + +func TestApplyVariables_Good_MixedSyntax(t *testing.T) { + content := ` +hostname: ${HOSTNAME:-myhost} +ssh_key: ${SSH_KEY} +memory: ${MEMORY:-512} +` + vars := map[string]string{ + "SSH_KEY": "ssh-rsa AAAA...", + "HOSTNAME": "custom-host", + } + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Contains(t, result, "hostname: custom-host") + assert.Contains(t, result, "ssh_key: ssh-rsa AAAA...") + assert.Contains(t, result, "memory: 512") +} + +func TestApplyVariables_Good_EmptyDefault(t *testing.T) { + content := "value: ${OPT:-}" + vars := map[string]string{} + + result, err := ApplyVariables(content, vars) + + require.NoError(t, err) + assert.Equal(t, "value: ", result) +} + +func TestApplyVariables_Bad_MissingRequired(t *testing.T) { + content := "SSH Key: ${SSH_KEY}" + vars := map[string]string{} // Missing required SSH_KEY + + _, err := ApplyVariables(content, vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") + assert.Contains(t, err.Error(), "SSH_KEY") +} + +func TestApplyVariables_Bad_MultipleMissing(t *testing.T) { + content := "${VAR1} and ${VAR2} and ${VAR3}" + vars := map[string]string{ + "VAR2": "provided", + } + + _, err := ApplyVariables(content, vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") + // Should mention both missing vars + errStr := err.Error() + assert.True(t, strings.Contains(errStr, "VAR1") || strings.Contains(errStr, "VAR3")) +} + +func TestApplyTemplate_Good(t *testing.T) { + vars := map[string]string{ + "SSH_KEY": "ssh-rsa AAAA... user@host", + } + + result, err := ApplyTemplate("core-dev", vars) + + require.NoError(t, err) + assert.NotEmpty(t, result) + assert.Contains(t, result, "ssh-rsa AAAA... user@host") + // Default values should be applied + assert.Contains(t, result, "core-dev") // HOSTNAME default +} + +func TestApplyTemplate_Bad_TemplateNotFound(t *testing.T) { + vars := map[string]string{ + "SSH_KEY": "test", + } + + _, err := ApplyTemplate("nonexistent", vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "template not found") +} + +func TestApplyTemplate_Bad_MissingVariable(t *testing.T) { + // server-php requires SSH_KEY + vars := map[string]string{} // Missing required SSH_KEY + + _, err := ApplyTemplate("server-php", vars) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required variables") +} + +func TestExtractVariables_Good(t *testing.T) { + content := ` +hostname: ${HOSTNAME:-myhost} +ssh_key: ${SSH_KEY} +memory: ${MEMORY:-1024} +cpus: ${CPUS:-2} +api_key: ${API_KEY} +` + required, optional := ExtractVariables(content) + + // Required variables (no default) + assert.Contains(t, required, "SSH_KEY") + assert.Contains(t, required, "API_KEY") + assert.Len(t, required, 2) + + // Optional variables (with defaults) + assert.Equal(t, "myhost", optional["HOSTNAME"]) + assert.Equal(t, "1024", optional["MEMORY"]) + assert.Equal(t, "2", optional["CPUS"]) + assert.Len(t, optional, 3) +} + +func TestExtractVariables_Good_NoVariables(t *testing.T) { + content := "This has no variables at all" + + required, optional := ExtractVariables(content) + + assert.Empty(t, required) + assert.Empty(t, optional) +} + +func TestExtractVariables_Good_OnlyDefaults(t *testing.T) { + content := "${A:-default1} ${B:-default2}" + + required, optional := ExtractVariables(content) + + assert.Empty(t, required) + assert.Len(t, optional, 2) + assert.Equal(t, "default1", optional["A"]) + assert.Equal(t, "default2", optional["B"]) +} + +func TestScanUserTemplates_Good(t *testing.T) { + // Create a temporary directory with template files + tmpDir := t.TempDir() + + // Create a valid template file + templateContent := `# My Custom Template +# A custom template for testing +kernel: + image: linuxkit/kernel:6.6 +` + err := os.WriteFile(filepath.Join(tmpDir, "custom.yml"), []byte(templateContent), 0644) + require.NoError(t, err) + + // Create a non-template file (should be ignored) + err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("Not a template"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 1) + assert.Equal(t, "custom", templates[0].Name) + assert.Equal(t, "My Custom Template", templates[0].Description) +} + +func TestScanUserTemplates_Good_MultipleTemplates(t *testing.T) { + tmpDir := t.TempDir() + + // Create multiple template files + err := os.WriteFile(filepath.Join(tmpDir, "web.yml"), []byte("# Web Server\nkernel:"), 0644) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "db.yaml"), []byte("# Database Server\nkernel:"), 0644) + require.NoError(t, err) + + templates := scanUserTemplates(tmpDir) + + assert.Len(t, templates, 2) + + // Check names are extracted correctly + names := make(map[string]bool) + for _, tmpl := range templates { + names[tmpl.Name] = true + } + assert.True(t, names["web"]) + assert.True(t, names["db"]) +} + +func TestScanUserTemplates_Good_EmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + + templates := scanUserTemplates(tmpDir) + + assert.Empty(t, templates) +} + +func TestScanUserTemplates_Bad_NonexistentDirectory(t *testing.T) { + templates := scanUserTemplates("/nonexistent/path/to/templates") + + assert.Empty(t, templates) +} + +func TestExtractTemplateDescription_Good(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + content := `# My Template Description +# More details here +kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Equal(t, "My Template Description", desc) +} + +func TestExtractTemplateDescription_Good_NoComments(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.yml") + + content := `kernel: + image: test +` + err := os.WriteFile(path, []byte(content), 0644) + require.NoError(t, err) + + desc := extractTemplateDescription(path) + + assert.Empty(t, desc) +} + +func TestExtractTemplateDescription_Bad_FileNotFound(t *testing.T) { + desc := extractTemplateDescription("/nonexistent/file.yml") + + assert.Empty(t, desc) +} + +func TestVariablePatternEdgeCases_Good(t *testing.T) { + tests := []struct { + name string + content string + vars map[string]string + expected string + }{ + { + name: "underscore in name", + content: "${MY_VAR:-default}", + vars: map[string]string{"MY_VAR": "value"}, + expected: "value", + }, + { + name: "numbers in name", + content: "${VAR123:-default}", + vars: map[string]string{}, + expected: "default", + }, + { + name: "default with special chars", + content: "${URL:-http://localhost:8080}", + vars: map[string]string{}, + expected: "http://localhost:8080", + }, + { + name: "default with path", + content: "${PATH:-/usr/local/bin}", + vars: map[string]string{}, + expected: "/usr/local/bin", + }, + { + name: "adjacent variables", + content: "${A:-a}${B:-b}${C:-c}", + vars: map[string]string{"B": "X"}, + expected: "aXc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ApplyVariables(tt.content, tt.vars) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +}