Support provision feature on Windows VM

Current support has some caveats:
- boot mode isn't supported since Windows VM doesn't have an equivalent step to Cloud-init's init stage
- yq mode isn't supported since Windows VM doesn't have yq engine by default
- all data mode files are stored under C:\ProgramData\Lima\provision\provision.data
- skipDefaultDependencyResolution in dependency mode is currently ignored

Signed-off-by: mie313 <mie313.tanaka@gmail.com>
This commit is contained in:
mie313 committed 2026-07-28 23:10:30 +01:00
1 parent af91a4b1e0
commit d14213b186
12 files changed
+359 -63

No files matched your search

+41 -24
View File
@@ -432,30 +432,9 @@ func GenerateISO9660(ctx context.Context, drv driver.Driver, instDir, name strin
})
}
for i, f := range instConfig.Provision {
switch f.Mode {
case limatype.ProvisionModeSystem, limatype.ProvisionModeUser, limatype.ProvisionModeDependency:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Script),
})
case limatype.ProvisionModeData:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Content),
})
case limatype.ProvisionModeYQ:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Expression),
})
case limatype.ProvisionModeBoot:
continue
case limatype.ProvisionModeAnsible:
continue
default:
return "", fmt.Errorf("unknown provision mode %#q", f.Mode)
}
layout, err = appendProvisionEntries(layout, instConfig.Provision)
if err != nil {
return "", err
}
if guestAgentBinary != "" {
@@ -582,6 +561,39 @@ func writeCIDataDir(rootPath string, layout []iso9660util.Entry) error {
return nil
}
func appendProvisionEntries(layout []iso9660util.Entry, provisions []limatype.Provision) ([]iso9660util.Entry, error) {
for i, f := range provisions {
switch f.Mode {
case limatype.ProvisionModeSystem, limatype.ProvisionModeUser, limatype.ProvisionModeDependency:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Script),
})
// Boot commands are not treated as script files in cidata.iso.
// Instead of that, they are stored in TemplateArgs.BootCmds then embedded in bootCmd field in user-data.
// For Windows VM, there is no boot step where we can run commands.
case limatype.ProvisionModeBoot:
continue
case limatype.ProvisionModeData:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Content),
})
case limatype.ProvisionModeYQ:
layout = append(layout, iso9660util.Entry{
Path: fmt.Sprintf("provision.%s/%08d", f.Mode, i),
Reader: strings.NewReader(*f.Expression),
})
case limatype.ProvisionModeAnsible:
continue
default:
return nil, fmt.Errorf("unknown provision mode %#q", f.Mode)
}
}
return layout, nil
}
func GenerateWindowsISO(ctx context.Context, instDir, name string, instConfig *limatype.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort, vsockPort int, virtioPort string, noCloudInit, rosettaEnabled, rosettaBinFmt bool) (string, error) {
args, err := templateArgs(ctx, true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, noCloudInit, rosettaEnabled, rosettaBinFmt)
if err != nil {
@@ -608,6 +620,11 @@ func GenerateWindowsISO(ctx context.Context, instDir, name string, instConfig *l
return "", fmt.Errorf("failed to create Windows ISO entries: %w", err)
}
layout, err = appendProvisionEntries(layout, instConfig.Provision)
if err != nil {
return "", err
}
layout = append(layout, iso9660util.Entry{
Path: "ssh_authorized_keys",
Reader: strings.NewReader(strings.Join(args.SSHPubKeys, "\n")),
+51
View File
@@ -4,6 +4,8 @@
package cidata
import (
"fmt"
"io"
"net"
"net/url"
"strings"
@@ -11,6 +13,8 @@ import (
"gotest.tools/v3/assert"
"github.com/lima-vm/lima/v2/pkg/iso9660util"
"github.com/lima-vm/lima/v2/pkg/limatype"
"github.com/lima-vm/lima/v2/pkg/networks"
)
@@ -60,3 +64,50 @@ func TestSetupInvalidEnv(t *testing.T) {
assert.NilError(t, err)
assert.Equal(t, envs[envKey], envValue)
}
func TestAppendProvisionEntries(t *testing.T) {
scriptBoot := "Write-Host boot"
scriptSystem := "Write-Host system"
contentData := "hello"
exprYQ := ".foo=1"
provisions := []limatype.Provision{
{Mode: limatype.ProvisionModeBoot, Script: &scriptBoot},
{Mode: limatype.ProvisionModeSystem, Script: &scriptSystem},
{Mode: limatype.ProvisionModeData, ProvisionData: limatype.ProvisionData{Content: &contentData}},
{Mode: limatype.ProvisionModeYQ, Expression: &exprYQ},
}
layout, err := appendProvisionEntries(nil, provisions)
assert.NilError(t, err)
// Boot file is not added into the ISO file.
assert.Assert(t, !hasEntryPath(layout, "provision.boot/00000000"))
assert.Assert(t, hasEntryPath(layout, "provision.data/00000002"))
assert.Assert(t, hasEntryPath(layout, "provision.yq/00000003"))
gotData, err := readEntry(layout, "provision.data/00000002")
assert.NilError(t, err)
assert.Equal(t, gotData, contentData)
}
func hasEntryPath(layout []iso9660util.Entry, path string) bool {
for _, e := range layout {
if e.Path == path {
return true
}
}
return false
}
func readEntry(layout []iso9660util.Entry, path string) (string, error) {
for _, e := range layout {
if e.Path != path {
continue
}
b, err := io.ReadAll(e.Reader)
if err != nil {
return "", err
}
return string(b), nil
}
return "", fmt.Errorf("entry not found: %s", path)
}
+7 -39
View File
@@ -208,12 +208,12 @@ func ExecuteTemplateCloudConfig(args *TemplateArgs) ([]byte, error) {
return textutil.ExecuteTemplate(cloudConfigYaml, args)
}
func ExecuteTemplateCIDataISO(args *TemplateArgs) ([]iso9660util.Entry, error) {
func executeTemplateWalkDir(args *TemplateArgs, fsys fs.FS, root string) ([]iso9660util.Entry, error) {
if err := ValidateTemplateArgs(args); err != nil {
return nil, err
}
fsys, err := fs.Sub(templateFS, templateFSRoot)
fsys, err := fs.Sub(fsys, root)
if err != nil {
return nil, err
}
@@ -251,42 +251,10 @@ func ExecuteTemplateCIDataISO(args *TemplateArgs) ([]iso9660util.Entry, error) {
return layout, nil
}
func ExecuteTemplateCIDataISO(args *TemplateArgs) ([]iso9660util.Entry, error) {
return executeTemplateWalkDir(args, templateFS, templateFSRoot)
}
func ExecuteTemplateWindowsISO(args *TemplateArgs) ([]iso9660util.Entry, error) {
fs := windowsTemplateFS
root := windowsTemplateFSRoot
// Execute template for autounattend.xml
xmlTemplate, err := fs.ReadFile(path.Join(root, "autounattend.xml"))
if err != nil {
return nil, err
}
xmlfile, err := textutil.ExecuteTemplate(string(xmlTemplate), args)
if err != nil {
return nil, fmt.Errorf("failed to render autounattend.xml: %w", err)
}
// Execute template for powershell script file
ps1Template, err := fs.ReadFile(path.Join(root, "first_logon.ps1"))
if err != nil {
return nil, err
}
ps1file, err := textutil.ExecuteTemplate(string(ps1Template), args)
if err != nil {
return nil, fmt.Errorf("failed to render ps1 file: %w", err)
}
layout := []iso9660util.Entry{
{
Path: "autounattend.xml",
Reader: bytes.NewReader(xmlfile),
},
{
Path: "first_logon.ps1",
Reader: bytes.NewReader(ps1file),
},
}
return layout, nil
return executeTemplateWalkDir(args, windowsTemplateFS, windowsTemplateFSRoot)
}
+20
View File
@@ -224,6 +224,10 @@ func TestExecuteTemplateWindowsISO(t *testing.T) {
name: "windows server 2025",
args: &TemplateArgs{
Name: "windows",
UID: 501,
Home: "/home/foo.guest",
Shell: "powershell.exe",
SSHPubKeys: []string{"ssh-rsa dummy foo@example.com"},
Arch: limatype.X8664,
User: "windows-user",
WindowsInitialPassword: "dummy-password",
@@ -245,6 +249,10 @@ func TestExecuteTemplateWindowsISO(t *testing.T) {
name: "windows 11 x86_64",
args: &TemplateArgs{
Name: "windows",
UID: 501,
Home: "/home/foo.guest",
Shell: "powershell.exe",
SSHPubKeys: []string{"ssh-rsa dummy foo@example.com"},
Arch: limatype.X8664,
User: "windows-user",
WindowsInitialPassword: "dummy-password",
@@ -265,6 +273,10 @@ func TestExecuteTemplateWindowsISO(t *testing.T) {
name: "legacyBIOS",
args: &TemplateArgs{
Name: "windows",
UID: 501,
Home: "/home/foo.guest",
Shell: "powershell.exe",
SSHPubKeys: []string{"ssh-rsa dummy foo@example.com"},
Arch: limatype.X8664,
User: "windows-user",
WindowsInitialPassword: "dummy-password",
@@ -287,6 +299,10 @@ func TestExecuteTemplateWindowsISO(t *testing.T) {
name: "disable TPM on Windows 11",
args: &TemplateArgs{
Name: "windows",
UID: 501,
Home: "/home/foo.guest",
Shell: "powershell.exe",
SSHPubKeys: []string{"ssh-rsa dummy foo@example.com"},
Arch: limatype.X8664,
User: "windows-user",
WindowsInitialPassword: "dummy-password",
@@ -308,6 +324,10 @@ func TestExecuteTemplateWindowsISO(t *testing.T) {
name: "Windows 11 arm64",
args: &TemplateArgs{
Name: "windows",
UID: 501,
Home: "/home/foo.guest",
Shell: "powershell.exe",
SSHPubKeys: []string{"ssh-rsa dummy foo@example.com"},
Arch: limatype.AARCH64,
User: "windows-user",
WindowsInitialPassword: "dummy-password",
@@ -76,6 +76,20 @@ icacls $pubkeyLocation /grant "Administrators:F"
$elapsed = (Get-Date) - $sectionStart
Write-Output "SSH setting completed in $($elapsed.TotalSeconds) seconds"
# Install provision runner and set task scheduler.
$root = Split-Path -Path $PSScriptRoot -Qualifier
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$root\install_provision.ps1" -Root "$root\"
# Execute provision scripts here for the current boot.
$runnerPath = "C:\ProgramData\Lima\provision\startup_runner.ps1"
# Dependency scripts are only run here, not scheduled.
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$runnerPath" -Mode "dependency"
# Startup System scripts are run here, and run in startup of a next boot.
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$runnerPath" -Mode "system"
# Startup User scripts are run here, and run in startup of a next boot.
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$runnerPath" -Mode "user"
# Finally, it creates a marker file. Host agent will check the file.
$bootDoneDir = 'C:\ProgramData\Lima'
New-Item -ItemType Directory -Path $bootDoneDir -Force | Out-Null
@@ -0,0 +1,67 @@
param(
[string]$Root = "F:\"
)
$ErrorActionPreference = "Stop"
function Copy-Provision {
param(
[string]$Src,
[string]$Dst,
[bool]$Extension
)
if (-not (Test-Path -LiteralPath $Src)) {
return
}
New-Item -ItemType Directory -Path $Dst -Force | Out-Null
foreach ($item in Get-ChildItem -LiteralPath $Src) {
$dst = $Dst
if ($Extension) {
# `.ps1` extension is necessary for script otherwise Powershell returns an error.
$dst = Join-Path $Dst ("{0}.ps1" -f $item.BaseName)
}
Copy-Item -LiteralPath $item.FullName -Destination $dst
}
}
$provisionDir = "C:\ProgramData\Lima\provision"
New-Item -ItemType Directory -Path $provisionDir -Force | Out-Null
# Copy startup runner and System scripts
$startupSystemRunnerSrc = Join-Path $Root "startup_runner.ps1"
$startupSystemRunnerDst = Join-Path $provisionDir "startup_runner.ps1"
Copy-Item -LiteralPath $startupSystemRunnerSrc -Destination $startupSystemRunnerDst
$startupSystemScriptsSrc = Join-Path $Root "provision.system"
$startupSystemScriptsDst = Join-Path $provisionDir "provision.system"
Copy-Provision -Src $startupSystemScriptsSrc -Dst $startupSystemScriptsDst -Extension
# Copy startup User scripts
$startupUserScriptsSrc = Join-Path $Root "provision.user"
$startupUserScriptsDst = Join-Path $provisionDir "provision.user"
Copy-Provision -Src $startupUserScriptsSrc -Dst $startupUserScriptsDst -Extension
# Copy dependency scripts
$startupDependencyScriptsSrc = Join-Path $Root "provision.dependency"
$startupDependencyScriptsDst = Join-Path $provisionDir "provision.dependency"
Copy-Provision -Src $startupDependencyScriptsSrc -Dst $startupDependencyScriptsDst -Extension
# Copy data files
$startupDataFilesSrc = Join-Path $Root "provision.data"
$startupDataFilesDst = Join-Path $provisionDir "provision.data"
Copy-Provision -Src $startupDataFilesSrc -Dst $startupDataFilesDst
# Set startup System task
$startupTrigger = New-ScheduledTaskTrigger -AtStartup
$startupActionSystem = New-ScheduledTaskAction -Execute "powershell.exe" -Argument ("-NoProfile -NonInteractive -ExecutionPolicy Bypass -File $startupSystemRunnerDst -Mode system")
$startupPrincipalSystem = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName "LimaProvisionStartupSystem" -TaskPath "\Lima\" -Action $startupActionSystem -Trigger $startupTrigger -Principal $startupPrincipalSystem -Force | Out-Null
# Set startup User task
$startupActionUser = New-ScheduledTaskAction -Execute "powershell.exe" -Argument ("-NoProfile -NonInteractive -ExecutionPolicy Bypass -File $startupSystemRunnerDst -Mode user")
$startupPrincipalUser = New-ScheduledTaskPrincipal -UserId "{{.User}}" -LogonType S4U -RunLevel Limited
Register-ScheduledTask -TaskName "LimaProvisionStartupUser" -TaskPath "\Lima\" -Action $startupActionUser -Trigger $startupTrigger -Principal $startupPrincipalUser -Force | Out-Null
@@ -0,0 +1,37 @@
param(
[string]$Mode = "system"
)
function Get-ProvisionFiles {
param(
[string]$Root,
[string]$Mode
)
$dir = Join-Path $Root ("provision.{0}" -f $Mode)
if (-not (Test-Path -LiteralPath $dir)) {
return @()
}
return @(Get-ChildItem -LiteralPath $dir -File | Sort-Object Name)
}
function Invoke-ProvisionScriptFile {
param(
[System.IO.FileInfo]$File
)
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $File.FullName
if ($LASTEXITCODE -ne 0) {
Write-Error ("Provision script exited with code {0}: {1}" -f $LASTEXITCODE, $File.FullName)
}
return $LASTEXITCODE
}
$baseDir = "C:\ProgramData\Lima\provision"
$exitCode = 0
foreach ($f in (Get-ProvisionFiles -Root $baseDir -Mode $Mode)) {
$code = Invoke-ProvisionScriptFile -File $f
if ($code -ne 0) {
$exitCode = $code
}
}
exit $exitCode
+4
View File
@@ -208,6 +208,10 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
i, limatype.ProvisionModeDependency))
}
if *y.OS == limatype.WINDOWS && (p.Mode == limatype.ProvisionModeAnsible || p.Mode == limatype.ProvisionModeBoot || p.Mode == limatype.ProvisionModeYQ) {
errs = errors.Join(errs, fmt.Errorf("provision mode %#q is not supported on Windows VM", p.Mode))
}
// This can lead to fatal Panic if p.Path is nil, better to return an error here
switch p.Mode {
case limatype.ProvisionModeData, limatype.ProvisionModeYQ:
+67
View File
@@ -147,6 +147,73 @@ func TestValidateProvisionMode(t *testing.T) {
"field `provision[0].script` must not be empty")
}
func TestValidateProvisionModeOnWindows(t *testing.T) {
windowsBase := `
os: Windows
arch: x86_64
vmType: qemu
images:
- location: /
osOpts:
Windows:
virtioWin:
- location: /
arch: "x86_64"
`
tests := []struct {
name string
provision string
wantErrMsg string
}{
{
name: "ansible not supported on windows",
provision: `provision: [{mode: ansible}]`,
wantErrMsg: "provision mode `ansible` is not supported on Windows VM",
},
{
name: "boot not supported on windows",
provision: `provision: [{mode: boot, script: "echo hi"}]`,
wantErrMsg: "provision mode `boot` is not supported on Windows VM",
},
{
name: "yq not supported on windows",
provision: `provision: [{mode: yq, expression: ".x=1", path: /tmp}]`,
wantErrMsg: "provision mode `yq` is not supported on Windows VM",
},
{
name: "system is supported on windows",
provision: `provision: [{mode: system, script: "echo hi"}]`,
},
{
name: "user is supported on windows",
provision: `provision: [{mode: user, script: "echo hi"}]`,
},
{
name: "dependency is supported on windows",
provision: `provision: [{mode: dependency, script: "echo hi"}]`,
},
{
name: "data is supported on windows",
provision: `provision: [{mode: data, path: /tmp, content: hello}]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
y, err := Load(t.Context(), []byte(windowsBase+"\n"+tt.provision), "template.yaml")
assert.NilError(t, err)
err = Validate(y, false)
if tt.wantErrMsg == "" {
assert.NilError(t, err)
} else {
assert.ErrorContains(t, err, tt.wantErrMsg)
}
})
}
}
func TestValidateProvisionData(t *testing.T) {
images := `images: [{location: /}]`
validData := `provision: [{mode: data, path: /tmp, content: hello}]`
+25
View File
@@ -33,3 +33,28 @@ plain: true
video:
# This is for development purpose. You can set `none`.
display: default
# Windows VM doesn't support boot mode because there is no phase which is equivalent to Cloud-init's 'init' stage.
# Currently Windows VM doesn't support yq mode.
# provision:
# - mode: system
# script: |
# Write-Output "this is system mode"
# - mode: user
# script: |
# Write-Output "this is user mode"
# # dependency is only run at once, when the guest is installed.
# # Currently Windows VM does not support skipDefaultDependencyResolution.
# - mode: dependency
# skipDefaultDependencyResolution: false
# script: |
# Write-Output "this is dependency mode"
# # All data mode files are copied in `C:\ProgramData\Lima\provision\provision.data\`, and `path` is ignored.
# - mode: data
# path: /this-field-is-ignored
# content: |
# FOO=bar
# - mode: data
# path: /this-field-is-ignored
# content: |
# BAR=foo
+25
View File
@@ -25,3 +25,28 @@ plain: true
video:
# This is for development purpose. You can set `none`.
display: default
# Windows VM doesn't support boot mode because there is no phase which is equivalent to Cloud-init's 'init' stage.
# Currently Windows VM doesn't support yq mode.
# provision:
# - mode: system
# script: |
# Write-Output "this is system mode"
# - mode: user
# script: |
# Write-Output "this is user mode"
# # dependency is only run at once, when the guest is installed.
# # Currently Windows VM does not support skipDefaultDependencyResolution.
# - mode: dependency
# skipDefaultDependencyResolution: false
# script: |
# Write-Output "this is dependency mode"
# # All data mode files are copied in `C:\ProgramData\Lima\provision\provision.data\`, and `path` is ignored.
# - mode: data
# path: /this-field-is-ignored
# content: |
# FOO=bar
# - mode: data
# path: /this-field-is-ignored
# content: |
# BAR=foo
@@ -34,4 +34,5 @@ For Windows server 2025, TPM emulation is disabled by default. However, there ar
## Caveats
- For Windows 11 guest, you need to download the installer ISO manually from [here](https://www.microsoft.com/en-us/software-download/windows11)
- QEMU is the only VM driver that supports Windows guests
- provision feature is limited support (no `boot`, `yq` modes, and `data` and `dependency` modes have limitations)
- Only plain mode is supported (no file mount, no dynamic port-forwarding)