package limatmpl
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/sirupsen/logrus"
"github.com/lima-vm/lima/v2/pkg/identifiers"
"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
func Read(ctx context.Context, name, locator string) (*Template, 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
}
isTemplateURL, templateName := SeemsTemplateURL(locator)
switch {
case isTemplateURL:
logrus.Debugf("interpreting argument %q as a template name %q", locator, templateName)
if tmpl.Name == "" {
tmpl.Name, err = InstNameFromYAMLPath(templateName)
if err != nil {
return nil, err
}
}
tmpl.Bytes, err = templatestore.Read(templateName)
if err != nil {
return nil, err
}
case SeemsHTTPURL(locator):
if tmpl.Name == "" {
tmpl.Name, err = InstNameFromURL(locator)
if err != nil {
return nil, err
}
}
logrus.Debugf("interpreting argument %q as a http url for instance %q", locator, tmpl.Name)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, locator, http.NoBody)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
tmpl.Bytes, err = ioutilx.ReadAtMaximum(resp.Body, yBytesLimit)
if err != nil {
return nil, err
}
case SeemsFileURL(locator):
if tmpl.Name == "" {
tmpl.Name, err = InstNameFromURL(locator)
if err != nil {
return nil, err
}
}
logrus.Debugf("interpreting argument %q as a file URL for instance %q", locator, tmpl.Name)
filePath := strings.TrimPrefix(locator, "file://")
if !filepath.IsAbs(filePath) {
return nil, fmt.Errorf("file URL %q is not an absolute path", locator)
}
r, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer r.Close()
tmpl.Bytes, err = ioutilx.ReadAtMaximum(r, yBytesLimit)
if err != nil {
return nil, err
}
case locator == "-":
tmpl.Bytes, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, fmt.Errorf("unexpected error reading stdin: %w", err)
}
default:
if tmpl.Name == "" {
tmpl.Name, err = InstNameFromYAMLPath(locator)
if err != nil {
return nil, err
}
}
logrus.Debugf("interpreting argument %q as a file path for instance %q", locator, tmpl.Name)
if locator, err = filepath.Abs(locator); err != nil {
return nil, err
}
tmpl.Locator = locator
r, err := os.Open(locator)
if err != nil {
return nil, err
}
defer r.Close()
tmpl.Bytes, err = ioutilx.ReadAtMaximum(r, yBytesLimit)
if err != nil {
return nil, err
}
}
return tmpl, nil
}
var imageURLRegex = regexp.MustCompile(`\.(img|qcow2|raw|iso)(\.(gz|xz|bz2|zstd))?$`)
var archKeywords = map[string]limatype.Arch{
"aarch64": limatype.AARCH64,
"amd64": limatype.X8664,
"arm64": limatype.AARCH64,
"armhf": limatype.ARMV7L,
"armv7l": limatype.ARMV7L,
"ppc64el": limatype.PPC64LE,
"ppc64le": limatype.PPC64LE,
"riscv64": limatype.RISCV64,
"s390x": limatype.S390X,
"x86_64": limatype.X8664,
}
var genericTags = []string{
"base",
"cloud",
"cloudimg",
"cloudinit",
"daily",
"default",
"generic",
"genericcloud",
"kvm",
"latest",
"linux",
"minimal",
"openstack",
"server",
"std",
"stream",
"uefi",
"vm",
}
func imageTemplate(tmpl *Template, locator string) bool {
if !imageURLRegex.MatchString(locator) {
return false
}
var imageArch limatype.Arch
for keyword, arch := range archKeywords {
pattern := fmt.Sprintf(`\b%s\b`, keyword)
if regexp.MustCompile(pattern).MatchString(locator) {
imageArch = arch
break
}
}
if imageArch == "" {
imageArch = limatype.NewArch(runtime.GOARCH)
logrus.Warnf("cannot determine image arch from URL %q; assuming %q", locator, imageArch)
}
template := `arch: %q
images:
- location: %q
arch: %q
`
tmpl.Bytes = fmt.Appendf(nil, template, imageArch, locator, imageArch)
tmpl.Name = InstNameFromImageURL(locator, imageArch)
return true
}
func InstNameFromImageURL(locator, imageArch string) string {
name := strings.ToLower(filepath.Base(path.Base(locator)))
name = imageURLRegex.ReplaceAllString(name, "")
name = strings.TrimPrefix(name, "nocloud_")
for _, tag := range genericTags {
re := regexp.MustCompile(fmt.Sprintf(`[-_.]%s\b`, tag))
name = re.ReplaceAllString(name, "")
}
if limayaml.IsNativeArch(imageArch) {
re := regexp.MustCompile(fmt.Sprintf(`[-_.]%s\b`, imageArch))
name = re.ReplaceAllString(name, "")
}
name = regexp.MustCompile(`[-_.]20\d{6}([-_.]\d+)?\b`).ReplaceAllString(name, "")
name = regexp.MustCompile(`^arch\b`).ReplaceAllString(name, "archlinux")
re := regexp.MustCompile(`-(\d+)-(\d+)\.`)
name = re.ReplaceAllStringFunc(name, func(match string) string {
submatch := re.FindStringSubmatch(match)
if submatch[1] == submatch[2] {
return "-" + submatch[1] + "."
}
return match
})
return name
}
func SeemsTemplateURL(arg string) (isTemplate bool, templateName string) {
u, err := url.Parse(arg)
if err != nil {
return false, ""
}
if u.Scheme == "template" {
if u.Opaque == "" {
return true, path.Join(u.Host, u.Path)
}
return true, u.Opaque
}
return false, ""
}
func SeemsHTTPURL(arg string) bool {
u, err := url.Parse(arg)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
return true
}
func SeemsFileURL(arg string) bool {
u, err := url.Parse(arg)
if err != nil {
return false
}
return u.Scheme == "file"
}
func InstNameFromURL(urlStr string) (string, error) {
u, err := url.Parse(urlStr)
if err != nil {
return "", err
}
return InstNameFromYAMLPath(path.Base(u.Path))
}
func InstNameFromYAMLPath(yamlPath string) (string, error) {
s := strings.ToLower(filepath.Base(yamlPath))
s = strings.TrimSuffix(strings.TrimSuffix(s, ".yml"), ".yaml")
if err := identifiers.Validate(s); err != nil {
return "", fmt.Errorf("filename %q is invalid: %w", yamlPath, err)
}
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
}
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
}
if u.Scheme == "github" {
return transformGitHubURL(ctx, u.Opaque)
}
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)
}
return strings.TrimSpace(string(stdout)), nil
}
func TransformCustomURL(ctx context.Context, locator string) (string, error) {
seen := make(map[string]bool)
origLocator := locator
githubSchemeDetected := false
for !seen[locator] {
seen[locator] = true
if strings.HasPrefix(locator, "github:") {
githubSchemeDetected = true
}
newLocator, err := transformCustomURL(ctx, locator)
if err != nil {
return "", err
}
if newLocator == locator {
if githubSchemeDetected {
logrus.Warn("The github: scheme is still EXPERIMENTAL")
}
return newLocator, nil
}
logrus.Debugf("Locator %q replaced with %q", locator, newLocator)
locator = newLocator
}
return "", fmt.Errorf("custom locator %q has a redirect loop", origLocator)
}