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:
parent
3bd9f9bc3d
commit
b4e1b1423d
9 changed files with 1560 additions and 8 deletions
121
.core/linuxkit/core-dev.yml
Normal file
121
.core/linuxkit/core-dev.yml
Normal 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
|
||||
142
.core/linuxkit/server-php.yml
Normal file
142
.core/linuxkit/server-php.yml
Normal 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
|
||||
|
|
@ -30,26 +30,49 @@ func AddRunCommand(parent *clir.Cli) {
|
|||
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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
318
cmd/core/cmd/templates.go
Normal 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
299
pkg/container/templates.go
Normal 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 ""
|
||||
}
|
||||
121
pkg/container/templates/core-dev.yml
Normal file
121
pkg/container/templates/core-dev.yml
Normal 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
|
||||
142
pkg/container/templates/server-php.yml
Normal file
142
pkg/container/templates/server-php.yml
Normal 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
|
||||
385
pkg/container/templates_test.go
Normal file
385
pkg/container/templates_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue