Skip to content

Commit e9b3042

Browse files
committed
feature(cli): allow multiple template sources on command line
Enable passing multiple template files/URLs to `limactl create` and `limactl start`. Example usage: limactl create template:docker my-overrides.yaml limactl create https://example.com/base.yaml secrets.yaml This is equivalent to using the `base:` property in YAML: base: [https://example.com/template.yaml, $PWD/secrets.yaml] Fixes #3404 Signed-off-by: Jonas Irgens Kylling <jkylling@gmail.com>
1 parent 4970639 commit e9b3042

3 files changed

Lines changed: 275 additions & 5 deletions

File tree

cmd/limactl/start.go

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/lima-vm/lima/v2/pkg/limatype/dirnames"
2727
"github.com/lima-vm/lima/v2/pkg/limatype/filenames"
2828
"github.com/lima-vm/lima/v2/pkg/limayaml"
29+
"github.com/lima-vm/lima/v2/pkg/localpathutil"
2930
"github.com/lima-vm/lima/v2/pkg/networks/reconcile"
3031
"github.com/lima-vm/lima/v2/pkg/registry"
3132
"github.com/lima-vm/lima/v2/pkg/store"
@@ -44,7 +45,7 @@ func registerCreateFlags(cmd *cobra.Command, commentPrefix string) {
4445

4546
func newCreateCommand() *cobra.Command {
4647
createCommand := &cobra.Command{
47-
Use: "create FILE.yaml|URL",
48+
Use: "create [FILE.yaml|URL...]",
4849
Example: `
4950
To create an instance "default" from the default Ubuntu template:
5051
$ limactl create
@@ -69,9 +70,15 @@ func newCreateCommand() *cobra.Command {
6970
7071
To create an instance "local" from a template passed to stdin (--name parameter is required):
7172
$ cat template.yaml | limactl create --name=local -
73+
74+
To create an instance from a template with local overrides:
75+
$ limactl create template:docker my-overrides.yaml
76+
77+
To create an instance from multiple templates (merged in order):
78+
$ limactl create https://example.com/base.yaml secrets.yaml
7279
`,
7380
Short: "Create an instance of Lima",
74-
Args: WrapArgsError(cobra.MaximumNArgs(1)),
81+
Args: WrapArgsError(cobra.ArbitraryArgs),
7582
ValidArgsFunction: createBashComplete,
7683
RunE: createAction,
7784
GroupID: basicCommand,
@@ -82,16 +89,19 @@ func newCreateCommand() *cobra.Command {
8289

8390
func newStartCommand() *cobra.Command {
8491
startCommand := &cobra.Command{
85-
Use: "start NAME|FILE.yaml|URL",
92+
Use: "start [NAME|FILE.yaml|URL...]",
8693
Example: `
8794
To create an instance "default" (if not created yet) from the default Ubuntu template, and start it:
8895
$ limactl start
8996
9097
To create an instance "default" from a template "docker", and start it:
9198
$ limactl start --name=default template:docker
99+
100+
To create an instance from a template with local overrides, and start it:
101+
$ limactl start template:docker my-overrides.yaml
92102
`,
93103
Short: "Start an instance of Lima",
94-
Args: WrapArgsError(cobra.MaximumNArgs(1)),
104+
Args: WrapArgsError(cobra.ArbitraryArgs),
95105
ValidArgsFunction: startBashComplete,
96106
RunE: startAction,
97107
GroupID: basicCommand,
@@ -232,6 +242,7 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (*
232242
return nil, err
233243
}
234244
}
245+
235246
if isTemplateURL, templateName := limatmpl.SeemsTemplateURL(arg); isTemplateURL {
236247
switch templateName {
237248
case "experimental/vz":
@@ -281,6 +292,9 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (*
281292
if createOnly {
282293
return nil, fmt.Errorf("instance %q already exists", tmpl.Name)
283294
}
295+
if len(args) > 1 {
296+
return nil, fmt.Errorf("cannot specify additional templates when starting an existing instance %q", tmpl.Name)
297+
}
284298
logrus.Infof("Using the existing instance %q", tmpl.Name)
285299
yqExprs, err := editflags.YQExpressions(flags, false)
286300
if err != nil {
@@ -308,7 +322,7 @@ func loadOrCreateInstance(cmd *cobra.Command, args []string, createOnly bool) (*
308322
return nil, err
309323
}
310324
} else {
311-
tmpl, err = limatmpl.Read(cmd.Context(), name, arg)
325+
tmpl, err = loadMultipleTemplates(ctx, name, args)
312326
if err != nil {
313327
return nil, err
314328
}
@@ -615,6 +629,60 @@ func startAction(cmd *cobra.Command, args []string) error {
615629
return instance.Start(ctx, inst, launchHostAgentForeground, progress)
616630
}
617631

632+
// loadMultipleTemplates creates a template from multiple CLI arguments.
633+
// All arguments are treated as base templates and merged in order.
634+
// Relative and tilde paths are expanded to absolute paths.
635+
func loadMultipleTemplates(_ context.Context, name string, args []string) (*limatmpl.Template, error) {
636+
bases := make(limatype.BaseTemplates, 0, len(args))
637+
for _, a := range args {
638+
absLocator := a
639+
// Expand relative and tilde paths to absolute
640+
// "-" (stdin), URLs, and template: locators are kept as-is
641+
if a != "-" && !limatmpl.SeemsHTTPURL(a) && !limatmpl.SeemsFileURL(a) {
642+
if isTemplate, _ := limatmpl.SeemsTemplateURL(a); !isTemplate {
643+
var err error
644+
absLocator, err = localpathutil.Expand(a)
645+
if err != nil {
646+
return nil, fmt.Errorf("failed to expand path %q: %w", a, err)
647+
}
648+
}
649+
}
650+
bases = append(bases, limatype.LocatorWithDigest{URL: absLocator})
651+
}
652+
653+
// Create a minimal config with just the base templates
654+
config := &limatype.LimaYAML{Base: bases}
655+
bytes, err := limayaml.Marshal(config, false)
656+
if err != nil {
657+
return nil, fmt.Errorf("failed to marshal template: %w", err)
658+
}
659+
660+
cwd, err := os.Getwd()
661+
if err != nil {
662+
return nil, fmt.Errorf("failed to get working directory: %w", err)
663+
}
664+
665+
tmpl := &limatmpl.Template{
666+
Bytes: bytes,
667+
Name: name,
668+
Locator: cwd,
669+
}
670+
671+
// Derive instance name from first template if not specified
672+
if tmpl.Name == "" {
673+
tmpl.Name, err = limatmpl.InstNameFromURL(args[0])
674+
if err != nil {
675+
// fallback to InstNameFromYAMLPath if URL parsing fails
676+
tmpl.Name, err = limatmpl.InstNameFromYAMLPath(args[0])
677+
if err != nil {
678+
return nil, fmt.Errorf("cannot derive instance name from %q: %w", args[0], err)
679+
}
680+
}
681+
}
682+
683+
return tmpl, nil
684+
}
685+
618686
func createBashComplete(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
619687
return bashCompleteTemplateNames(cmd, toComplete)
620688
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# SPDX-FileCopyrightText: Copyright The Lima Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
load "../helpers/load"
5+
6+
NAME=multi-template
7+
8+
local_setup_file() {
9+
limactl delete --force "$NAME" || :
10+
}
11+
12+
local_teardown_file() {
13+
limactl delete --force "$NAME" || :
14+
}
15+
16+
local_teardown() {
17+
limactl delete --force "$NAME" || :
18+
}
19+
20+
@test 'create with multiple templates merges values' {
21+
# Create base template file (uses /etc/profile as dummy image like create_dummy_instance)
22+
cat > "${BATS_TEST_TMPDIR}/base.yaml" <<'EOF'
23+
images:
24+
- location: /etc/profile
25+
cpus: 3
26+
EOF
27+
28+
# Create override file with additional settings
29+
cat > "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
30+
memory: 5GiB
31+
disk: 37GiB
32+
EOF
33+
34+
run -0 limactl create --name "$NAME" "${BATS_TEST_TMPDIR}/base.yaml" "${BATS_TEST_TMPDIR}/override.yaml"
35+
36+
# Verify the base values were used (cpus should be 3 from first template)
37+
run -0 limactl list --format '{{.CPUs}}' "$NAME"
38+
assert_output "3"
39+
40+
# Verify the override values were merged (memory should be from second template)
41+
# 5GiB = 5368709120 bytes
42+
run -0 limactl list --format '{{.Memory}}' "$NAME"
43+
assert_output "5368709120"
44+
}
45+
46+
@test 'first template values take precedence for scalars' {
47+
cat > "${BATS_TEST_TMPDIR}/base.yaml" <<'EOF'
48+
images:
49+
- location: /etc/profile
50+
cpus: 3
51+
memory: 3GiB
52+
EOF
53+
54+
cat > "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
55+
cpus: 7
56+
memory: 7GiB
57+
EOF
58+
59+
run -0 limactl create --name "$NAME" "${BATS_TEST_TMPDIR}/base.yaml" "${BATS_TEST_TMPDIR}/override.yaml"
60+
61+
# cpus from first template should win
62+
run -0 limactl list --format '{{.CPUs}}' "$NAME"
63+
assert_output "3"
64+
65+
# memory from first template should win
66+
# 3GiB = 3221225472 bytes
67+
run -0 limactl list --format '{{.Memory}}' "$NAME"
68+
assert_output "3221225472"
69+
}
70+
71+
@test 'relative paths resolve from current directory' {
72+
mkdir -p "${BATS_TEST_TMPDIR}/testdir"
73+
74+
cat > "${BATS_TEST_TMPDIR}/testdir/base.yaml" <<'EOF'
75+
images:
76+
- location: /etc/profile
77+
cpus: 5
78+
EOF
79+
80+
cat > "${BATS_TEST_TMPDIR}/testdir/config.yaml" <<'EOF'
81+
memory: 7GiB
82+
EOF
83+
84+
cd "${BATS_TEST_TMPDIR}/testdir"
85+
run -0 limactl create --name "$NAME" base.yaml config.yaml
86+
87+
run -0 limactl list --format '{{.CPUs}}' "$NAME"
88+
assert_output "5"
89+
90+
# 7GiB = 7516192768 bytes
91+
run -0 limactl list --format '{{.Memory}}' "$NAME"
92+
assert_output "7516192768"
93+
}
94+
95+
@test 'multiple args with existing instance produces error' {
96+
# Create an instance first using stdin
97+
limactl create --name "$NAME" - <<'EOF'
98+
images:
99+
- location: /etc/profile
100+
EOF
101+
102+
cat > "${BATS_TEST_TMPDIR}/extra.yaml" <<'EOF'
103+
cpus: 3
104+
EOF
105+
106+
# Attempting to start the existing instance with additional templates should error
107+
run ! limactl start "$NAME" "${BATS_TEST_TMPDIR}/extra.yaml"
108+
assert_output --partial "cannot specify additional templates"
109+
}
110+
111+
@test 'instance name derived from first template filename' {
112+
# Clean up any stale myinstance from previous runs
113+
limactl delete --force myinstance || :
114+
115+
cat > "${BATS_TEST_TMPDIR}/myinstance.yaml" <<'EOF'
116+
images:
117+
- location: /etc/profile
118+
EOF
119+
120+
cat > "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
121+
cpus: 3
122+
EOF
123+
124+
run -0 limactl create "${BATS_TEST_TMPDIR}/myinstance.yaml" "${BATS_TEST_TMPDIR}/override.yaml"
125+
126+
# Instance should be named after first template
127+
run -0 limactl list --format '{{.Name}}'
128+
assert_output --partial "myinstance"
129+
130+
limactl delete --force myinstance || :
131+
}
132+
133+
@test 'explicit --name flag overrides derived name' {
134+
cat > "${BATS_TEST_TMPDIR}/base.yaml" <<'EOF'
135+
images:
136+
- location: /etc/profile
137+
EOF
138+
139+
cat > "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
140+
cpus: 5
141+
EOF
142+
143+
run -0 limactl create --name "$NAME" "${BATS_TEST_TMPDIR}/base.yaml" "${BATS_TEST_TMPDIR}/override.yaml"
144+
145+
run -0 limactl list --format '{{.Name}}' "$NAME"
146+
assert_output "$NAME"
147+
}
148+
149+
@test 'three templates merge correctly' {
150+
cat > "${BATS_TEST_TMPDIR}/t1.yaml" <<'EOF'
151+
images:
152+
- location: /etc/profile
153+
cpus: 3
154+
EOF
155+
156+
cat > "${BATS_TEST_TMPDIR}/t2.yaml" <<'EOF'
157+
memory: 5GiB
158+
EOF
159+
160+
cat > "${BATS_TEST_TMPDIR}/t3.yaml" <<'EOF'
161+
disk: 73GiB
162+
EOF
163+
164+
run -0 limactl create --name "$NAME" "${BATS_TEST_TMPDIR}/t1.yaml" "${BATS_TEST_TMPDIR}/t2.yaml" "${BATS_TEST_TMPDIR}/t3.yaml"
165+
166+
run -0 limactl list --format '{{.CPUs}}' "$NAME"
167+
assert_output "3"
168+
169+
# 5GiB = 5368709120 bytes
170+
run -0 limactl list --format '{{.Memory}}' "$NAME"
171+
assert_output "5368709120"
172+
173+
# 73GiB = 78383153152 bytes
174+
run -0 limactl list --format '{{.Disk}}' "$NAME"
175+
assert_output "78383153152"
176+
}
177+
178+
@test 'stdin can be used as one of the templates' {
179+
cat > "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
180+
memory: 5GiB
181+
EOF
182+
183+
# Use stdin as the first template, with a file as the second
184+
run -0 limactl create --name "$NAME" - "${BATS_TEST_TMPDIR}/override.yaml" <<'EOF'
185+
images:
186+
- location: /etc/profile
187+
cpus: 7
188+
EOF
189+
190+
# cpus from stdin template should be used
191+
run -0 limactl list --format '{{.CPUs}}' "$NAME"
192+
assert_output "7"
193+
194+
# memory from override file should be merged
195+
# 5GiB = 5368709120 bytes
196+
run -0 limactl list --format '{{.Memory}}' "$NAME"
197+
assert_output "5368709120"
198+
}

pkg/limatmpl/abs.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ func absPath(locator, basePath string) (string, error) {
106106
if locator == "" {
107107
return "", errors.New("locator is empty")
108108
}
109+
// "-" means stdin, return as-is
110+
if locator == "-" {
111+
return locator, nil
112+
}
109113
u, err := url.Parse(locator)
110114
if err == nil && len(u.Scheme) > 1 {
111115
return locator, nil

0 commit comments

Comments
 (0)