From f398e8613fedc26c7858faba446fae6b4cf6d1c1 Mon Sep 17 00:00:00 2001 From: German Maglione Date: Fri, 5 Jul 2024 14:13:13 +0200 Subject: [PATCH] Add convert command using bib Add convert command that uses bootc image builder (bib) to create disk images Signed-off-by: German Maglione --- cmd/convert.go | 98 ++++++++++++++++++++ pkg/bib/build.go | 229 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 cmd/convert.go create mode 100644 pkg/bib/build.go diff --git a/cmd/convert.go b/cmd/convert.go new file mode 100644 index 0000000..5d06e69 --- /dev/null +++ b/cmd/convert.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/containers/podman-bootc/pkg/bib" + "github.com/containers/podman-bootc/pkg/user" + "github.com/containers/podman-bootc/pkg/utils" + + "github.com/containers/podman/v5/pkg/bindings" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + convertCmd = &cobra.Command{ + Use: "convert ", + Short: "Creates a disk image using bootc-image-builder", + Long: "Creates a disk image using bootc-image-builder", + Args: cobra.ExactArgs(1), + RunE: doConvert, + } + options bib.BuildOption + amiCfg bib.AmiConfig + quiet bool +) + +func init() { + RootCmd.AddCommand(convertCmd) + convertCmd.Flags().BoolVar(&quiet, "quiet", false, "Suppress output from disk image creation") + convertCmd.Flags().StringVar(&options.Config, "config", "", "Image builder config file") + convertCmd.Flags().StringVar(&options.Output, "output", ".", "output directory (default \".\")") + // Corresponds to bib '--rootfs', we don't use 'rootfs' so to not be confused with podman's 'rootfs' options + // Note: we cannot provide a default value for the filesystem, since this options will overwrite the one defined in + // the image + convertCmd.Flags().StringVar(&options.Filesystem, "filesystem", "", "Overrides the root filesystem (e.g. xfs, btrfs, ext4)") + // Corresponds to bib '--type', using '--format' to be consistent with podman + convertCmd.Flags().StringVar(&options.Format, "format", "qcow2", "Disk image type (ami, anaconda-iso, iso, qcow2, raw, vmdk) [default: qcow2]") + // Corresponds to bib '--target-arch', using '--arch' to be consistent with podman + convertCmd.Flags().StringVar(&options.Arch, "arch", "", "Build for the given target architecture (experimental)") + + // Extra AMi flags + convertCmd.Flags().StringVar(&amiCfg.Name, "aws-ami-name", "", "Name for the AMI in AWS (only for format=ami)") + convertCmd.Flags().StringVar(&amiCfg.Bucket, "aws-bucket", "", "Target S3 bucket name for intermediate storage when creating AMI (only for format=ami)") + convertCmd.Flags().StringVar(&amiCfg.Region, "aws-region", "", "Target region for AWS uploads (only for format=ami)") +} + +func doConvert(_ *cobra.Command, args []string) (err error) { + //get user info who is running the podman bootc command + user, err := user.NewUser() + if err != nil { + return fmt.Errorf("unable to get user: %w", err) + } + + //podman machine connection + machineInfo, err := utils.GetMachineInfo(user) + if err != nil { + return err + } + + if machineInfo == nil { + println(utils.PodmanMachineErrorMessage) + return errors.New("rootful podman machine is required, please run 'podman machine init --rootful'") + } + + if !machineInfo.Rootful { + println(utils.PodmanMachineErrorMessage) + return errors.New("rootful podman machine is required, please run 'podman machine set --rootful'") + } + + if _, err := os.Stat(machineInfo.PodmanSocket); err != nil { + println(utils.PodmanMachineErrorMessage) + logrus.Errorf("podman machine socket is missing. Is podman machine running?\n%s", err) + return err + } + + ctx, err := bindings.NewConnectionWithIdentity( + context.Background(), + fmt.Sprintf("unix://%s", machineInfo.PodmanSocket), + machineInfo.SSHIdentityPath, + true) + if err != nil { + println(utils.PodmanMachineErrorMessage) + logrus.Errorf("failed to connect to the podman socket. Is podman machine running?\n%s", err) + return err + } + + idOrName := args[0] + err = bib.Build(ctx, quiet, idOrName, user, options, amiCfg) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/bib/build.go b/pkg/bib/build.go new file mode 100644 index 0000000..58b1b2e --- /dev/null +++ b/pkg/bib/build.go @@ -0,0 +1,229 @@ +package bib + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/containers/podman-bootc/pkg/user" + + "github.com/containers/podman/v5/pkg/bindings/containers" + "github.com/containers/podman/v5/pkg/bindings/images" + "github.com/containers/podman/v5/pkg/domain/entities/types" + "github.com/containers/podman/v5/pkg/specgen" + "github.com/opencontainers/runtime-spec/specs-go" +) + +type BuildOption struct { + Config string + Output string + Filesystem string + Format string + Arch string +} + +type AmiConfig struct { + Name string + Bucket string + Region string +} + +const bibImage = "quay.io/centos-bootc/bootc-image-builder:latest" + +func Build(ctx context.Context, quiet bool, imageNameOrId string, user user.User, buildOption BuildOption, amiCnfg AmiConfig) error { + outputInfo, err := os.Stat(buildOption.Output) + if err != nil { + return fmt.Errorf("output directory %s: %w", buildOption.Output, err) + } + + if !outputInfo.IsDir() { + return fmt.Errorf("%s is not a directory ", buildOption.Output) + } + + _, err = os.Stat(buildOption.Config) + if err != nil { + return fmt.Errorf("config file %s: %w", buildOption.Config, err) + } + + // Let's convert both the config file and the output directory to their absolute paths. + buildOption.Output, err = filepath.Abs(buildOption.Output) + if err != nil { + return fmt.Errorf("getting output directory absolute path: %w", err) + } + + buildOption.Config, err = filepath.Abs(buildOption.Config) + if err != nil { + return fmt.Errorf("getting config file absolute path: %w", err) + } + + // We assume the user's home directory is accessible from the podman machine VM, this + // will fail if any of the output or the config file are outside the user's home directory. + if !strings.HasPrefix(buildOption.Output, user.HomeDir()) { + return errors.New("the output directory must be inside the user's home directory") + } + + if !strings.HasPrefix(buildOption.Config, user.HomeDir()) { + return errors.New("the output directory must be inside the user's home directory") + } + + // Let's pull the Bootc Image Builder if necessary + _, err = pullImage(ctx, bibImage) + if err != nil { + return fmt.Errorf("pulling bootc image builder image: %w", err) + } + + imageFullName, err := pullImage(ctx, imageNameOrId) + if err != nil { + return fmt.Errorf("pulling image: %w", err) + } + + bibContainer, err := createBibContainer(ctx, imageFullName, buildOption, amiCnfg) + if err != nil { + return fmt.Errorf("failed to create image builder container: %w", err) + } + + err = containers.Start(ctx, bibContainer.ID, &containers.StartOptions{}) + if err != nil { + return fmt.Errorf("failed to start image builder container: %w", err) + } + + // Ensure we've cancelled the container attachment when exiting this function, as + // it takes over stdout/stderr handling + attachCancelCtx, cancelAttach := context.WithCancel(ctx) + defer cancelAttach() + + if !quiet { + attachOpts := new(containers.AttachOptions).WithStream(true) + if err := containers.Attach(attachCancelCtx, bibContainer.ID, os.Stdin, os.Stdout, os.Stderr, nil, attachOpts); err != nil { + return fmt.Errorf("attaching image builder container: %w", err) + } + } + + exitCode, err := containers.Wait(ctx, bibContainer.ID, nil) + if err != nil { + return fmt.Errorf("failed to wait for image builder container: %w", err) + } + + if exitCode != 0 { + return fmt.Errorf("failed to run image builder") + } + + return nil +} + +// pullImage fetches the container image if not present +func pullImage(ctx context.Context, imageNameOrId string) (imageFullName string, err error) { + pullPolicy := "missing" + ids, err := images.Pull(ctx, imageNameOrId, &images.PullOptions{Policy: &pullPolicy}) + if err != nil { + return "", fmt.Errorf("failed to pull image: %w", err) + } + + if len(ids) == 0 { + return "", fmt.Errorf("no ids returned from image pull") + } + + if len(ids) > 1 { + return "", fmt.Errorf("multiple ids returned from image pull") + } + + imageInfo, err := images.GetImage(ctx, imageNameOrId, &images.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get image: %w", err) + } + + return imageInfo.RepoTags[0], nil +} + +func createBibContainer(ctx context.Context, imageFullName string, buildOption BuildOption, amiCnfg AmiConfig) (types.ContainerCreateResponse, error) { + privileged := true + autoRemove := true + labelNested := true + terminal := true // Allocate pty so we can show progress bars, spinners etc. + + bibArgs := bibArguments(imageFullName, buildOption, amiCnfg) + + s := &specgen.SpecGenerator{ + ContainerBasicConfig: specgen.ContainerBasicConfig{ + Remove: &autoRemove, + Annotations: map[string]string{"io.podman.annotations.label": "type:unconfined_t"}, + Terminal: &terminal, + Command: bibArgs, + SdNotifyMode: "container", // required otherwise crun will fail to open the sd-bus + }, + ContainerStorageConfig: specgen.ContainerStorageConfig{ + Image: bibImage, + Mounts: []specs.Mount{ + { + Source: buildOption.Config, + Destination: "/config.toml", + Type: "bind", + }, + { + Source: buildOption.Output, + Destination: "/output", + Type: "bind", + Options: []string{"nosuid", "nodev"}, + }, + { + Source: "/var/lib/containers/storage", + Destination: "/var/lib/containers/storage", + Type: "bind", + }, + }, + }, + ContainerSecurityConfig: specgen.ContainerSecurityConfig{ + Privileged: &privileged, + LabelNested: &labelNested, + SelinuxOpts: []string{"type:unconfined_t"}, + }, + ContainerNetworkConfig: specgen.ContainerNetworkConfig{ + NetNS: specgen.Namespace{ + NSMode: specgen.Bridge, + }, + }, + } + + createResponse, err := containers.CreateWithSpec(ctx, s, &containers.CreateOptions{}) + if err != nil { + return createResponse, fmt.Errorf("failed to create image builder container: %w", err) + } + return createResponse, nil +} + +func bibArguments(imageNameOrId string, buildOption BuildOption, amiCfg AmiConfig) []string { + args := []string{ + "--local", // we pull the image if necessary, so don't pull it from a registry + } + + if buildOption.Filesystem != "" { + args = append(args, "--rootfs", buildOption.Filesystem) + } + + if buildOption.Arch != "" { + args = append(args, "--target-arch", buildOption.Arch) + } + + if buildOption.Format != "" { + args = append(args, "--type", buildOption.Format) + } + + // AMI specific options + if amiCfg.Name != "" { + args = append(args, "--aws-ami-name", amiCfg.Name) + } + + if amiCfg.Bucket != "" { + args = append(args, "--aws-bucket", amiCfg.Bucket) + } + + if amiCfg.Region != "" { + args = append(args, "--aws-region", amiCfg.Region) + } + + args = append(args, imageNameOrId) + return args +}