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
|
memory int
|
||||||
cpus int
|
cpus int
|
||||||
sshPort 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" +
|
runCmd.LongDescription("Runs a LinuxKit image as a VM using the available hypervisor.\n\n" +
|
||||||
"Supported image formats: .iso, .qcow2, .vmdk, .raw\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" +
|
"Examples:\n" +
|
||||||
" core run image.iso\n" +
|
" core run image.iso\n" +
|
||||||
" core run -d image.qcow2\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.StringFlag("name", "Name for the container", &name)
|
||||||
runCmd.BoolFlag("d", "Run in detached mode (background)", &detach)
|
runCmd.BoolFlag("d", "Run in detached mode (background)", &detach)
|
||||||
runCmd.IntFlag("memory", "Memory in MB (default: 1024)", &memory)
|
runCmd.IntFlag("memory", "Memory in MB (default: 1024)", &memory)
|
||||||
runCmd.IntFlag("cpus", "Number of CPUs (default: 1)", &cpus)
|
runCmd.IntFlag("cpus", "Number of CPUs (default: 1)", &cpus)
|
||||||
runCmd.IntFlag("ssh-port", "SSH port for exec commands (default: 2222)", &sshPort)
|
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 {
|
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()
|
args := runCmd.OtherArgs()
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("image path is required")
|
return fmt.Errorf("image path is required (or use --template)")
|
||||||
}
|
}
|
||||||
image := args[0]
|
image := args[0]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ func Execute() error {
|
||||||
AddInstallCommand(app)
|
AddInstallCommand(app)
|
||||||
AddReleaseCommand(app)
|
AddReleaseCommand(app)
|
||||||
AddContainerCommands(app)
|
AddContainerCommands(app)
|
||||||
|
AddTemplatesCommand(app)
|
||||||
// Run the application
|
// Run the application
|
||||||
return app.Run()
|
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