Skip to content

Commit c0fccd5

Browse files
committed
Add customize command
The customize command can be used to customize a pre-built installer image (right now only ISO supported). Use single call to xorriso to map files onto the ISO. Write a grubenv to /boot/grub2/grubenv with extra_cmdline parameter used by grub_live.cfg. Also overwrite /LiveOS/setup.sh with file provided in --config and map the passed in --overlay over / Signed-off-by: Fredrik Lönnegren <[email protected]>
1 parent 640a35a commit c0fccd5

File tree

7 files changed

+376
-6
lines changed

7 files changed

+376
-6
lines changed

cmd/elemental/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ func main() {
3333
cmd.GlobalFlags(),
3434
cmd.Setup,
3535
cmd.NewBuildCommand(appName, action.Build),
36+
cmd.NewCustomizeCommand(appName, action.Customize),
3637
cmd.NewVersionCommand(appName))
3738

3839
if err := application.Run(os.Args); err != nil {

docs/customizing-installers.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Customizing installers
2+
3+
Elemental installer images can be customized using the `elemental3 customize` command.
4+
5+
The following command takes an input (./build/installer.iso) and generates a
6+
new installer2.iso in the output directory including a new config-script, an
7+
overlay and some extra kernel cmdline parameters:
8+
9+
```sh
10+
./build/elemental3 customize \
11+
--input ./build/installer.iso
12+
--output ./build
13+
--name installer2
14+
--cmdline="console=ttyS0"
15+
--config ./examples/elemental/install/config.sh
16+
--overlay tar:./build/overlays.tar.gz
17+
```

internal/cli/action/customize.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
Copyright © 2025 SUSE LLC
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package action
19+
20+
import (
21+
"fmt"
22+
"os/signal"
23+
"syscall"
24+
25+
"github.com/urfave/cli/v2"
26+
27+
"github.com/suse/elemental/v3/internal/cli/cmd"
28+
"github.com/suse/elemental/v3/pkg/deployment"
29+
"github.com/suse/elemental/v3/pkg/installermedia"
30+
"github.com/suse/elemental/v3/pkg/sys"
31+
)
32+
33+
func Customize(ctx *cli.Context) error {
34+
if ctx.App.Metadata == nil || ctx.App.Metadata["system"] == nil {
35+
return fmt.Errorf("error setting up initial configuration")
36+
}
37+
s := ctx.App.Metadata["system"].(*sys.System)
38+
args := &cmd.CustomizeArgs
39+
logger := s.Logger()
40+
41+
ctxCancel, cancelFunc := signal.NotifyContext(ctx.Context, syscall.SIGTERM, syscall.SIGINT)
42+
defer cancelFunc()
43+
44+
logger.Info("Customizing image")
45+
46+
media := installermedia.NewISO(ctxCancel, s)
47+
48+
err := digestCustomizeSetup(args, media)
49+
if err != nil {
50+
return fmt.Errorf("invalid customize setup: %w", err)
51+
}
52+
53+
err = media.Customize()
54+
if err != nil {
55+
return fmt.Errorf("failed customizing installer media: %w", err)
56+
}
57+
58+
s.Logger().Info("Customize complete")
59+
60+
return nil
61+
}
62+
63+
func digestCustomizeSetup(flags *cmd.CustomizeFlags, media *installermedia.ISO) error {
64+
if flags.Overlay != "" {
65+
src, err := deployment.NewSrcFromURI(flags.Overlay)
66+
if err != nil {
67+
return fmt.Errorf("invalid overlay data URI (%s) to add into the customization: %w", flags.Overlay, err)
68+
}
69+
media.OverlayTree = src
70+
}
71+
72+
if flags.ConfigScript != "" {
73+
media.CfgScript = flags.ConfigScript
74+
}
75+
76+
if flags.Name != "" {
77+
media.Name = flags.Name
78+
}
79+
80+
if flags.OutputDir != "" {
81+
media.OutputDir = flags.OutputDir
82+
}
83+
84+
if flags.Label != "" {
85+
media.Label = flags.Label
86+
}
87+
88+
if flags.KernelCmdline != "" {
89+
media.KernelCmdLine = flags.KernelCmdline
90+
}
91+
92+
if flags.InputFile != "" {
93+
media.InputFile = flags.InputFile
94+
}
95+
return nil
96+
}

internal/cli/cmd/customize.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
Copyright © 2025 SUSE LLC
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package cmd
19+
20+
import (
21+
"fmt"
22+
23+
"github.com/urfave/cli/v2"
24+
)
25+
26+
type CustomizeFlags struct {
27+
InputFile string
28+
OutputDir string
29+
Name string
30+
Target string
31+
Description string
32+
ConfigScript string
33+
Overlay string
34+
Label string
35+
KernelCmdline string
36+
}
37+
38+
var CustomizeArgs CustomizeFlags
39+
40+
func NewCustomizeCommand(appName string, action func(*cli.Context) error) *cli.Command {
41+
return &cli.Command{
42+
Name: "customize",
43+
Usage: "Customize installer artifact",
44+
UsageText: fmt.Sprintf("%s customize", appName),
45+
Action: action,
46+
Flags: []cli.Flag{
47+
&cli.StringFlag{
48+
Name: "input",
49+
Usage: "Path to local image to customize",
50+
Destination: &CustomizeArgs.InputFile,
51+
},
52+
&cli.StringFlag{
53+
Name: "config",
54+
Usage: "Path to installer media config script",
55+
Destination: &CustomizeArgs.ConfigScript,
56+
},
57+
&cli.StringFlag{
58+
Name: "output",
59+
Usage: "Location for the temporary builtime files and the resulting image",
60+
Destination: &CustomizeArgs.OutputDir,
61+
Required: true,
62+
},
63+
&cli.StringFlag{
64+
Name: "name",
65+
Usage: "Name of the resulting image file",
66+
Destination: &CustomizeArgs.Name,
67+
},
68+
&cli.StringFlag{
69+
Name: "overlay",
70+
Usage: "URI of the data to include in installer media",
71+
Destination: &CustomizeArgs.Overlay,
72+
},
73+
&cli.StringFlag{
74+
Name: "cmdline",
75+
Usage: "Kernel command line to boot the installer media",
76+
Destination: &CustomizeArgs.KernelCmdline,
77+
},
78+
},
79+
}
80+
}

pkg/bootloader/grubtemplates/grub_live.cfg

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ set default=0
22
set timeout=5
33
set timeout_style=menu
44

5+
load_env --file (${root})/boot/grub2/grubenv
6+
57
menuentry "{{.DisplayName}}" --class os --unrestricted {
68
echo Loading kernel...
7-
linux ($root){{.Linux}} cdroot {{.CmdLine}}
9+
linux ($root){{.Linux}} cdroot {{.CmdLine}} ${extra_cmdline}
810
echo Loading initrd...
911
initrd ($root){{.Initrd}}
10-
}
12+
}

pkg/installermedia/iso.go

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const (
4545
isoBootCatalog = "boot.catalog"
4646
isoMountPoint = "/run/initramfs/live"
4747
installerCfg = "setup.sh"
48+
xorriso = "xorriso"
4849

4950
SquashfsPath = isoMountPoint + "/" + liveDir + "/" + squashfsImg
5051
)
@@ -59,6 +60,7 @@ type ISO struct {
5960
OutputDir string
6061
Label string
6162
KernelCmdLine string
63+
InputFile string
6264

6365
s *sys.System
6466
ctx context.Context
@@ -168,6 +170,61 @@ func (i ISO) Build(d *deployment.Deployment) (err error) {
168170
return nil
169171
}
170172

173+
// Customize repacks an existing installer with more artifacts.
174+
func (i *ISO) Customize() (err error) {
175+
err = i.sanitizeCustomize()
176+
if err != nil {
177+
return fmt.Errorf("cannot proceed with customize due to inconsistent setup: %w", err)
178+
}
179+
180+
cleanup := cleanstack.NewCleanStack()
181+
defer func() { err = cleanup.Cleanup(err) }()
182+
183+
tempDir, err := vfs.TempDir(i.s.FS(), "/tmp", "elemental-installer")
184+
if err != nil {
185+
return fmt.Errorf("could note create working directory for installer ISO build: %w", err)
186+
}
187+
cleanup.Push(func() error { return i.s.FS().RemoveAll(tempDir) })
188+
189+
ovDir := filepath.Join(tempDir, "overlay")
190+
err = vfs.MkdirAll(i.s.FS(), ovDir, vfs.FilePerm)
191+
if err != nil {
192+
return fmt.Errorf("could note create working directory for installer ISO build: %w", err)
193+
}
194+
195+
grubEnvPath := filepath.Join(tempDir, "grubenv")
196+
err = i.writeGrubEnv(grubEnvPath, map[string]string{"extra_cmdline": i.KernelCmdLine})
197+
if err != nil {
198+
return fmt.Errorf("error writing %s: %s", grubEnvPath, err.Error())
199+
}
200+
201+
m := map[string]string{
202+
grubEnvPath: "/boot/grub2/grubenv",
203+
}
204+
205+
if i.CfgScript != "" {
206+
m[i.CfgScript] = filepath.Join("/LiveOS", installerCfg)
207+
}
208+
209+
if i.OverlayTree != nil {
210+
unpacker, err := unpack.NewUnpacker(
211+
i.s, i.OverlayTree,
212+
append(i.unpackOpts, unpack.WithRsyncFlags(rsync.OverlayTreeSyncFlags()...))...,
213+
)
214+
if err != nil {
215+
return fmt.Errorf("could not initate overlay unpacker: %w", err)
216+
}
217+
_, err = unpacker.Unpack(i.ctx, ovDir)
218+
if err != nil {
219+
return fmt.Errorf("overlay unpack failed: %w", err)
220+
}
221+
222+
m[ovDir] = "/"
223+
}
224+
225+
return i.mapFiles(i.InputFile, i.outputFile, m)
226+
}
227+
171228
// sanitize checks the current public attributes of the ISO object
172229
// and checks if they are good enough to proceed with an ISO build.
173230
func (i *ISO) sanitize() error {
@@ -199,6 +256,65 @@ func (i *ISO) sanitize() error {
199256
return nil
200257
}
201258

259+
// sanitizeCustomize checks the current public attributes of the ISO object
260+
// and checks if they are good enough to proceed with an ISO build.
261+
func (i *ISO) sanitizeCustomize() error {
262+
if i.Label == "" {
263+
return fmt.Errorf("undefined label for the installer filesystem")
264+
}
265+
266+
if i.OutputDir == "" {
267+
return fmt.Errorf("undefined output directory")
268+
}
269+
270+
if i.Name == "" {
271+
return fmt.Errorf("undefined name of the installer media")
272+
}
273+
274+
if ok, _ := vfs.Exists(i.s.FS(), i.InputFile); !ok {
275+
return fmt.Errorf("target input file %s does not exist", i.InputFile)
276+
}
277+
278+
i.outputFile = filepath.Join(i.OutputDir, fmt.Sprintf("%s.iso", i.Name))
279+
if ok, _ := vfs.Exists(i.s.FS(), i.outputFile); ok {
280+
return fmt.Errorf("target output file %s is an already existing file", i.outputFile)
281+
}
282+
283+
return nil
284+
}
285+
286+
func (i ISO) mapFiles(inputFile, outputFile string, fileMap map[string]string) error {
287+
args := []string{"-indev", inputFile, "-outdev", outputFile, "-boot_image", "any", "replay"}
288+
289+
for f, m := range fileMap {
290+
args = append(args, "-map", f, m)
291+
}
292+
293+
_, err := i.s.Runner().RunContext(i.ctx, xorriso, args...)
294+
if err != nil {
295+
return fmt.Errorf("failed creating the installer ISO image: %w", err)
296+
}
297+
298+
return nil
299+
}
300+
301+
func (i ISO) writeGrubEnv(file string, vars map[string]string) error {
302+
arr := make([]string, len(vars)+2)
303+
304+
arr[0] = file
305+
arr[1] = "set"
306+
307+
j := 2
308+
for k, v := range vars {
309+
arr[j] = fmt.Sprintf("%s=%s", k, v)
310+
311+
j++
312+
}
313+
314+
_, err := i.s.Runner().Run("grub2-editenv", arr...)
315+
return err
316+
}
317+
202318
// prepareRoot arranges the root directory tree that will be used to build the ISO's
203319
// squashfs image. It essentially extracts OS OCI images to the given location.
204320
func (i ISO) prepareRoot(rootDir string) error {
@@ -225,7 +341,7 @@ func (i ISO) prepareRoot(rootDir string) error {
225341

226342
err = selinux.Relabel(i.ctx, i.s, rootDir)
227343
if err != nil {
228-
return fmt.Errorf("SELinux labelling failed: %w", err)
344+
i.s.Logger().Warn("Error selinux relabelling: %s", err.Error())
229345
}
230346
return nil
231347
}
@@ -367,21 +483,20 @@ func (i ISO) addInstallationAssets(targetDir string, d *deployment.Deployment) e
367483

368484
// burnISO creates the ISO image from the prepared data
369485
func (i ISO) burnISO(isoDir, output, efiImg string) error {
370-
cmd := "xorriso"
371486
args := []string{
372487
"-volid", "LIVE", "-padding", "0",
373488
"-outdev", output, "-map", isoDir, "/", "-chmod", "0755", "--",
374489
}
375490
args = append(args, xorrisoBootloaderArgs(efiImg)...)
376491

377-
_, err := i.s.Runner().RunContext(i.ctx, cmd, args...)
492+
_, err := i.s.Runner().RunContext(i.ctx, xorriso, args...)
378493
if err != nil {
379494
return fmt.Errorf("failed creating the installer ISO image: %w", err)
380495
}
381496

382497
checksum, err := calcFileChecksum(i.s.FS(), output)
383498
if err != nil {
384-
return fmt.Errorf("could not comput ISO's checksum: %w", err)
499+
return fmt.Errorf("could not compute ISO's checksum: %w", err)
385500
}
386501

387502
checksumFile := fmt.Sprintf("%s.sha256", output)

0 commit comments

Comments
 (0)