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 <name>

CLI commands:
- core templates - list available templates
- core templates show <name> - display template
- core templates vars <name> - show variables
- core run --template <name> --var KEY=value

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 18:59:45 +00:00
parent 3bd9f9bc3d
commit b4e1b1423d
9 changed files with 1560 additions and 8 deletions

121
.core/linuxkit/core-dev.yml Normal file
View file

@ -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

View file

@ -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: |
<?php
echo "Welcome to ${APP_NAME:-app}";
mode: "0644"
- path: /app/public/health.php
contents: |
<?php
header('Content-Type: application/json');
echo json_encode([
'status' => '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

View file

@ -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]

View file

@ -86,6 +86,7 @@ func Execute() error {
AddInstallCommand(app)
AddReleaseCommand(app)
AddContainerCommands(app)
AddTemplatesCommand(app)
// Run the application
return app.Run()
}

318
cmd/core/cmd/templates.go Normal file
View file

@ -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 <name>"))
fmt.Printf("Show variables: %s\n", dimStyle.Render("core templates vars <name>"))
fmt.Printf("Run from template: %s\n", dimStyle.Render("core run --template <name> --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 <output> <yaml>
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
}

299
pkg/container/templates.go Normal file
View file

@ -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 ""
}

View file

@ -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

View file

@ -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: |
<?php
echo "Welcome to ${APP_NAME:-app}";
mode: "0644"
- path: /app/public/health.php
contents: |
<?php
header('Content-Type: application/json');
echo json_encode([
'status' => '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

View file

@ -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)
})
}
}