Skip to content

Commit

Permalink
Add Host Provision
Browse files Browse the repository at this point in the history
Host provisioning scripts are executed every time before starting the instance.
- the working directory is the instance directory `{{.Dir}}`
- the `runtime.GOOS` is used to determine the host OS. e.g. `darwin` for macOS, `linux` for Linux, and `windows` for Windows.
- if `wait` is true and the script exits with a non-zero status, the instance start will be aborted.

`shell` and `script` can include these template variables:
- `{{.ScriptName}}` that represents the temporary script file path.
- `{{.Index}}` that represents the index in the list of host provisioning scripts (0-based).
- template variables available in `limactl list --format` command.

🟢 Builtin default: null

e.g.
```yaml
hostProvision:
- debug: false      # change the temporary script location to {{.Dir}} and not delete it after execution. default: false
  hostOS: darwin    # string or []string. The script is executed only on the specified host OS.
  script: |         # passed to the shell as temporary file argument if exists
    xattr -w com.apple.metadata:com_apple_backup_excludeItem true {{.Dir}}/{basedisk,diffdisk}
  shell: bash       # default: null
  wait: true        # wait for the script to finish before starting the instance. default: true
```
If no shell is given, the default shell is selected based on the host OS. If the default shell is not located on the PATH, fallbacks to `sh` (when host OS is not windows) or `powershell` (when host OS is windows).

`shell` can be either:
1. Builtin / Explicitly supported keywords

| Keyword      | Command run internally                                 | Description                                    |
| ------------ | ------------------------------------------------------ | ---------------------------------------------- |
| `bash`       | `bash --noprofile --norc -eo pipefail {{.ScriptName}}` | The default shell when host OS is not windows. |
| `sh`         | `sh -e {{.ScriptName}}`                                |                                                |
| `pwsh`       | `pwsh -command ". '{{.ScriptName}}'"`                  | The default shell when host OS is windows.     |
| `powershell` | `powershell -command ". '{{.ScriptName}}'"`            |                                                |
| `cmd`        | `cmd /D /E:ON /V:OFF /S /C "CALL "{{.ScriptName}}""`   |                                                |

2. Template string: `command [...options] {{.ScriptName}} [...more_options]`
  `{{.ScriptName}}` is replaced with the temporary script file path

there are shorthand forms for the builtin shells:
```yaml
- bash: echo "executed by bash"                    # interpreted as {shell: bash, hostOS: [darwin, linux], script: ...}
- sh: echo "executed by sh"                        # interpreted as {shell: sh, hostOS: [darwin, linux], script: ...}
- pwsh: Write-Host "executed by pwsh"              # interpreted as {shell: pwsh, hostOS: [windows], script: ...}
- powershell: Write-Host "executed by powershell"  # interpreted as {shell: powershell, hostOS: [windows], script: ...}
- cmd: echo "executed by cmd"                      # interpreted as {shell: cmd, hostOS: [windows], script: ...}
```

e.g.
```yaml
- bash: | # Post a notification when an error by the hostProvision script is detected
    jq=/opt/homebrew/bin/jq && test -x $jq || exit 0
    tail -n0 -F ha.stderr.log | while read -r line; do
      msg=$(echo "$line"|$jq -er '
        select(.hostProvision and .hostProvision != {{.Index}})| # select log lines from other hostProvision scripts
        select(.level == "error")|                               # select error log lines
        .msg
      ') || continue
      osascript -e "on run argv" -e "display notification (item 1 of argv) with title \"Lima\"" -e "end run" "$msg"
      echo Posted a notification
    done
  debug: false
  hostOS: darwin
  wait: false
```

This PR is an alternative solution to lima-vm#2159.

Signed-off-by: Norio Nomura <norio.nomura@gmail.com>
  • Loading branch information
norio-nomura committed Feb 4, 2024
1 parent 752afc0 commit 0b3af6e
Show file tree
Hide file tree
Showing 10 changed files with 686 additions and 8 deletions.
4 changes: 4 additions & 0 deletions cmd/limactl/hostagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func hostagentAction(cmd *cobra.Command, args []string) error {
return err
}

if err := ha.ExecuteHostProvision(); err != nil {
return err
}

backend := &server.Backend{
Agent: ha,
}
Expand Down
53 changes: 53 additions & 0 deletions examples/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,59 @@ containerd:
# vim was not installed in the guest. Make sure the package system is working correctly.
# Also see "/var/log/cloud-init-output.log" in the guest.

# Host provisioning scripts are executed every time before starting the instance.
# - the working directory is the instance directory `{{.Dir}}`
# - the `runtime.GOOS` is used to determine the host OS. e.g. `darwin` for macOS, `linux` for Linux, and `windows` for Windows.
# - if `wait` is true and the script exits with a non-zero status, the instance start will be aborted.
# `shell` and `script` can include these template variables:
# - `{{.ScriptName}}` that represents the temporary script file path.
# - `{{.Index}}` that represents the index in the list of host provisioning scripts (0-based).
# - template variables available in `limactl list --format` command.
# 🟢 Builtin default: null
# hostProvision:
# - debug: false # change the temporary script location to {{.Dir}} and not delete it after execution. default: false
# hostOS: darwin # string or []string. The script is executed only on the specified host OS.
# script: | # passed to the shell as temporary file argument if exists
# xattr -w com.apple.metadata:com_apple_backup_excludeItem true {{.Dir}}/{basedisk,diffdisk}
# shell: bash # default: null
# wait: true # wait for the script to finish before starting the instance. default: true
# # If no shell is given, the default shell is selected based on the host OS. If the default shell is not
# # located on the PATH, fallbacks to `sh` (when host OS is not windows) or `powershell` (when host OS is windows).
# # `shell` can be either:
# # 1. Builtin / Explicitly supported keywords
# # | Keyword | Command run internally | Description |
# # | ------------ | ------------------------------------------------------ | ---------------------------------------------- |
# # | `bash` | `bash --noprofile --norc -eo pipefail {{.ScriptName}}` | The default shell when host OS is not windows. |
# # | `sh` | `sh -e {{.ScriptName}}` | |
# # | `pwsh` | `pwsh -command ". '{{.ScriptName}}'"` | The default shell when host OS is windows. |
# # | `powershell` | `powershell -command ". '{{.ScriptName}}'"` | |
# # | `cmd` | `cmd /D /E:ON /V:OFF /S /C "CALL "{{.ScriptName}}""` | |
# # 2. Template string: `command [...options] {{.ScriptName}} [...more_options]`
# # `{{.ScriptName}}` is replaced with the temporary script file path
# #
# # there are shorthand forms for the builtin shells:
# - bash: echo "executed by bash" # interpreted as {shell: bash, hostOS: [darwin, linux], script: ...}
# - sh: echo "executed by sh" # interpreted as {shell: sh, hostOS: [darwin, linux], script: ...}
# - pwsh: Write-Host "executed by pwsh" # interpreted as {shell: pwsh, hostOS: [windows], script: ...}
# - powershell: Write-Host "executed by powershell" # interpreted as {shell: powershell, hostOS: [windows], script: ...}
# - cmd: echo "executed by cmd" # interpreted as {shell: cmd, hostOS: [windows], script: ...}
# # e.g.
# - bash: | # Post a notification when an error by the hostProvision script is detected
# jq=/opt/homebrew/bin/jq && test -x $jq || exit 0
# tail -n0 -F ha.stderr.log | while read -r line; do
# msg=$(echo "$line"|$jq -er '
# select(.hostProvision and .hostProvision != {{.Index}})| # select log lines from other hostProvision scripts
# select(.level == "error")| # select error log lines
# .msg
# ') || continue
# osascript -e "on run argv" -e "display notification (item 1 of argv) with title \"Lima\"" -e "end run" "$msg"
# echo Posted a notification
# done
# debug: false
# hostOS: darwin
# wait: false


# ===================================================================== #
# FURTHER ADVANCED CONFIGURATION
# ===================================================================== #
Expand Down
261 changes: 261 additions & 0 deletions pkg/hostagent/host_provision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package hostagent

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path"
"runtime"
"strings"
"text/template"

"github.com/lima-vm/lima/pkg/limayaml"
"github.com/lima-vm/lima/pkg/ptr"
"github.com/lima-vm/lima/pkg/store"
"github.com/lima-vm/lima/pkg/textutil"
"github.com/mattn/go-shellwords"
"github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
)

var (
defaultArguments = map[string][]string{
limayaml.HostProvisionShellBash: {"--noprofile", "--norc", "-e", "-o", "pipefail", "{{.ScriptName}}"},
limayaml.HostProvisionShellSh: {"-e", "{{.ScriptName}}"},
limayaml.HostProvisionShellPwsh: {"-command", ". '{{.ScriptName}}'"},
limayaml.HostProvisionShellPowerShell: {"-command", ". '{{.ScriptName}}'"},
limayaml.HostProvisionShellCmd: {"/D", "/E:ON", "/V:OFF", "/S", "/C", "CALL \"{{.ScriptName}}\""},
}
defaultHostOS = map[string]*limayaml.StringArray{
limayaml.HostProvisionShellBash: {"darwin", "linux"},
limayaml.HostProvisionShellSh: {"darwin", "linux"},
limayaml.HostProvisionShellPwsh: {"windows"},
limayaml.HostProvisionShellPowerShell: {"windows"},
limayaml.HostProvisionShellCmd: {"windows"},
}
extensions = map[string]string{
limayaml.HostProvisionShellBash: ".sh",
limayaml.HostProvisionShellSh: ".sh",
limayaml.HostProvisionShellPwsh: ".ps1",
limayaml.HostProvisionShellPowerShell: ".ps1",
limayaml.HostProvisionShellCmd: ".cmd",
}
)

func interpretShorthandHostProvision(p *limayaml.HostProvision) (limayaml.HostProvision, error) {
var interpreted limayaml.HostProvision
interpreted.Debug = p.Debug
interpreted.Wait = p.Wait
switch {
case p.Bash != nil:
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellBash)
interpreted.Script = p.Bash
case p.Sh != nil:
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellSh)
interpreted.Script = p.Sh
case p.Pwsh != nil:
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellPwsh)
interpreted.Script = p.Pwsh
case p.PowerShell != nil:
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellPowerShell)
interpreted.Script = p.PowerShell
case p.Cmd != nil:
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellCmd)
interpreted.Script = p.Cmd
case p.Shell != nil && p.Script != nil:
interpreted.Shell = p.Shell
interpreted.Script = p.Script
case p.Shell == nil && p.Script != nil:
if runtime.GOOS == "windows" {
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellPwsh)
} else {
interpreted.Shell = ptr.Of(limayaml.HostProvisionShellBash)
}
interpreted.Script = p.Script
case p.Shell != nil && p.Script == nil:
interpreted.Shell = p.Shell
}
if p.HostOS != nil {
interpreted.HostOS = p.HostOS
} else if interpreted.Shell != nil {
interpreted.HostOS = defaultHostOS[*interpreted.Shell]
} else if runtime.GOOS == "windows" {
interpreted.HostOS = defaultHostOS[limayaml.HostProvisionShellPwsh]
} else {
interpreted.HostOS = defaultHostOS[limayaml.HostProvisionShellBash]
}
return interpreted, nil
}

type HostProvisionFormatData struct {
store.Instance
ScriptName string
Index int
}

func executeHostProvisionTemplate(format string, data *HostProvisionFormatData) (bytes.Buffer, error) {
tmpl, err := template.New("executeHostProvisionTemplate").Funcs(textutil.TemplateFuncMap).Parse(format)
if err == nil {
var out bytes.Buffer
if err := tmpl.Execute(&out, data); err == nil {
return out, nil
}
}
return bytes.Buffer{}, err
}

func templateAppliedDefaultArguments(shell string, data *HostProvisionFormatData) ([]string, error) {
args := defaultArguments[shell]
templateAppliedArgs := make([]string, len(args))
for i, arg := range args {
out, err := executeHostProvisionTemplate(arg, data)
if err != nil {
return nil, err
}
templateAppliedArgs[i] = out.String()
}
return templateAppliedArgs, nil
}

func prepareHostProvision(hp limayaml.HostProvision, index int, instance *store.Instance) (func(o, e io.Writer) error, error) {
var data HostProvisionFormatData
data.Instance = *instance
data.Index = index
isDebug := hp.Debug != nil && *hp.Debug
if hp.Script != nil {
debugProvisionScriptPath := path.Join(instance.Dir, fmt.Sprintf("provision%d%s", index, extensions[*hp.Shell]))
var tmpHostProvisionScriptFile *os.File
var err error
if isDebug {
tmpHostProvisionScriptFile, err = os.Create(debugProvisionScriptPath)
} else {
os.RemoveAll(debugProvisionScriptPath)
tmpHostProvisionScriptFile, err = os.CreateTemp("", "lima-provision-*"+extensions[*hp.Shell])
}
if err != nil {
return nil, err
}
data.ScriptName = tmpHostProvisionScriptFile.Name()
if runtime.GOOS == "windows" {
*hp.Script = strings.ReplaceAll(*hp.Script, "\r\n", "\n")
}
out, err := executeHostProvisionTemplate(*hp.Script, &data)
if err != nil {
return nil, err
}
if _, err := tmpHostProvisionScriptFile.Write(out.Bytes()); err != nil {
return nil, err
}
if err := tmpHostProvisionScriptFile.Close(); err != nil {
return nil, err
}
}
var arg0 string
var args []string
var err error
if hp.Shell == nil { // The default shell has fallback functionality.
var defaultShells []string
if runtime.GOOS == "windows" {
defaultShells = []string{limayaml.HostProvisionShellPwsh, limayaml.HostProvisionShellPowerShell}
} else {
defaultShells = []string{limayaml.HostProvisionShellBash, limayaml.HostProvisionShellSh}
}
for _, shell := range defaultShells {
if found, err := exec.LookPath(shell); err == nil {
arg0 = found
args, err = templateAppliedDefaultArguments(shell, &data)
if err != nil {
return nil, err
}
break
}
}
if arg0 == "" {
return nil, fmt.Errorf("failed to find a default shell in %v", defaultShells)
}
} else {
args, err = templateAppliedDefaultArguments(*hp.Shell, &data)
if err != nil {
return nil, err
}
if len(args) == 0 { // custom shell
out, err := executeHostProvisionTemplate(*hp.Shell, &data)
if err != nil {
return nil, err
}
parsedArgs, err := shellwords.Parse(out.String())
if err != nil {
return nil, err
}
arg0 = parsedArgs[0]
args = parsedArgs[1:]
} else {
arg0 = *hp.Shell
}
arg0, err = exec.LookPath(arg0)
if err != nil {
return nil, err
}
}

cmd := exec.Command(arg0, args...)
cmd.Dir = instance.Dir
return func(o, e io.Writer) error {
if hp.Script != nil && !isDebug {
defer os.RemoveAll(data.ScriptName)
}
cmd.Stdout = o
cmd.Stderr = e
return cmd.Run()
}, nil
}

func (a *HostAgent) ExecuteHostProvision() error {
if len(a.y.HostProvision) == 0 {
return nil
}
instance, err := store.Inspect(a.instName)
if err != nil {
return err
}
for i, p := range a.y.HostProvision {
hp, err := interpretShorthandHostProvision(&p)
if err != nil {
return err
}
log := logrus.WithField("hostProvision", i)
log.WithField("interpreted", hp).Debug("interpreted hostProvision")
if hp.HostOS == nil {
log.Debugf("hostProvision[%d] executing because hostOS is not specified", i)
} else if !slices.Contains(*hp.HostOS, runtime.GOOS) {
log.Warnf("hostProvision[%d] skipped because runtime.GOOS=%q is not in %v", i, runtime.GOOS, *hp.HostOS)
continue
}
output, err := prepareHostProvision(hp, i, instance)
if err != nil {
return err
}
o := log.WriterLevel(logrus.DebugLevel)
e := log.WriterLevel(logrus.ErrorLevel)
if hp.Wait != nil && *hp.Wait {
if err := output(o, e); err != nil {
return fmt.Errorf("hostProvision[%d] failed: %w", i, err)
}
log.Debugf("hostProvision[%d] succeeded", i)
} else {
i := i
go func() {
log.Debugf("hostProvision[%d] started", i)
if err := output(o, e); err != nil {
log.Errorf("hostProvision[%d] failed: %v", i, err)
} else {
log.Debugf("hostProvision[%d] terminated", i)
}
}()
}
}

return nil
}
Loading

0 comments on commit 0b3af6e

Please sign in to comment.