diff --git a/cmd/apptainer.lima b/cmd/apptainer.lima index 31ace6d57fc..ac267da0786 100755 --- a/cmd/apptainer.lima +++ b/cmd/apptainer.lima @@ -4,7 +4,7 @@ set -eu : "${APPTAINER_BINDPATH:=}" if [ "$(limactl ls -q "$LIMA_INSTANCE" 2>/dev/null)" != "$LIMA_INSTANCE" ]; then - echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template://apptainer\` to create a new instance" >&2 + echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template:apptainer\` to create a new instance" >&2 exit 1 elif [ "$(limactl ls -f '{{ .Status }}' "$LIMA_INSTANCE" 2>/dev/null)" != "Running" ]; then echo "instance \"$LIMA_INSTANCE\" is not running, run \`limactl start $LIMA_INSTANCE\` to start the existing instance" >&2 diff --git a/cmd/docker.lima b/cmd/docker.lima index 2c2151cd5df..4340def65bd 100755 --- a/cmd/docker.lima +++ b/cmd/docker.lima @@ -8,7 +8,7 @@ set -eu : "${DOCKER:=docker}" if [ "$(limactl ls -q "$LIMA_INSTANCE" 2>/dev/null)" != "$LIMA_INSTANCE" ]; then - echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template://docker\` to create a new instance" >&2 + echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template:docker\` to create a new instance" >&2 exit 1 elif [ "$(limactl ls -f '{{ .Status }}' "$LIMA_INSTANCE" 2>/dev/null)" != "Running" ]; then echo "instance \"$LIMA_INSTANCE\" is not running, run \`limactl start $LIMA_INSTANCE\` to start the existing instance" >&2 diff --git a/cmd/kubectl.lima b/cmd/kubectl.lima index 40822e8c973..863a52e43a2 100755 --- a/cmd/kubectl.lima +++ b/cmd/kubectl.lima @@ -14,8 +14,8 @@ if [ -z "$LIMA_INSTANCE" ]; then LIMA_INSTANCE=k8s else echo "No k3s or k8s existing instances found. Either create one with" >&2 - echo "limactl create --name=k3s template://k3s" >&2 - echo "limactl create --name=k8s template://k8s" >&2 + echo "limactl create --name=k3s template:k3s" >&2 + echo "limactl create --name=k8s template:k8s" >&2 echo "or set LIMA_INSTANCE to the name of your Kubernetes instance" >&2 exit 1 fi diff --git a/cmd/limactl/completion.go b/cmd/limactl/completion.go index 9e5b949db6a..b9d961a2eed 100644 --- a/cmd/limactl/completion.go +++ b/cmd/limactl/completion.go @@ -28,7 +28,7 @@ func bashCompleteTemplateNames(_ *cobra.Command, toComplete string) ([]string, c var comp []string if templates, err := templatestore.Templates(); err == nil { for _, f := range templates { - name := "template://" + f.Name + name := "template:" + f.Name if !strings.HasPrefix(name, toComplete) { continue } diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 39b93421a72..874a4a10165 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -21,7 +21,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/fsutil" "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" "github.com/lima-vm/lima/v2/pkg/osutil" - "github.com/lima-vm/lima/v2/pkg/plugin" + "github.com/lima-vm/lima/v2/pkg/plugins" "github.com/lima-vm/lima/v2/pkg/version" ) @@ -47,8 +47,7 @@ func main() { } } } - rootCmd := newApp() - err := executeWithPluginSupport(rootCmd, os.Args[1:]) + err := newApp().Execute() server.StopAllExternalDrivers() osutil.HandleExitError(err) if err != nil { @@ -56,6 +55,41 @@ func main() { } } +func processGlobalFlags(rootCmd *cobra.Command) error { + // --log-level will override --debug, but will not reset debugutil.Debug + if debug, _ := rootCmd.Flags().GetBool("debug"); debug { + logrus.SetLevel(logrus.DebugLevel) + debugutil.Debug = true + } + + l, _ := rootCmd.Flags().GetString("log-level") + if l != "" { + lvl, err := logrus.ParseLevel(l) + if err != nil { + return err + } + logrus.SetLevel(lvl) + } + + logFormat, _ := rootCmd.Flags().GetString("log-format") + switch logFormat { + case "json": + formatter := new(logrus.JSONFormatter) + logrus.StandardLogger().SetFormatter(formatter) + case "text": + // logrus use text format by default. + if runtime.GOOS == "windows" && isatty.IsCygwinTerminal(os.Stderr.Fd()) { + formatter := new(logrus.TextFormatter) + // the default setting does not recognize cygwin on windows + formatter.ForceColors = true + logrus.StandardLogger().SetFormatter(formatter) + } + default: + return fmt.Errorf("unsupported log-format: %q", logFormat) + } + return nil +} + func newApp() *cobra.Command { templatesDir := "$PREFIX/share/lima/templates" if exe, err := os.Executable(); err == nil { @@ -92,30 +126,8 @@ func newApp() *cobra.Command { rootCmd.PersistentFlags().Bool("tty", isatty.IsTerminal(os.Stdout.Fd()), "Enable TUI interactions such as opening an editor. Defaults to true when stdout is a terminal. Set to false for automation.") rootCmd.PersistentFlags().BoolP("yes", "y", false, "Alias of --tty=false") rootCmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { - l, _ := cmd.Flags().GetString("log-level") - if l != "" { - lvl, err := logrus.ParseLevel(l) - if err != nil { - return err - } - logrus.SetLevel(lvl) - } - - logFormat, _ := cmd.Flags().GetString("log-format") - switch logFormat { - case "json": - formatter := new(logrus.JSONFormatter) - logrus.StandardLogger().SetFormatter(formatter) - case "text": - // logrus use text format by default. - if runtime.GOOS == "windows" && isatty.IsCygwinTerminal(os.Stderr.Fd()) { - formatter := new(logrus.TextFormatter) - // the default setting does not recognize cygwin on windows - formatter.ForceColors = true - logrus.StandardLogger().SetFormatter(formatter) - } - default: - return fmt.Errorf("unsupported log-format: %q", logFormat) + if err := processGlobalFlags(rootCmd); err != nil { + return err } if osutil.IsBeingRosettaTranslated() && cmd.Parent().Name() != "completion" && cmd.Name() != "generate-doc" && cmd.Name() != "validate" { @@ -191,47 +203,61 @@ func newApp() *cobra.Command { newNetworkCommand(), newCloneCommand(), ) + addPluginCommands(rootCmd) return rootCmd } -func executeWithPluginSupport(rootCmd *cobra.Command, args []string) error { - rootCmd.SetArgs(args) - - if err := rootCmd.ParseFlags(args); err == nil { - if debug, _ := rootCmd.Flags().GetBool("debug"); debug { - logrus.SetLevel(logrus.DebugLevel) - debugutil.Debug = true - } +func addPluginCommands(rootCmd *cobra.Command) { + // The global options are only processed when rootCmd.Execute() is called. + // Let's take a sneak peek here to help debug the plugin discovery code. + if len(os.Args) > 1 && os.Args[1] == "--debug" { + logrus.SetLevel(logrus.DebugLevel) } - addPluginCommands(rootCmd) - - return rootCmd.Execute() -} - -func addPluginCommands(rootCmd *cobra.Command) { - plugins, err := plugin.DiscoverPlugins() + allPlugins, err := plugins.Discover() if err != nil { logrus.Warnf("Failed to discover plugins: %v", err) return } - for _, p := range plugins { - pluginName := p.Name + for _, plugin := range allPlugins { pluginCmd := &cobra.Command{ - Use: pluginName, - Short: p.Description, + Use: plugin.Name, + Short: plugin.Description, GroupID: "plugin", DisableFlagParsing: true, - Run: func(cmd *cobra.Command, args []string) { - plugin.RunExternalPlugin(cmd.Context(), pluginName, args) + SilenceErrors: true, + SilenceUsage: true, + PreRunE: func(*cobra.Command, []string) error { + for i, arg := range os.Args { + if arg == plugin.Name { + // parse global options but ignore plugin options + err := rootCmd.ParseFlags(os.Args[1:i]) + if err == nil { + err = processGlobalFlags(rootCmd) + } + return err + } + } + // unreachable + return nil + }, + Run: func(cmd *cobra.Command, _ []string) { + for i, arg := range os.Args { + if arg == plugin.Name { + // ignore global options + plugin.Run(cmd.Context(), os.Args[i+1:]) + // plugin.Run() never returns because it calls os.Exit() + } + } + // unreachable }, } - - pluginCmd.SilenceUsage = true - pluginCmd.SilenceErrors = true - + // Don't show the url scheme helper in the help output. + if strings.HasPrefix(plugin.Name, "url-") { + pluginCmd.Hidden = true + } rootCmd.AddCommand(pluginCmd) } } diff --git a/cmd/limactl/start.go b/cmd/limactl/start.go index 4b6c2dc016f..982075a3ee0 100644 --- a/cmd/limactl/start.go +++ b/cmd/limactl/start.go @@ -49,7 +49,7 @@ func newCreateCommand() *cobra.Command { $ limactl create To create an instance "default" from a template "docker": - $ limactl create --name=default template://docker + $ limactl create --name=default template:docker To create an instance "default" with modified parameters: $ limactl create --cpus=2 --memory=2 @@ -87,7 +87,7 @@ func newStartCommand() *cobra.Command { $ limactl start To create an instance "default" from a template "docker", and start it: - $ limactl start --name=default template://docker + $ limactl start --name=default template:docker `, Short: "Start an instance of Lima", Args: WrapArgsError(cobra.MaximumNArgs(1)), @@ -234,19 +234,19 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (* if isTemplateURL, templateName := limatmpl.SeemsTemplateURL(arg); isTemplateURL { switch templateName { case "experimental/vz": - logrus.Warn("template://experimental/vz was merged into the default template in Lima v1.0. See also .") + logrus.Warn("template:experimental/vz was merged into the default template in Lima v1.0. See also .") case "experimental/riscv64": - logrus.Warn("template://experimental/riscv64 was merged into the default template in Lima v1.0. Use `limactl create --arch=riscv64 template://default` instead.") + logrus.Warn("template:experimental/riscv64 was merged into the default template in Lima v1.0. Use `limactl create --arch=riscv64 template:default` instead.") case "experimental/armv7l": - logrus.Warn("template://experimental/armv7l was merged into the default template in Lima v1.0. Use `limactl create --arch=armv7l template://default` instead.") + logrus.Warn("template:experimental/armv7l was merged into the default template in Lima v1.0. Use `limactl create --arch=armv7l template:default` instead.") case "vmnet": - logrus.Warn("template://vmnet was removed in Lima v1.0. Use `limactl create --network=lima:shared template://default` instead. See also .") + logrus.Warn("template:vmnet was removed in Lima v1.0. Use `limactl create --network=lima:shared template:default` instead. See also .") case "experimental/net-user-v2": - logrus.Warn("template://experimental/net-user-v2 was removed in Lima v1.0. Use `limactl create --network=lima:user-v2 template://default` instead. See also .") + logrus.Warn("template:experimental/net-user-v2 was removed in Lima v1.0. Use `limactl create --network=lima:user-v2 template:default` instead. See also .") case "experimental/9p": - logrus.Warn("template://experimental/9p was removed in Lima v1.0. Use `limactl create --vm-type=qemu --mount-type=9p template://default` instead. See also .") + logrus.Warn("template:experimental/9p was removed in Lima v1.0. Use `limactl create --vm-type=qemu --mount-type=9p template:default` instead. See also .") case "experimental/virtiofs-linux": - logrus.Warn("template://experimental/virtiofs-linux was removed in Lima v1.0. Use `limactl create --mount-type=virtiofs template://default` instead. See also .") + logrus.Warn("template:experimental/virtiofs-linux was removed in Lima v1.0. Use `limactl create --mount-type=virtiofs template:default` instead. See also .") } } if arg == "-" { @@ -298,8 +298,8 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (* return nil, err } if arg != "" && arg != DefaultInstanceName { - logrus.Infof("Creating an instance %q from template://default (Not from template://%s)", tmpl.Name, tmpl.Name) - logrus.Warnf("This form is deprecated. Use `limactl create --name=%s template://default` instead", tmpl.Name) + logrus.Infof("Creating an instance %q from template:default (Not from template:%s)", tmpl.Name, tmpl.Name) + logrus.Warnf("This form is deprecated. Use `limactl create --name=%s template:default` instead", tmpl.Name) } // Read the default template for creating a new instance tmpl.Bytes, err = templatestore.Read(templatestore.Default) diff --git a/cmd/limactl/template.go b/cmd/limactl/template.go index 005829cfb83..318836b2e7b 100644 --- a/cmd/limactl/template.go +++ b/cmd/limactl/template.go @@ -40,6 +40,7 @@ func newTemplateCommand() *cobra.Command { newTemplateCopyCommand(), newTemplateValidateCommand(), newTemplateYQCommand(), + newTemplateURLCommand(), ) return templateCommand } @@ -51,13 +52,13 @@ func newValidateCommand() *cobra.Command { return validateCommand } -var templateCopyExample = ` Template locators are local files, file://, https://, or template:// URLs +var templateCopyExample = ` Template locators are local files, file://, https://, or template: URLs # Copy default template to STDOUT - limactl template copy template://default - + limactl template copy template:default - # Copy template from web location to local file and embed all external references - # (this does not embed template:// references) + # (this does not embed template: references) limactl template copy --embed https://example.com/lima.yaml mighty-machine.yaml ` @@ -176,10 +177,10 @@ External references are embedded and default values are filled in before the YQ expression is evaluated. Example: - limactl template yq template://default '.images[].location' + limactl template yq template:default '.images[].location' The example command is equivalent to using an external yq command like this: - limactl template copy --fill template://default - | yq '.images[].location' + limactl template copy --fill template:default - | yq '.images[].location' ` func newTemplateYQCommand() *cobra.Command { @@ -281,3 +282,22 @@ func templateValidateAction(cmd *cobra.Command, args []string) error { return nil } + +func newTemplateURLCommand() *cobra.Command { + templateURLCommand := &cobra.Command{ + Use: "url CUSTOM_URL", + Short: "Transform custom template URLs to regular file or https URLs", + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: templateURLAction, + } + return templateURLCommand +} + +func templateURLAction(cmd *cobra.Command, args []string) error { + url, err := limatmpl.TransformCustomURL(cmd.Context(), args[0]) + if err != nil { + return err + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), url) + return err +} diff --git a/cmd/podman.lima b/cmd/podman.lima index b05d23b5d0a..59ab326bc7c 100755 --- a/cmd/podman.lima +++ b/cmd/podman.lima @@ -4,7 +4,7 @@ set -eu : "${PODMAN:=podman}" if [ "$(limactl ls -q "$LIMA_INSTANCE" 2>/dev/null)" != "$LIMA_INSTANCE" ]; then - echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template://podman\` to create a new instance" >&2 + echo "instance \"$LIMA_INSTANCE\" does not exist, run \`limactl create --name=$LIMA_INSTANCE template:podman\` to create a new instance" >&2 exit 1 elif [ "$(limactl ls -f '{{ .Status }}' "$LIMA_INSTANCE" 2>/dev/null)" != "Running" ]; then echo "instance \"$LIMA_INSTANCE\" is not running, run \`limactl start $LIMA_INSTANCE\` to start the existing instance" >&2 diff --git a/hack/bats/tests/preserve-env.bats b/hack/bats/tests/preserve-env.bats index e53257356a5..86db1a14111 100644 --- a/hack/bats/tests/preserve-env.bats +++ b/hack/bats/tests/preserve-env.bats @@ -17,7 +17,7 @@ local_setup_file() { limactl delete --force "$NAME" || : # Make sure that the host agent doesn't inherit file handles 3 or 4. # Otherwise bats will not finish until the host agent exits. - limactl start --yes --name "$NAME" template://default 3>&- 4>&- + limactl start --yes --name "$NAME" template:default 3>&- 4>&- } local_teardown_file() { diff --git a/hack/common.inc.sh b/hack/common.inc.sh index 8e6f94fe22d..109021a7d23 100644 --- a/hack/common.inc.sh +++ b/hack/common.inc.sh @@ -31,7 +31,7 @@ _IPERF3=iperf3 : "${IPERF3:=$_IPERF3}" # Setup LIMA_TEMPLATES_PATH because the templates are not installed, but reference base templates -# via template://_images/* and template://_default/*. +# via template:_images/* and template:_default/*. templates_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../templates" && pwd)" : "${LIMA_TEMPLATES_PATH:-$templates_dir}" export LIMA_TEMPLATES_PATH diff --git a/hack/test-templates/test-misc.yaml b/hack/test-templates/test-misc.yaml index a365160e387..4dddf23d732 100644 --- a/hack/test-templates/test-misc.yaml +++ b/hack/test-templates/test-misc.yaml @@ -3,7 +3,7 @@ # - snapshots # - (More to come) # -base: template://ubuntu-22.04 +base: template:ubuntu-22.04 # 9p is not compatible with `limactl snapshot` mountTypesUnsupported: ["9p"] diff --git a/pkg/limainfo/limainfo.go b/pkg/limainfo/limainfo.go index 93235c846a2..bfeb2ad2258 100644 --- a/pkg/limainfo/limainfo.go +++ b/pkg/limainfo/limainfo.go @@ -17,7 +17,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" "github.com/lima-vm/lima/v2/pkg/limatype/filenames" "github.com/lima-vm/lima/v2/pkg/limayaml" - "github.com/lima-vm/lima/v2/pkg/plugin" + "github.com/lima-vm/lima/v2/pkg/plugins" "github.com/lima-vm/lima/v2/pkg/registry" "github.com/lima-vm/lima/v2/pkg/templatestore" "github.com/lima-vm/lima/v2/pkg/usrlocalsharelima" @@ -36,7 +36,7 @@ type LimaInfo struct { HostOS string `json:"hostOS"` // since Lima v2.0.0 HostArch string `json:"hostArch"` // since Lima v2.0.0 IdentityFile string `json:"identityFile"` // since Lima v2.0.0 - Plugins []plugin.Plugin `json:"plugins"` // since Lima v2.0.0 + Plugins []plugins.Plugin `json:"plugins"` // since Lima v2.0.0 } type DriverExt struct { @@ -48,7 +48,7 @@ type GuestAgent struct { } // New returns a LimaInfo object with the Lima version, a list of all Templates and their location, -// the DefaultTemplate corresponding to template://default with all defaults filled in, the +// the DefaultTemplate corresponding to template:default with all defaults filled in, the // LimaHome location, a list of all supported VMTypes, and a map of GuestAgents for each architecture. func New(ctx context.Context) (*LimaInfo, error) { b, err := templatestore.Read(templatestore.Default) @@ -111,13 +111,10 @@ func New(ctx context.Context) (*LimaInfo, error) { } } - plugins, err := plugin.DiscoverPlugins() + info.Plugins, err = plugins.Discover() if err != nil { - logrus.WithError(err).Warn("Failed to discover plugins") // Don't fail the entire info command if plugin discovery fails. - info.Plugins = []plugin.Plugin{} - } else { - info.Plugins = plugins + logrus.WithError(err).Warn("Failed to discover plugins") } return info, nil diff --git a/pkg/limatmpl/abs.go b/pkg/limatmpl/abs.go index bfc7060e9e5..cd1ee5047ae 100644 --- a/pkg/limatmpl/abs.go +++ b/pkg/limatmpl/abs.go @@ -89,6 +89,9 @@ func basePath(locator string) (string, error) { // Single-letter schemes will be drive names on Windows, e.g. "c:/foo" if err == nil && len(u.Scheme) > 1 { // path.Dir("") returns ".", which must be removed for url.JoinPath() to do the right thing later + if u.Opaque != "" { + return u.Scheme + ":" + strings.TrimSuffix(path.Dir(u.Opaque), "."), nil + } return u.Scheme + "://" + strings.TrimSuffix(path.Dir(path.Join(u.Host, u.Path)), "."), nil } base, err := filepath.Abs(filepath.Dir(locator)) @@ -132,6 +135,10 @@ func absPath(locator, basePath string) (string, error) { return "", err } if len(u.Scheme) > 1 { + // Treat empty "template:" URL as opaque + if u.Opaque != "" || (u.Scheme == "template" && u.Host == "") { + return u.Scheme + ":" + path.Join(u.Opaque, locator), nil + } return u.JoinPath(locator).String(), nil } locator = filepath.Join(basePath, locator) diff --git a/pkg/limatmpl/abs_test.go b/pkg/limatmpl/abs_test.go index 5b78e9c155b..e033a0364de 100644 --- a/pkg/limatmpl/abs_test.go +++ b/pkg/limatmpl/abs_test.go @@ -23,67 +23,73 @@ type useAbsLocatorsTestCase struct { var useAbsLocatorsTestCases = []useAbsLocatorsTestCase{ { "Template without base or script file", - "template://foo", + "template:foo", `arch: aarch64`, `arch: aarch64`, }, { "Single string base template", + "template:foo", + `base: bar.yaml`, + `base: template:bar.yaml`, + }, + { + "Legacy template:// base template", "template://foo", `base: bar.yaml`, - `base: template://bar.yaml`, + `base: template:bar.yaml`, }, { "Flow style array of one base template", - "template://foo", + "template:foo", `base: [{url: bar.yaml, digest: deadbeef}]`, // not sure why the quotes around the URL were added; maybe because we don't copy the style from the source - `base: [{url: 'template://bar.yaml', digest: deadbeef}]`, + `base: [{url: 'template:bar.yaml', digest: deadbeef}]`, }, { "Flow style array of sequence of two base URLs", - "template://foo", + "template:foo", `base: [bar.yaml, baz.yaml]`, - `base: ['template://bar.yaml', 'template://baz.yaml']`, + `base: ['template:bar.yaml', 'template:baz.yaml']`, }, { "Flow style array of sequence of two base locator objects", - "template://foo", + "template:foo", `base: [{url: bar.yaml, digest: deadbeef}, {url: baz.yaml, digest: decafbad}]`, - `base: [{url: 'template://bar.yaml', digest: deadbeef}, {url: 'template://baz.yaml', digest: decafbad}]`, + `base: [{url: 'template:bar.yaml', digest: deadbeef}, {url: 'template:baz.yaml', digest: decafbad}]`, }, { "Block style array of one base template", - "template://foo", + "template:foo", ` base: - bar.yaml `, ` base: -- template://bar.yaml`, +- template:bar.yaml`, }, { "Block style of four base templates", - "template://foo", + "template:foo", ` base: - bar.yaml -- template://my +- template:my - https://example.com/my.yaml - baz.yaml `, ` base: -- template://bar.yaml -- template://my +- template:bar.yaml +- template:my - https://example.com/my.yaml -- template://baz.yaml +- template:baz.yaml `, }, { "Provisioning and probe scripts", - "template://experimental/foo", + "template:experimental/foo", ` provision: - mode: user @@ -101,15 +107,15 @@ probes: ` provision: - mode: user - file: template://experimental/userscript.sh + file: template:experimental/userscript.sh - mode: system file: - url: template://experimental/systemscript.sh + url: template:experimental/systemscript.sh digest: abc123 probes: -- file: template://experimental/probe.sh +- file: template:experimental/probe.sh - file: - url: template://experimental/probe.sh + url: template:experimental/probe.sh digest: digest `, }, @@ -153,15 +159,15 @@ func TestBasePath(t *testing.T) { }) t.Run("", func(t *testing.T) { - actual, err := basePath("template://foo") + actual, err := basePath("template:foo") assert.NilError(t, err) - assert.Equal(t, actual, "template://") + assert.Equal(t, actual, "template:") }) t.Run("", func(t *testing.T) { - actual, err := basePath("template://foo/bar") + actual, err := basePath("template:foo/bar") assert.NilError(t, err) - assert.Equal(t, actual, "template://foo") + assert.Equal(t, actual, "template:foo") }) t.Run("", func(t *testing.T) { @@ -216,9 +222,9 @@ func TestAbsPath(t *testing.T) { }) t.Run("", func(t *testing.T) { - actual, err := absPath("template://foo", volume+"/root") + actual, err := absPath("template:foo", volume+"/root") assert.NilError(t, err) - assert.Equal(t, actual, "template://foo") + assert.Equal(t, actual, "template:foo") }) t.Run("", func(t *testing.T) { @@ -272,15 +278,15 @@ func TestAbsPath(t *testing.T) { } t.Run("", func(t *testing.T) { - actual, err := absPath("foo", "template://") + actual, err := absPath("foo", "template:") assert.NilError(t, err) - assert.Equal(t, actual, "template://foo") + assert.Equal(t, actual, "template:foo") }) t.Run("", func(t *testing.T) { - actual, err := absPath("bar", "template://foo") + actual, err := absPath("bar", "template:foo") assert.NilError(t, err) - assert.Equal(t, actual, "template://foo/bar") + assert.Equal(t, actual, "template:foo/bar") }) t.Run("", func(t *testing.T) { diff --git a/pkg/limatmpl/embed.go b/pkg/limatmpl/embed.go index a58712247bf..a505af1d634 100644 --- a/pkg/limatmpl/embed.go +++ b/pkg/limatmpl/embed.go @@ -76,7 +76,7 @@ func (tmpl *Template) embedAllBases(ctx context.Context, embedAll, defaultBase b } isTemplate, _ := SeemsTemplateURL(baseLocator.URL) if isTemplate && !embedAll { - // Once we skip a template:// URL we can no longer embed any other base template + // Once we skip a template: URL we can no longer embed any other base template for i := 1; i < len(tmpl.Config.Base); i++ { isTemplate, _ = SeemsTemplateURL(tmpl.Config.Base[i].URL) if !isTemplate { @@ -84,7 +84,7 @@ func (tmpl *Template) embedAllBases(ctx context.Context, embedAll, defaultBase b } } break - // TODO should we track embedding of template:// URLs so we can warn if we embed a non-template:// URL afterwards? + // TODO should we track embedding of template: URLs so we can warn if we embed a non-template: URL afterwards? } if seen[baseLocator.URL] { diff --git a/pkg/limatmpl/embed_test.go b/pkg/limatmpl/embed_test.go index 54e4a2fedf2..a1d43b4f1e2 100644 --- a/pkg/limatmpl/embed_test.go +++ b/pkg/limatmpl/embed_test.go @@ -181,24 +181,24 @@ mounts: `mounts: [{location: loc1}, {location: loc1, mountPoint: loc2, writable: true, sshfs: {followSymlinks: true}}, {location: loc1, mountPoint: loc3, writable: true}]`, }, { - "template:// URLs are not embedded when embedAll is false", + "template: URLs are not embedded when embedAll is false", // also tests file.url format ``, ` -base: template://default +base: template:default provision: - file: - url: template://provision.sh + url: template:provision.sh probes: - file: - url: template://probe.sh + url: template:probe.sh `, ` -base: template://default +base: template:default provision: -- file: template://provision.sh +- file: template:provision.sh probes: -- file: template://probe.sh +- file: template:probe.sh `, }, { @@ -214,18 +214,18 @@ vmType: qemu `base template loop detected`, }, { - "ERROR All bases following template:// bases must be template:// URLs too when embedAll is false", + "ERROR All bases following template: bases must be template: URLs too when embedAll is false", ``, - `base: [template://default, base1.yaml]`, + `base: [template:default, base1.yaml]`, "after not embedding", }, { - "ERROR All bases following template:// bases must be template:// URLs too when embedAll is false", + "ERROR All bases following template: bases must be template: URLs too when embedAll is false", ``, ` base: [base1.yaml, base2.yaml] --- -base: template://default +base: template:default --- base: baseX.yaml`, "after not embedding", diff --git a/pkg/limatmpl/locator.go b/pkg/limatmpl/locator.go index 98e664af711..5627b087dfd 100644 --- a/pkg/limatmpl/locator.go +++ b/pkg/limatmpl/locator.go @@ -5,11 +5,13 @@ package limatmpl import ( "context" + "errors" "fmt" "io" "net/http" "net/url" "os" + "os/exec" "path" "path/filepath" "regexp" @@ -22,18 +24,23 @@ import ( "github.com/lima-vm/lima/v2/pkg/ioutilx" "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/limayaml" + "github.com/lima-vm/lima/v2/pkg/plugins" "github.com/lima-vm/lima/v2/pkg/templatestore" ) const yBytesLimit = 4 * 1024 * 1024 // 4MiB func Read(ctx context.Context, name, locator string) (*Template, error) { - var err error tmpl := &Template{ Name: name, Locator: locator, } + locator, err := TransformCustomURL(ctx, locator) + if err != nil { + return nil, err + } + if imageTemplate(tmpl, locator) { return tmpl, nil } @@ -241,7 +248,10 @@ func SeemsTemplateURL(arg string) (isTemplate bool, templateName string) { return false, "" } if u.Scheme == "template" { - return true, path.Join(u.Host, u.Path) + if u.Opaque == "" { + return true, path.Join(u.Host, u.Path) + } + return true, u.Opaque } return false, "" } @@ -285,3 +295,55 @@ func InstNameFromYAMLPath(yamlPath string) (string, error) { } return s, nil } + +func TransformCustomURL(ctx context.Context, locator string) (string, error) { + u, err := url.Parse(locator) + if err != nil || len(u.Scheme) <= 1 { + return locator, nil + } + + if u.Scheme == "template" { + if u.Opaque != "" { + return locator, nil + } + // Fix malformed "template:" URLs. + newLocator := "template:" + path.Join(u.Host, u.Path) + logrus.Warnf("Template locator %q should be written %q since Lima v2.0", locator, newLocator) + return newLocator, nil + } + + plugin, err := plugins.Find("url-" + u.Scheme) + if err != nil { + return "", err + } + if plugin == nil { + return locator, nil + } + + currentPath := os.Getenv("PATH") + defer os.Setenv("PATH", currentPath) + err = plugins.UpdatePath() + if err != nil { + return "", err + } + + cmd := exec.CommandContext(ctx, plugin.Path, strings.TrimPrefix(u.String(), u.Scheme+":")) + cmd.Env = os.Environ() + + stdout, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + stderrMsg := string(exitErr.Stderr) + if stderrMsg != "" { + return "", fmt.Errorf("command %q failed: %s", cmd.String(), strings.TrimSpace(stderrMsg)) + } + } + return "", fmt.Errorf("command %q failed: %w", cmd.String(), err) + } + newLocator := strings.TrimSpace(string(stdout)) + if newLocator != locator { + logrus.Debugf("Custom locator %q replaced with %q", locator, newLocator) + } + return newLocator, nil +} diff --git a/pkg/limatmpl/locator_test.go b/pkg/limatmpl/locator_test.go index b611cf3bc75..5b566200713 100644 --- a/pkg/limatmpl/locator_test.go +++ b/pkg/limatmpl/locator_test.go @@ -64,7 +64,7 @@ func TestInstNameFromImageURL(t *testing.T) { } func TestSeemsTemplateURL(t *testing.T) { - arg := "template://foo/bar" + arg := "template:foo/bar" t.Run(arg, func(t *testing.T) { is, name := limatmpl.SeemsTemplateURL(arg) assert.Equal(t, is, true) @@ -96,7 +96,7 @@ func TestSeemsHTTPURL(t *testing.T) { } notHTTPURLs := []string{ "file:///foo", - "template://foo", + "template:foo", "foo", } for _, arg := range notHTTPURLs { @@ -114,7 +114,7 @@ func TestSeemsFileURL(t *testing.T) { notFileURLs := []string{ "http://foo", "https://foo", - "template://foo", + "template:foo", "foo", } for _, arg := range notFileURLs { diff --git a/pkg/plugin/plugin.go b/pkg/plugins/plugins.go similarity index 77% rename from pkg/plugin/plugin.go rename to pkg/plugins/plugins.go index 81849db12b9..2f8823b3a9e 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugins/plugins.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright The Lima Authors // SPDX-License-Identifier: Apache-2.0 -package plugin +package plugins import ( "cmp" @@ -13,6 +13,7 @@ import ( "runtime" "slices" "strings" + "sync" "github.com/sirupsen/logrus" @@ -28,7 +29,7 @@ type Plugin struct { Description string `json:"description,omitempty"` } -func DiscoverPlugins() ([]Plugin, error) { +var Discover = sync.OnceValues(func() ([]Plugin, error) { var plugins []Plugin seen := make(map[string]bool) @@ -47,9 +48,9 @@ func DiscoverPlugins() ([]Plugin, error) { }) return plugins, nil -} +}) -func getPluginDirectories() []string { +var getPluginDirectories = sync.OnceValue(func() []string { dirs := usrlocalsharelima.SelfDirs() pathEnv := os.Getenv("PATH") @@ -65,7 +66,7 @@ func getPluginDirectories() []string { } return dirs -} +}) // isWindowsExecutableExt checks if the given extension is a valid Windows executable extension // according to PATHEXT environment variable. @@ -74,20 +75,9 @@ func isWindowsExecutableExt(ext string) bool { return false } - pathExt := os.Getenv("PATHEXT") - if pathExt == "" { - pathExt = defaultPathExt - } - + pathExt := cmp.Or(os.Getenv("PATHEXT"), defaultPathExt) extensions := strings.Split(strings.ToUpper(pathExt), ";") - extUpper := strings.ToUpper(ext) - - for _, validExt := range extensions { - if validExt == extUpper { - return true - } - } - return false + return slices.Contains(extensions, strings.ToUpper(ext)) } func isExecutable(path string) bool { @@ -104,18 +94,7 @@ func isExecutable(path string) bool { return info.Mode()&0o111 != 0 } - ext := strings.ToLower(filepath.Ext(path)) - pathExt := os.Getenv("PATHEXT") - if pathExt == "" { - pathExt = defaultPathExt - } - - for _, e := range strings.Split(strings.ToLower(pathExt), ";") { - if e == ext { - return true - } - } - return false + return isWindowsExecutableExt(filepath.Ext(path)) } func scanDirectory(dir string) []Plugin { @@ -168,46 +147,24 @@ func scanDirectory(dir string) []Plugin { return plugins } -func RunExternalPlugin(ctx context.Context, name string, args []string) { - if ctx == nil { - ctx = context.Background() - } - - if err := UpdatePathForPlugins(); err != nil { +func (plugin *Plugin) Run(ctx context.Context, args []string) { + if err := UpdatePath(); err != nil { logrus.Warnf("failed to update PATH environment: %v", err) // PATH update failure shouldn't prevent plugin execution } - plugins, err := DiscoverPlugins() - if err != nil { - logrus.Warnf("failed to discover plugins: %v", err) - return - } - - var execPath string - for _, plugin := range plugins { - if plugin.Name == name { - execPath = plugin.Path - break - } - } - - if execPath == "" { - return - } - - cmd := exec.CommandContext(ctx, execPath, args...) + cmd := exec.CommandContext(ctx, plugin.Path, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = os.Environ() - err = cmd.Run() + err := cmd.Run() osutil.HandleExitError(err) if err == nil { os.Exit(0) //nolint:revive // it's intentional to call os.Exit in this function } - logrus.Fatalf("external command %q failed: %v", execPath, err) + logrus.Fatalf("external command %q failed: %v", plugin.Path, err) } var descRegex = regexp.MustCompile(`(.*?)`) @@ -235,7 +192,22 @@ func extractDescFromScript(path string) string { return desc } -func UpdatePathForPlugins() error { +// Find locates a plugin by name and returns a pointer to a copy. +func Find(name string) (*Plugin, error) { + allPlugins, err := Discover() + if err != nil { + return nil, err + } + for _, plugin := range allPlugins { + if name == plugin.Name { + pluginCopy := plugin + return &pluginCopy, nil + } + } + return nil, nil +} + +func UpdatePath() error { pluginDirs := getPluginDirectories() newPath := strings.Join(pluginDirs, string(filepath.ListSeparator)) return os.Setenv("PATH", newPath)