├── .gitignore ├── .bootc-dev-infra-commit.txt ├── renovate.json ├── test ├── resources │ ├── Containerfile.2 │ ├── Containerfile.1 │ ├── README.md │ └── build.images.sh └── e2e │ ├── e2e_utils_darwin.go │ ├── e2e_utils_linux.go │ ├── test_vm.go │ ├── e2e_utils.go │ └── e2e_test.go ├── pkg ├── config │ └── config.go ├── utils │ ├── process.go │ ├── net.go │ ├── types.go │ ├── errors.go │ ├── locks.go │ ├── file.go │ └── podman.go ├── vm │ ├── oemstring.go │ ├── cloudinit.go │ ├── domain-template.xml │ ├── gvproxy.go │ ├── krunkit.go │ ├── monitor.go │ ├── vm_darwin.go │ ├── vm.go │ ├── vm_test.go │ └── vm_linux.go ├── credentials │ └── ssh.go ├── user │ └── user.go └── bootc │ └── bootc_disk.go ├── .gemini └── config.yaml ├── docs ├── podman-bootc-stop.1.md ├── podman-bootc-list.1.md ├── podman-bootc-images.1.md ├── podman-bootc-completion.1.md ├── podman-bootc-ssh.1.md ├── podman-bootc-rm.1.md ├── Makefile ├── podman-bootc-run.1.md └── podman-bootc.1.md ├── rpm ├── go-vendor-tools.toml ├── README.md ├── packit.sh └── podman-bootc.spec ├── Makefile ├── .packit.yaml ├── podman-bootc.go ├── cmd ├── stop.go ├── root.go ├── ssh.go ├── vmmon.go ├── list.go ├── rm.go ├── images.go └── run.go ├── .github └── workflows │ └── ci.yml ├── README.md ├── hack ├── man-page-checker └── xref-helpmsgs-manpages ├── go.mod └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | test/e2e/e2e.test 3 | docs/*.1 -------------------------------------------------------------------------------- /.bootc-dev-infra-commit.txt: -------------------------------------------------------------------------------- 1 | 3249ff02e990cb856da25dcf44add398202088c0 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /test/resources/Containerfile.2: -------------------------------------------------------------------------------- 1 | FROM quay.io/centos-bootc/centos-bootc:stream9 2 | RUN dnf install -y gcc 3 | -------------------------------------------------------------------------------- /test/resources/Containerfile.1: -------------------------------------------------------------------------------- 1 | FROM quay.io/centos-bootc/centos-bootc:stream9 2 | RUN dnf install -y tmux 3 | -------------------------------------------------------------------------------- /test/resources/README.md: -------------------------------------------------------------------------------- 1 | These Containerfiles are used to build test images for the e2e tests. 2 | They are built with a multi-arch manifest and pushed to quay.io/ckyrouac/podman-bootc-test:[one|two] 3 | 4 | See build.images.sh 5 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const ( 4 | ProjectName = "podman-bootc" 5 | CacheDir = ".cache" 6 | RunPidFile = "run.pid" 7 | OciArchiveOutput = "image-archive.tar" 8 | DiskImage = "disk.raw" 9 | CiDataIso = "cidata.iso" 10 | SshKeyFile = "sshkey" 11 | CfgFile = "bc.cfg" 12 | LibvirtUri = "qemu:///session" 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/utils/process.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "syscall" 6 | ) 7 | 8 | func IsProcessAlive(pid int) bool { 9 | process, err := os.FindProcess(pid) 10 | if err != nil { 11 | return false 12 | } 13 | 14 | err = process.Signal(syscall.Signal(0)) 15 | return err == nil 16 | } 17 | 18 | // SendInterrupt sends SIGINT to pid 19 | func SendInterrupt(pid int) error { 20 | process, err := os.FindProcess(pid) 21 | if err != nil { 22 | return err 23 | } 24 | 25 | return process.Signal(os.Interrupt) 26 | } 27 | -------------------------------------------------------------------------------- /.gemini/config.yaml: -------------------------------------------------------------------------------- 1 | # This config mainly overrides `summary: false` by default 2 | # as it's really noisy. 3 | have_fun: true 4 | code_review: 5 | disable: false 6 | # Even medium level can be quite noisy, I don't think 7 | # we need LOW. Anyone who wants that type of stuff should 8 | # be able to get it locally or before review. 9 | comment_severity_threshold: MEDIUM 10 | max_review_comments: -1 11 | pull_request_opened: 12 | help: false 13 | summary: false # turned off by default 14 | code_review: true 15 | ignore_patterns: [] 16 | -------------------------------------------------------------------------------- /pkg/utils/net.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | func GetFreeLocalTcpPort() (int, error) { 10 | listener, err := net.Listen("tcp", "127.0.0.1:0") 11 | if err != nil { 12 | return -1, err 13 | } 14 | defer listener.Close() 15 | 16 | port := listener.Addr().(*net.TCPAddr).Port 17 | return port, nil 18 | } 19 | 20 | func IsPortOpen(port int) bool { 21 | timeout := time.Second 22 | conn, _ := net.DialTimeout("tcp", net.JoinHostPort("localhost", strconv.Itoa(port)), timeout) 23 | if conn != nil { 24 | defer conn.Close() 25 | return true 26 | } 27 | return false 28 | } 29 | -------------------------------------------------------------------------------- /pkg/utils/types.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type MachineInspect struct { 4 | ConnectionInfo ConnectionInfo `json:"ConnectionInfo"` 5 | SSHConfig SSHConfig `json:"SSHConfig"` 6 | Rootful bool `json:"Rootful"` 7 | } 8 | 9 | type PodmanSocket struct { 10 | Path string `json:"Path"` 11 | } 12 | 13 | type ConnectionInfo struct { 14 | PodmanSocket PodmanSocket `json:"PodmanSocket"` 15 | } 16 | 17 | type SSHConfig struct { 18 | IdentityPath string `json:"IdentityPath"` 19 | } 20 | 21 | type MachineList struct { 22 | Name string `json:"Name"` 23 | Running bool `json:"Running"` 24 | Default bool `json:"Default"` 25 | } 26 | -------------------------------------------------------------------------------- /docs/podman-bootc-stop.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-stop 1 2 | 3 | ## NAME 4 | podman-bootc-stop - Stop an existing OS Container machine 5 | 6 | ## SYNOPSIS 7 | **podman-bootc stop** *id* 8 | 9 | ## DESCRIPTION 10 | **podman-bootc stop** stops a running OS container machine. 11 | 12 | ## OPTIONS 13 | 14 | #### **--help**, **-h** 15 | Help for stop 16 | 17 | #### **--log-level**=*level* 18 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 19 | 20 | ## SEE ALSO 21 | 22 | **[podman-bootc(1)](podman-bootc.1.md)** 23 | 24 | ## HISTORY 25 | Dec, 2024, Originally compiled by Martin Skøtt 26 | -------------------------------------------------------------------------------- /rpm/go-vendor-tools.toml: -------------------------------------------------------------------------------- 1 | [archive] 2 | 3 | [licensing] 4 | detector = "askalono" 5 | [[licensing.licenses]] 6 | path = "vendor/github.com/fsouza/go-dockerclient/DOCKER-LICENSE" 7 | sha256sum = "04649aa5a97550d0bb083955b37586eb0ed6c6caa6e8a32f9cc840bbb3274254" 8 | expression = "Apache-2.0" 9 | 10 | [[licensing.licenses]] 11 | path = "vendor/github.com/shirou/gopsutil/v3/LICENSE" 12 | sha256sum = "ad1e64b82c04fb2ee6bfe521bff01266971ffaa70500024d4ac767c6033aafb9" 13 | expression = "BSD-3-Clause" 14 | 15 | [[licensing.licenses]] 16 | path = "vendor/gopkg.in/yaml.v3/LICENSE" 17 | sha256sum = "d18f6323b71b0b768bb5e9616e36da390fbd39369a81807cca352de4e4e6aa0b" 18 | expression = "MIT" 19 | -------------------------------------------------------------------------------- /docs/podman-bootc-list.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-list 1 2 | 3 | ## NAME 4 | podman-bootc-list - List installed OS Containers 5 | 6 | ## SYNOPSIS 7 | **podman-bootc list** 8 | 9 | ## DESCRIPTION 10 | **podman-bootc list** displays installed OS containers and their status. 11 | 12 | The podman machine must be running to use this command. 13 | 14 | ## OPTIONS 15 | 16 | #### **--help**, **-h** 17 | Help for list 18 | 19 | #### **--log-level**=*level* 20 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 21 | 22 | ## SEE ALSO 23 | 24 | **[podman-bootc(1)](podman-bootc.1.md)** 25 | 26 | ## HISTORY 27 | Dec, 2024, Originally compiled by Martin Skøtt 28 | -------------------------------------------------------------------------------- /docs/podman-bootc-images.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-images 1 2 | 3 | ## NAME 4 | podman-bootc-images - List bootc images in the local containers store 5 | 6 | ## SYNOPSIS 7 | **podman-bootc images** 8 | 9 | ## DESCRIPTION 10 | **podman-bootc images** list bootc images in the containers store of the podman machine. 11 | The podman machine must be running to use this command. 12 | 13 | ## OPTIONS 14 | 15 | #### **--help**, **-h** 16 | Help for images 17 | 18 | #### **--log-level**=*level* 19 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 20 | 21 | ## SEE ALSO 22 | 23 | **[podman-bootc(1)](podman-bootc.1.md)** 24 | 25 | ## HISTORY 26 | Dec, 2024, Originally compiled by Martin Skøtt 27 | -------------------------------------------------------------------------------- /docs/podman-bootc-completion.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-completion 1 2 | 3 | ## NAME 4 | podman-bootc-completion - Generate the autocompletion script for the specified shell 5 | 6 | ## SYNOPSIS 7 | **podman-bootc completion** *bash* | *fish* | *powershell* | *zsh* [*options*] 8 | 9 | ## DESCRIPTION 10 | **podman-bootc completion** generate shell completion scripts for a variety of shells. 11 | Supported shells are *bash*, *fish*, *powershell*, and *zsh*. 12 | 13 | ## OPTIONS 14 | 15 | #### **--help**, **-h** 16 | Show details on how to use the script generated for the particular shell. 17 | 18 | #### **--no-descriptions** 19 | Disable completion descriptions. 20 | 21 | ## SEE ALSO 22 | 23 | **[podman-bootc(1)](podman-bootc.1.md)** 24 | 25 | ## HISTORY 26 | Dec, 2024, Originally compiled by Martin Skøtt 27 | -------------------------------------------------------------------------------- /pkg/utils/errors.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "os/exec" 6 | ) 7 | 8 | const ( 9 | PodmanMachineErrorMessage = ` 10 | ****************************************************************** 11 | **** A rootful Podman machine is required to run podman-bootc **** 12 | ****************************************************************** 13 | ` 14 | // PodmanMachineErrorMessage = "\n**** A rootful Podman machine is required to run podman-bootc ****\n" 15 | ) 16 | 17 | // SetExitCode set the exit code for exec.ExitError errors, and no 18 | // error is returned 19 | func WithExitCode(err error) (int, error) { 20 | if err == nil { 21 | return 0, nil 22 | } 23 | 24 | var exitError *exec.ExitError 25 | if errors.As(err, &exitError) { 26 | return exitError.ExitCode(), nil 27 | } 28 | return 1, err 29 | } 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | binary_name = podman-bootc 2 | output_dir = bin 3 | build_tags = exclude_graphdriver_btrfs,btrfs_noversion,exclude_graphdriver_devicemapper,containers_image_openpgp,remote 4 | 5 | all: out_dir docs 6 | go build -tags $(build_tags) $(GOOPTS) -o $(output_dir)/$(binary_name) 7 | 8 | out_dir: 9 | mkdir -p $(output_dir) 10 | 11 | lint: validate_docs 12 | golangci-lint --build-tags $(build_tags) run 13 | 14 | integration_tests: 15 | ginkgo run -tags $(build_tags) --skip-package test ./... 16 | 17 | # !! These tests will modify your system's resources. See note in e2e_test.go. !! 18 | e2e_test: all 19 | ginkgo -tags $(build_tags) ./test/... 20 | 21 | .PHONY: docs 22 | docs: 23 | make -C docs 24 | 25 | clean: 26 | rm -f $(output_dir)/* 27 | make -C docs clean 28 | 29 | .PHONY: validate_docs 30 | validate_docs: 31 | hack/man-page-checker 32 | hack/xref-helpmsgs-manpages 33 | -------------------------------------------------------------------------------- /docs/podman-bootc-ssh.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-ssh 1 2 | 3 | ## NAME 4 | podman-bootc-ssh - SSH into an existing OS Container machine 5 | 6 | ## SYNOPSIS 7 | **podman-bootc ssh** *id* [*options*] 8 | 9 | ## DESCRIPTION 10 | **podman-bootc ssh** opens an SSH connection to a running OS container machine. 11 | 12 | Use **[podman-bootc list](podman-bootc-list.1.md)** to find the IDs of installed VMs. 13 | 14 | ## OPTIONS 15 | 16 | #### **--help**, **-h** 17 | Help for ssh 18 | 19 | #### **--log-level**=*level* 20 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 21 | 22 | #### **--user**, **-u**=**root** | *user name* 23 | User name to use for connection, default: root 24 | 25 | ## SEE ALSO 26 | 27 | **[podman-bootc(1)](podman-bootc.1.md)**, **[podman-bootc-list(1)](podman-bootc-list.1.md)** 28 | 29 | ## HISTORY 30 | Dec, 2024, Originally compiled by Martin Skøtt 31 | -------------------------------------------------------------------------------- /.packit.yaml: -------------------------------------------------------------------------------- 1 | specfile_path: rpm/podman-bootc.spec 2 | upstream_tag_template: v{version} 3 | 4 | # add or remove files that should be synced 5 | files_to_sync: 6 | - rpm/podman-bootc.spec 7 | - .packit.yaml 8 | 9 | # name in upstream package repository or registry (e.g. in PyPI) 10 | upstream_package_name: podman-bootc 11 | # downstream (Fedora) RPM package name 12 | downstream_package_name: podman-bootc 13 | 14 | srpm_build_deps: 15 | - git-archive-all 16 | - make 17 | - golang 18 | 19 | actions: 20 | fix-spec-file: 21 | - "bash rpm/packit.sh" 22 | 23 | jobs: 24 | - job: copr_build 25 | trigger: pull_request 26 | enable_net: true 27 | targets: 28 | - fedora-all-x86_64 29 | 30 | - job: copr_build 31 | trigger: commit 32 | enable_net: true 33 | branch: main 34 | owner: gmaglione 35 | project: podman-bootc 36 | list_on_homepage: true 37 | preserve_project: true 38 | targets: 39 | - fedora-all 40 | 41 | -------------------------------------------------------------------------------- /docs/podman-bootc-rm.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-rm 1 2 | 3 | ## NAME 4 | podman-bootc-rm - Remove installed bootc VMs 5 | 6 | ## SYNOPSIS 7 | **podman-bootc rm** *id* [*options*] 8 | 9 | ## DESCRIPTION 10 | **podman-bootc rm** removes an installed bootc VM/container from the podman machine. 11 | 12 | Use **[podman-bootc list](podman-bootc-list.1.md)** to find the IDs of installed VMs. 13 | 14 | The podman machine must be running to use this command. 15 | 16 | ## OPTIONS 17 | 18 | #### **--all** 19 | Removes all non-running bootc VMs 20 | 21 | #### **--force**, **-f** 22 | Terminate a running VM 23 | 24 | #### **--help**, **-h** 25 | Help for rm 26 | 27 | #### **--log-level**=*level* 28 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 29 | 30 | ## SEE ALSO 31 | 32 | **[podman-bootc(1)](podman-bootc.1.md)**, **[podman-bootc-list(1)](podman-bootc-list.1.md)** 33 | 34 | ## HISTORY 35 | Dec, 2024, Originally compiled by Martin Skøtt 36 | -------------------------------------------------------------------------------- /pkg/vm/oemstring.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func oemStringSystemdCredential(username, sshIdentity string) (string, error) { 11 | tmpFilesCmd, err := tmpFileSshKey(username, sshIdentity) 12 | if err != nil { 13 | return "", err 14 | } 15 | oemString := fmt.Sprintf("io.systemd.credential.binary:tmpfiles.extra=%s", tmpFilesCmd) 16 | return oemString, nil 17 | } 18 | 19 | func tmpFileSshKey(username, sshIdentity string) (string, error) { 20 | pubKey, err := os.ReadFile(sshIdentity + ".pub") 21 | if err != nil { 22 | return "", err 23 | } 24 | pubKeyEnc := base64.StdEncoding.EncodeToString(pubKey) 25 | 26 | userHomeDir := "/root" 27 | if username != "root" { 28 | userHomeDir = filepath.Join("/home", username) 29 | } 30 | 31 | tmpFileCmd := fmt.Sprintf("d %[1]s/.ssh 0750 %[2]s %[2]s -\nf+~ %[1]s/.ssh/authorized_keys 700 %[2]s %[2]s - %[3]s", userHomeDir, username, pubKeyEnc) 32 | 33 | tmpFileCmdEnc := base64.StdEncoding.EncodeToString([]byte(tmpFileCmd)) 34 | return tmpFileCmdEnc, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/vm/cloudinit.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/containers/podman-bootc/pkg/config" 11 | ) 12 | 13 | func (b *BootcVMCommon) ParseCloudInit() (err error) { 14 | if b.hasCloudInit { 15 | if b.cloudInitDir == "" { 16 | return errors.New("empty cloud init directory") 17 | } 18 | 19 | err = b.createCiDataIso(b.cloudInitDir) 20 | if err != nil { 21 | return fmt.Errorf("creating cloud-init iso: %w", err) 22 | } 23 | 24 | ciDataIso := filepath.Join(b.cacheDir, config.CiDataIso) 25 | b.cloudInitArgs = ciDataIso 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func (b *BootcVMCommon) createCiDataIso(inDir string) error { 32 | isoOutFile := filepath.Join(b.cacheDir, config.CiDataIso) 33 | 34 | args := []string{"-output", isoOutFile} 35 | args = append(args, "-volid", "cidata", "-joliet", "-rock", "-partition_cyl_align", "on") 36 | args = append(args, inDir) 37 | 38 | cmd := exec.Command("xorrisofs", args...) 39 | 40 | cmd.Stdout = os.Stdout 41 | cmd.Stderr = os.Stderr 42 | cmd.Stdin = os.Stdin 43 | 44 | return cmd.Run() 45 | } 46 | -------------------------------------------------------------------------------- /pkg/utils/locks.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/gofrs/flock" 7 | ) 8 | 9 | type AccessMode uint 10 | 11 | const ( 12 | Exclusive AccessMode = iota 13 | Shared 14 | ) 15 | 16 | type CacheLock struct { 17 | inner *flock.Flock 18 | } 19 | 20 | // NewCacheLock returns a new instance of *CacheLock. It takes the path to the VM cache dir. 21 | func NewCacheLock(lockDir, cacheDir string) CacheLock { 22 | imageLongID := filepath.Base(cacheDir) 23 | cacheDirLockFile := filepath.Join(lockDir, imageLongID+".lock") 24 | return CacheLock{inner: flock.New(cacheDirLockFile)} 25 | } 26 | 27 | // TryLock takes an exclusive or shared lock, based on the parameter mode. 28 | // The lock is non-blocking, if we are unable to lock the cache directory, 29 | // the function will return false instead of waiting for the lock. 30 | func (l CacheLock) TryLock(mode AccessMode) (bool, error) { 31 | if mode == Exclusive { 32 | return l.inner.TryLock() 33 | } else { 34 | return l.inner.TryRLock() 35 | } 36 | } 37 | 38 | // Unlock unlocks the cache lock. 39 | func (l CacheLock) Unlock() error { 40 | return l.inner.Unlock() 41 | } 42 | -------------------------------------------------------------------------------- /test/e2e/e2e_utils_darwin.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/containers/podman-bootc/pkg/config" 7 | "github.com/containers/podman-bootc/pkg/user" 8 | "github.com/containers/podman-bootc/pkg/utils" 9 | "github.com/containers/podman-bootc/pkg/vm" 10 | ) 11 | 12 | func pidFilePath(id string) (pidFilePath string, err error) { 13 | user, err := user.NewUser() 14 | if err != nil { 15 | return 16 | } 17 | 18 | _, cacheDir, err := vm.GetVMCachePath(id, user) 19 | if err != nil { 20 | return 21 | } 22 | 23 | return filepath.Join(cacheDir, config.RunPidFile), nil 24 | } 25 | 26 | func VMExists(id string) (exits bool, err error) { 27 | pidFilePath, err := pidFilePath(id) 28 | if err != nil { 29 | return false, err 30 | } 31 | return utils.FileExists(pidFilePath) 32 | } 33 | 34 | func VMIsRunning(id string) (exits bool, err error) { 35 | pidFilePath, err := pidFilePath(id) 36 | if err != nil { 37 | return false, err 38 | } 39 | 40 | pid, err := utils.ReadPidFile(pidFilePath) 41 | if err != nil { 42 | return false, err 43 | } 44 | 45 | if pid != -1 && utils.IsProcessAlive(pid) { 46 | return true, nil 47 | } else { 48 | return false, nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /podman-bootc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/containers/podman-bootc/cmd" 9 | "github.com/containers/podman-bootc/pkg/bootc" 10 | "github.com/containers/podman-bootc/pkg/user" 11 | "github.com/containers/podman-bootc/pkg/utils" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | func cleanup() { 17 | user, err := user.NewUser() 18 | if err != nil { 19 | logrus.Errorf("unable to get user info: %s", err) 20 | os.Exit(0) 21 | } 22 | 23 | machine, err := utils.GetMachineContext() 24 | if err != nil { 25 | println(utils.PodmanMachineErrorMessage) 26 | logrus.Errorf("failed to connect to podman machine. Is podman machine running?\n%s", err) 27 | os.Exit(1) 28 | } 29 | 30 | //delete the disk image 31 | err = bootc.NewBootcDisk("", machine.Ctx, user).Cleanup() 32 | if err != nil { 33 | logrus.Errorf("unable to get podman machine info: %s", err) 34 | os.Exit(0) 35 | } 36 | } 37 | 38 | func main() { 39 | c := make(chan os.Signal, 1) 40 | signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) 41 | go func() { 42 | <-c 43 | cleanup() 44 | os.Exit(1) 45 | }() 46 | 47 | cmd.Execute() 48 | os.Exit(cmd.ExitCode) 49 | } 50 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | PREFIX := /usr/local 2 | DATADIR := ${PREFIX}/share 3 | MANDIR := $(DATADIR)/man 4 | GO ?= go 5 | GOMD2MAN ?= go-md2man 6 | ifeq ($(shell uname -s),FreeBSD) 7 | SED=gsed 8 | else 9 | SED=sed 10 | endif 11 | # This must never include the 'hack' directory 12 | export PATH := $(shell $(GO) env GOPATH)/bin:$(PATH) 13 | 14 | docs: $(patsubst %.md,%,$(wildcard *[1].md)) 15 | 16 | %.1: %.1.md 17 | ### sed is used to filter http/s links as well as relative links 18 | ### replaces "\" at the end of a line with two spaces 19 | ### this ensures that manpages are rendered correctly 20 | @$(SED) -e 's/\((podman-bootc[^)]*\.md\(#.*\)\?)\)//g' \ 21 | -e 's/\[\(podman-bootc[^]]*\)\]/\1/g' \ 22 | -e 's/\[\([^]]*\)](http[^)]\+)/\1/g' \ 23 | -e 's;<\(/\)\?\(a\|a\s\+[^>]*\|sup\)>;;g' \ 24 | -e 's/\\$$/ /g' $< | \ 25 | $(GOMD2MAN) -in /dev/stdin -out $@ 26 | 27 | .PHONY: install 28 | install: docs 29 | install -d ${DESTDIR}/${MANDIR}/man1 30 | install -m 0644 podman-bootc*.1 ${DESTDIR}/${MANDIR}/man1 31 | install -m 0644 links/podman-bootc*.1 ${DESTDIR}/${MANDIR}/man1 32 | 33 | .PHONY: install-tools 34 | install-tools: 35 | go install github.com/cpuguy83/go-md2man@latest 36 | 37 | .PHONY: clean 38 | clean: 39 | $(RM) -f podman-bootc*.1 40 | -------------------------------------------------------------------------------- /pkg/credentials/ssh.go: -------------------------------------------------------------------------------- 1 | package credentials 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | 10 | "github.com/containers/podman-bootc/pkg/config" 11 | ) 12 | 13 | // Generatekeys creates an RSA set of keys 14 | func Generatekeys(outputDir string) (string, error) { 15 | sshIdentity := filepath.Join(outputDir, config.SshKeyFile) 16 | _ = os.Remove(sshIdentity) 17 | _ = os.Remove(sshIdentity + ".pub") 18 | 19 | // we use RSA here so it works on FIPS mode 20 | args := []string{"-N", "", "-t", "rsa", "-f", sshIdentity} 21 | cmd := exec.Command("ssh-keygen", args...) 22 | stdErr, err := cmd.StderrPipe() 23 | if err != nil { 24 | return "", fmt.Errorf("ssh key generation: redirecting stderr: %w", err) 25 | } 26 | 27 | if err := cmd.Start(); err != nil { 28 | return "", fmt.Errorf("ssh key generation: executing ssh-keygen: %w", err) 29 | } 30 | 31 | waitErr := cmd.Wait() 32 | if waitErr == nil { 33 | return sshIdentity, nil 34 | } 35 | 36 | errMsg, err := io.ReadAll(stdErr) 37 | if err != nil { 38 | return "", fmt.Errorf("ssh key generation, unable to read from stderr: %w", waitErr) 39 | } 40 | 41 | return "", fmt.Errorf("failed to generate ssh keys: %s: %w", string(errMsg), waitErr) 42 | } 43 | -------------------------------------------------------------------------------- /rpm/README.md: -------------------------------------------------------------------------------- 1 | # Building the rpm locally 2 | 3 | The revised spec file uses [`go-vendor-tools`] 4 | (https://fedora.gitlab.io/sigs/go/go-vendor-tools/scenarios/#generate-specfile-with-go2rpm) 5 | which enable vendoring for Go 6 | language packages in Fedora. 7 | 8 | Follow these steps to build an rpm locally using Fedora packager tools. 9 | These steps assume a Fedora machine. 10 | 11 | 1. Install packager tools. See [Installing Packager Tools](https://docs.fedoraproject.org/en-US/package-maintainers/Installing_Packager_Tools/). 12 | 13 | 1. Install go-vendor-tools: 14 | 15 | ``` bash 16 | sudo dnf install go-vendor-tools* 17 | ``` 18 | 19 | 1. Copy `podman-bootc.spec` and `go-vendor-tools.toml` into a directory. 20 | 21 | 1. Change into the directory. 22 | 23 | 1. Change version in spec file if needed. 24 | 25 | 1. Download tar file: 26 | 27 | ``` bash 28 | spectool -g -s 0 podman-bootc.spec 29 | ``` 30 | 31 | 1. Generate archive: 32 | 33 | ```bash 34 | go_vendor_archive create --config go-vendor-tools.toml podman-bootc.spec 35 | ``` 36 | 37 | 1. Build rpm locally: 38 | 39 | ```bash 40 | fedpkg --release rawhide mockbuild --srpm-mock 41 | ``` 42 | 43 | 1. Check output in the `results_podman-bootc` subdirectory 44 | -------------------------------------------------------------------------------- /test/resources/build.images.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | podman build --platform linux/amd64 -f Containerfile.1 -t quay.io/ckyrouac/podman-bootc-test:one-amd64 . 6 | podman build --platform linux/arm64 -f Containerfile.1 -t quay.io/ckyrouac/podman-bootc-test:one-arm64 . 7 | podman push quay.io/ckyrouac/podman-bootc-test:one-amd64 8 | podman push quay.io/ckyrouac/podman-bootc-test:one-arm64 9 | podman manifest create quay.io/ckyrouac/podman-bootc-test:one quay.io/ckyrouac/podman-bootc-test:one-arm64 quay.io/ckyrouac/podman-bootc-test:one-amd64 10 | podman manifest push quay.io/ckyrouac/podman-bootc-test:one 11 | podman manifest rm quay.io/ckyrouac/podman-bootc-test:one 12 | 13 | podman build --platform linux/amd64 -f Containerfile.2 -t quay.io/ckyrouac/podman-bootc-test:two-amd64 . 14 | podman build --platform linux/arm64 -f Containerfile.2 -t quay.io/ckyrouac/podman-bootc-test:two-arm64 . 15 | podman push quay.io/ckyrouac/podman-bootc-test:two-amd64 16 | podman push quay.io/ckyrouac/podman-bootc-test:two-arm64 17 | podman manifest create quay.io/ckyrouac/podman-bootc-test:two quay.io/ckyrouac/podman-bootc-test:two-arm64 quay.io/ckyrouac/podman-bootc-test:two-amd64 18 | podman manifest push quay.io/ckyrouac/podman-bootc-test:two 19 | podman manifest rm quay.io/ckyrouac/podman-bootc-test:two 20 | -------------------------------------------------------------------------------- /cmd/stop.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/containers/podman-bootc/pkg/config" 5 | "github.com/containers/podman-bootc/pkg/user" 6 | "github.com/containers/podman-bootc/pkg/utils" 7 | "github.com/containers/podman-bootc/pkg/vm" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var stopCmd = &cobra.Command{ 14 | Use: "stop ID", 15 | Short: "Stop an existing OS Container machine", 16 | Long: "Stop an existing OS Container machine", 17 | Args: cobra.ExactArgs(1), 18 | RunE: doStop, 19 | } 20 | 21 | func init() { 22 | RootCmd.AddCommand(stopCmd) 23 | } 24 | 25 | func doStop(_ *cobra.Command, args []string) (err error) { 26 | user, err := user.NewUser() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | id := args[0] 32 | bootcVM, err := vm.NewVM(vm.NewVMParameters{ 33 | ImageID: id, 34 | LibvirtUri: config.LibvirtUri, 35 | User: user, 36 | Locking: utils.Exclusive, 37 | }) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | // Let's be explicit instead of relying on the defer exec order 43 | defer func() { 44 | bootcVM.CloseConnection() 45 | if err := bootcVM.Unlock(); err != nil { 46 | logrus.Warningf("unable to unlock VM %s: %v", id, err) 47 | } 48 | }() 49 | 50 | return bootcVM.Delete() 51 | } 52 | -------------------------------------------------------------------------------- /rpm/packit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eox pipefail 4 | 5 | PACKAGE=podman-bootc 6 | 7 | # Set path to rpm spec file 8 | SPEC_FILE=rpm/$PACKAGE.spec 9 | 10 | # Get full version from HEAD 11 | VERSION=$(git describe --always --long --dirty) 12 | 13 | # RPM Version can't take "-" 14 | RPM_VERSION="${VERSION//-/\~}" 15 | 16 | # Generate source tarball from HEAD 17 | RPM_SOURCE_FILE=$PACKAGE-$VERSION.tar.gz 18 | git-archive-all -C "$(git rev-parse --show-toplevel)" --prefix="$PACKAGE-$RPM_VERSION/" "rpm/$RPM_SOURCE_FILE" 19 | 20 | # Generate vendor dir 21 | RPM_VENDOR_FILE=$PACKAGE-$VERSION-vendor.tar.gz 22 | go mod vendor 23 | tar -czf "rpm/$RPM_VENDOR_FILE" vendor/ 24 | 25 | # RPM Spec modifications 26 | # Use the Version from HEAD in rpm spec 27 | sed -i "s/^Version:.*/Version: $RPM_VERSION/" $SPEC_FILE 28 | 29 | # Use Packit's supplied variable in the Release field in rpm spec. 30 | sed -i "s/^Release:.*/Release: $PACKIT_RPMSPEC_RELEASE%{?dist}/" $SPEC_FILE 31 | 32 | # Ensure last part of the release string is the git shortcommit without a prepended "g" 33 | sed -i "/^Release: $PACKIT_RPMSPEC_RELEASE%{?dist}/ s/\(.*\)g/\1/" $SPEC_FILE 34 | 35 | # Use above generated tarballs as Sources in rpm spec 36 | sed -i "s/^Source0:.*/Source0: $RPM_SOURCE_FILE/" $SPEC_FILE 37 | sed -i "s/^Source1:.*/Source1: $RPM_VENDOR_FILE/" $SPEC_FILE 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ main ] 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | container: quay.io/centos/centos:stream9 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: build 14 | run: | 15 | set -xeuo pipefail 16 | dnf -y install 'dnf-command(config-manager)' 17 | dnf -y config-manager --set-enabled crb 18 | dnf -y install go-toolset libvirt-devel 19 | # Because all the git clones are much less reliable 20 | export GOPROXY=https://proxy.golang.org 21 | go install github.com/onsi/ginkgo/v2/ginkgo@latest 22 | go install github.com/cpuguy83/go-md2man@latest 23 | make GOOPTS=-buildvcs=false 24 | export PATH=$PATH:$HOME/go/bin 25 | make integration_tests 26 | - name: lint 27 | run: | 28 | # Used by xref-helpmsgs-manpages 29 | dnf -y install perl-Clone perl-FindBin 30 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.61.0 31 | export PATH=$PATH:$HOME/go/bin 32 | make lint || true 33 | - name: gofmt 34 | run: | 35 | if test -z $(gofmt -l .); then exit 0; else gofmt -d -e . && exit 1; fi 36 | -------------------------------------------------------------------------------- /pkg/vm/domain-template.xml: -------------------------------------------------------------------------------- 1 | 2 | {{.Name}} 3 | 2 4 | 5 | 6 | 7 | 8 | 2 9 | 10 | 11 | 12 | 13 | destroy 14 | restart 15 | destroy 16 | 17 | hvm 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{.CloudInitCDRom}} 36 | 37 | 38 | 39 | 40 | 41 | 42 | {{.SMBios}} 43 | 44 | 45 | -------------------------------------------------------------------------------- /test/e2e/e2e_utils_linux.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "github.com/containers/podman-bootc/pkg/config" 5 | 6 | "libvirt.org/go/libvirt" 7 | ) 8 | 9 | func VMExists(id string) (exits bool, err error) { 10 | vmName := "podman-bootc-" + id 11 | 12 | libvirtConnection, err := libvirt.NewConnect(config.LibvirtUri) 13 | if err != nil { 14 | return false, err 15 | } 16 | 17 | defer libvirtConnection.Close() 18 | 19 | domains, err := libvirtConnection.ListAllDomains(libvirt.ConnectListAllDomainsFlags(0)) 20 | if err != nil { 21 | return false, err 22 | } 23 | for _, domain := range domains { 24 | name, err := domain.GetName() 25 | if err != nil { 26 | return false, err 27 | } 28 | 29 | if name == vmName { 30 | return true, nil 31 | } 32 | } 33 | 34 | return false, nil 35 | } 36 | 37 | func VMIsRunning(id string) (exits bool, err error) { 38 | vmName := "podman-bootc-" + id 39 | 40 | libvirtConnection, err := libvirt.NewConnect(config.LibvirtUri) 41 | if err != nil { 42 | return false, err 43 | } 44 | defer libvirtConnection.Close() 45 | 46 | domain, err := libvirtConnection.LookupDomainByName(vmName) 47 | if err != nil { 48 | return false, err 49 | } 50 | 51 | if domain == nil { 52 | return false, nil 53 | } 54 | 55 | state, _, err := domain.GetState() 56 | if err != nil { 57 | return false, err 58 | } 59 | 60 | if state == libvirt.DOMAIN_RUNNING { 61 | return true, nil 62 | } else { 63 | return false, nil 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/containers/podman-bootc/pkg/user" 7 | 8 | "github.com/sirupsen/logrus" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // RootCmd represents the base command when called without any subcommands 13 | var ( 14 | RootCmd = &cobra.Command{ 15 | Use: "podman-bootc", 16 | Short: "Run bootable containers as a virtual machine", 17 | Long: "Run bootable containers as a virtual machine", 18 | PersistentPreRunE: preExec, 19 | SilenceUsage: true, 20 | } 21 | ExitCode int 22 | ) 23 | 24 | var rootLogLevel string 25 | 26 | func preExec(cmd *cobra.Command, args []string) error { 27 | if rootLogLevel != "" { 28 | level, err := logrus.ParseLevel(rootLogLevel) 29 | if err != nil { 30 | return err 31 | } 32 | logrus.SetLevel(level) 33 | } 34 | 35 | user, err := user.NewUser() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if err := user.InitOSCDirs(); err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | 46 | // Execute adds all child commands to the root command and sets flags appropriately. 47 | // This is called by main.main(). It only needs to happen once to the rootCmd. 48 | func Execute() { 49 | err := RootCmd.Execute() 50 | if err != nil { 51 | os.Exit(1) 52 | } 53 | } 54 | 55 | func init() { 56 | logrus.SetLevel(logrus.WarnLevel) 57 | RootCmd.PersistentFlags().StringVarP(&rootLogLevel, "log-level", "", "", "Set log level") 58 | } 59 | -------------------------------------------------------------------------------- /pkg/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | func ReadPidFile(pidFile string) (int, error) { 13 | if _, err := os.Stat(pidFile); err != nil { 14 | return -1, err 15 | } 16 | 17 | fileContent, err := os.ReadFile(pidFile) 18 | if err != nil { 19 | return -1, err 20 | } 21 | pidStr := string(bytes.Trim(fileContent, "\n")) 22 | pid, err := strconv.ParseInt(pidStr, 10, 64) 23 | if err != nil { 24 | return -1, err 25 | } 26 | return int(pid), nil 27 | } 28 | 29 | func WritePidFile(pidFile string, pid int) error { 30 | if pid < 1 { 31 | // We might be running as PID 1 when running docker-in-docker, 32 | // but 0 or negative PIDs are not acceptable. 33 | return fmt.Errorf("invalid negative PID %d", pid) 34 | } 35 | return os.WriteFile(pidFile, []byte(strconv.Itoa(pid)), 0o644) 36 | } 37 | 38 | func FileExists(path string) (bool, error) { 39 | _, err := os.Stat(path) 40 | exists := false 41 | 42 | if err == nil { 43 | exists = true 44 | } else if errors.Is(err, os.ErrNotExist) { 45 | err = nil 46 | } 47 | return exists, err 48 | } 49 | 50 | // WaitForFileWithBackoffs attempts to discover a file in maxBackoffs attempts 51 | func WaitForFileWithBackoffs(maxBackoffs int, backoff time.Duration, path string) error { 52 | backoffWait := backoff 53 | for i := 0; i < maxBackoffs; i++ { 54 | e, _ := FileExists(path) 55 | if e { 56 | return nil 57 | } 58 | time.Sleep(backoffWait) 59 | backoffWait *= 2 60 | } 61 | return fmt.Errorf("unable to find file at %q", path) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/ssh.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/containers/podman-bootc/pkg/config" 5 | "github.com/containers/podman-bootc/pkg/user" 6 | "github.com/containers/podman-bootc/pkg/utils" 7 | "github.com/containers/podman-bootc/pkg/vm" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var sshCmd = &cobra.Command{ 14 | Use: "ssh ", 15 | Short: "SSH into an existing OS Container machine", 16 | Long: "SSH into an existing OS Container machine", 17 | Args: cobra.MinimumNArgs(1), 18 | RunE: doSsh, 19 | } 20 | var sshUser string 21 | 22 | func init() { 23 | RootCmd.AddCommand(sshCmd) 24 | sshCmd.Flags().StringVarP(&sshUser, "user", "u", "root", "--user (default: root)") 25 | } 26 | 27 | func doSsh(_ *cobra.Command, args []string) error { 28 | user, err := user.NewUser() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | id := args[0] 34 | 35 | vm, err := vm.NewVM(vm.NewVMParameters{ 36 | ImageID: id, 37 | User: user, 38 | LibvirtUri: config.LibvirtUri, 39 | Locking: utils.Shared, 40 | }) 41 | 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // Let's be explicit instead of relying on the defer exec order 47 | defer func() { 48 | vm.CloseConnection() 49 | if err := vm.Unlock(); err != nil { 50 | logrus.Warningf("unable to unlock VM %s: %v", id, err) 51 | } 52 | }() 53 | 54 | err = vm.SetUser(sshUser) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | cmd := make([]string, 0) 60 | if len(args) > 1 { 61 | cmd = args[1:] 62 | } 63 | 64 | ExitCode, err = utils.WithExitCode(vm.RunSSH(cmd)) 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /test/e2e/test_vm.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type TestVM struct { 11 | StdIn io.WriteCloser 12 | StdOut []string 13 | IsBooted bool 14 | Id string 15 | } 16 | 17 | func (w *TestVM) SetId(id string) { 18 | w.Id = id 19 | } 20 | 21 | func (w *TestVM) GetId() string { 22 | return w.Id 23 | } 24 | 25 | func (w *TestVM) Write(p []byte) (n int, err error) { 26 | if strings.Contains(string(p), "Connecting to vm") { 27 | w.IsBooted = true 28 | } 29 | print(string(p)) 30 | w.StdOut = append(w.StdOut, string(p)) 31 | return len(p), nil 32 | } 33 | 34 | func (w *TestVM) WaitForBoot() (err error) { 35 | timeout := 10 * time.Minute 36 | interval := 1 * time.Second 37 | for { 38 | if w.IsBooted { 39 | break 40 | } 41 | time.Sleep(interval) 42 | timeout -= interval 43 | if timeout <= 0 { 44 | return fmt.Errorf("VM did not boot within timeout") 45 | } 46 | } 47 | 48 | return 49 | } 50 | 51 | // SendCommand sends a command to the VM's stdin and waits for the output 52 | func (w *TestVM) SendCommand(cmd string, output string) (err error) { 53 | _, err = w.StdIn.Write([]byte(cmd + "\n")) 54 | if err != nil { 55 | return fmt.Errorf("unable to write to VM stdin: %w", err) 56 | } 57 | 58 | timeout := 2 * time.Minute 59 | interval := 1 * time.Second 60 | for { 61 | if strings.Index(w.StdOut[len(w.StdOut)-1], output) == 0 { 62 | break 63 | } 64 | time.Sleep(interval) 65 | timeout -= interval 66 | if timeout <= 0 { 67 | return fmt.Errorf("VM did not output expected string within timeout") 68 | } 69 | } 70 | 71 | return 72 | } 73 | -------------------------------------------------------------------------------- /cmd/vmmon.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | 3 | package cmd 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | 12 | "github.com/containers/podman-bootc/pkg/user" 13 | "github.com/containers/podman-bootc/pkg/vm" 14 | 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | // We treat this command as internal, we don't expect the user to call 19 | // this directly. In the future this will be replaced by an external binary 20 | var ( 21 | monCmd = &cobra.Command{ 22 | Use: "vmmon ", 23 | Hidden: true, 24 | Args: cobra.ExactArgs(4), 25 | RunE: doMon, 26 | } 27 | console bool 28 | ) 29 | 30 | func init() { 31 | RootCmd.AddCommand(monCmd) 32 | runCmd.Flags().BoolVar(&console, "console", false, "Show boot console") 33 | } 34 | 35 | func doMon(_ *cobra.Command, args []string) error { 36 | usr, err := user.NewUser() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | ctx := context.Background() 42 | fullImageId := args[0] 43 | username := args[1] 44 | sshIdentity := args[2] 45 | 46 | sshPort, err := strconv.Atoi(args[3]) 47 | if err != nil { 48 | return fmt.Errorf("invalid ssh port: %w", err) 49 | } 50 | 51 | cacheDir := filepath.Join(usr.CacheDir(), fullImageId) 52 | 53 | // MacOS has a 104 bytes limit for a unix socket path 54 | runDir := filepath.Join(usr.RunDir(), fullImageId[0:12]) 55 | if err := os.MkdirAll(runDir, os.ModePerm); err != nil { 56 | return err 57 | } 58 | 59 | params := vm.MonitorParmeters{ 60 | CacheDir: cacheDir, 61 | RunDir: runDir, 62 | Username: username, 63 | SshIdentity: sshIdentity, 64 | SshPort: sshPort, 65 | } 66 | 67 | return vm.StartMonitor(ctx, params) 68 | } 69 | -------------------------------------------------------------------------------- /pkg/user/user.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "runtime" 9 | "strconv" 10 | 11 | "github.com/containers/podman-bootc/pkg/config" 12 | 13 | "github.com/adrg/xdg" 14 | "github.com/containers/podman/v5/pkg/rootless" 15 | ) 16 | 17 | type User struct { 18 | OSUser *user.User 19 | } 20 | 21 | func NewUser() (u User, err error) { 22 | rootlessId := rootless.GetRootlessUID() 23 | 24 | var osUser *user.User 25 | if rootlessId < 0 { 26 | osUser, err = user.Current() 27 | } else { 28 | osUser, err = user.LookupId(strconv.Itoa(rootlessId)) 29 | } 30 | 31 | if err != nil { 32 | return u, fmt.Errorf("failed to get user: %w", err) 33 | } 34 | 35 | return User{ 36 | OSUser: osUser, 37 | }, nil 38 | } 39 | 40 | func (u *User) HomeDir() string { 41 | return u.OSUser.HomeDir 42 | } 43 | 44 | func (u *User) Username() string { 45 | return u.OSUser.Username 46 | } 47 | 48 | func (u *User) SSHDir() string { 49 | return filepath.Join(u.HomeDir(), ".ssh") 50 | } 51 | 52 | func (u *User) CacheDir() string { 53 | return filepath.Join(u.HomeDir(), config.CacheDir, config.ProjectName) 54 | } 55 | 56 | func (u *User) DefaultIdentity() string { 57 | return filepath.Join(u.SSHDir(), "id_rsa") 58 | } 59 | 60 | func (u *User) RunDir() string { 61 | if runtime.GOOS == "darwin" { 62 | tmpDir, ok := os.LookupEnv("TMPDIR") 63 | if !ok { 64 | tmpDir = "/tmp" 65 | } 66 | 67 | return filepath.Join(tmpDir, config.ProjectName, "run") 68 | } 69 | 70 | return filepath.Join(xdg.RuntimeDir, config.ProjectName, "run") 71 | } 72 | 73 | func (u *User) InitOSCDirs() error { 74 | if err := os.MkdirAll(u.CacheDir(), os.ModePerm); err != nil { 75 | return err 76 | } 77 | 78 | if err := os.MkdirAll(u.RunDir(), os.ModePerm); err != nil { 79 | return err 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (u *User) RemoveOSCDirs() error { 86 | if err := os.RemoveAll(u.CacheDir()); err != nil { 87 | return err 88 | } 89 | 90 | if err := os.RemoveAll(u.RunDir()); err != nil { 91 | return err 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /docs/podman-bootc-run.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc-run 1 2 | 3 | ## NAME 4 | podman-bootc-run - Run a bootc container as a VM 5 | 6 | ## SYNOPSIS 7 | **podman-bootc run** [*options*] *image* | *id* 8 | 9 | ## DESCRIPTION 10 | **podman-bootc run** creates a new virtual machine from a bootc container image or starts an existing one. 11 | It then creates an SSH connection to the VM using injected credentials (see *--background* to run in the background). 12 | 13 | The podman machine must be running to use this command. 14 | 15 | ## OPTIONS 16 | 17 | #### **--background**, **-B** 18 | Do not spawn SSH, run in background. 19 | 20 | #### **--cloudinit**=**string** 21 | --cloud-init 22 | 23 | #### **--disk-size**=**string** 24 | Allocate a disk image of this size in bytes; optionally accepts M, G, T suffixes 25 | 26 | #### **--filesystem**=**string** 27 | Override the root filesystem, e.g. xfs, btrfs, ext4. 28 | 29 | #### **--help**, **-h** 30 | Help for run 31 | 32 | #### **--log-level**=*level* 33 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 34 | 35 | #### **--quiet** 36 | Suppress output from bootc disk creation and VM boot console 37 | 38 | #### **--rm** 39 | Remove the VM and its disk image when the SSH connection exits. Cannot be used with *--background* 40 | 41 | #### **--root-size-max**=**string** 42 | Maximum size of root filesystem in bytes; optionally accepts M, G, T suffixes 43 | 44 | #### **--user**, **-u**=**root** | *user name* 45 | User name of injected user, default: root 46 | 47 | ## EXAMPLES 48 | Create a virtual machine based on the latest bootable image from Fedora using XFS as the root filesystem. 49 | ``` 50 | $ podman-bootc run --filesystem=xfs quay.io/fedora/fedora-bootc:latest 51 | ``` 52 | 53 | Start a previously created VM, using *podman-bootc list* to find its ID. 54 | ``` 55 | $ podman-bootc list 56 | ID REPO SIZE CREATED RUNNING SSH PORT 57 | d0300f628e13 quay.io/fedora/fedora-bootc:latest 10.7GB 4 minutes ago false 34173 58 | $ podman-bootc run d0300f628e13 59 | ``` 60 | 61 | ## SEE ALSO 62 | 63 | **[podman-bootc(1)](podman-bootc.1.md)** 64 | 65 | ## HISTORY 66 | Dec, 2024, Originally compiled by Martin Skøtt 67 | -------------------------------------------------------------------------------- /docs/podman-bootc.1.md: -------------------------------------------------------------------------------- 1 | % podman-bootc 1 2 | 3 | ## NAME 4 | podman-bootc - Run bootable containers as a virtual machine 5 | 6 | ## SYNOPSIS 7 | **podman-bootc** [*options*] *command* 8 | 9 | ## DESCRIPTION 10 | **podman-bootc** is a tool to streamline the local development cycle when working with bootable containers. 11 | It makes it easy to run a local bootc image and get shell access to it without first setting up a virtual machine. 12 | 13 | podman-bootc requires a rootful podman machine to be running before running a bootable container. 14 | A machine can be set up using e.g. `podman machine init --rootful --now`. 15 | See `podman-machine(1)` for details. 16 | 17 | **podman-bootc [GLOBAL OPTIONS]** 18 | 19 | ## GLOBAL OPTIONS 20 | 21 | #### **--help**, **-h** 22 | Print usage statement 23 | 24 | #### **--log-level**=*level* 25 | Log messages at and above specified level: __debug__, __info__, __warn__, __error__, __fatal__ or __panic__ (default: _warn_) 26 | 27 | ## COMMANDS 28 | 29 | | Command | Description | 30 | |------------------------------------------------------------|------------------------------------------------------------| 31 | | [podman-bootc-completion(1)](podman-bootc-completion.1.md) | Generate the autocompletion script for the specified shell | 32 | | [podman-bootc-images(1)](podman-bootc-images.1.md) | List bootc images in the local containers store | 33 | | [podman-bootc-list(1)](podman-bootc-list.1.md) | List installed OS Containers | 34 | | [podman-bootc-rm(1)](podman-bootc-rm.1.md) | Remove installed bootc VMs | 35 | | [podman-bootc-run(1)](podman-bootc-run.1.md) | Run a bootc container as a VM | 36 | | [podman-bootc-ssh(1)](podman-bootc-ssh.1.md) | SSH into an existing OS Container machine | 37 | | [podman-bootc-stop(1)](podman-bootc-stop.1.md) | Stop an existing OS Container machine | 38 | 39 | ## SEE ALSO 40 | **[podman-machine(1)](https://github.com/containers/podman/blob/main/docs/source/markdown/podman-machine.1.md)** 41 | 42 | ## HISTORY 43 | Dec, 2024, Originally compiled by Martin Skøtt 44 | -------------------------------------------------------------------------------- /pkg/vm/gvproxy.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/containers/podman-bootc/pkg/utils" 13 | 14 | gvproxy "github.com/containers/gvisor-tap-vsock/pkg/types" 15 | "github.com/sirupsen/logrus" 16 | ) 17 | 18 | const gvproxyBinaryName = "gvproxy" 19 | 20 | type Vmm int 21 | 22 | const ( 23 | pidFileName = "gvproxy.pid" 24 | socketFile = "net.sock" 25 | // How log we should wait for gvproxy to be ready 26 | maxBackoffs = 5 27 | backoff = time.Millisecond * 200 28 | ) 29 | 30 | type gvproxyParams struct { 31 | SshPort int 32 | } 33 | 34 | type gvproxyDaemon struct { 35 | socketPath string 36 | pidFile string 37 | cmd *exec.Cmd 38 | } 39 | 40 | func newGvproxy(ctx context.Context, binaryPath, rundir string, param gvproxyParams) *gvproxyDaemon { 41 | socketPath := filepath.Join(rundir, socketFile) 42 | pidFile := filepath.Join(rundir, pidFileName) 43 | 44 | gvpCmd := gvproxy.NewGvproxyCommand() 45 | gvpCmd.SSHPort = param.SshPort 46 | gvpCmd.PidFile = pidFile 47 | gvpCmd.AddVfkitSocket(fmt.Sprintf("unixgram://%s", socketPath)) 48 | 49 | cmdLine := gvpCmd.ToCmdline() 50 | cmd := exec.CommandContext(ctx, binaryPath, cmdLine...) 51 | logrus.Debugf("gvproxy command-line: %s %s", binaryPath, strings.Join(cmdLine, " ")) 52 | 53 | return &gvproxyDaemon{socketPath: socketPath, pidFile: pidFile, cmd: cmd} 54 | } 55 | 56 | // Start spawn the gvproxy daemon, killing any running daemon using the same unix socket file. 57 | // It blocks until the unix socket file exists, this does not guarantee that the socket will be 58 | // on listen state 59 | func (d *gvproxyDaemon) start() error { 60 | cleanup(d.pidFile, d.socketPath) 61 | 62 | if err := d.cmd.Start(); err != nil { 63 | return fmt.Errorf("unable to start gvproxy: %w", err) 64 | } 65 | 66 | // this is racy, the socket file could exist but not be in the listen state yet 67 | if err := utils.WaitForFileWithBackoffs(maxBackoffs, backoff, d.socketPath); err != nil { 68 | return fmt.Errorf("waiting for gvproxy socket: %w", err) 69 | } 70 | return nil 71 | } 72 | 73 | func (d *gvproxyDaemon) stop() error { 74 | return d.cmd.Cancel() 75 | } 76 | 77 | func (d *gvproxyDaemon) wait() error { 78 | return d.cmd.Wait() 79 | } 80 | 81 | func cleanup(pidFile, socketPath string) { 82 | // Let's kill any possible running daemon 83 | pid, err := utils.ReadPidFile(pidFile) 84 | if err == nil { 85 | _ = utils.SendInterrupt(pid) 86 | } 87 | 88 | _ = os.Remove(pidFile) 89 | _ = os.Remove(socketPath) 90 | } 91 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/containers/podman-bootc/pkg/config" 7 | "github.com/containers/podman-bootc/pkg/user" 8 | "github.com/containers/podman-bootc/pkg/utils" 9 | "github.com/containers/podman-bootc/pkg/vm" 10 | 11 | "github.com/containers/common/pkg/report" 12 | "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // listCmd represents the hello command 17 | var listCmd = &cobra.Command{ 18 | Use: "list", 19 | Short: "List installed OS Containers", 20 | Long: "List installed OS Containers", 21 | RunE: doList, 22 | } 23 | 24 | func init() { 25 | RootCmd.AddCommand(listCmd) 26 | } 27 | 28 | func doList(_ *cobra.Command, _ []string) error { 29 | hdrs := report.Headers(vm.BootcVMConfig{}, map[string]string{ 30 | "RepoTag": "Repo", 31 | "DiskSize": "Size", 32 | }) 33 | 34 | rpt := report.New(os.Stdout, "list") 35 | defer rpt.Flush() 36 | 37 | rpt, err := rpt.Parse( 38 | report.OriginPodman, 39 | "{{range . }}{{.Id}}\t{{.RepoTag}}\t{{.DiskSize}}\t{{.Created}}\t{{.Running}}\t{{.SshPort}}\n{{end -}}") 40 | 41 | if err != nil { 42 | return err 43 | } 44 | 45 | if err := rpt.Execute(hdrs); err != nil { 46 | return err 47 | } 48 | 49 | user, err := user.NewUser() 50 | if err != nil { 51 | return err 52 | } 53 | 54 | vmList, err := CollectVmList(user, config.LibvirtUri) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | return rpt.Execute(vmList) 60 | } 61 | 62 | func CollectVmList(user user.User, libvirtUri string) (vmList []vm.BootcVMConfig, err error) { 63 | files, err := os.ReadDir(user.CacheDir()) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | for _, f := range files { 69 | if f.IsDir() { 70 | cfg, err := getVMInfo(user, libvirtUri, f.Name()) 71 | if err != nil { 72 | logrus.Warningf("skipping vm %s reason: %v", f.Name(), err) 73 | continue 74 | } 75 | 76 | vmList = append(vmList, *cfg) 77 | } 78 | } 79 | return vmList, nil 80 | } 81 | 82 | func getVMInfo(user user.User, libvirtUri string, imageId string) (*vm.BootcVMConfig, error) { 83 | bootcVM, err := vm.NewVM(vm.NewVMParameters{ 84 | ImageID: imageId, 85 | User: user, 86 | LibvirtUri: libvirtUri, 87 | Locking: utils.Shared, 88 | }) 89 | 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | // Let's be explicit instead of relying on the defer exec order 95 | defer func() { 96 | bootcVM.CloseConnection() 97 | if err := bootcVM.Unlock(); err != nil { 98 | logrus.Warningf("unable to unlock VM %s: %v", imageId, err) 99 | } 100 | }() 101 | 102 | cfg, err := bootcVM.GetConfig() 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | return cfg, nil 108 | } 109 | -------------------------------------------------------------------------------- /rpm/podman-bootc.spec: -------------------------------------------------------------------------------- 1 | # Generated by go2rpm 1.16.0.post0 2 | %bcond check 1 3 | 4 | # https://github.com/containers/podman-bootc 5 | %global goipath github.com/containers/podman-bootc 6 | Version: 0.1.2 7 | 8 | %gometa -L -f 9 | 10 | %global common_description %{expand: 11 | A scriptable CLI that offers an efficient and ergonomic "edit-compile-debug" 12 | cycle for bootable containers.} 13 | 14 | Name: podman-bootc 15 | Release: %autorelease 16 | Summary: Streamlining podman + bootc interactions 17 | 18 | # Generated by go-vendor-tools 19 | License: Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND ISC AND MIT AND MPL-2.0 AND Unlicense 20 | URL: %{gourl} 21 | Source0: %{gosource} 22 | # Generated by go-vendor-tools 23 | Source1: %{archivename}-vendor.tar.bz2 24 | Source2: go-vendor-tools.toml 25 | 26 | BuildRequires: go-vendor-tools 27 | # make needed for man generation but not available in release 28 | # BuildRequires: make 29 | BuildRequires: gcc 30 | BuildRequires: libvirt-devel 31 | BuildRequires: go-md2man 32 | 33 | Requires: podman-machine 34 | Requires: xorriso 35 | Requires: podman 36 | Requires: qemu 37 | Requires: libvirt 38 | 39 | %description %{common_description} 40 | 41 | %prep 42 | %goprep -A 43 | %setup -q -T -D -a1 %{forgesetupargs} 44 | %autopatch -p1 45 | 46 | %generate_buildrequires 47 | %go_vendor_license_buildrequires -c %{S:2} 48 | 49 | %build 50 | # explicitly turn on module support for gotest and gobuild 51 | %global gomodulesmode GO111MODULE=on 52 | 53 | # define global for use in gobuild and gotest 54 | %global buildtags exclude_graphdriver_btrfs btrfs_noversion exclude_graphdriver_devicemapper containers_image_openpgp remote 55 | export GO_BUILDTAGS="%{buildtags}" 56 | 57 | %gobuild -o %{gobuilddir}/bin/podman-bootc %{goipath} 58 | 59 | # docs directory not included in current source tar file 60 | # build man files 61 | # %%make_build docs 62 | 63 | %install 64 | %go_vendor_license_install -c %{S:2} 65 | install -m 0755 -vd %{buildroot}%{_bindir} 66 | install -m 0755 -vp %{gobuilddir}/bin/* %{buildroot}%{_bindir}/ 67 | # docs directory not included in current source tar file 68 | # install -m 0755 -vd %%{buildroot}%%{_mandir}/man1 69 | # install -m 0755 -vp docs/*.1 %%{buildroot}%%{_mandir}/man1/ 70 | 71 | %check 72 | %go_vendor_license_check -c %{S:2} 73 | 74 | %if %{with check} 75 | 76 | # redefine gotestflags to include needed buildtags 77 | %define gotestflags %{gocompilerflags} '-tags=%{buildtags}' 78 | 79 | # skip ./pkg/vms - cannot run vm tests in build 80 | # skip ./test - end-to-end integration tests that cannot run in build 81 | %gotest $(go list ./... | awk '!/(vm|test)/ {print $1}') 82 | %endif 83 | 84 | %files -f %{go_vendor_license_filelist} 85 | %license vendor/modules.txt 86 | %doc README.md 87 | %{_bindir}/podman-bootc 88 | # docs directory not included in current source tar file 89 | # %%{_mandir}/man1/*.1* 90 | 91 | %changelog 92 | %autochangelog 93 | -------------------------------------------------------------------------------- /pkg/vm/krunkit.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/containers/podman-bootc/pkg/utils" 11 | 12 | "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const krunkitBinaryName = "krunkit" 16 | 17 | const defaultCpus = 4 18 | const defaultMemory = 2048 19 | 20 | type krunkitParams struct { 21 | disk string 22 | netSocket string 23 | oemString string 24 | pidFile string 25 | } 26 | 27 | type krunkit struct { 28 | pidFile string 29 | cmd *exec.Cmd 30 | } 31 | 32 | func newKrunkit(ctx context.Context, binaryPath string, params krunkitParams) *krunkit { 33 | cmdLine := newKrunkitCmdLine(defaultCpus, defaultMemory) 34 | cmdLine.addRngDevice() 35 | cmdLine.addBlockDevice(params.disk) 36 | cmdLine.addNetworkDevice(params.netSocket) 37 | cmdLine.addOemString(params.oemString) 38 | 39 | cmdLineSlice := cmdLine.asSlice() 40 | cmd := exec.CommandContext(ctx, binaryPath, cmdLineSlice...) 41 | logrus.Debugf("krunkit command-line: %s %s", binaryPath, strings.Join(cmdLineSlice, " ")) 42 | 43 | return &krunkit{cmd: cmd, pidFile: params.pidFile} 44 | } 45 | 46 | func (k *krunkit) start() error { 47 | if err := k.cmd.Start(); err != nil { 48 | return fmt.Errorf("unable to start krunkit: %w", err) 49 | } 50 | 51 | if err := utils.WritePidFile(k.pidFile, k.cmd.Process.Pid); err != nil { 52 | if err := k.cmd.Cancel(); err != nil { 53 | logrus.Debugf("stopping krunkit: %v", err) 54 | } 55 | return fmt.Errorf("writing pid file %s: %w", k.pidFile, err) 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func (k *krunkit) wait() error { 62 | return k.cmd.Wait() 63 | } 64 | 65 | type krunkitCmdLine struct { 66 | cpus int 67 | memory int 68 | devices []string 69 | oemString []string 70 | } 71 | 72 | func newKrunkitCmdLine(cpus int, memory int) *krunkitCmdLine { 73 | return &krunkitCmdLine{cpus: cpus, memory: memory} 74 | } 75 | 76 | func (kc *krunkitCmdLine) addDevice(device string) { 77 | kc.devices = append(kc.devices, device) 78 | } 79 | 80 | func (kc *krunkitCmdLine) addRngDevice() { 81 | kc.addDevice("virtio-rng") 82 | } 83 | 84 | func (kc *krunkitCmdLine) addBlockDevice(diskAbsPath string) { 85 | kc.addDevice(fmt.Sprintf("virtio-blk,path=%s", diskAbsPath)) 86 | } 87 | 88 | func (kc *krunkitCmdLine) addNetworkDevice(socketAbsPath string) { 89 | kc.addDevice(fmt.Sprintf("virtio-net,unixSocketPath=%s,mac=5a:94:ef:e4:0c:ee", socketAbsPath)) 90 | } 91 | 92 | func (kc *krunkitCmdLine) addOemString(oemStr string) { 93 | kc.oemString = append(kc.oemString, oemStr) 94 | } 95 | 96 | func (kc *krunkitCmdLine) asSlice() []string { 97 | args := []string{} 98 | 99 | args = append(args, "--cpus", strconv.Itoa(kc.cpus)) 100 | args = append(args, "--memory", strconv.Itoa(kc.memory)) 101 | 102 | for _, device := range kc.devices { 103 | args = append(args, "--device", device) 104 | } 105 | 106 | for _, oemStr := range kc.oemString { 107 | args = append(args, "--oem-string", oemStr) 108 | } 109 | return args 110 | } 111 | -------------------------------------------------------------------------------- /pkg/vm/monitor.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/containers/podman-bootc/pkg/config" 10 | 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | type stopFunction func() error 15 | type waitFunction func() error 16 | 17 | type MonitorParmeters struct { 18 | CacheDir string 19 | RunDir string 20 | Username string 21 | SshIdentity string 22 | SshPort int 23 | } 24 | 25 | func StartMonitor(ctx context.Context, params MonitorParmeters) error { 26 | netSocket, stopGvpd, err := startNetworkDaemon(ctx, params.RunDir, params.SshPort) 27 | if err != nil { 28 | return err 29 | } 30 | defer func() { 31 | if err := stopGvpd(); err != nil { 32 | logrus.Errorf("stoping gvproxy: %v", err) 33 | } 34 | }() 35 | 36 | krkWait, err := startKrunkit(ctx, params.CacheDir, params.Username, params.SshIdentity, netSocket) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if err := krkWait(); err != nil { 42 | logrus.Debugf("krunkit wait return error: %v", err) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func startNetworkDaemon(ctx context.Context, runDir string, sshPort int) (string, stopFunction, error) { 49 | binaryPath, err := getBinaryPath(gvproxyBinaryName) 50 | if err != nil { 51 | return "", nil, err 52 | } 53 | 54 | params := gvproxyParams{ 55 | SshPort: sshPort, 56 | } 57 | 58 | daemon := newGvproxy(ctx, binaryPath, runDir, params) 59 | 60 | if err := daemon.start(); err != nil { 61 | return "", nil, fmt.Errorf("could not start %s: %w", binaryPath, err) 62 | } 63 | 64 | // the only purpose of this gorutine is to capture the signal from the child process 65 | go func(ctx context.Context, daemon *gvproxyDaemon) { 66 | if err := daemon.wait(); err != nil { 67 | logrus.Debugf("gvproxy wait return error: %v", err) 68 | } 69 | }(ctx, daemon) 70 | 71 | return daemon.socketPath, daemon.stop, nil 72 | } 73 | 74 | func startKrunkit(ctx context.Context, cacheDir, username, sshIdentity, netSocketPath string) (waitFunction, error) { 75 | binaryPath, err := getBinaryPath(krunkitBinaryName) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | pidFile := filepath.Join(cacheDir, config.RunPidFile) 81 | disk := filepath.Join(cacheDir, config.DiskImage) 82 | 83 | oemString, err := oemStringSystemdCredential(username, sshIdentity) 84 | if err != nil { 85 | return nil, fmt.Errorf("creating oemstring systemd credential %w", err) 86 | } 87 | 88 | params := krunkitParams{ 89 | disk: disk, 90 | netSocket: netSocketPath, 91 | oemString: oemString, 92 | pidFile: pidFile, 93 | } 94 | 95 | krk := newKrunkit(ctx, binaryPath, params) 96 | 97 | err = krk.start() 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | return krk.wait, nil 103 | } 104 | 105 | func getBinaryPath(binaryName string) (string, error) { 106 | binaryPath, err := exec.LookPath(binaryName) 107 | if err != nil { 108 | return "", fmt.Errorf("could not find %s: %w", binaryName, err) 109 | } 110 | 111 | binaryPath, err = filepath.Abs(binaryPath) 112 | if err != nil { 113 | return "", fmt.Errorf("could not get absolut path of %s: %w", binaryPath, err) 114 | } 115 | 116 | return binaryPath, nil 117 | } 118 | -------------------------------------------------------------------------------- /cmd/rm.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/containers/podman-bootc/pkg/config" 8 | "github.com/containers/podman-bootc/pkg/user" 9 | "github.com/containers/podman-bootc/pkg/utils" 10 | "github.com/containers/podman-bootc/pkg/vm" 11 | 12 | "github.com/sirupsen/logrus" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | force = false 18 | removeAll = false 19 | rmCmd = &cobra.Command{ 20 | Use: "rm ", 21 | Short: "Remove installed bootc VMs", 22 | Long: "Remove installed bootc VMs", 23 | Args: oneOrAll(), 24 | RunE: doRemove, 25 | } 26 | ) 27 | 28 | func init() { 29 | RootCmd.AddCommand(rmCmd) 30 | rmCmd.Flags().BoolVar(&removeAll, "all", false, "Removes all non-running bootc VMs") 31 | rmCmd.Flags().BoolVarP(&force, "force", "f", false, "Terminate a running VM") 32 | } 33 | 34 | func oneOrAll() cobra.PositionalArgs { 35 | return func(_ *cobra.Command, args []string) error { 36 | if len(args) != 1 && !removeAll { 37 | return fmt.Errorf("accepts 1 arg(s), received %d", len(args)) 38 | } 39 | if len(args) != 0 && removeAll { 40 | return fmt.Errorf("accepts 0 arg(s), received %d", len(args)) 41 | } 42 | return nil 43 | } 44 | } 45 | 46 | func doRemove(_ *cobra.Command, args []string) error { 47 | if removeAll { 48 | return pruneAll() 49 | } 50 | 51 | return prune(args[0]) 52 | } 53 | 54 | func prune(id string) error { 55 | user, err := user.NewUser() 56 | if err != nil { 57 | return err 58 | } 59 | 60 | bootcVM, err := vm.NewVM(vm.NewVMParameters{ 61 | ImageID: id, 62 | LibvirtUri: config.LibvirtUri, 63 | User: user, 64 | Locking: utils.Exclusive, 65 | }) 66 | if err != nil { 67 | return fmt.Errorf("unable to get VM %s: %v", id, err) 68 | } 69 | 70 | // Let's be explicit instead of relying on the defer exec order 71 | defer func() { 72 | bootcVM.CloseConnection() 73 | if err := bootcVM.Unlock(); err != nil { 74 | logrus.Warningf("unable to unlock VM %s: %v", id, err) 75 | } 76 | }() 77 | 78 | if force { 79 | err := forceKillVM(bootcVM) 80 | if err != nil { 81 | return fmt.Errorf("unable to force kill %s", id) 82 | } 83 | } else { 84 | err := killVM(bootcVM) 85 | if err != nil { 86 | return fmt.Errorf("unable to kill %s", id) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func pruneAll() error { 94 | user, err := user.NewUser() 95 | if err != nil { 96 | return err 97 | } 98 | 99 | files, err := os.ReadDir(user.CacheDir()) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | for _, f := range files { 105 | if f.IsDir() { 106 | vmID := f.Name() 107 | err := prune(vmID) 108 | if err != nil { 109 | logrus.Errorf("unable to remove %s: %v", vmID, err) 110 | } 111 | } 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func killVM(bootcVM vm.BootcVM) (err error) { 118 | var isRunning bool 119 | isRunning, err = bootcVM.IsRunning() 120 | if err != nil { 121 | return fmt.Errorf("unable to check if VM is running: %v", err) 122 | } 123 | 124 | if isRunning { 125 | return fmt.Errorf("VM is currently running. Stop it first or use the -f flag.") 126 | } else { 127 | err = bootcVM.Delete() 128 | if err != nil { 129 | return 130 | } 131 | } 132 | 133 | return bootcVM.DeleteFromCache() 134 | } 135 | 136 | func forceKillVM(bootcVM vm.BootcVM) (err error) { 137 | err = bootcVM.Delete() 138 | if err != nil { 139 | return 140 | } 141 | 142 | return bootcVM.DeleteFromCache() 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Streamlining podman + bootc interactions 2 | 3 | NOTE: This project is now in maintenance mode, and will be succeeded by 4 | [bcvk](https://github.com/bootc-dev/bcvk). Currently though, bcvk [does not 5 | support MacOS](https://github.com/bootc-dev/bcvk/issues/21) and so MacOS 6 | users should continue to use podman-bootc for now. 7 | 8 | ## Goals 9 | 10 | - Be a scriptable CLI that offers an efficient and ergonomic "edit-compile-debug" cycle for bootable containers. 11 | - Be a backend for 12 | - Work on both MacOS and Linux 13 | 14 | ## Running 15 | 16 | First and foremost, `podman-bootc` requires a *rootful* Podman Machine to be 17 | running, which is the default on MacOS and Windows. On Linux, make sure to 18 | create a Podman Machine via `podman machine init --rootful --now` which implies 19 | that you need to run podman with `--remote` command to make built images 20 | available to `podman-bootc`. 21 | 22 | The core command right now is: 23 | 24 | ```shell 25 | podman-bootc run 26 | ``` 27 | 28 | This command creates a new virtual machine, backed by a persistent disk 29 | image from a "self install" of the container image, and makes a SSH 30 | connection to it. 31 | 32 | This requires SSH to be enabled by default in your base image; by 33 | default an automatically generated SSH key is injected via a systemd 34 | credential attached to qemu. 35 | 36 | Even after you close the SSH connection, the machine continues to run. 37 | 38 | ### Other commands: 39 | 40 | - `podman-bootc list`: List running VMs 41 | - `podman-bootc ssh`: Connect to a VM 42 | - `podman-bootc rm`: Remove a VM 43 | 44 | ### Architecture 45 | 46 | At the current time the `run` command uses a 47 | [bootc install](https://containers.github.io/bootc/bootc-install.html) 48 | flow - where the container installs itself executed in a privileged 49 | mode inside the podman-machine VM. 50 | 51 | The installation target is a raw disk image is created on the host, but loopback 52 | mounted over virtiofs/9p from the podman-machine VM. 53 | 54 | (The need for a real-root privileged container to write Linux filesystems is part of the 55 | rationale for requiring podman-machine even on Linux is that 56 | it keeps the architecture aligned with MacOS (where it's always required)) 57 | 58 | In the future, support for installing via [Anaconda](https://github.com/rhinstaller/anaconda/) 59 | and [bootc-image-builder](https://github.com/osbuild/bootc-image-builder) 60 | will be added. 61 | 62 | ## Installation 63 | 64 | ### MacOS 65 | 66 | First be sure you have the Podman Desktop [bootc extension requirements](https://github.com/containers/podman-desktop-extension-bootc?tab=readme-ov-file#requirements). 67 | 68 | On MacOS you can use homebrew to install podman-bootc: 69 | 70 | ``` 71 | brew tap germag/podman-bootc 72 | brew install podman-bootc 73 | ``` 74 | 75 | alternatively, you can download the latest development cutting-edge source 76 | 77 | ``` 78 | brew install --head podman-bootc 79 | ``` 80 | 81 | It will install xorriso and libvirt, but it doesn't install qemu. 82 | You need to install qemu manually, using brew: 83 | ``` 84 | brew install qemu 85 | ``` 86 | or by other mean and make it available in the path. 87 | 88 | ### Fedora 89 | 90 | For Fedora 40+ and Rawhide we provide a COPR repository. 91 | First, enable the COPR repository: 92 | 93 | ``` 94 | sudo dnf -y install 'dnf-command(copr)' 95 | sudo dnf -y copr enable gmaglione/podman-bootc 96 | ``` 97 | 98 | then you can install `podman-bootc` as usual: 99 | 100 | ``` 101 | sudo dnf -y install podman-bootc 102 | ``` 103 | 104 | ## Building from source: 105 | 106 | Our generic dependencies: 107 | 108 | - qemu-system-x86_64 / qemu-system-aarch64 109 | - xorriso/osirrox 110 | - golang 111 | - libvirt-devel 112 | 113 | To compile it, just run in the project directory: 114 | 115 | ```shell 116 | make 117 | ``` 118 | -------------------------------------------------------------------------------- /pkg/vm/vm_darwin.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strconv" 9 | 10 | "github.com/containers/podman-bootc/pkg/config" 11 | "github.com/containers/podman-bootc/pkg/utils" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | type BootcVMMac struct { 17 | socketFile string 18 | BootcVMCommon 19 | } 20 | 21 | func NewVM(params NewVMParameters) (vm *BootcVMMac, err error) { 22 | if params.ImageID == "" { 23 | return nil, fmt.Errorf("image ID is required") 24 | } 25 | 26 | longId, cacheDir, err := GetVMCachePath(params.ImageID, params.User) 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to get VM cache path: %w", err) 29 | } 30 | 31 | lock, err := lockVM(params, cacheDir) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | vm = &BootcVMMac{ 37 | socketFile: filepath.Join(params.User.CacheDir(), longId[:12]+"-console.sock"), 38 | BootcVMCommon: BootcVMCommon{ 39 | imageID: longId, 40 | cacheDir: cacheDir, 41 | diskImagePath: filepath.Join(cacheDir, config.DiskImage), 42 | pidFile: filepath.Join(cacheDir, config.RunPidFile), 43 | user: params.User, 44 | cacheDirLock: lock, 45 | }, 46 | } 47 | 48 | return vm, nil 49 | 50 | } 51 | 52 | func (b *BootcVMMac) CloseConnection() { 53 | return //no-op when using qemu 54 | } 55 | 56 | func (b *BootcVMMac) PrintConsole() (err error) { 57 | return nil 58 | } 59 | 60 | func (b *BootcVMMac) GetConfig() (cfg *BootcVMConfig, err error) { 61 | cfg, err = b.LoadConfigFile() 62 | if err != nil { 63 | return 64 | } 65 | 66 | vmPidFile := filepath.Join(b.cacheDir, config.RunPidFile) 67 | pid, _ := utils.ReadPidFile(vmPidFile) 68 | if pid != -1 && utils.IsProcessAlive(pid) { 69 | cfg.Running = true 70 | } else { 71 | cfg.Running = false 72 | } 73 | 74 | return 75 | } 76 | 77 | func (b *BootcVMMac) Run(params RunVMParameters) (err error) { 78 | b.sshPort = params.SSHPort 79 | b.removeVm = params.RemoveVm 80 | b.background = params.Background 81 | b.cmd = params.Cmd 82 | b.hasCloudInit = params.CloudInitData 83 | b.cloudInitDir = params.CloudInitDir 84 | b.vmUsername = params.VMUser 85 | b.sshIdentity = params.SSHIdentity 86 | 87 | execPath, err := os.Executable() 88 | if err != nil { 89 | return fmt.Errorf("getting executable path: %w", err) 90 | } 91 | 92 | execPath, err = filepath.EvalSymlinks(execPath) 93 | if err != nil { 94 | return fmt.Errorf("following executable symlink: %w", err) 95 | } 96 | 97 | execPath, err = filepath.Abs(execPath) 98 | if err != nil { 99 | return fmt.Errorf("getting executable absolute path: %w", err) 100 | } 101 | 102 | args := []string{"vmmon", b.imageID, b.vmUsername, b.sshIdentity, strconv.Itoa(b.sshPort)} 103 | cmd := exec.Command(execPath, args...) 104 | 105 | logrus.Debugf("Executing: %v", cmd.Args) 106 | cmd.Stdout = os.Stdout 107 | cmd.Stderr = os.Stderr 108 | return cmd.Start() 109 | } 110 | 111 | func (b *BootcVMMac) Delete() error { 112 | logrus.Debugf("Deleting Mac VM %s", b.cacheDir) 113 | 114 | isRunning, err := b.IsRunning() 115 | if err != nil { 116 | return fmt.Errorf("checking if VM is running: %w", err) 117 | } 118 | 119 | if !isRunning { 120 | return nil 121 | } 122 | 123 | pid, err := utils.ReadPidFile(b.pidFile) 124 | if err != nil { 125 | return fmt.Errorf("reading pid file: %w", err) 126 | } 127 | 128 | process, err := os.FindProcess(pid) 129 | if err != nil { 130 | return fmt.Errorf("process not found while attempting to delete VM: %w", err) 131 | } 132 | 133 | return process.Signal(os.Interrupt) 134 | } 135 | 136 | func (b *BootcVMMac) IsRunning() (bool, error) { 137 | pidFileExists, err := utils.FileExists(b.pidFile) 138 | if !pidFileExists { 139 | logrus.Debugf("pid file does not exist, assuming VM is not running.") 140 | return false, nil //assume if pid is missing the VM is not running 141 | } 142 | 143 | pid, err := utils.ReadPidFile(b.pidFile) 144 | if err != nil { 145 | return false, fmt.Errorf("reading pid file: %w", err) 146 | } 147 | 148 | if pid != -1 && utils.IsProcessAlive(pid) { 149 | return true, nil 150 | } else { 151 | return false, nil 152 | } 153 | } 154 | 155 | func (b *BootcVMMac) Exists() (bool, error) { 156 | return utils.FileExists(b.pidFile) 157 | } 158 | 159 | func (v *BootcVMMac) Unlock() error { 160 | return v.cacheDirLock.Unlock() 161 | } 162 | -------------------------------------------------------------------------------- /test/e2e/e2e_utils.go: -------------------------------------------------------------------------------- 1 | package e2e 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/containers/podman-bootc/pkg/user" 12 | ) 13 | 14 | const DefaultBaseImage = "quay.io/centos-bootc/centos-bootc-dev:stream9" 15 | const TestImageOne = "quay.io/ckyrouac/podman-bootc-test:one" 16 | const TestImageTwo = "quay.io/ckyrouac/podman-bootc-test:two" 17 | 18 | var BaseImage = GetBaseImage() 19 | 20 | func GetBaseImage() string { 21 | if os.Getenv("BASE_IMAGE") != "" { 22 | return os.Getenv("BASE_IMAGE") 23 | } else { 24 | return DefaultBaseImage 25 | } 26 | } 27 | 28 | func PodmanBootcBinary() string { 29 | return ProjectRoot() + "/../../bin/podman-bootc" 30 | } 31 | 32 | func ProjectRoot() string { 33 | ex, err := os.Executable() 34 | if err != nil { 35 | panic(err) 36 | } 37 | projectRoot := filepath.Dir(ex) 38 | return projectRoot 39 | } 40 | 41 | func RunCmd(cmd string, args ...string) (stdout string, stderr string, err error) { 42 | execCmd := exec.Command(cmd, args...) 43 | 44 | var stdOut strings.Builder 45 | execCmd.Stdout = &stdOut 46 | 47 | var stdErr strings.Builder 48 | execCmd.Stderr = &stdErr 49 | 50 | err = execCmd.Run() 51 | if err != nil { 52 | println(stdOut.String()) 53 | println(stdErr.String()) 54 | return 55 | } 56 | 57 | return stdOut.String(), stdErr.String(), nil 58 | } 59 | 60 | func RunPodmanBootc(args ...string) (stdout string, stderr string, err error) { 61 | return RunCmd(PodmanBootcBinary(), args...) 62 | } 63 | 64 | func RunPodman(args ...string) (stdout string, stderr string, err error) { 65 | podmanArgs := append([]string{"-c", "podman-machine-default-root"}, args...) 66 | return RunCmd("podman", podmanArgs...) 67 | } 68 | 69 | func ListCacheDirs() (vmDirs []string, err error) { 70 | user, err := user.NewUser() 71 | if err != nil { 72 | return 73 | } 74 | cacheDirContents, err := os.ReadDir(user.CacheDir()) 75 | if err != nil { 76 | return 77 | } 78 | 79 | for _, dir := range cacheDirContents { 80 | if dir.IsDir() { 81 | vmDirs = append(vmDirs, filepath.Join(user.CacheDir(), dir.Name())) 82 | } 83 | } 84 | return 85 | } 86 | 87 | func GetVMIdFromContainerImage(image string) (vmId string, err error) { 88 | imagesListOutput, _, err := RunPodman("images", image, "--format", "json") 89 | if err != nil { 90 | return 91 | } 92 | 93 | imagesList := []map[string]interface{}{} 94 | err = json.Unmarshal([]byte(imagesListOutput), &imagesList) 95 | if err != nil { 96 | return 97 | } 98 | 99 | if len(imagesList) != 1 { 100 | err = fmt.Errorf("Expected 1 image, got %d", len(imagesList)) 101 | return 102 | } 103 | 104 | vmId = imagesList[0]["Id"].(string)[:12] 105 | return 106 | } 107 | 108 | func BootVM(image string) (vm *TestVM, err error) { 109 | runActiveCmd := exec.Command(PodmanBootcBinary(), "run", image) 110 | stdIn, err := runActiveCmd.StdinPipe() 111 | 112 | if err != nil { 113 | return 114 | } 115 | 116 | vm = &TestVM{ 117 | StdIn: stdIn, 118 | } 119 | 120 | runActiveCmd.Stdout = vm 121 | runActiveCmd.Stderr = vm 122 | 123 | go func() { 124 | err = runActiveCmd.Run() 125 | if err != nil { 126 | return 127 | } 128 | }() 129 | 130 | err = vm.WaitForBoot() 131 | 132 | // populate the vm id after podman-bootc run 133 | // so we can get the id from the pulled container image 134 | vmId, err := GetVMIdFromContainerImage(image) 135 | if err != nil { 136 | return 137 | } 138 | vm.Id = vmId 139 | 140 | return 141 | } 142 | 143 | func Cleanup() (err error) { 144 | _, _, err = RunPodmanBootc("rm", "--all", "-f") 145 | if err != nil { 146 | return 147 | } 148 | 149 | _, _, err = RunPodman("rmi", BaseImage, "-f") 150 | if err != nil { 151 | return 152 | } 153 | 154 | _, _, err = RunPodman("rmi", TestImageTwo, "-f") 155 | if err != nil { 156 | return 157 | } 158 | 159 | _, _, err = RunPodman("rmi", TestImageOne, "-f") 160 | if err != nil { 161 | return 162 | } 163 | 164 | user, err := user.NewUser() 165 | if err != nil { 166 | return 167 | } 168 | 169 | err = user.RemoveOSCDirs() 170 | return 171 | } 172 | 173 | type ListEntry struct { 174 | Id string 175 | Repo string 176 | Running string 177 | } 178 | 179 | // ParseListOutput parses the output of the podman bootc list command for easier comparison 180 | func ParseListOutput(stdout string) (listOutput []ListEntry) { 181 | listOuputLines := strings.Split(stdout, "\n") 182 | 183 | for i, line := range listOuputLines { 184 | if i == 0 { 185 | continue //skip the header 186 | } 187 | 188 | if len(strings.Fields(line)) == 0 { 189 | continue //skip the empty line 190 | } 191 | 192 | entryArray := strings.Fields(line) 193 | entry := ListEntry{ 194 | Id: string(entryArray[0]), 195 | Repo: string(entryArray[1]), 196 | Running: string(entryArray[len(entryArray)-2]), 197 | } 198 | 199 | listOutput = append(listOutput, entry) 200 | } 201 | 202 | return 203 | } 204 | -------------------------------------------------------------------------------- /cmd/images.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "time" 9 | "unicode" 10 | 11 | "github.com/containers/common/pkg/report" 12 | "github.com/containers/podman-bootc/pkg/utils" 13 | "github.com/containers/podman/v5/pkg/bindings/images" 14 | "github.com/containers/podman/v5/pkg/domain/entities" 15 | "github.com/distribution/reference" 16 | "github.com/docker/go-units" 17 | 18 | "github.com/sirupsen/logrus" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | var ( 23 | imagesCmd = &cobra.Command{ 24 | Use: "images", 25 | Short: "List bootc images in the local containers store", 26 | Long: "List bootc images in the local container store", 27 | RunE: doImages, 28 | } 29 | ) 30 | 31 | func init() { 32 | RootCmd.AddCommand(imagesCmd) 33 | } 34 | 35 | func doImages(flags *cobra.Command, args []string) error { 36 | machine, err := utils.GetMachineContext() 37 | if err != nil { 38 | println(utils.PodmanMachineErrorMessage) 39 | logrus.Errorf("failed to connect to podman machine. Is podman machine running?\n%s", err) 40 | return err 41 | } 42 | 43 | filters := map[string][]string{"label": []string{"containers.bootc=1"}} 44 | imageList, err := images.List(machine.Ctx, new(images.ListOptions).WithFilters(filters)) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | imageReports, err := sortImages(imageList) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | return writeImagesTemplate(imageReports) 55 | } 56 | 57 | func writeImagesTemplate(imgs []imageReporter) error { 58 | hdrs := report.Headers(imageReporter{}, map[string]string{ 59 | "ID": "IMAGE ID", 60 | "ReadOnly": "R/O", 61 | }) 62 | 63 | rpt := report.New(os.Stdout, "images") 64 | defer rpt.Flush() 65 | 66 | rpt, err := rpt.Parse(report.OriginPodman, lsFormatFromFlags()) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | if err := rpt.Execute(hdrs); err != nil { 72 | return err 73 | } 74 | 75 | return rpt.Execute(imgs) 76 | } 77 | 78 | func sortImages(imageS []*entities.ImageSummary) ([]imageReporter, error) { 79 | imgs := make([]imageReporter, 0, len(imageS)) 80 | var err error 81 | for _, e := range imageS { 82 | var h imageReporter 83 | if len(e.RepoTags) > 0 { 84 | tagged := []imageReporter{} 85 | untagged := []imageReporter{} 86 | for _, tag := range e.RepoTags { 87 | h.ImageSummary = *e 88 | h.Repository, h.Tag, err = tokenRepoTag(tag) 89 | if err != nil { 90 | return nil, fmt.Errorf("parsing repository tag: %q: %w", tag, err) 91 | } 92 | if h.Tag == "" { 93 | untagged = append(untagged, h) 94 | } else { 95 | tagged = append(tagged, h) 96 | } 97 | } 98 | // Note: we only want to display "" if we 99 | // couldn't find any tagged name in RepoTags. 100 | if len(tagged) > 0 { 101 | imgs = append(imgs, tagged...) 102 | } else { 103 | imgs = append(imgs, untagged[0]) 104 | } 105 | } else { 106 | h.ImageSummary = *e 107 | h.Repository = "" 108 | h.Tag = "" 109 | imgs = append(imgs, h) 110 | } 111 | } 112 | 113 | sort.Slice(imgs, sortFunc("created", imgs)) 114 | return imgs, err 115 | } 116 | 117 | func tokenRepoTag(ref string) (string, string, error) { 118 | if ref == ":" { 119 | return "", "", nil 120 | } 121 | 122 | repo, err := reference.Parse(ref) 123 | if err != nil { 124 | return "", "", err 125 | } 126 | 127 | named, ok := repo.(reference.Named) 128 | if !ok { 129 | return ref, "", nil 130 | } 131 | name := named.Name() 132 | if name == "" { 133 | name = "" 134 | } 135 | 136 | tagged, ok := repo.(reference.Tagged) 137 | if !ok { 138 | return name, "", nil 139 | } 140 | tag := tagged.Tag() 141 | if tag == "" { 142 | tag = "" 143 | } 144 | 145 | return name, tag, nil 146 | } 147 | 148 | func sortFunc(key string, data []imageReporter) func(i, j int) bool { 149 | switch key { 150 | case "id": 151 | return func(i, j int) bool { 152 | return data[i].ID() < data[j].ID() 153 | } 154 | case "repository": 155 | return func(i, j int) bool { 156 | return data[i].Repository < data[j].Repository 157 | } 158 | case "size": 159 | return func(i, j int) bool { 160 | return data[i].size() < data[j].size() 161 | } 162 | case "tag": 163 | return func(i, j int) bool { 164 | return data[i].Tag < data[j].Tag 165 | } 166 | default: 167 | // case "created": 168 | return func(i, j int) bool { 169 | return data[i].created().After(data[j].created()) 170 | } 171 | } 172 | } 173 | 174 | func lsFormatFromFlags() string { 175 | row := []string{ 176 | "{{if .Repository}}{{.Repository}}{{else}}{{end}}", 177 | "{{if .Tag}}{{.Tag}}{{else}}{{end}}", 178 | "{{.ID}}", "{{.Created}}", "{{.Size}}", 179 | } 180 | return "{{range . }}" + strings.Join(row, "\t") + "\n{{end -}}" 181 | } 182 | 183 | type imageReporter struct { 184 | Repository string `json:"repository,omitempty"` 185 | Tag string `json:"tag,omitempty"` 186 | entities.ImageSummary 187 | } 188 | 189 | func (i imageReporter) ID() string { 190 | return i.ImageSummary.ID[0:12] 191 | } 192 | 193 | func (i imageReporter) Created() string { 194 | return units.HumanDuration(time.Since(i.created())) + " ago" 195 | } 196 | 197 | func (i imageReporter) created() time.Time { 198 | return time.Unix(i.ImageSummary.Created, 0).UTC() 199 | } 200 | 201 | func (i imageReporter) Size() string { 202 | s := units.HumanSizeWithPrecision(float64(i.ImageSummary.Size), 3) 203 | j := strings.LastIndexFunc(s, unicode.IsNumber) 204 | return s[:j+1] + " " + s[j+1:] 205 | } 206 | 207 | func (i imageReporter) History() string { 208 | return strings.Join(i.ImageSummary.History, ", ") 209 | } 210 | 211 | func (i imageReporter) CreatedAt() string { 212 | return i.created().String() 213 | } 214 | 215 | func (i imageReporter) CreatedSince() string { 216 | return i.Created() 217 | } 218 | 219 | func (i imageReporter) CreatedTime() string { 220 | return i.CreatedAt() 221 | } 222 | 223 | func (i imageReporter) size() int64 { 224 | return i.ImageSummary.Size 225 | } 226 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/containers/podman-bootc/pkg/bootc" 8 | "github.com/containers/podman-bootc/pkg/config" 9 | "github.com/containers/podman-bootc/pkg/credentials" 10 | "github.com/containers/podman-bootc/pkg/user" 11 | "github.com/containers/podman-bootc/pkg/utils" 12 | "github.com/containers/podman-bootc/pkg/vm" 13 | 14 | "github.com/sirupsen/logrus" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | type osVmConfig struct { 19 | User string 20 | CloudInitDir string 21 | KsFile string 22 | Background bool 23 | RemoveVm bool // Kill the running VM when it exits 24 | RemoveDiskImage bool // After exit of the VM, remove the disk image 25 | Quiet bool 26 | } 27 | 28 | var ( 29 | // listCmd represents the hello command 30 | runCmd = &cobra.Command{ 31 | Use: "run", 32 | Short: "Run a bootc container as a VM", 33 | Long: "Run a bootc container as a VM", 34 | Args: cobra.MinimumNArgs(1), 35 | RunE: doRun, 36 | SilenceUsage: true, 37 | } 38 | 39 | vmConfig = osVmConfig{} 40 | diskImageConfigInstance = bootc.DiskImageConfig{} 41 | ) 42 | 43 | func init() { 44 | RootCmd.AddCommand(runCmd) 45 | runCmd.Flags().StringVarP(&vmConfig.User, "user", "u", "root", "--user (default: root)") 46 | 47 | runCmd.Flags().StringVar(&vmConfig.CloudInitDir, "cloudinit", "", "--cloudinit ") 48 | 49 | runCmd.Flags().StringVar(&diskImageConfigInstance.Filesystem, "filesystem", "", "Override the root filesystem (e.g. xfs, btrfs, ext4)") 50 | runCmd.Flags().BoolVarP(&vmConfig.Background, "background", "B", false, "Do not spawn SSH, run in background") 51 | runCmd.Flags().BoolVar(&vmConfig.RemoveVm, "rm", false, "Remove the VM and it's disk when the SSH session exits. Cannot be used with --background") 52 | runCmd.Flags().BoolVar(&vmConfig.Quiet, "quiet", false, "Suppress output from bootc disk creation and VM boot console") 53 | runCmd.Flags().StringVar(&diskImageConfigInstance.RootSizeMax, "root-size-max", "", "Maximum size of root filesystem in bytes; optionally accepts M, G, T suffixes") 54 | runCmd.Flags().StringVar(&diskImageConfigInstance.DiskSize, "disk-size", "", "Allocate a disk image of this size in bytes; optionally accepts M, G, T suffixes") 55 | } 56 | 57 | func doRun(flags *cobra.Command, args []string) error { 58 | //get user info who is running the podman bootc command 59 | user, err := user.NewUser() 60 | if err != nil { 61 | return fmt.Errorf("unable to get user: %w", err) 62 | } 63 | 64 | machine, err := utils.GetMachineContext() 65 | if err != nil { 66 | println(utils.PodmanMachineErrorMessage) 67 | logrus.Errorf("failed to connect to podman machine. Is podman machine running?\n%s", err) 68 | return err 69 | } 70 | 71 | // create the disk image 72 | idOrName := args[0] 73 | bootcDisk := bootc.NewBootcDisk(idOrName, machine.Ctx, user) 74 | err = bootcDisk.Install(vmConfig.Quiet, diskImageConfigInstance) 75 | 76 | if err != nil { 77 | return fmt.Errorf("unable to install bootc image: %w", err) 78 | } 79 | 80 | //start the VM 81 | println("Booting the VM...") 82 | sshPort, err := utils.GetFreeLocalTcpPort() 83 | if err != nil { 84 | return fmt.Errorf("unable to get free port for SSH: %w", err) 85 | } 86 | 87 | bootcVM, err := vm.NewVM(vm.NewVMParameters{ 88 | ImageID: bootcDisk.GetImageId(), 89 | User: user, 90 | LibvirtUri: config.LibvirtUri, 91 | Locking: utils.Shared, 92 | }) 93 | 94 | if err != nil { 95 | return fmt.Errorf("unable to initialize VM: %w", err) 96 | } 97 | 98 | // Let's be explicit instead of relying on the defer exec order 99 | defer func() { 100 | bootcVM.CloseConnection() 101 | if err := bootcVM.Unlock(); err != nil { 102 | logrus.Warningf("unable to unlock VM %s: %v", bootcDisk.GetImageId(), err) 103 | } 104 | }() 105 | 106 | sSHIdentityPath, err := credentials.Generatekeys(bootcVM.CacheDir()) 107 | if err != nil { 108 | return fmt.Errorf("unable to generate ssh key: %w", err) 109 | } 110 | 111 | cmd := args[1:] 112 | err = bootcVM.Run(vm.RunVMParameters{ 113 | Cmd: cmd, 114 | CloudInitDir: vmConfig.CloudInitDir, 115 | CloudInitData: flags.Flags().Changed("cloudinit"), 116 | RemoveVm: vmConfig.RemoveVm, 117 | Background: vmConfig.Background, 118 | SSHPort: sshPort, 119 | SSHIdentity: sSHIdentityPath, 120 | VMUser: vmConfig.User, 121 | }) 122 | 123 | if err != nil { 124 | return fmt.Errorf("runBootcVM: %w", err) 125 | } 126 | 127 | // write down the config file 128 | if err = bootcVM.WriteConfig(*bootcDisk); err != nil { 129 | return err 130 | } 131 | 132 | if !vmConfig.Background { 133 | if !vmConfig.Quiet { 134 | go func() { 135 | err := bootcVM.PrintConsole() 136 | if err != nil { 137 | logrus.Errorf("error printing VM console: %v", err) 138 | } 139 | }() 140 | 141 | err = bootcVM.WaitForSSHToBeReady() 142 | if err != nil { 143 | return fmt.Errorf("WaitSshReady: %w", err) 144 | } 145 | 146 | // the PrintConsole routine is suddenly stopped without waiting for 147 | // the print buffer to be flushed, this can lead to the consoel output 148 | // printing after the ssh prompt begins. Sleeping for a second 149 | // should prevent this from happening on most systems. 150 | // 151 | // The libvirt console stream API blocks while waiting for data, so 152 | // cleanly stopping the routing via a channel is not possible. 153 | time.Sleep(1 * time.Second) 154 | } else { 155 | err = bootcVM.WaitForSSHToBeReady() 156 | if err != nil { 157 | return fmt.Errorf("WaitSshReady: %w", err) 158 | } 159 | } 160 | 161 | // ssh into the VM 162 | ExitCode, err = utils.WithExitCode(bootcVM.RunSSH(cmd)) 163 | if err != nil { 164 | return fmt.Errorf("ssh: %w", err) 165 | } 166 | 167 | // Always remove when executing a command 168 | if vmConfig.RemoveVm || len(cmd) > 0 { 169 | err = bootcVM.Delete() //delete the VM, but keep the disk image 170 | if err != nil { 171 | return fmt.Errorf("unable to remove VM from cache: %w", err) 172 | } 173 | } 174 | } 175 | 176 | return nil 177 | } 178 | -------------------------------------------------------------------------------- /pkg/utils/podman.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "github.com/containers/podman/v5/pkg/bindings/images" 9 | "github.com/containers/podman/v5/pkg/domain/entities/types" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | 14 | "github.com/containers/podman/v5/pkg/bindings" 15 | "github.com/containers/podman/v5/pkg/machine" 16 | "github.com/containers/podman/v5/pkg/machine/define" 17 | "github.com/containers/podman/v5/pkg/machine/env" 18 | "github.com/containers/podman/v5/pkg/machine/provider" 19 | "github.com/containers/podman/v5/pkg/machine/vmconfigs" 20 | ) 21 | 22 | type MachineContext struct { 23 | Ctx context.Context 24 | SSHIdentityPath string 25 | } 26 | 27 | type machineInfo struct { 28 | podmanSocket string 29 | sshIdentityPath string 30 | rootful bool 31 | } 32 | 33 | // PullAndInspect inpects the image, pulling in if the image if required 34 | func PullAndInspect(ctx context.Context, imageNameOrId string) (*types.ImageInspectReport, error) { 35 | pullPolicy := "missing" 36 | _, err := images.Pull(ctx, imageNameOrId, &images.PullOptions{Policy: &pullPolicy}) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to pull image: %w", err) 39 | } 40 | 41 | imageInfo, err := images.GetImage(ctx, imageNameOrId, &images.GetOptions{}) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to inspect image: %w", err) 44 | } 45 | 46 | return imageInfo, nil 47 | } 48 | 49 | func GetMachineContext() (*MachineContext, error) { 50 | //podman machine connection 51 | machineInfo, err := getMachineInfo() 52 | if err != nil { 53 | return nil, fmt.Errorf("unable to get podman machine info: %w", err) 54 | } 55 | 56 | if machineInfo == nil { 57 | return nil, errors.New("rootful podman machine is required, please run 'podman machine init --rootful'") 58 | } 59 | 60 | if !machineInfo.rootful { 61 | return nil, errors.New("rootful podman machine is required, please run 'podman machine set --rootful'") 62 | } 63 | 64 | if _, err := os.Stat(machineInfo.podmanSocket); err != nil { 65 | return nil, fmt.Errorf("podman machine socket is missing: %w", err) 66 | } 67 | 68 | ctx, err := bindings.NewConnectionWithIdentity( 69 | context.Background(), 70 | fmt.Sprintf("unix://%s", machineInfo.podmanSocket), 71 | machineInfo.sshIdentityPath, 72 | true) 73 | if err != nil { 74 | return nil, fmt.Errorf("failed to connect to the podman socket: %w", err) 75 | } 76 | 77 | mc := &MachineContext{ 78 | Ctx: ctx, 79 | SSHIdentityPath: machineInfo.sshIdentityPath, 80 | } 81 | return mc, nil 82 | } 83 | 84 | func getMachineInfo() (*machineInfo, error) { 85 | minfo, err := getPv5MachineInfo() 86 | if err != nil { 87 | var errIncompatibleMachineConfig *define.ErrIncompatibleMachineConfig 88 | var errVMDoesNotExist *define.ErrVMDoesNotExist 89 | if errors.As(err, &errIncompatibleMachineConfig) || errors.As(err, &errVMDoesNotExist) { 90 | minfo, err := getPv4MachineInfo() 91 | if err != nil { 92 | return nil, err 93 | } 94 | return minfo, nil 95 | } 96 | return nil, err 97 | } 98 | 99 | return minfo, nil 100 | } 101 | 102 | // Get podman v5 machine info 103 | func getPv5MachineInfo() (*machineInfo, error) { 104 | prov, err := provider.Get() 105 | if err != nil { 106 | return nil, fmt.Errorf("getting podman machine provider: %w", err) 107 | } 108 | 109 | dirs, err := env.GetMachineDirs(prov.VMType()) 110 | if err != nil { 111 | return nil, fmt.Errorf("getting podman machine dirs: %w", err) 112 | } 113 | 114 | pm, err := vmconfigs.LoadMachineByName(machine.DefaultMachineName, dirs) 115 | if err != nil { 116 | return nil, fmt.Errorf("load podman machine info: %w", err) 117 | } 118 | 119 | podmanSocket, _, err := pm.ConnectionInfo(prov.VMType()) 120 | if err != nil { 121 | return nil, fmt.Errorf("getting podman machine connection info: %w", err) 122 | } 123 | 124 | pmi := machineInfo{ 125 | podmanSocket: podmanSocket.GetPath(), 126 | sshIdentityPath: pm.SSH.IdentityPath, 127 | rootful: pm.HostUser.Rootful, 128 | } 129 | return &pmi, nil 130 | } 131 | 132 | // Just to support podman v4.9, it will be removed in the future 133 | func getPv4MachineInfo() (*machineInfo, error) { 134 | //check if a default podman machine exists 135 | listCmd := exec.Command("podman", "machine", "list", "--format", "json") 136 | var listCmdOutput strings.Builder 137 | listCmd.Stdout = &listCmdOutput 138 | err := listCmd.Run() 139 | if err != nil { 140 | return nil, fmt.Errorf("failed to list podman machines: %w", err) 141 | } 142 | var machineList []MachineList 143 | err = json.Unmarshal([]byte(listCmdOutput.String()), &machineList) 144 | if err != nil { 145 | return nil, fmt.Errorf("failed to unmarshal podman machine inspect output: %w", err) 146 | } 147 | 148 | var defaultMachineName string 149 | if len(machineList) == 0 { 150 | return nil, errors.New("no podman machine found") 151 | } else if len(machineList) == 1 { 152 | // if there is only one machine, use it as the default 153 | // afaict, podman will use a single machine as the default, even if Default is false 154 | // in the output of `podman machine list` 155 | if !machineList[0].Running { 156 | println(PodmanMachineErrorMessage) 157 | return nil, errors.New("the default podman machine is not running") 158 | } 159 | defaultMachineName = machineList[0].Name 160 | } else { 161 | foundDefaultMachine := false 162 | for _, machine := range machineList { 163 | if machine.Default { 164 | if !machine.Running { 165 | println(PodmanMachineErrorMessage) 166 | return nil, errors.New("the default podman machine is not running") 167 | } 168 | 169 | foundDefaultMachine = true 170 | defaultMachineName = machine.Name 171 | } 172 | } 173 | 174 | if !foundDefaultMachine { 175 | println(PodmanMachineErrorMessage) 176 | return nil, errors.New("a default podman machine is not running") 177 | } 178 | } 179 | 180 | // check if the default podman machine is rootful 181 | inspectCmd := exec.Command("podman", "machine", "inspect", defaultMachineName) 182 | var inspectCmdOutput strings.Builder 183 | inspectCmd.Stdout = &inspectCmdOutput 184 | err = inspectCmd.Run() 185 | if err != nil { 186 | return nil, fmt.Errorf("failed to inspect podman machine: %w", err) 187 | } 188 | 189 | var machineInspect []MachineInspect 190 | err = json.Unmarshal([]byte(inspectCmdOutput.String()), &machineInspect) 191 | if err != nil { 192 | return nil, fmt.Errorf("failed to unmarshal podman machine inspect output: %w", err) 193 | } 194 | 195 | if len(machineInspect) == 0 { 196 | return nil, errors.New("no podman machine found") 197 | } 198 | 199 | return &machineInfo{ 200 | podmanSocket: machineInspect[0].ConnectionInfo.PodmanSocket.Path, 201 | sshIdentityPath: machineInspect[0].SSHConfig.IdentityPath, 202 | rootful: machineInspect[0].Rootful, 203 | }, nil 204 | } 205 | -------------------------------------------------------------------------------- /hack/man-page-checker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # man-page-checker - validate and cross-reference man page names 4 | # 5 | verbose= 6 | for i; do 7 | case "$i" in 8 | -v|--verbose) verbose=verbose ;; 9 | esac 10 | done 11 | 12 | 13 | die() { 14 | echo "$(basename $0): $*" >&2 15 | exit 1 16 | } 17 | 18 | cd $(dirname $0)/../docs/ || die "Please run me from top-level libpod dir" 19 | 20 | rc=0 21 | 22 | for md in *.1.md;do 23 | # Read the first line after '# NAME' (or '## NAME'). (FIXME: # and ## 24 | # are not the same; should we stick to one convention?) 25 | # There may be more than one name, e.g. podman-bootc-info.1.md has 26 | # podman-bootc-system-info then another line with podman-bootc-info. We 27 | # care only about the first. 28 | name=$(grep -E -A1 '^#* NAME' $md|tail -1|awk '{print $1}' | tr -d \\\\) 29 | 30 | expect=$(basename $md .1.md) 31 | if [ "$name" != "$expect" ]; then 32 | echo 33 | printf "Inconsistent program NAME in %s:\n" $md 34 | printf " NAME= %s (expected: %s)\n" $name $expect 35 | rc=1 36 | fi 37 | done 38 | 39 | # Pass 2: compare descriptions. 40 | # 41 | # Make sure the descriptive text in podman-bootc-foo.1.md matches the one 42 | # in the table in podman-bootc.1.md. 43 | for md in $(ls -1 *-*-*.1.md | grep -v remote);do 44 | desc=$(grep -E -A1 '^#* NAME' $md|tail -1|sed -e 's/^podman-bootc[^ ]\+ - //') 45 | 46 | # podman-bootc.1.md has a two-column table; podman-bootc-*-*.1.md all have three. 47 | parent=$(echo $md | sed -e 's/^\(.*\)-.*$/\1.1.md/') 48 | x=3 49 | if expr -- "$parent" : ".*-.*-" >/dev/null; then 50 | x=4 51 | fi 52 | 53 | # Find the descriptive text in the parent man page. 54 | # Strip off the final period; let's not warn about such minutia. 55 | parent_desc=$(grep $md $parent | awk -F'|' "{print \$$x}" | sed -e 's/^ \+//' -e 's/ \+$//' -e 's/\.$//') 56 | 57 | if [ "$desc" != "$parent_desc" ]; then 58 | echo 59 | printf "Inconsistent subcommand descriptions:\n" 60 | printf " %-32s = '%s'\n" $md "$desc" 61 | printf " %-32s = '%s'\n" $parent "$parent_desc" 62 | printf "Please ensure that the NAME section of $md\n" 63 | printf "matches the subcommand description in $parent\n" 64 | rc=1 65 | fi 66 | done 67 | 68 | # Helper function: compares man page synopsis vs --help usage message 69 | function compare_usage() { 70 | local cmd="$1" 71 | local from_man="$2" 72 | 73 | # Sometimes in CI we run before podman-bootc gets built. 74 | test -x ../../../bin/podman-bootc || return 75 | 76 | # Run 'cmd --help', grab the line immediately after 'Usage:' 77 | local help_output=$(../../../bin/$cmd --help) 78 | local from_help=$(echo "$help_output" | grep -A1 '^Usage:' | tail -1) 79 | 80 | # strip off command name from both 81 | from_man=$(sed -e "s/\*\*$cmd\*\*[[:space:]]*//" <<<"$from_man") 82 | from_help=$(sed -e "s/^[[:space:]]*${cmd}[[:space:]]*//" <<<"$from_help") 83 | 84 | # man page lists 'foo [*options*]', help msg shows 'foo [flags]'. 85 | # Make sure if one has it, the other does too. 86 | if expr "$from_man" : "\[\*options\*\]" >/dev/null; then 87 | if expr "$from_help" : "\[options\]" >/dev/null; then 88 | : 89 | else 90 | echo "WARNING: $cmd: man page shows '[*options*]', help does not show [options]" 91 | rc=1 92 | fi 93 | elif expr "$from_help" : "\[flags\]" >/dev/null; then 94 | echo "WARNING: $cmd: --help shows [flags], man page does not show [*options*]" 95 | rc=1 96 | fi 97 | 98 | # Strip off options and flags; start comparing arguments 99 | from_man=$(sed -e 's/^\[\*options\*\][[:space:]]*//' <<<"$from_man") 100 | from_help=$(sed -e 's/^\[flags\][[:space:]]*//' <<<"$from_help") 101 | 102 | # Args in man page are '*foo*', in --help are 'FOO'. Convert all to 103 | # UPCASE simply because it stands out better to the eye. 104 | from_man=$(sed -e 's/\*\([a-z-]\+\)\*/\U\1/g' <<<"$from_man") 105 | 106 | # FIXME: one of the common patterns is for --help to show 'POD [POD...]' 107 | # but man page show 'pod ...'. This conversion may help one day, but 108 | # not yet: there are too many inconsistencies such as '[pod ...]' 109 | # (brackets) and 'pod...' (no space between). 110 | # from_help=$(sed -e 's/\([A-Z]\+\)[[:space:]]\+\[\1[[:space:]]*\.\.\.\]/\1 .../' <<<"$from_help") 111 | 112 | # Compare man-page and --help usage strings. For now, do so only 113 | # when run with --verbose. 114 | if [[ "$from_man" != "$from_help" ]]; then 115 | if [ -n "$verbose" ]; then 116 | printf "%-25s man='%s' help='%s'\n" "$cmd:" "$from_man" "$from_help" 117 | # Yeah, we're not going to enable this as a blocker any time soon. 118 | # rc=1 119 | fi 120 | fi 121 | } 122 | 123 | # Pass 3: compare synopses. 124 | # 125 | # Make sure the SYNOPSIS line in podman-bootc-foo.1.md reads '**podman-bootc foo** ...' 126 | for md in *.1.md;do 127 | # FIXME: several pages have a multi-line form of SYNOPSIS in which 128 | # many or all flags are enumerated. Some of these are trivial 129 | # and really should be made into one line (podman-bootc-container-exists, 130 | # container-prune, others); some are more complicated and I 131 | # would still like to see them one-lined (container-runlabel, 132 | # image-trust) but I'm not 100% comfortable doing so myself. 133 | # To view those: 134 | # $ less $(for i in docs/*.1.md;do x=$(grep -A2 '^#* SYNOPSIS' $i|tail -1); if [ -n "$x" ]; then echo $i;fi;done) 135 | # 136 | synopsis=$(grep -E -A1 '^#* SYNOPSIS' $md|tail -1) 137 | 138 | # Command name must be bracketed by double asterisks; options and 139 | # arguments are bracketed by single ones. 140 | # E.g. '**podman-bootc volume inspect** [*options*] *volume*...' 141 | # Get the command name, and confirm that it matches the md file name. 142 | cmd=$(echo "$synopsis" | sed -e 's/\(.*\)\*\*.*/\1/' | tr -d \*) 143 | md_nodash=$(basename "$md" .1.md | sed 's/-/ /2') 144 | if [[ "$cmd" != "$md_nodash" ]]; then 145 | echo 146 | printf "Inconsistent program name in SYNOPSIS in %s:\n" $md 147 | printf " SYNOPSIS = %s (expected: '%s')\n" "$cmd" "$md_nodash" 148 | rc=1 149 | fi 150 | 151 | # The convention is to use UPPER CASE in 'podman-bootc foo --help', 152 | # but *lower case bracketed by asterisks* in the man page 153 | if expr "$synopsis" : ".*[A-Z]" >/dev/null; then 154 | echo 155 | printf "Inconsistent capitalization in SYNOPSIS in %s\n" $md 156 | printf " '%s' should not contain upper-case characters\n" "$synopsis" 157 | rc=1 158 | fi 159 | 160 | # (for debugging, and getting a sense of standard conventions) 161 | #printf " %-32s ------ '%s'\n" $md "$synopsis" 162 | 163 | # If bin/podman-bootc is available, run "cmd --help" and compare Usage 164 | # messages. This is complicated, so do it in a helper function. 165 | compare_usage "$md_nodash" "$synopsis" 166 | done 167 | 168 | exit $rc 169 | -------------------------------------------------------------------------------- /pkg/vm/vm.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/containers/podman-bootc/pkg/bootc" 15 | "github.com/containers/podman-bootc/pkg/config" 16 | "github.com/containers/podman-bootc/pkg/user" 17 | "github.com/containers/podman-bootc/pkg/utils" 18 | 19 | "github.com/docker/go-units" 20 | "github.com/sirupsen/logrus" 21 | "golang.org/x/crypto/ssh" 22 | ) 23 | 24 | var ErrVMInUse = errors.New("VM already in use") 25 | 26 | // GetVMCachePath returns the path to the VM cache directory 27 | func GetVMCachePath(imageId string, user user.User) (longID string, path string, err error) { 28 | files, err := os.ReadDir(user.CacheDir()) 29 | if err != nil { 30 | return "", "", err 31 | } 32 | 33 | fullImageId := "" 34 | for _, f := range files { 35 | if f.IsDir() && len(f.Name()) == 64 && strings.HasPrefix(f.Name(), imageId) { 36 | fullImageId = f.Name() 37 | } 38 | } 39 | 40 | if fullImageId == "" { 41 | return "", "", fmt.Errorf("local installation '%s' does not exists", imageId) 42 | } 43 | 44 | return fullImageId, filepath.Join(user.CacheDir(), fullImageId), nil 45 | } 46 | 47 | type NewVMParameters struct { 48 | ImageID string 49 | User user.User //user who is running the podman bootc command 50 | LibvirtUri string //linux only 51 | Locking utils.AccessMode 52 | } 53 | 54 | type RunVMParameters struct { 55 | VMUser string //user to use when connecting to the VM 56 | CloudInitDir string 57 | CloudInitData bool 58 | SSHIdentity string 59 | SSHPort int 60 | Cmd []string 61 | RemoveVm bool 62 | Background bool 63 | } 64 | 65 | type BootcVM interface { 66 | Run(RunVMParameters) error 67 | Delete() error 68 | IsRunning() (bool, error) 69 | WriteConfig(bootc.BootcDisk) error 70 | WaitForSSHToBeReady() error 71 | RunSSH([]string) error 72 | DeleteFromCache() error 73 | CacheDir() string 74 | Exists() (bool, error) 75 | GetConfig() (*BootcVMConfig, error) 76 | CloseConnection() 77 | PrintConsole() error 78 | Unlock() error 79 | } 80 | 81 | type BootcVMCommon struct { 82 | vmName string 83 | cacheDir string 84 | diskImagePath string 85 | vmUsername string 86 | user user.User 87 | sshIdentity string 88 | sshPort int 89 | removeVm bool 90 | background bool 91 | cmd []string 92 | pidFile string 93 | imageID string 94 | hasCloudInit bool 95 | cloudInitDir string 96 | cloudInitArgs string 97 | cacheDirLock utils.CacheLock 98 | } 99 | 100 | type BootcVMConfig struct { 101 | Id string `json:"Id,omitempty"` 102 | SshPort int `json:"SshPort"` 103 | SshIdentity string `json:"SshPriKey"` 104 | RepoTag string `json:"Repository"` 105 | Created string `json:"Created,omitempty"` 106 | DiskSize string `json:"DiskSize,omitempty"` 107 | Running bool `json:"Running,omitempty"` 108 | } 109 | 110 | // writeConfig writes the configuration for the VM to the disk 111 | func (v *BootcVMCommon) WriteConfig(bootcDisk bootc.BootcDisk) error { 112 | size, err := bootcDisk.GetSize() 113 | if err != nil { 114 | return fmt.Errorf("get disk size: %w", err) 115 | } 116 | bcConfig := BootcVMConfig{ 117 | Id: v.imageID[0:12], 118 | SshPort: v.sshPort, 119 | SshIdentity: v.sshIdentity, 120 | RepoTag: bootcDisk.GetRepoTag(), 121 | Created: bootcDisk.GetCreatedAt().Format(time.RFC3339), 122 | DiskSize: strconv.FormatInt(size, 10), 123 | } 124 | 125 | bcConfigMsh, err := json.Marshal(bcConfig) 126 | if err != nil { 127 | return fmt.Errorf("marshal config data: %w", err) 128 | } 129 | cfgFile := filepath.Join(v.cacheDir, config.CfgFile) 130 | err = os.WriteFile(cfgFile, bcConfigMsh, 0660) 131 | if err != nil { 132 | return fmt.Errorf("write config file: %w", err) 133 | } 134 | return nil 135 | 136 | } 137 | 138 | func (v *BootcVMCommon) LoadConfigFile() (cfg *BootcVMConfig, err error) { 139 | cfgFile := filepath.Join(v.cacheDir, config.CfgFile) 140 | fileContent, err := os.ReadFile(cfgFile) 141 | if err != nil { 142 | return 143 | } 144 | 145 | cfg = new(BootcVMConfig) 146 | if err = json.Unmarshal(fileContent, cfg); err != nil { 147 | return 148 | } 149 | 150 | //format the config values for display 151 | createdTime, err := time.Parse(time.RFC3339, cfg.Created) 152 | if err != nil { 153 | return nil, fmt.Errorf("error parsing created time: %w", err) 154 | } 155 | cfg.Created = units.HumanDuration(time.Since(createdTime)) + " ago" 156 | 157 | diskSizeFloat, err := strconv.ParseFloat(cfg.DiskSize, 64) 158 | if err != nil { 159 | return nil, fmt.Errorf("error parsing disk size: %w", err) 160 | } 161 | cfg.DiskSize = units.HumanSizeWithPrecision(diskSizeFloat, 3) 162 | 163 | return 164 | } 165 | 166 | func (v *BootcVMCommon) SetUser(user string) error { 167 | if user == "" { 168 | return fmt.Errorf("user is required") 169 | } 170 | 171 | v.vmUsername = user 172 | return nil 173 | } 174 | 175 | func (v *BootcVMCommon) WaitForSSHToBeReady() error { 176 | timeout := 1 * time.Minute 177 | elapsed := 0 * time.Millisecond 178 | interval := 500 * time.Millisecond 179 | 180 | key, err := os.ReadFile(v.sshIdentity) 181 | if err != nil { 182 | return fmt.Errorf("failed to read private key file: %s\n", err) 183 | } 184 | 185 | signer, err := ssh.ParsePrivateKey(key) 186 | if err != nil { 187 | return fmt.Errorf("failed to parse private key: %s\n", err) 188 | } 189 | 190 | config := &ssh.ClientConfig{ 191 | User: v.vmUsername, 192 | Auth: []ssh.AuthMethod{ 193 | ssh.PublicKeys(signer), 194 | }, 195 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 196 | Timeout: 1 * time.Second, 197 | } 198 | 199 | for elapsed < timeout { 200 | client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", "localhost", v.sshPort), config) 201 | if err != nil { 202 | logrus.Debugf("failed to connect to SSH server: %s\n", err) 203 | time.Sleep(interval) 204 | elapsed += interval 205 | } else { 206 | client.Close() 207 | return nil 208 | } 209 | } 210 | 211 | return fmt.Errorf("SSH did not become ready in %s seconds", timeout) 212 | } 213 | 214 | // RunSSH runs a command over ssh or starts an interactive ssh connection if no command is provided 215 | func (v *BootcVMCommon) RunSSH(inputArgs []string) error { 216 | cfg, err := v.LoadConfigFile() 217 | if err != nil { 218 | return fmt.Errorf("failed to load VM config: %w", err) 219 | } 220 | 221 | v.sshPort = cfg.SshPort 222 | v.sshIdentity = cfg.SshIdentity 223 | 224 | sshDestination := v.vmUsername + "@localhost" 225 | port := strconv.Itoa(v.sshPort) 226 | 227 | args := []string{"-i", v.sshIdentity, "-p", port, sshDestination, 228 | "-o", "IdentitiesOnly=yes", 229 | "-o", "PasswordAuthentication=no", 230 | "-o", "StrictHostKeyChecking=no", 231 | "-o", "LogLevel=ERROR", 232 | "-o", "SetEnv=LC_ALL=", 233 | "-o", "UserKnownHostsFile=/dev/null"} 234 | if len(inputArgs) > 0 { 235 | args = append(args, inputArgs...) 236 | } else { 237 | fmt.Printf("Connecting to vm %s. To close connection, use `~.` or `exit`\n", v.imageID) 238 | } 239 | 240 | cmd := exec.Command("ssh", args...) 241 | 242 | logrus.Debugf("Running ssh command: %s", cmd.String()) 243 | 244 | cmd.Stdout = os.Stdout 245 | cmd.Stderr = os.Stderr 246 | cmd.Stdin = os.Stdin 247 | 248 | return cmd.Run() 249 | } 250 | 251 | // Delete removes the VM disk image and the VM configuration from the podman-bootc cache 252 | func (v *BootcVMCommon) DeleteFromCache() error { 253 | return os.RemoveAll(v.cacheDir) 254 | } 255 | 256 | func (v *BootcVMCommon) CacheDir() string { 257 | return v.cacheDir 258 | } 259 | 260 | func (b *BootcVMCommon) oemString() (string, error) { 261 | systemdOemString, err := oemStringSystemdCredential(b.vmUsername, b.sshIdentity) 262 | if err != nil { 263 | return "", err 264 | } 265 | 266 | return fmt.Sprintf("type=11,value=%s", systemdOemString), nil 267 | } 268 | 269 | func lockVM(params NewVMParameters, cacheDir string) (utils.CacheLock, error) { 270 | lock := utils.NewCacheLock(params.User.RunDir(), cacheDir) 271 | locked, err := lock.TryLock(params.Locking) 272 | if err != nil { 273 | return lock, fmt.Errorf("unable to lock the VM cache path: %w", err) 274 | } 275 | 276 | if !locked { 277 | return lock, ErrVMInUse 278 | } 279 | 280 | cacheDirExists, err := utils.FileExists(cacheDir) 281 | if err != nil { 282 | if err := lock.Unlock(); err != nil { 283 | logrus.Debugf("unlock failed: %v", err) 284 | } 285 | return lock, fmt.Errorf("unable to check cache path: %w", err) 286 | } 287 | if !cacheDirExists { 288 | if err := lock.Unlock(); err != nil { 289 | logrus.Debugf("unlock failed: %v", err) 290 | } 291 | return lock, fmt.Errorf("'%s' does not exists", params.ImageID) 292 | } 293 | 294 | return lock, nil 295 | } 296 | -------------------------------------------------------------------------------- /pkg/vm/vm_test.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package vm_test 4 | 5 | import ( 6 | "context" 7 | "os" 8 | osUser "os/user" 9 | "path/filepath" 10 | "testing" 11 | "time" 12 | 13 | "github.com/containers/podman-bootc/cmd" 14 | "github.com/containers/podman-bootc/pkg/bootc" 15 | "github.com/containers/podman-bootc/pkg/user" 16 | "github.com/containers/podman-bootc/pkg/utils" 17 | "github.com/containers/podman-bootc/pkg/vm" 18 | 19 | . "github.com/onsi/ginkgo/v2" 20 | . "github.com/onsi/gomega" 21 | "libvirt.org/go/libvirt" 22 | ) 23 | 24 | func TestPodmanBootc(t *testing.T) { 25 | RegisterFailHandler(Fail) 26 | RunSpecs(t, "Functional Test Suite") 27 | } 28 | 29 | func projectRoot() string { 30 | ex, err := os.Executable() 31 | if err != nil { 32 | panic(err) 33 | } 34 | projectRoot := filepath.Dir(ex) 35 | return projectRoot 36 | } 37 | 38 | var testUser = user.User{ 39 | OSUser: &osUser.User{ 40 | Uid: "1000", 41 | Gid: "1000", 42 | Username: "test", 43 | Name: "test", 44 | HomeDir: filepath.Join(projectRoot(), ".test-user-home"), 45 | }, 46 | } 47 | 48 | const ( 49 | testImageID = "a025064b145ed339eeef86046aea3ee221a2a5a16f588aff4f43a42e5ca9f844" 50 | testRepoTag = "quay.io/test/test:latest" 51 | testLibvirtUri = "test:///default" 52 | ) 53 | 54 | var testUserSSHKey = filepath.Join(testUser.SSHDir(), "podman-machine-default") 55 | 56 | var _ = BeforeSuite(func() { 57 | // populate the test user home directory. 58 | // This is most likely temporary. It enables the VM tests 59 | // to run, but there is propably a better solution that can be used 60 | // for other tests (e.g. disk image) 61 | err := os.MkdirAll(testUser.HomeDir(), 0700) 62 | Expect(err).To(Not(HaveOccurred())) 63 | err = os.MkdirAll(testUser.SSHDir(), 0700) 64 | Expect(err).To(Not(HaveOccurred())) 65 | err = os.WriteFile(testUserSSHKey, []byte(""), 0700) 66 | Expect(err).To(Not(HaveOccurred())) 67 | err = os.WriteFile(testUserSSHKey+".pub", []byte(""), 0700) 68 | Expect(err).To(Not(HaveOccurred())) 69 | err = os.MkdirAll(filepath.Join(testUser.HomeDir(), ".local/share/containers/podman/machine/qemu"), 0700) 70 | Expect(err).To(Not(HaveOccurred())) 71 | err = os.WriteFile(filepath.Join(testUser.HomeDir(), ".local/share/containers/podman/machine/qemu/podman.sock"), []byte(""), 0700) 72 | Expect(err).To(Not(HaveOccurred())) 73 | }) 74 | 75 | var _ = AfterSuite(func() { 76 | err := os.RemoveAll(testUser.HomeDir()) 77 | Expect(err).To(Not(HaveOccurred())) 78 | }) 79 | 80 | func createTestVM(imageId string) (bootcVM *vm.BootcVMLinux) { 81 | err := os.MkdirAll(filepath.Join(testUser.CacheDir(), imageId), 0700) 82 | Expect(err).To(Not(HaveOccurred())) 83 | 84 | bootcVM, err = vm.NewVM(vm.NewVMParameters{ 85 | ImageID: imageId, 86 | User: testUser, 87 | LibvirtUri: testLibvirtUri, 88 | Locking: utils.Shared, 89 | }) 90 | Expect(err).To(Not(HaveOccurred())) 91 | 92 | return 93 | } 94 | 95 | func runTestVM(bootcVM vm.BootcVM) { 96 | err := bootcVM.Run(vm.RunVMParameters{ 97 | VMUser: "root", 98 | CloudInitDir: "", 99 | CloudInitData: false, 100 | SSHPort: 22, 101 | Cmd: []string{}, 102 | RemoveVm: false, 103 | Background: false, 104 | SSHIdentity: testUserSSHKey, 105 | }) 106 | Expect(err).To(Not(HaveOccurred())) 107 | 108 | now := time.Now() 109 | now = now.Add(-time.Duration(1 * time.Minute)) 110 | bootcDisk := bootc.BootcDisk{ 111 | ImageNameOrId: testImageID, 112 | User: testUser, 113 | Ctx: context.Background(), 114 | ImageId: testImageID, 115 | RepoTag: testRepoTag, 116 | CreatedAt: now, 117 | Directory: filepath.Join(testUser.CacheDir(), testImageID), 118 | } 119 | 120 | err = os.WriteFile(filepath.Join(testUser.CacheDir(), testImageID, "disk.raw"), []byte(""), 0700) 121 | Expect(err).To(Not(HaveOccurred())) 122 | 123 | err = bootcVM.WriteConfig(bootcDisk) 124 | Expect(err).To(Not(HaveOccurred())) 125 | } 126 | 127 | func deleteAllVMs() { 128 | conn, err := libvirt.NewConnect("test:///default") 129 | Expect(err).To(Not(HaveOccurred())) 130 | defer conn.Close() 131 | 132 | var flags libvirt.ConnectListAllDomainsFlags 133 | domains, err := conn.ListAllDomains(flags) 134 | Expect(err).To(Not(HaveOccurred())) 135 | for _, domain := range domains { 136 | err = domain.Destroy() 137 | Expect(err).To(Not(HaveOccurred())) 138 | err = domain.Undefine() 139 | Expect(err).To(Not(HaveOccurred())) 140 | } 141 | } 142 | 143 | var _ = Describe("VM", func() { 144 | AfterEach(func() { 145 | deleteAllVMs() 146 | err := testUser.RemoveOSCDirs() 147 | Expect(err).To(Not(HaveOccurred())) 148 | }) 149 | 150 | BeforeEach(func() { 151 | err := testUser.InitOSCDirs() 152 | Expect(err).To(Not(HaveOccurred())) 153 | }) 154 | 155 | Context("does not exist", func() { 156 | It("should create and start the VM after calling Run", func() { 157 | bootcVM := createTestVM(testImageID) 158 | defer func() { 159 | _ = bootcVM.Unlock() 160 | }() 161 | 162 | runTestVM(bootcVM) 163 | exists, err := bootcVM.Exists() 164 | Expect(err).To(Not(HaveOccurred())) 165 | Expect(exists).To(BeTrue()) 166 | 167 | isRunning, err := bootcVM.IsRunning() 168 | Expect(err).To(Not(HaveOccurred())) 169 | Expect(isRunning).To(BeTrue()) 170 | }) 171 | 172 | It("should return false when calling Exists before Run", func() { 173 | bootcVM := createTestVM(testImageID) 174 | defer func() { 175 | _ = bootcVM.Unlock() 176 | }() 177 | 178 | exists, err := bootcVM.Exists() 179 | Expect(err).To(Not(HaveOccurred())) 180 | Expect(exists).To(BeFalse()) 181 | }) 182 | 183 | It("should return an empty list when listing", func() { 184 | vmList, err := cmd.CollectVmList(testUser, testLibvirtUri) 185 | Expect(err).To(Not(HaveOccurred())) 186 | Expect(vmList).To(HaveLen(0)) 187 | }) 188 | }) 189 | 190 | Context("is running", func() { 191 | It("should remove the VM from the hypervisor after calling Delete", func() { 192 | //create vm and start it 193 | bootcVM := createTestVM(testImageID) 194 | defer func() { 195 | _ = bootcVM.Unlock() 196 | }() 197 | 198 | runTestVM(bootcVM) 199 | 200 | //assert that the VM exists 201 | exists, err := bootcVM.Exists() 202 | Expect(err).To(Not(HaveOccurred())) 203 | Expect(exists).To(BeTrue()) 204 | 205 | //attempt to stop and delete the VM 206 | err = bootcVM.Delete() 207 | Expect(err).To(Not(HaveOccurred())) 208 | 209 | //assert that the VM is stopped and deleted 210 | exists, err = bootcVM.Exists() 211 | Expect(err).To(Not(HaveOccurred())) 212 | Expect(exists).To(BeFalse()) 213 | }) 214 | 215 | It("should list the VM", func() { 216 | bootcVM := createTestVM(testImageID) 217 | defer func() { 218 | _ = bootcVM.Unlock() 219 | }() 220 | 221 | runTestVM(bootcVM) 222 | vmList, err := cmd.CollectVmList(testUser, testLibvirtUri) 223 | Expect(err).To(Not(HaveOccurred())) 224 | 225 | Expect(vmList).To(HaveLen(1)) 226 | Expect(vmList[0]).To(Equal(vm.BootcVMConfig{ 227 | Id: testImageID[:12], 228 | SshPort: 22, 229 | SshIdentity: testUserSSHKey, 230 | RepoTag: testRepoTag, 231 | Created: "About a minute ago", 232 | DiskSize: "0B", 233 | Running: true, 234 | })) 235 | }) 236 | }) 237 | 238 | Context("multiple running", func() { 239 | It("should list all VMs", func() { 240 | bootcVM := createTestVM(testImageID) 241 | defer func() { 242 | _ = bootcVM.Unlock() 243 | }() 244 | 245 | runTestVM(bootcVM) 246 | 247 | id2 := "1234564b145ed339eeef86046aea3ee221a2a5a16f588aff4f43a42e5ca9f844" 248 | bootcVM2 := createTestVM(id2) 249 | defer func() { 250 | _ = bootcVM2.Unlock() 251 | }() 252 | 253 | runTestVM(bootcVM2) 254 | 255 | id3 := "2345674b145ed339eeef86046aea3ee221a2a5a16f588aff4f43a42e5ca9f844" 256 | bootcVM3 := createTestVM(id3) 257 | defer func() { 258 | _ = bootcVM3.Unlock() 259 | }() 260 | 261 | runTestVM(bootcVM3) 262 | 263 | vmList, err := cmd.CollectVmList(testUser, testLibvirtUri) 264 | Expect(err).To(Not(HaveOccurred())) 265 | 266 | Expect(vmList).To(HaveLen(3)) 267 | Expect(vmList).To(ContainElement(vm.BootcVMConfig{ 268 | Id: testImageID[:12], 269 | SshPort: 22, 270 | SshIdentity: testUserSSHKey, 271 | RepoTag: testRepoTag, 272 | Created: "About a minute ago", 273 | DiskSize: "0B", 274 | Running: true, 275 | })) 276 | 277 | Expect(vmList).To(ContainElement(vm.BootcVMConfig{ 278 | Id: id2[:12], 279 | SshPort: 22, 280 | SshIdentity: testUserSSHKey, 281 | RepoTag: testRepoTag, 282 | Created: "About a minute ago", 283 | DiskSize: "0B", 284 | Running: true, 285 | })) 286 | 287 | Expect(vmList).To(ContainElement(vm.BootcVMConfig{ 288 | Id: id3[:12], 289 | SshPort: 22, 290 | SshIdentity: testUserSSHKey, 291 | RepoTag: testRepoTag, 292 | Created: "About a minute ago", 293 | DiskSize: "0B", 294 | Running: true, 295 | })) 296 | }) 297 | }) 298 | }) 299 | -------------------------------------------------------------------------------- /pkg/vm/vm_linux.go: -------------------------------------------------------------------------------- 1 | package vm 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "errors" 7 | "fmt" 8 | "path/filepath" 9 | "strconv" 10 | "text/template" 11 | "time" 12 | 13 | "github.com/containers/podman-bootc/pkg/config" 14 | 15 | "github.com/sirupsen/logrus" 16 | "libvirt.org/go/libvirt" 17 | ) 18 | 19 | //go:embed domain-template.xml 20 | var domainTemplate string 21 | 22 | type BootcVMLinux struct { 23 | domain *libvirt.Domain 24 | libvirtUri string 25 | libvirtConnection *libvirt.Connect 26 | BootcVMCommon 27 | } 28 | 29 | func vmName(id string) string { 30 | return "podman-bootc-" + id[:12] 31 | } 32 | 33 | func NewVM(params NewVMParameters) (vm *BootcVMLinux, err error) { 34 | if params.ImageID == "" { 35 | return nil, fmt.Errorf("image ID is required") 36 | } 37 | 38 | if params.LibvirtUri == "" { 39 | return nil, fmt.Errorf("libvirt URI is required") 40 | } 41 | 42 | longId, cacheDir, err := GetVMCachePath(params.ImageID, params.User) 43 | if err != nil { 44 | return nil, fmt.Errorf("unable to get VM cache path: %w", err) 45 | } 46 | 47 | lock, err := lockVM(params, cacheDir) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | vm = &BootcVMLinux{ 53 | libvirtUri: params.LibvirtUri, 54 | BootcVMCommon: BootcVMCommon{ 55 | vmName: vmName(longId), 56 | imageID: longId, 57 | cacheDir: cacheDir, 58 | diskImagePath: filepath.Join(cacheDir, config.DiskImage), 59 | user: params.User, 60 | cacheDirLock: lock, 61 | }, 62 | } 63 | 64 | err = vm.loadExistingDomain() 65 | if err != nil { 66 | if err := vm.Unlock(); err != nil { 67 | logrus.Debugf("unlock failed: %v", err) 68 | } 69 | return vm, fmt.Errorf("unable to load existing libvirt domain: %w", err) 70 | } 71 | 72 | return vm, nil 73 | } 74 | 75 | func (v *BootcVMLinux) GetConfig() (cfg *BootcVMConfig, err error) { 76 | cfg, err = v.LoadConfigFile() 77 | if err != nil { 78 | return 79 | } 80 | 81 | cfg.Running, err = v.IsRunning() 82 | if err != nil { 83 | return 84 | } 85 | 86 | return 87 | } 88 | 89 | func (v *BootcVMLinux) PrintConsole() (err error) { 90 | stream, err := v.libvirtConnection.NewStream(libvirt.StreamFlags(0)) 91 | if err != nil { 92 | return fmt.Errorf("unable to create console stream: %w", err) 93 | } 94 | 95 | err = v.domain.OpenConsole("serial0", stream, libvirt.DOMAIN_CONSOLE_FORCE) 96 | if err != nil { 97 | return fmt.Errorf("unable to open console: %w", err) 98 | } 99 | 100 | for { 101 | streamBytes := make([]byte, 8192) 102 | got, err := stream.Recv(streamBytes) 103 | if err != nil { 104 | return fmt.Errorf("unable to receive console output: %w", err) 105 | } 106 | if got <= 0 { 107 | break 108 | } 109 | 110 | print(string(streamBytes)) 111 | } 112 | 113 | return 114 | } 115 | 116 | func (v *BootcVMLinux) Run(params RunVMParameters) (err error) { 117 | v.sshPort = params.SSHPort 118 | v.removeVm = params.RemoveVm 119 | v.background = params.Background 120 | v.cmd = params.Cmd 121 | v.hasCloudInit = params.CloudInitData 122 | v.cloudInitDir = params.CloudInitDir 123 | v.vmUsername = params.VMUser 124 | v.sshIdentity = params.SSHIdentity 125 | 126 | if v.domain != nil { 127 | isRunning, err := v.IsRunning() 128 | if err != nil { 129 | return fmt.Errorf("unable to check if VM is running: %w", err) 130 | } 131 | 132 | if !isRunning { 133 | logrus.Debugf("Deleting stopped VM %s\n", v.imageID) 134 | err = v.Delete() 135 | if err != nil { 136 | return fmt.Errorf("unable to delete stopped VM: %w", err) 137 | } 138 | } else { 139 | return errors.New("VM is already running") 140 | } 141 | } 142 | 143 | //domain doesn't exist, create it 144 | logrus.Debugf("Creating VM %s\n", v.imageID) 145 | 146 | domainXML, err := v.parseDomainTemplate() 147 | if err != nil { 148 | return fmt.Errorf("unable to parse domain template: %w", err) 149 | } 150 | 151 | logrus.Debugf("domainXML: %s", domainXML) 152 | 153 | v.domain, err = v.libvirtConnection.DomainDefineXMLFlags(domainXML, libvirt.DOMAIN_DEFINE_VALIDATE) 154 | if err != nil { 155 | return fmt.Errorf("unable to define virtual machine domain: %w", err) 156 | } 157 | 158 | err = v.domain.Create() 159 | if err != nil { 160 | return fmt.Errorf("unable to start virtual machine domain: %w", err) 161 | } 162 | 163 | err = v.waitForVMToBeRunning() 164 | if err != nil { 165 | return fmt.Errorf("unable to wait for VM to be running: %w", err) 166 | } 167 | 168 | return 169 | } 170 | 171 | func (v *BootcVMLinux) parseDomainTemplate() (domainXML string, err error) { 172 | tmpl, err := template.New("domain-template").Parse(domainTemplate) 173 | if err != nil { 174 | return "", fmt.Errorf("unable to parse domain template: %w", err) 175 | } 176 | 177 | var domainXMLBuf bytes.Buffer 178 | 179 | type TemplateParams struct { 180 | DiskImagePath string 181 | Port string 182 | PIDFile string 183 | SMBios string 184 | Name string 185 | CloudInitCDRom string 186 | CloudInitSMBios string 187 | } 188 | 189 | templateParams := TemplateParams{ 190 | DiskImagePath: v.diskImagePath, 191 | Port: strconv.Itoa(v.sshPort), 192 | PIDFile: v.pidFile, 193 | Name: v.vmName, 194 | } 195 | 196 | if v.sshIdentity != "" { 197 | smbiosCmd, err := v.oemString() 198 | if err != nil { 199 | return domainXML, fmt.Errorf("unable to get OEM string: %w", err) 200 | } 201 | 202 | //this is gross but it's probably better than parsing the XML 203 | templateParams.SMBios = fmt.Sprintf(` 204 | 205 | 206 | `, smbiosCmd) 207 | } 208 | 209 | err = v.ParseCloudInit() 210 | if err != nil { 211 | return "", fmt.Errorf("unable to set cloud-init: %w", err) 212 | } 213 | 214 | if v.hasCloudInit { 215 | templateParams.CloudInitCDRom = fmt.Sprintf(` 216 | 217 | 218 | 219 | 220 | 221 | 222 | `, v.cloudInitArgs) 223 | } 224 | 225 | err = tmpl.Execute(&domainXMLBuf, templateParams) 226 | if err != nil { 227 | return "", fmt.Errorf("unable to execute domain template: %w", err) 228 | } 229 | 230 | return domainXMLBuf.String(), nil 231 | } 232 | 233 | func (v *BootcVMLinux) waitForVMToBeRunning() error { 234 | timeout := 60 * time.Second 235 | elapsed := 0 * time.Second 236 | 237 | for elapsed < timeout { 238 | state, _, err := v.domain.GetState() 239 | 240 | if err != nil { 241 | return fmt.Errorf("unable to get VM state: %w", err) 242 | } 243 | 244 | if state == libvirt.DOMAIN_RUNNING { 245 | return nil 246 | } 247 | 248 | time.Sleep(1 * time.Second) 249 | elapsed += 1 * time.Second 250 | } 251 | 252 | return fmt.Errorf("VM did not start in %s seconds", timeout) 253 | } 254 | 255 | func (v *BootcVMLinux) CloseConnection() { 256 | v.libvirtConnection.Close() 257 | } 258 | 259 | // loadExistingDomain loads the existing domain and it's config, no-op if domain is already loaded 260 | func (v *BootcVMLinux) loadExistingDomain() (err error) { 261 | //check if domain is already loaded 262 | if v.domain != nil { 263 | return 264 | } 265 | 266 | //look for existing VM 267 | v.libvirtConnection, err = libvirt.NewConnect(v.libvirtUri) 268 | if err != nil { 269 | return 270 | } 271 | 272 | name := vmName(v.imageID) 273 | v.domain, err = v.libvirtConnection.LookupDomainByName(name) 274 | if err != nil { 275 | if errors.Is(err, libvirt.ERR_NO_DOMAIN) { 276 | logrus.Debugf("VM %s not found", name) // allow for domain not found 277 | } else { 278 | return 279 | } 280 | } 281 | 282 | return nil 283 | } 284 | 285 | // Delete the VM definition 286 | func (v *BootcVMLinux) Delete() (err error) { 287 | err = v.Shutdown() 288 | if err != nil { 289 | return fmt.Errorf("unable to shutdown VM: %w", err) 290 | } 291 | 292 | domainExists, err := v.Exists() 293 | if err != nil { 294 | return fmt.Errorf("unable to check if VM exists: %w", err) 295 | } 296 | 297 | if domainExists { 298 | err = v.domain.UndefineFlags(libvirt.DOMAIN_UNDEFINE_NVRAM) 299 | if errors.As(err, &libvirt.Error{Code: libvirt.ERR_INVALID_ARG}) { 300 | err = v.domain.Undefine() 301 | } 302 | 303 | if err != nil { 304 | return fmt.Errorf("unable to undefine VM: %w", err) 305 | } 306 | } 307 | 308 | return 309 | } 310 | 311 | // Shutdown the VM 312 | func (v *BootcVMLinux) Shutdown() (err error) { 313 | //check if domain is running and shut it down 314 | isRunning, err := v.IsRunning() 315 | if err != nil { 316 | return fmt.Errorf("unable to check if VM is running: %w", err) 317 | } 318 | 319 | if isRunning { 320 | err := v.domain.Destroy() 321 | if err != nil { 322 | return fmt.Errorf("unable to destroy VM: %w", err) 323 | } 324 | } 325 | 326 | return 327 | } 328 | 329 | func (v *BootcVMLinux) Exists() (bool, error) { 330 | var flags libvirt.ConnectListAllDomainsFlags 331 | domains, err := v.libvirtConnection.ListAllDomains(flags) 332 | if err != nil { 333 | return false, fmt.Errorf("unable to list all domains: %w", err) 334 | } 335 | for _, domain := range domains { 336 | name, err := domain.GetName() 337 | if err != nil { 338 | return false, err 339 | } 340 | 341 | if name == v.vmName { 342 | return true, nil 343 | } 344 | } 345 | 346 | return false, nil 347 | } 348 | 349 | func (v *BootcVMLinux) IsRunning() (exists bool, err error) { 350 | if v.domain == nil { // domain hasn't been created yet 351 | return false, nil 352 | } 353 | 354 | state, _, err := v.domain.GetState() 355 | if err != nil { 356 | return false, fmt.Errorf("unable to get VM state: %w", err) 357 | } 358 | 359 | if state == libvirt.DOMAIN_RUNNING { 360 | return true, nil 361 | } else { 362 | return false, nil 363 | } 364 | } 365 | 366 | func (v *BootcVMLinux) Unlock() error { 367 | return v.cacheDirLock.Unlock() 368 | } 369 | -------------------------------------------------------------------------------- /test/e2e/e2e_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | // **************************************************************************** 4 | // These are end-to-end tests that run the podman-bootc binary. 5 | // A rootful podman machine is assumed to already be running. 6 | // The tests interact directly with libvirt (on linux), qemu (on darwin), 7 | // podman-bootc cache dirs, and podman images and containers. 8 | // 9 | // Running these tests will create/delete VMs, pull/remove podman images 10 | // and containers, and remove the entire podman-bootc cache dir. 11 | // 12 | // These tests depend on the quay.io/ckyrouac/podman-bootc-test image 13 | // which is built from the Containerfiles in the test/resources directory. 14 | // **************************************************************************** 15 | 16 | import ( 17 | "encoding/json" 18 | "os" 19 | "path/filepath" 20 | "sync" 21 | "testing" 22 | 23 | "github.com/containers/podman-bootc/pkg/config" 24 | "github.com/containers/podman-bootc/test/e2e" 25 | 26 | . "github.com/onsi/ginkgo/v2" 27 | . "github.com/onsi/gomega" 28 | ) 29 | 30 | func TestPodmanBootcE2E(t *testing.T) { 31 | RegisterFailHandler(Fail) 32 | RunSpecs(t, "End to End Test Suite") 33 | } 34 | 35 | var _ = BeforeSuite(func() { 36 | err := e2e.Cleanup() 37 | Expect(err).To(Not(HaveOccurred())) 38 | }) 39 | 40 | var _ = AfterSuite(func() { 41 | err := e2e.Cleanup() 42 | Expect(err).To(Not(HaveOccurred())) 43 | }) 44 | 45 | var _ = Describe("E2E", func() { 46 | Context("Run with no args from a fresh install", Ordered, func() { 47 | // Create the disk/VM once to avoid the overhead of creating it for each test 48 | var vm *e2e.TestVM 49 | 50 | BeforeAll(func() { 51 | var err error 52 | vm, err = e2e.BootVM(e2e.BaseImage) 53 | Expect(err).To(Not(HaveOccurred())) 54 | }) 55 | 56 | It("should pull the container image", func() { 57 | imagesListOutput, _, err := e2e.RunPodman("images", e2e.BaseImage, "--format", "json") 58 | Expect(err).To(Not(HaveOccurred())) 59 | imagesList := []map[string]interface{}{} 60 | err = json.Unmarshal([]byte(imagesListOutput), &imagesList) 61 | Expect(err).To(Not(HaveOccurred())) 62 | Expect(imagesList).To(HaveLen(1)) 63 | }) 64 | 65 | It("should create a bootc disk image", func() { 66 | vmDirs, err := e2e.ListCacheDirs() 67 | Expect(err).To(Not(HaveOccurred())) 68 | Expect(vmDirs).To(HaveLen(1)) 69 | 70 | _, err = os.Stat(filepath.Join(vmDirs[0], config.DiskImage)) 71 | Expect(err).To(Not(HaveOccurred())) 72 | }) 73 | 74 | It("should create a new virtual machine", func() { 75 | vmExists, err := e2e.VMExists(vm.Id) 76 | Expect(err).To(Not(HaveOccurred())) 77 | Expect(vmExists).To(BeTrue()) 78 | }) 79 | 80 | It("should start an ssh session into the VM", func() { 81 | // Send a command to the VM and check the output 82 | err := vm.SendCommand("echo 'hello'", "hello") 83 | Expect(err).To(Not(HaveOccurred())) 84 | Expect(vm.StdOut[len(vm.StdOut)-1]).To(ContainSubstring("hello")) 85 | }) 86 | 87 | It("should keep the VM running after the initial ssh session is closed", func() { 88 | vm.StdIn.Close() // this closes the ssh session 89 | 90 | vmIsRunning, err := e2e.VMIsRunning(vm.Id) 91 | Expect(err).To(Not(HaveOccurred())) 92 | Expect(vmIsRunning).To(BeTrue()) 93 | }) 94 | 95 | It("should open a new ssh session into the VM via the ssh cmd", func() { 96 | _, _, err := e2e.RunPodmanBootc("ssh", vm.Id) //TODO: test the output, send a command 97 | Expect(err).To(Not(HaveOccurred())) 98 | }) 99 | 100 | It("Should delete the VM and persist the disk image when calling stop", func() { 101 | _, _, err := e2e.RunPodmanBootc("stop", vm.Id) 102 | Expect(err).To(Not(HaveOccurred())) 103 | 104 | //qemu doesn't immediately stop the VM, so we need to wait for it to stop 105 | Eventually(func() bool { 106 | vmExists, err := e2e.VMExists(vm.Id) 107 | Expect(err).To(Not(HaveOccurred())) 108 | return vmExists 109 | }).Should(BeFalse()) 110 | 111 | vmDirs, err := e2e.ListCacheDirs() 112 | Expect(err).To(Not(HaveOccurred())) 113 | 114 | _, err = os.Stat(filepath.Join(vmDirs[0], config.DiskImage)) 115 | Expect(err).To(Not(HaveOccurred())) 116 | }) 117 | 118 | It("Should remove the disk image when calling rm", func() { 119 | _, _, err := e2e.RunPodmanBootc("rm", vm.Id) 120 | Expect(err).To(Not(HaveOccurred())) 121 | 122 | vmDirs, err := e2e.ListCacheDirs() 123 | Expect(err).To(Not(HaveOccurred())) 124 | 125 | Expect(vmDirs).To(HaveLen(0)) 126 | }) 127 | 128 | It("Should recreate the disk and VM when calling run", func() { 129 | var err error 130 | vm, err = e2e.BootVM(e2e.BaseImage) 131 | Expect(err).To(Not(HaveOccurred())) 132 | 133 | vmDirs, err := e2e.ListCacheDirs() 134 | Expect(err).To(Not(HaveOccurred())) 135 | Expect(vmDirs).To(HaveLen(1)) 136 | 137 | vmExists, err := e2e.VMExists(vm.Id) 138 | Expect(err).To(Not(HaveOccurred())) 139 | Expect(vmExists).To(BeTrue()) 140 | }) 141 | 142 | It("Should prevent removing a VM with an active SSH session", func() { 143 | _, _, err := e2e.RunPodmanBootc("rm", "-f", vm.Id) 144 | Expect(err).To(HaveOccurred()) 145 | 146 | Eventually(func() int { 147 | vmDirs, err := e2e.ListCacheDirs() 148 | Expect(err).To(Not(HaveOccurred())) 149 | return len(vmDirs) 150 | }).Should(Equal(1)) 151 | }) 152 | 153 | It("Should remove the cache directory when calling rm -f while VM is running", func() { 154 | // the SSH connection needs to be closed before attempting rm -f 155 | err := vm.StdIn.Close() 156 | Expect(err).To(Not(HaveOccurred())) 157 | 158 | _, _, err = e2e.RunPodmanBootc("rm", "-f", vm.Id) 159 | Expect(err).To(Not(HaveOccurred())) 160 | 161 | Eventually(func() int { 162 | vmDirs, err := e2e.ListCacheDirs() 163 | Expect(err).To(Not(HaveOccurred())) 164 | return len(vmDirs) 165 | }).Should(Equal(0)) 166 | }) 167 | 168 | AfterAll(func() { 169 | vm.StdIn.Close() 170 | err := e2e.Cleanup() 171 | if err != nil { 172 | Fail(err.Error()) 173 | } 174 | }) 175 | }) 176 | 177 | Context("Multiple VMs exist", Ordered, func() { 178 | var activeVM *e2e.TestVM 179 | var inactiveVM *e2e.TestVM 180 | var stoppedVM *e2e.TestVM 181 | 182 | BeforeAll(func() { 183 | var err error 184 | errors := make(chan error) 185 | 186 | var wg sync.WaitGroup 187 | wg.Add(1) 188 | go func() { 189 | // create an "active" VM 190 | // running with an active SSH session 191 | println("**** STARTING ACTIVE VM") 192 | activeVM, err = e2e.BootVM(e2e.TestImageTwo) 193 | if err != nil { 194 | errors <- err 195 | } 196 | wg.Done() 197 | }() 198 | 199 | wg.Add(1) 200 | go func() { 201 | // create an "inactive" VM 202 | // running with no active SSH session 203 | inactiveVM, err = e2e.BootVM(e2e.TestImageOne) 204 | if err != nil { 205 | errors <- err 206 | } 207 | err = inactiveVM.StdIn.Close() 208 | if err != nil { 209 | errors <- err 210 | } 211 | wg.Done() 212 | }() 213 | 214 | wg.Add(1) 215 | go func() { 216 | // create a "stopped" VM 217 | // VM does not exist but the VM directory containing the cached disk image does 218 | stoppedVM, err = e2e.BootVM(e2e.BaseImage) 219 | if err != nil { 220 | errors <- err 221 | } 222 | err = stoppedVM.StdIn.Close() //ssh needs to be closed before stopping the VM 223 | if err != nil { 224 | errors <- err 225 | } 226 | _, _, err = e2e.RunPodmanBootc("stop", stoppedVM.Id) 227 | if err != nil { 228 | errors <- err 229 | } 230 | wg.Done() 231 | }() 232 | 233 | wg.Wait() 234 | close(errors) 235 | 236 | if err := <-errors; err != nil { 237 | Fail(err.Error()) 238 | } 239 | 240 | // validate there are 3 vm directories 241 | vmDirs, err := e2e.ListCacheDirs() 242 | Expect(err).To(Not(HaveOccurred())) 243 | Expect(vmDirs).To(HaveLen(3)) 244 | }) 245 | 246 | It("Should list multiple VMs", func() { 247 | stdout, _, err := e2e.RunPodmanBootc("list") 248 | Expect(err).To(Not(HaveOccurred())) 249 | 250 | listOutput := e2e.ParseListOutput(stdout) 251 | Expect(listOutput).To(HaveLen(3)) 252 | Expect(listOutput).To(ContainElement(e2e.ListEntry{ 253 | Id: activeVM.Id, 254 | Repo: e2e.TestImageTwo, 255 | Running: "true", 256 | })) 257 | 258 | Expect(listOutput).To(ContainElement(e2e.ListEntry{ 259 | Id: inactiveVM.Id, 260 | Repo: e2e.TestImageOne, 261 | Running: "true", 262 | })) 263 | 264 | Expect(listOutput).To(ContainElement(e2e.ListEntry{ 265 | Id: stoppedVM.Id, 266 | Repo: e2e.BaseImage, 267 | Running: "false", 268 | })) 269 | }) 270 | 271 | It("Should remove all inactive VMs and caches when calling rm -f --all", func() { 272 | _, _, err := e2e.RunPodmanBootc("rm", "-f", "--all") 273 | Expect(err).To(Not(HaveOccurred())) 274 | 275 | stdout, _, err := e2e.RunPodmanBootc("list") 276 | Expect(err).To(Not(HaveOccurred())) 277 | 278 | // should keep the active VM that has an ssh session open 279 | Expect(stdout).To(ContainSubstring(activeVM.Id)) 280 | 281 | // should remove the other VMs 282 | Expect(stdout).To(Not(ContainSubstring(stoppedVM.Id))) 283 | Expect(stdout).To(Not(ContainSubstring(inactiveVM.Id))) 284 | 285 | vmDirs, err := e2e.ListCacheDirs() 286 | Expect(err).To(Not(HaveOccurred())) 287 | Expect(vmDirs).To(HaveLen(1)) 288 | Expect(vmDirs[0]).To(ContainSubstring(activeVM.Id)) 289 | }) 290 | 291 | It("Should no-op and return successfully when rm -f --all with no VMs", func() { 292 | //cleanup the remaining active VM first 293 | err := activeVM.StdIn.Close() 294 | Expect(err).To(Not(HaveOccurred())) 295 | _, _, err = e2e.RunPodmanBootc("rm", "-f", activeVM.Id) 296 | Expect(err).To(Not(HaveOccurred())) 297 | 298 | // verify there are no VMs 299 | vmDirs, err := e2e.ListCacheDirs() 300 | Expect(err).To(Not(HaveOccurred())) 301 | Expect(vmDirs).To(HaveLen(0)) 302 | 303 | // attempt rm -f --all 304 | _, _, err = e2e.RunPodmanBootc("rm", "-f", "--all") 305 | Expect(err).To(Not(HaveOccurred())) 306 | }) 307 | 308 | AfterAll(func() { 309 | activeVM.StdIn.Close() 310 | inactiveVM.StdIn.Close() 311 | stoppedVM.StdIn.Close() 312 | err := e2e.Cleanup() 313 | if err != nil { 314 | Fail(err.Error()) 315 | } 316 | }) 317 | }) 318 | }) 319 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/containers/podman-bootc 2 | 3 | go 1.22.6 4 | 5 | require ( 6 | github.com/adrg/xdg v0.4.0 7 | github.com/containers/common v0.58.1 8 | github.com/containers/gvisor-tap-vsock v0.7.3 9 | github.com/containers/podman/v5 v5.0.1 10 | github.com/distribution/reference v0.5.0 11 | github.com/docker/go-units v0.5.0 12 | github.com/gofrs/flock v0.8.1 13 | github.com/onsi/ginkgo/v2 v2.17.1 14 | github.com/onsi/gomega v1.32.0 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/cobra v1.8.0 17 | golang.org/x/crypto v0.28.0 18 | golang.org/x/sys v0.26.0 19 | golang.org/x/term v0.25.0 20 | libvirt.org/go/libvirt v1.10002.0 21 | ) 22 | 23 | require ( 24 | dario.cat/mergo v1.0.0 // indirect 25 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 26 | github.com/BurntSushi/toml v1.3.2 // indirect 27 | github.com/Microsoft/go-winio v0.6.1 // indirect 28 | github.com/Microsoft/hcsshim v0.12.0-rc.3 // indirect 29 | github.com/VividCortex/ewma v1.2.0 // indirect 30 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect 31 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 32 | github.com/blang/semver/v4 v4.0.0 // indirect 33 | github.com/bytedance/sonic v1.12.3 // indirect 34 | github.com/bytedance/sonic/loader v0.2.1 // indirect 35 | github.com/chzyer/readline v1.5.1 // indirect 36 | github.com/cilium/ebpf v0.11.0 // indirect 37 | github.com/cloudwego/base64x v0.1.4 // indirect 38 | github.com/cloudwego/iasm v0.2.0 // indirect 39 | github.com/containerd/cgroups/v3 v3.0.3 // indirect 40 | github.com/containerd/containerd v1.7.13 // indirect 41 | github.com/containerd/errdefs v0.1.0 // indirect 42 | github.com/containerd/log v0.1.0 // indirect 43 | github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect 44 | github.com/containerd/typeurl/v2 v2.1.1 // indirect 45 | github.com/containers/buildah v1.35.3 // indirect 46 | github.com/containers/image/v5 v5.30.0 // indirect 47 | github.com/containers/libhvee v0.7.0 // indirect 48 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect 49 | github.com/containers/ocicrypt v1.1.9 // indirect 50 | github.com/containers/psgo v1.9.0 // indirect 51 | github.com/containers/storage v1.53.0 // indirect 52 | github.com/containers/winquit v1.1.0 // indirect 53 | github.com/coreos/go-systemd/v22 v22.5.1-0.20231103132048-7d375ecc2b09 // indirect 54 | github.com/crc-org/crc/v2 v2.32.0 // indirect 55 | github.com/crc-org/vfkit v0.5.1 // indirect 56 | github.com/cyberphone/json-canonicalization v0.0.0-20231217050601-ba74d44ecf5f // indirect 57 | github.com/cyphar/filepath-securejoin v0.2.4 // indirect 58 | github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e // indirect 59 | github.com/digitalocean/go-qemu v0.0.0-20230711162256-2e3d0186973e // indirect 60 | github.com/disiqueira/gotree/v3 v3.0.2 // indirect 61 | github.com/docker/distribution v2.8.3+incompatible // indirect 62 | github.com/docker/docker v25.0.3+incompatible // indirect 63 | github.com/docker/docker-credential-helpers v0.8.1 // indirect 64 | github.com/docker/go-connections v0.5.0 // indirect 65 | github.com/felixge/httpsnoop v1.0.4 // indirect 66 | github.com/fsnotify/fsnotify v1.7.0 // indirect 67 | github.com/fsouza/go-dockerclient v1.10.1 // indirect 68 | github.com/gabriel-vasile/mimetype v1.4.6 // indirect 69 | github.com/gin-contrib/sse v0.1.0 // indirect 70 | github.com/gin-gonic/gin v1.10.0 // indirect 71 | github.com/go-jose/go-jose/v3 v3.0.3 // indirect 72 | github.com/go-logr/logr v1.4.1 // indirect 73 | github.com/go-logr/stdr v1.2.2 // indirect 74 | github.com/go-ole/go-ole v1.3.0 // indirect 75 | github.com/go-openapi/analysis v0.21.4 // indirect 76 | github.com/go-openapi/errors v0.21.1 // indirect 77 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 78 | github.com/go-openapi/jsonreference v0.20.2 // indirect 79 | github.com/go-openapi/loads v0.21.2 // indirect 80 | github.com/go-openapi/runtime v0.26.0 // indirect 81 | github.com/go-openapi/spec v0.20.9 // indirect 82 | github.com/go-openapi/strfmt v0.22.2 // indirect 83 | github.com/go-openapi/swag v0.22.10 // indirect 84 | github.com/go-openapi/validate v0.22.1 // indirect 85 | github.com/go-playground/locales v0.14.1 // indirect 86 | github.com/go-playground/universal-translator v0.18.1 // indirect 87 | github.com/go-playground/validator/v10 v10.22.1 // indirect 88 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 89 | github.com/goccy/go-json v0.10.3 // indirect 90 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 91 | github.com/gogo/protobuf v1.3.2 // indirect 92 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 93 | github.com/golang/protobuf v1.5.3 // indirect 94 | github.com/google/go-cmp v0.6.0 // indirect 95 | github.com/google/go-containerregistry v0.19.0 // indirect 96 | github.com/google/go-intervals v0.0.2 // indirect 97 | github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect 98 | github.com/google/uuid v1.6.0 // indirect 99 | github.com/gorilla/mux v1.8.1 // indirect 100 | github.com/gorilla/schema v1.2.1 // indirect 101 | github.com/hashicorp/errwrap v1.1.0 // indirect 102 | github.com/hashicorp/go-multierror v1.1.1 // indirect 103 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 104 | github.com/jinzhu/copier v0.4.0 // indirect 105 | github.com/josharian/intern v1.0.0 // indirect 106 | github.com/json-iterator/go v1.1.12 // indirect 107 | github.com/klauspost/compress v1.17.7 // indirect 108 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 109 | github.com/klauspost/pgzip v1.2.6 // indirect 110 | github.com/kr/fs v0.1.0 // indirect 111 | github.com/leodido/go-urn v1.4.0 // indirect 112 | github.com/letsencrypt/boulder v0.0.0-20230907030200-6d76a0f91e1e // indirect 113 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 114 | github.com/mailru/easyjson v0.7.7 // indirect 115 | github.com/manifoldco/promptui v0.9.0 // indirect 116 | github.com/mattn/go-colorable v0.1.13 // indirect 117 | github.com/mattn/go-isatty v0.0.20 // indirect 118 | github.com/mattn/go-runewidth v0.0.15 // indirect 119 | github.com/mattn/go-shellwords v1.0.12 // indirect 120 | github.com/mattn/go-sqlite3 v1.14.22 // indirect 121 | github.com/miekg/pkcs11 v1.1.1 // indirect 122 | github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect 123 | github.com/mitchellh/mapstructure v1.5.0 // indirect 124 | github.com/moby/buildkit v0.12.5 // indirect 125 | github.com/moby/patternmatcher v0.6.0 // indirect 126 | github.com/moby/sys/mountinfo v0.7.1 // indirect 127 | github.com/moby/sys/sequential v0.5.0 // indirect 128 | github.com/moby/sys/user v0.1.0 // indirect 129 | github.com/moby/term v0.5.0 // indirect 130 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 131 | github.com/modern-go/reflect2 v1.0.2 // indirect 132 | github.com/morikuni/aec v1.0.0 // indirect 133 | github.com/nxadm/tail v1.4.11 // indirect 134 | github.com/oklog/ulid v1.3.1 // indirect 135 | github.com/opencontainers/go-digest v1.0.0 // indirect 136 | github.com/opencontainers/image-spec v1.1.0 // indirect 137 | github.com/opencontainers/runc v1.1.12 // indirect 138 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 139 | github.com/opencontainers/runtime-tools v0.9.1-0.20230914150019-408c51e934dc // indirect 140 | github.com/opencontainers/selinux v1.11.0 // indirect 141 | github.com/openshift/imagebuilder v1.2.6 // indirect 142 | github.com/ostreedev/ostree-go v0.0.0-20210805093236-719684c64e4f // indirect 143 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 144 | github.com/pkg/errors v0.9.1 // indirect 145 | github.com/pkg/sftp v1.13.6 // indirect 146 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 147 | github.com/proglottis/gpgme v0.1.3 // indirect 148 | github.com/rivo/uniseg v0.4.7 // indirect 149 | github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect 150 | github.com/shirou/gopsutil/v3 v3.24.2 // indirect 151 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 152 | github.com/sigstore/fulcio v1.4.3 // indirect 153 | github.com/sigstore/rekor v1.2.2 // indirect 154 | github.com/sigstore/sigstore v1.8.2 // indirect 155 | github.com/spf13/pflag v1.0.5 // indirect 156 | github.com/stefanberger/go-pkcs11uri v0.0.0-20201008174630-78d3cae3a980 // indirect 157 | github.com/sylabs/sif/v2 v2.15.1 // indirect 158 | github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 // indirect 159 | github.com/tchap/go-patricia/v2 v2.3.1 // indirect 160 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect 161 | github.com/tklauser/go-sysconf v0.3.12 // indirect 162 | github.com/tklauser/numcpus v0.6.1 // indirect 163 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 164 | github.com/ugorji/go/codec v1.2.12 // indirect 165 | github.com/ulikunitz/xz v0.5.11 // indirect 166 | github.com/vbatts/tar-split v0.11.5 // indirect 167 | github.com/vbauerster/mpb/v8 v8.7.2 // indirect 168 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 169 | go.mongodb.org/mongo-driver v1.14.0 // indirect 170 | go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect 171 | go.opencensus.io v0.24.0 // indirect 172 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect 173 | go.opentelemetry.io/otel v1.22.0 // indirect 174 | go.opentelemetry.io/otel/metric v1.22.0 // indirect 175 | go.opentelemetry.io/otel/trace v1.22.0 // indirect 176 | golang.org/x/arch v0.11.0 // indirect 177 | golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect 178 | golang.org/x/mod v0.17.0 // indirect 179 | golang.org/x/net v0.30.0 // indirect 180 | golang.org/x/sync v0.8.0 // indirect 181 | golang.org/x/text v0.19.0 // indirect 182 | golang.org/x/time v0.3.0 // indirect 183 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 184 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 // indirect 185 | google.golang.org/grpc v1.61.0 // indirect 186 | google.golang.org/protobuf v1.35.1 // indirect 187 | gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect 188 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect 189 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 190 | gopkg.in/yaml.v3 v3.0.1 // indirect 191 | sigs.k8s.io/yaml v1.4.0 // indirect 192 | tags.cncf.io/container-device-interface v0.6.2 // indirect 193 | ) 194 | -------------------------------------------------------------------------------- /pkg/bootc/bootc_disk.go: -------------------------------------------------------------------------------- 1 | package bootc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "sync" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/containers/podman-bootc/pkg/config" 16 | "github.com/containers/podman-bootc/pkg/user" 17 | "github.com/containers/podman-bootc/pkg/utils" 18 | 19 | "github.com/containers/podman/v5/pkg/bindings/containers" 20 | "github.com/containers/podman/v5/pkg/domain/entities/types" 21 | "github.com/docker/go-units" 22 | "github.com/sirupsen/logrus" 23 | "golang.org/x/sys/unix" 24 | "golang.org/x/term" 25 | ) 26 | 27 | // As a baseline heuristic we double the size of 28 | // the input container to support in-place updates. 29 | // This is planned to be more configurable in the 30 | // future. See also bootc-image-builder 31 | const containerSizeToDiskSizeMultiplier = 2 32 | const diskSizeMinimum = 10 * 1024 * 1024 * 1024 // 10GB 33 | const imageMetaXattr = "user.bootc.meta" 34 | 35 | // DiskImageConfig defines configuration for the 36 | type DiskImageConfig struct { 37 | Filesystem string 38 | RootSizeMax string 39 | DiskSize string 40 | } 41 | 42 | // diskFromContainerMeta is serialized to JSON in a user xattr on a disk image 43 | type diskFromContainerMeta struct { 44 | // imageDigest is the digested sha256 of the container that was used to build this disk 45 | ImageDigest string `json:"imageDigest"` 46 | } 47 | 48 | type BootcDisk struct { 49 | ImageNameOrId string 50 | User user.User 51 | Ctx context.Context 52 | ImageId string 53 | imageData *types.ImageInspectReport 54 | RepoTag string 55 | CreatedAt time.Time 56 | Directory string 57 | file *os.File 58 | bootcInstallContainerId string 59 | } 60 | 61 | // create singleton for easy cleanup 62 | var ( 63 | instance *BootcDisk 64 | instanceOnce sync.Once 65 | ) 66 | 67 | func NewBootcDisk(imageNameOrId string, ctx context.Context, user user.User) *BootcDisk { 68 | instanceOnce.Do(func() { 69 | instance = &BootcDisk{ 70 | ImageNameOrId: imageNameOrId, 71 | Ctx: ctx, 72 | User: user, 73 | } 74 | }) 75 | return instance 76 | } 77 | 78 | func (p *BootcDisk) GetDirectory() string { 79 | return p.Directory 80 | } 81 | 82 | func (p *BootcDisk) GetImageId() string { 83 | return p.ImageId 84 | } 85 | 86 | // GetSize returns the virtual size of the disk in bytes; 87 | // this may be larger than the actual disk usage 88 | func (p *BootcDisk) GetSize() (int64, error) { 89 | st, err := os.Stat(filepath.Join(p.Directory, config.DiskImage)) 90 | if err != nil { 91 | return 0, err 92 | } 93 | return st.Size(), nil 94 | } 95 | 96 | // GetRepoTag returns the repository of the container image 97 | func (p *BootcDisk) GetRepoTag() string { 98 | return p.RepoTag 99 | } 100 | 101 | // GetCreatedAt returns the creation time of the disk image 102 | func (p *BootcDisk) GetCreatedAt() time.Time { 103 | return p.CreatedAt 104 | } 105 | 106 | func (p *BootcDisk) Install(quiet bool, config DiskImageConfig) (err error) { 107 | p.CreatedAt = time.Now() 108 | 109 | err = p.pullImage() 110 | if err != nil { 111 | return 112 | } 113 | 114 | // Create VM cache dir; one per oci bootc image 115 | p.Directory = filepath.Join(p.User.CacheDir(), p.ImageId) 116 | lock := utils.NewCacheLock(p.User.RunDir(), p.Directory) 117 | locked, err := lock.TryLock(utils.Exclusive) 118 | if err != nil { 119 | return fmt.Errorf("error locking the VM cache path: %w", err) 120 | } 121 | if !locked { 122 | return fmt.Errorf("unable to lock the VM cache path") 123 | } 124 | 125 | defer func() { 126 | if err := lock.Unlock(); err != nil { 127 | logrus.Errorf("unable to unlock VM %s: %v", p.ImageId, err) 128 | } 129 | }() 130 | 131 | if err := os.MkdirAll(p.Directory, os.ModePerm); err != nil { 132 | return fmt.Errorf("error while making bootc disk directory: %w", err) 133 | } 134 | 135 | err = p.getOrInstallImageToDisk(quiet, config) 136 | if err != nil { 137 | return 138 | } 139 | 140 | elapsed := time.Since(p.CreatedAt) 141 | logrus.Debugf("installImage elapsed: %v", elapsed) 142 | 143 | return 144 | } 145 | 146 | func (p *BootcDisk) Cleanup() (err error) { 147 | force := true 148 | if p.bootcInstallContainerId != "" { 149 | _, err := containers.Remove(p.Ctx, p.bootcInstallContainerId, &containers.RemoveOptions{Force: &force}) 150 | if err != nil { 151 | return fmt.Errorf("failed to remove bootc install container: %w", err) 152 | } 153 | } 154 | 155 | return 156 | } 157 | 158 | // getOrInstallImageToDisk checks if the disk is present and if not, installs the image to a new disk 159 | func (p *BootcDisk) getOrInstallImageToDisk(quiet bool, diskConfig DiskImageConfig) error { 160 | diskPath := filepath.Join(p.Directory, config.DiskImage) 161 | f, err := os.Open(diskPath) 162 | if err != nil { 163 | if !errors.Is(err, os.ErrNotExist) { 164 | return err 165 | } 166 | logrus.Debugf("No existing disk image found") 167 | return p.bootcInstallImageToDisk(quiet, diskConfig) 168 | } 169 | logrus.Debug("Found existing disk image, comparing digest") 170 | defer f.Close() 171 | buf := make([]byte, 4096) 172 | len, err := unix.Fgetxattr(int(f.Fd()), imageMetaXattr, buf) 173 | if err != nil { 174 | // If there's no xattr, just remove it 175 | os.Remove(diskPath) 176 | logrus.Debugf("No %s xattr found", imageMetaXattr) 177 | return p.bootcInstallImageToDisk(quiet, diskConfig) 178 | } 179 | bufTrimmed := buf[:len] 180 | var serializedMeta diskFromContainerMeta 181 | if err := json.Unmarshal(bufTrimmed, &serializedMeta); err != nil { 182 | logrus.Warnf("failed to parse serialized meta from %s (%v) %v", diskPath, buf, err) 183 | return p.bootcInstallImageToDisk(quiet, diskConfig) 184 | } 185 | 186 | logrus.Debugf("previous disk digest: %s current digest: %s", serializedMeta.ImageDigest, p.ImageId) 187 | if serializedMeta.ImageDigest == p.ImageId { 188 | return nil 189 | } 190 | 191 | return p.bootcInstallImageToDisk(quiet, diskConfig) 192 | } 193 | 194 | func align(size int64, align int64) int64 { 195 | rem := size % align 196 | if rem != 0 { 197 | size += (align - rem) 198 | } 199 | return size 200 | } 201 | 202 | // bootcInstallImageToDisk creates a disk image from a bootc container 203 | func (p *BootcDisk) bootcInstallImageToDisk(quiet bool, diskConfig DiskImageConfig) (err error) { 204 | fmt.Printf("Executing `bootc install to-disk` from container image %s to create disk image\n", p.RepoTag) 205 | p.file, err = os.CreateTemp(p.Directory, "podman-bootc-tempdisk") 206 | if err != nil { 207 | return err 208 | } 209 | size := p.imageData.Size * containerSizeToDiskSizeMultiplier 210 | if size < diskSizeMinimum { 211 | size = diskSizeMinimum 212 | } 213 | if diskConfig.DiskSize != "" { 214 | diskConfigSize, err := units.FromHumanSize(diskConfig.DiskSize) 215 | if err != nil { 216 | return err 217 | } 218 | if size < diskConfigSize { 219 | size = diskConfigSize 220 | } 221 | } 222 | // Round up to 4k; loopback wants at least 512b alignment 223 | size = align(size, 4096) 224 | humanContainerSize := units.HumanSize(float64(p.imageData.Size)) 225 | humanSize := units.HumanSize(float64(size)) 226 | logrus.Infof("container size: %s, disk size: %s", humanContainerSize, humanSize) 227 | 228 | if err := syscall.Ftruncate(int(p.file.Fd()), size); err != nil { 229 | return err 230 | } 231 | logrus.Debugf("Created %s with size %v", p.file.Name(), size) 232 | doCleanupDisk := true 233 | defer func() { 234 | if doCleanupDisk { 235 | os.Remove(p.file.Name()) 236 | } 237 | }() 238 | 239 | err = p.runInstallContainer(quiet, diskConfig) 240 | if err != nil { 241 | return fmt.Errorf("failed to create disk image: %w", err) 242 | } 243 | serializedMeta := diskFromContainerMeta{ 244 | ImageDigest: p.ImageId, 245 | } 246 | buf, err := json.Marshal(serializedMeta) 247 | if err != nil { 248 | return err 249 | } 250 | if err := unix.Fsetxattr(int(p.file.Fd()), imageMetaXattr, buf, 0); err != nil { 251 | return fmt.Errorf("failed to set xattr: %w", err) 252 | } 253 | diskPath := filepath.Join(p.Directory, config.DiskImage) 254 | 255 | if err := os.Rename(p.file.Name(), diskPath); err != nil { 256 | return fmt.Errorf("failed to rename to %s: %w", diskPath, err) 257 | } 258 | doCleanupDisk = false 259 | 260 | return nil 261 | } 262 | 263 | // pullImage fetches the container image if not present 264 | func (p *BootcDisk) pullImage() error { 265 | imageData, err := utils.PullAndInspect(p.Ctx, p.ImageNameOrId) 266 | if err != nil { 267 | return err 268 | } 269 | 270 | p.imageData = imageData 271 | p.ImageId = imageData.ID 272 | if len(imageData.RepoTags) > 0 { 273 | p.RepoTag = imageData.RepoTags[0] 274 | } 275 | 276 | return nil 277 | } 278 | 279 | // runInstallContainer runs the bootc installer in a container to create a disk image 280 | func (p *BootcDisk) runInstallContainer(quiet bool, config DiskImageConfig) error { 281 | c := p.createInstallContainer(config) 282 | if err := c.Run(); err != nil { 283 | return fmt.Errorf("failed to invoke install: %w", err) 284 | } 285 | return nil 286 | } 287 | 288 | // createInstallContainer creates a podman command to run the bootc installer. 289 | // Note: This code used to use the Go bindings for the podman remote client, but the 290 | // Attach interface currently leaks goroutines. 291 | func (p *BootcDisk) createInstallContainer(config DiskImageConfig) *exec.Cmd { 292 | bootcInstallArgs := []string{ 293 | "bootc", "install", "to-disk", "--via-loopback", "--generic-image", 294 | "--skip-fetch-check", 295 | } 296 | if config.Filesystem != "" { 297 | bootcInstallArgs = append(bootcInstallArgs, "--filesystem", config.Filesystem) 298 | } 299 | if config.RootSizeMax != "" { 300 | bootcInstallArgs = append(bootcInstallArgs, "--root-size="+config.RootSizeMax) 301 | } 302 | bootcInstallArgs = append(bootcInstallArgs, "/output/"+filepath.Base(p.file.Name())) 303 | 304 | // Basic config: 305 | // - force on --remote because we depend on podman machine. 306 | // - add privileged, pid=host, SELinux config and bind mounts per https://containers.github.io/bootc/bootc-install.html 307 | // - we need force running as root (i.e., --user=root:root) to overwrite any possible USER directive in the Containerfile 308 | podmanArgs := []string{"--remote", "run", "--rm", "-i", "--pid=host", "--user=root:root", "--privileged", "--security-opt=label=type:unconfined_t", "--volume=/dev:/dev", "--volume=/var/lib/containers:/var/lib/containers"} 309 | // Custom bind mounts 310 | podmanArgs = append(podmanArgs, fmt.Sprintf("--volume=%s:/output", p.Directory)) 311 | if term.IsTerminal(int(os.Stdin.Fd())) { 312 | podmanArgs = append(podmanArgs, "-t") 313 | } 314 | // Other conditional arguments 315 | if v, ok := os.LookupEnv("BOOTC_INSTALL_LOG"); ok { 316 | podmanArgs = append(podmanArgs, fmt.Sprintf("--env=RUST_LOG=%s", v)) 317 | } 318 | // The image name 319 | podmanArgs = append(podmanArgs, p.ImageNameOrId) 320 | // And the remaining arguments for bootc install 321 | podmanArgs = append(podmanArgs, bootcInstallArgs...) 322 | 323 | c := exec.Command("podman", podmanArgs...) 324 | c.Stdin = os.Stdin 325 | c.Stdout = os.Stdout 326 | c.Stderr = os.Stderr 327 | return c 328 | } 329 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | https://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /hack/xref-helpmsgs-manpages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | # 3 | # xref-helpmsgs-manpages - cross-reference --help options against man pages 4 | # 5 | package LibPod::CI::XrefHelpmsgsManpages; 6 | 7 | use v5.14; 8 | use utf8; 9 | 10 | use strict; 11 | use warnings; 12 | use Clone qw(clone); 13 | use FindBin; 14 | 15 | (our $ME = $0) =~ s|.*/||; 16 | our $VERSION = '0.1'; 17 | 18 | # For debugging, show data structures using DumpTree($var) 19 | #use Data::TreeDumper; $Data::TreeDumper::Displayaddress = 0; 20 | 21 | # unbuffer output 22 | $| = 1; 23 | 24 | ############################################################################### 25 | # BEGIN user-customizable section 26 | 27 | # Path to podman-bootc executable 28 | my $Default_PodmanBootc = "$FindBin::Bin/../bin/podman-bootc"; 29 | my $PODMANBOOTC = $ENV{PODMAN_BOOTC} || $Default_PodmanBootc; 30 | 31 | # Path to all doc files, including .rst and (down one level) markdown 32 | our $Docs_Path = 'docs'; 33 | 34 | # Path to podman-bootc markdown source files (of the form podman-bootc-*.1.md) 35 | our $Markdown_Path = "$Docs_Path"; 36 | 37 | # Global error count 38 | our $Errs = 0; 39 | 40 | # Table of exceptions for documenting fields in '--format {{.Foo}}' 41 | # 42 | # Autocomplete is wonderful, and it's even better when we document the 43 | # existing options. Unfortunately, sometimes internal structures get 44 | # exposed that are of no use to anyone and cannot be guaranteed. Avoid 45 | # documenting those. This table lists those exceptions. Format is: 46 | # 47 | # foo .Bar 48 | # 49 | # ...such that "podman-bootc foo --format '{{.Bar}}'" will not be documented. 50 | # 51 | my $Format_Exceptions = <<'END_EXCEPTIONS'; 52 | # Deep internal structs; pretty sure these are permanent exceptions 53 | events .Details 54 | history .ImageHistoryLayer 55 | images .Arch .ImageSummary .Os .IsManifestList 56 | network-ls .Network 57 | 58 | # FIXME: this one, maybe? But someone needs to write the text 59 | machine-list .Starting 60 | 61 | # No clue what these are. Some are just different-case dups of others. 62 | pod-ps .Containers .Id .InfraId .ListPodsReport .Namespace 63 | ps .Cgroup .CGROUPNS .IPC .ListContainer .MNT .Namespaces .NET .PIDNS .User .USERNS .UTS 64 | 65 | # I think .Destination is an internal struct, but .IsMachine maybe needs doc? 66 | system-connection-list .Destination .IsMachine 67 | END_EXCEPTIONS 68 | 69 | my %Format_Exceptions; 70 | for my $line (split "\n", $Format_Exceptions) { 71 | $line =~ s/#.*$//; # strip comments 72 | next unless $line; # skip empty lines 73 | my ($subcommand, @fields) = split(' ', $line); 74 | $Format_Exceptions{"podman-bootc-$subcommand"} = \@fields; 75 | } 76 | 77 | # Hardcoded list of existing duplicate-except-for-case format codes, 78 | # with their associated subcommands. Let's not add any more. 79 | my %Format_Option_Dup_Allowed = ( 80 | 'podman-bootc-images' => { '.id' => 1 }, 81 | 'podman-bootc-stats' => { '.avgcpu' => 1, '.pids' => 1 }, 82 | ); 83 | 84 | # Do not cross-reference these. 85 | my %Skip_Subcommand = map { $_ => 1 } ( 86 | "help", # has no man page 87 | "completion", # internal (hidden) subcommand 88 | "compose", # external tool, outside of our control 89 | ); 90 | 91 | # END user-customizable section 92 | ############################################################################### 93 | # BEGIN boilerplate args checking, usage messages 94 | 95 | sub usage { 96 | print <<"END_USAGE"; 97 | Usage: $ME [OPTIONS] 98 | 99 | $ME recursively runs 'podman-bootc --help' against 100 | all subcommands; and recursively reads podman-bootc-*.1.md files 101 | in $Markdown_Path, then cross-references that each --help 102 | option is listed in the appropriate man page and vice-versa. 103 | 104 | $ME invokes '\$PODMANBOOTC' (default: $Default_PodmanBootc). 105 | 106 | In the spirit of shoehorning functionality where it wasn't intended, 107 | $ME also checks the SEE ALSO section of each man page 108 | to ensure that references and links are properly formatted 109 | and valid. 110 | 111 | Exit status is zero if no inconsistencies found, one otherwise 112 | 113 | OPTIONS: 114 | 115 | -v, --verbose show verbose progress indicators 116 | -n, --dry-run make no actual changes 117 | 118 | --help display this message 119 | --version display program name and version 120 | END_USAGE 121 | 122 | exit; 123 | } 124 | 125 | # Command-line options. Note that this operates directly on @ARGV ! 126 | our $debug = 0; 127 | our $verbose = 0; 128 | sub handle_opts { 129 | use Getopt::Long; 130 | GetOptions( 131 | 'debug!' => \$debug, 132 | 'verbose|v' => \$verbose, 133 | 134 | help => \&usage, 135 | version => sub { print "$ME version $VERSION\n"; exit 0 }, 136 | ) or die "Try `$ME --help' for help\n"; 137 | } 138 | 139 | # END boilerplate args checking, usage messages 140 | ############################################################################### 141 | 142 | ############################## CODE BEGINS HERE ############################### 143 | 144 | # The term is "modulino". 145 | __PACKAGE__->main() unless caller(); 146 | 147 | # Main code. 148 | sub main { 149 | # Note that we operate directly on @ARGV, not on function parameters. 150 | # This is deliberate: it's because Getopt::Long only operates on @ARGV 151 | # and there's no clean way to make it use @_. 152 | handle_opts(); # will set package globals 153 | 154 | # Fetch command-line arguments. Barf if too many. 155 | die "$ME: Too many arguments; try $ME --help\n" if @ARGV; 156 | 157 | chdir "$FindBin::Bin/.." 158 | or die "$ME: FATAL: Cannot cd $FindBin::Bin/..: $!"; 159 | 160 | my $help = podman_bootc_help(); 161 | 162 | my $man = podman_bootc_man('podman-bootc'); 163 | my $rst = podman_bootc_rst(); 164 | 165 | xref_by_help($help, $man); 166 | xref_by_man($help, $man); 167 | 168 | exit !!$Errs; 169 | } 170 | 171 | ############################################################################### 172 | # BEGIN cross-referencing 173 | 174 | ################## 175 | # xref_by_help # Find keys in '--help' but not in man 176 | ################## 177 | sub xref_by_help { 178 | my ($help, $man, @subcommand) = @_; 179 | 180 | OPTION: 181 | for my $k (sort keys %$help) { 182 | next if $k =~ /^_/ || $k eq "ls"; # metadata ("_desc"). Ignore. 183 | 184 | if (! ref($man)) { 185 | # Super-unlikely but I've seen it 186 | warn "$ME: 'podman-bootc @subcommand' is not documented in man pages!\n"; 187 | ++$Errs; 188 | next OPTION; 189 | } 190 | 191 | if (exists $man->{$k}) { 192 | if (ref $help->{$k}) { 193 | # This happens when 'podman-bootc foo --format' offers 194 | # autocompletion that looks like a Go template, but those 195 | # template options aren't documented in the man pages. 196 | if ($k eq '--format' && ! ref($man->{$k})) { 197 | # "podman-bootc inspect" tries to autodetect if it's being run 198 | # on an image or container. It cannot sanely be documented. 199 | unless ("@subcommand" eq "inspect") { 200 | warn "$ME: 'podman-bootc @subcommand': --format options are available through autocomplete2, but are not documented in $man->{_path}\n"; 201 | ++$Errs; 202 | } 203 | next OPTION; 204 | } 205 | 206 | xref_by_help($help->{$k}, $man->{$k}, @subcommand, $k); 207 | } 208 | 209 | # Documenting --format fields is tricky! They can be scalars, structs, 210 | # or functions. This is a complicated block because if help & man don't 211 | # match, we want to give the most user-friendly message possible. 212 | elsif (@subcommand && $subcommand[-1] eq '--format') { 213 | # '!' is one of the Format_Exceptions defined at top 214 | if (($man->{$k} ne '!') && ($man->{$k} ne $help->{$k})) { 215 | # Fallback message 216 | my $msg = "TELL ED TO HANDLE THIS: man='$man->{$k}' help='$help->{$k}'"; 217 | 218 | # Many different permutations of mismatches. 219 | my $combo = "$man->{$k}-$help->{$k}"; 220 | if ($combo eq '0-...') { 221 | $msg = "is a nested structure. Please add '...' to man page."; 222 | } 223 | elsif ($combo =~ /^\d+-\.\.\.$/) { 224 | $msg = "is a nested structure, but the man page documents it as a function?!?"; 225 | } 226 | elsif ($combo eq '...-0') { 227 | $msg = "is a simple value, not a nested structure. Please remove '...' from man page."; 228 | } 229 | elsif ($combo =~ /^0-[1-9]\d*$/) { 230 | $msg = "is a function that calls for $help->{$k} args. Please investigate what those are, then add them to the man page. E.g., '$k *bool*' or '$k *path* *bool*'"; 231 | } 232 | elsif ($combo =~ /^\d+-[1-9]\d*$/) { 233 | $msg = "is a function that calls for $help->{$k} args; the man page lists $man->{$k}. Please fix the man page."; 234 | } 235 | 236 | warn "$ME: 'podman-bootc @subcommand {{$k' $msg\n"; 237 | ++$Errs; 238 | } 239 | } 240 | } 241 | else { 242 | # Not documented in man. However, handle '...' as a special case 243 | # in formatting strings. E.g., 'podman-bootc info .Host' is documented 244 | # in the man page as '.Host ...' to indicate that the subfields 245 | # are way too many to list individually. 246 | my $k_copy = $k; 247 | while ($k_copy =~ s/\.[^.]+$//) { 248 | my $parent_man = $man->{$k_copy} // ''; 249 | if (($parent_man eq '...') || ($parent_man eq '!')) { 250 | next OPTION; 251 | } 252 | } 253 | 254 | # Nope, it's not that case. 255 | my $man = $man->{_path} || 'man'; 256 | # The usual case is "podman-bootc ... --help"... 257 | my $what = '--help'; 258 | # ...but for *options* (e.g. --filter), we're checking command completion 259 | $what = '' if @subcommand && $subcommand[-1] =~ /^--/; 260 | warn "$ME: 'podman-bootc @subcommand $what' lists '$k', which is not in $man\n"; 261 | ++$Errs; 262 | } 263 | } 264 | } 265 | 266 | ################# 267 | # xref_by_man # Find keys in man pages but not in --help 268 | ################# 269 | # 270 | # In an ideal world we could share the functionality in one function; but 271 | # there are just too many special cases in man pages. 272 | # 273 | sub xref_by_man { 274 | my ($help, $man, @subcommand) = @_; 275 | 276 | # FIXME: this generates way too much output 277 | KEYWORD: 278 | for my $k (grep { $_ ne '_path' } sort keys %$man) { 279 | if ($k eq '--format' && ref($man->{$k}) && ! ref($help->{$k})) { 280 | # warn "$ME: 'podman-bootc @subcommand': --format options documented in man page, but not available via autocomplete1\n"; 281 | next KEYWORD; 282 | } 283 | 284 | if (exists $help->{$k}) { 285 | if (ref $man->{$k}) { 286 | xref_by_man($help->{$k}, $man->{$k}, @subcommand, $k); 287 | } 288 | elsif ($k =~ /^-/) { 289 | # This is OK: we don't recurse into options 290 | } 291 | else { 292 | # FIXME: should never get here, but we do. Figure it out later. 293 | } 294 | } 295 | elsif ($k ne '--help' && $k ne '-h') { 296 | my $man = $man->{_path} || 'man'; 297 | 298 | # Special case: podman-bootc-inspect serves dual purpose (image, ctr) 299 | my %ignore = map { $_ => 1 } qw(-l -s -t --latest --size --type); 300 | next if $man =~ /-inspect/ && $ignore{$k}; 301 | 302 | # Special case: podman-bootc-diff serves dual purpose (image, ctr) 303 | my %diffignore = map { $_ => 1 } qw(-l --latest ); 304 | next if $man =~ /-diff/ && $diffignore{$k}; 305 | 306 | # Special case: the 'trust' man page is a mess 307 | next if $man =~ /-trust/; 308 | 309 | # Special case: '--net' is an undocumented shortcut 310 | next if $k eq '--net' && $help->{'--network'}; 311 | 312 | # Special case: these are actually global options 313 | next if $k =~ /^--(cni-config-dir|runtime)$/ && $man =~ /-build/; 314 | 315 | # Special case: weirdness with Cobra and global/local options 316 | next if $k eq '--namespace' && $man =~ /-ps/; 317 | 318 | next if "@subcommand" eq 'system' && $k eq 'service'; 319 | 320 | # Special case for hidden or external commands 321 | next if $Skip_Subcommand{$k}; 322 | 323 | # It's not always --help, sometimes we check completion 324 | my $what = '--help'; 325 | $what = 'command completion' if @subcommand && $subcommand[-1] =~ /^--/; 326 | warn "$ME: 'podman-bootc @subcommand': '$k' in $man, but not in $what\n"; 327 | ++$Errs; 328 | } 329 | } 330 | } 331 | 332 | ############## 333 | # xref_rst # Cross-check *.rst files against help 334 | ############## 335 | # 336 | # This makes a pass over top-level commands only. There is no rst 337 | # documentation for any podman-bootc subcommands. 338 | # 339 | sub xref_rst { 340 | my ($help, $rst) = @_; 341 | 342 | 343 | # We key on $help because that is Absolute Truth: anything in podman-bootc --help 344 | # must be referenced in an rst (the converse is not necessarily true) 345 | for my $k (sort grep { $_ !~ /^[_-]/ } keys %$help) { 346 | if (exists $rst->{$k}) { 347 | # Descriptions must match 348 | if ($rst->{$k}{_desc} ne $help->{$k}{_desc}) { 349 | warn "$ME: podman-bootc $k: inconsistent description in $rst->{$k}{_source}:\n"; 350 | warn " help: '$help->{$k}{_desc}'\n"; 351 | warn " rst: '$rst->{$k}{_desc}'\n"; 352 | ++$Errs; 353 | } 354 | } 355 | else { 356 | warn "$ME: Not found in rst: $k\n"; 357 | ++$Errs; 358 | } 359 | } 360 | 361 | # Now the other way around: look for anything in Commands.rst that is 362 | # not in podman-bootc --help 363 | for my $k (sort grep { $rst->{$_}{_source} =~ /Commands.rst/ } keys %$rst) { 364 | if ($k ne 'podman-bootc' && ! exists $help->{$k}) { 365 | warn "$ME: 'podman-bootc $k' found in $rst->{$k}{_source} but not 'podman-bootc help'\n"; 366 | ++$Errs; 367 | } 368 | } 369 | } 370 | 371 | # END cross-referencing 372 | ############################################################################### 373 | # BEGIN data gathering 374 | 375 | ################# 376 | # podman_bootc_help # Parse output of 'podman-bootc [subcommand] --help' 377 | ################# 378 | sub podman_bootc_help { 379 | my %help; 380 | open my $fh, '-|', $PODMANBOOTC, @_, '--help' 381 | or die "$ME: Cannot fork: $!\n"; 382 | my $section = ''; 383 | while (my $line = <$fh>) { 384 | chomp $line; 385 | 386 | # First line of --help is a short command description. We compare it 387 | # (in a later step) against the blurb in Commands.rst. 388 | # FIXME: we should crossref against man pages, but as of 2024-03-18 389 | # it would be way too much work to get those aligned. 390 | $help{_desc} //= $line; 391 | 392 | # Cobra is blessedly consistent in its output: 393 | # [command blurb] 394 | # Description: ... 395 | # Usage: ... 396 | # Available Commands: 397 | # .... 398 | # Options: 399 | # .... 400 | # 401 | # Start by identifying the section we're in... 402 | if ($line =~ /^Available\s+(Commands):/) { 403 | if (@_ == 0) { 404 | $section = lc $1; 405 | } 406 | else { 407 | $section = 'flags'; 408 | } 409 | } 410 | elsif ($line =~ /^(Flags):/) { 411 | $section = lc $1; 412 | } 413 | 414 | # ...then track commands and options. For subcommands, recurse. 415 | elsif ($section eq 'commands') { 416 | if ($line =~ /^\s{1,4}(\S+)\s/) { 417 | my $subcommand = $1; 418 | print "> podman-bootc @_ $subcommand\n" if $debug; 419 | 420 | # check that the same subcommand is not listed twice (#12356) 421 | if (exists $help{$subcommand}) { 422 | warn "$ME: 'podman-bootc @_ help' lists '$subcommand' twice\n"; 423 | ++$Errs; 424 | } 425 | 426 | $help{$subcommand} = podman_bootc_help(@_, $subcommand) 427 | unless $Skip_Subcommand{$subcommand}; 428 | } 429 | } 430 | elsif ($section eq 'flags') { 431 | my $opt = ''; 432 | 433 | # Handle '--foo' or '-f, --foo' 434 | if ($line =~ /^\s{1,10}(--\S+)\s/) { 435 | print "> podman-bootc @_ $1\n" if $debug; 436 | $opt = $1; 437 | $help{$opt} = 1; 438 | } 439 | 440 | # Handle "-n, --noheading" and "-u USER, --username USER" 441 | elsif ($line =~ /^\s{1,10}(-\S)(\s+\S+)?,\s+(--\S+)\s/) { 442 | print "> podman-bootc @_ $1, $3\n" if $debug; 443 | $opt = $3; 444 | $help{$1} = $help{$opt} = 1; 445 | } 446 | } 447 | } 448 | close $fh 449 | or die "$ME: Error running 'podman-bootc @_ --help'\n"; 450 | 451 | return \%help; 452 | } 453 | 454 | 455 | ################ 456 | # podman_bootc_man # Parse contents of podman-bootc-*.1.md 457 | ################ 458 | our %Man_Seen; 459 | sub podman_bootc_man { 460 | my $command = shift; 461 | my $subpath = "$Markdown_Path/$command.1.md"; 462 | print "** $subpath \n" if $debug; 463 | 464 | my %man = (_path => $subpath); 465 | 466 | # We often get called multiple times on the same man page, 467 | # because (e.g.) podman-bootc-container-list == podman-bootc-ps. It's the 468 | # same man page text, though, and we don't know which subcommand 469 | # we're being called for, so there's nothing to be gained by 470 | # rereading the man page or by dumping yet more warnings 471 | # at the user. So, keep a cache of what we've done. 472 | if (my $seen = $Man_Seen{$subpath}) { 473 | return clone($seen); 474 | } 475 | $Man_Seen{$subpath} = \%man; 476 | 477 | open my $fh, '<', $subpath 478 | or die "$ME: Cannot read $subpath: $!\n"; 479 | my $section = ''; 480 | my @most_recent_flags; 481 | my $previous_subcmd = ''; 482 | my $previous_flag = ''; 483 | my $previous_format = ''; 484 | my $previous_filter = ''; 485 | LINE: 486 | while (my $line = <$fh>) { 487 | chomp $line; 488 | next LINE unless $line; # skip empty lines 489 | 490 | # First line (page title) must match the command name. 491 | if ($line =~ /^%\s+/) { 492 | my $expect = "% $command 1"; 493 | if ($line ne $expect) { 494 | warn "$ME: $subpath:$.: wrong title line '$line'; should be '$expect'\n"; 495 | ++$Errs; 496 | } 497 | } 498 | 499 | # .md files designate sections with leading double hash 500 | if ($line =~ /^##\s*(GLOBAL\s+)?OPTIONS/) { 501 | $section = 'flags'; 502 | $previous_flag = ''; 503 | } 504 | elsif ($line =~ /^###\s+\w+\s+OPTIONS/) { 505 | # podman-bootc image trust has sections for set & show 506 | $section = 'flags'; 507 | $previous_flag = ''; 508 | } 509 | elsif ($line =~ /^\#\#\s+(SUB)?COMMANDS/) { 510 | $section = 'commands'; 511 | } 512 | elsif ($line =~ /^\#\#\s+SEE\s+ALSO/) { 513 | $section = 'see-also'; 514 | } 515 | elsif ($line =~ /^\#\#[^#]/) { 516 | $section = ''; 517 | } 518 | 519 | # This will be a table containing subcommand names, links to man pages. 520 | # The format is slightly different between podman-bootc.1.md and subcommands. 521 | elsif ($section eq 'commands') { 522 | # In podman-bootc.1.md 523 | if ($line =~ /^\|\s*\[podman-bootc-(\S+?)\(\d\)\]/) { 524 | # $1 will be changed by recursion _*BEFORE*_ left-hand assignment 525 | my $subcmd = $1; 526 | $man{$subcmd} = podman_bootc_man("podman-bootc-$subcmd"); 527 | } 528 | 529 | # In podman-bootc-.1.md 530 | # 1 1 2 3 3 4 4 2 531 | elsif ($line =~ /^\|\s+(\S+)\s+\|\s+(\[(\S+)\]\((\S+)\.1\.md\))/) { 532 | my ($subcmd, $blob, $shown_name, $link_name) = ($1, $2, $3, $4); 533 | if ($previous_subcmd gt $subcmd) { 534 | warn "$ME: $subpath:$.: '$previous_subcmd' and '$subcmd' are out of order\n"; 535 | ++$Errs; 536 | } 537 | if ($previous_subcmd eq $subcmd) { 538 | warn "$ME: $subpath:$.: duplicate subcommand '$subcmd'\n"; 539 | ++$Errs; 540 | } 541 | $previous_subcmd = $subcmd; 542 | $man{$subcmd} = podman_bootc_man($link_name); 543 | 544 | # Check for inconsistencies between the displayed man page name 545 | # and the actual man page name, e.g. 546 | # '[podman-bootc-bar(1)](podman-bootc-baz.1.md) 547 | $shown_name =~ s/\(\d\)$//; 548 | $shown_name =~ s/\\//g; # backslashed hyphens 549 | (my $should_be = $link_name) =~ s/\.1\.md$//; 550 | if ($shown_name ne $should_be) { 551 | warn "$ME: $subpath:$.: '$shown_name' should be '$should_be' in '$blob'\n"; 552 | ++$Errs; 553 | } 554 | } 555 | } 556 | 557 | # Options should always be of the form '**-f**' or '**\-\-flag**', 558 | # possibly separated by comma-space. 559 | elsif ($section eq 'flags') { 560 | # e.g. 'podman-bootc run --ip6', documented in man page, but nonexistent 561 | if ($line =~ /^not\s+implemented/i) { 562 | delete $man{$_} for @most_recent_flags; 563 | } 564 | 565 | @most_recent_flags = (); 566 | # As of PR #8292, all options are

and anchored 567 | if ($line =~ s/^\#{4}\s+//) { 568 | # If option has long and short form, long must come first. 569 | # This is a while-loop because there may be multiple long 570 | # option names, e.g. --net/--network 571 | my $is_first = 1; 572 | while ($line =~ s/^\*\*(--[a-z0-9-]+)\*\*(,\s+)?//g) { 573 | my $flag = $1; 574 | $man{$flag} = 1; 575 | if ($flag lt $previous_flag && $is_first) { 576 | warn "$ME: $subpath:$.: $flag should precede $previous_flag\n"; 577 | ++$Errs; 578 | } 579 | if ($flag eq $previous_flag) { 580 | warn "$ME: $subpath:$.: flag '$flag' is a dup\n"; 581 | ++$Errs; 582 | } 583 | $previous_flag = $flag if $is_first; 584 | push @most_recent_flags, $flag; 585 | 586 | # Further iterations of /g are allowed to be out of order, 587 | # e.g., it's OK for "--namespace, -ns" to precede --nohead 588 | $is_first = 0; 589 | } 590 | # Short form 591 | if ($line =~ s/^\*\*(-[a-zA-Z0-9])\*\*//) { 592 | my $flag = $1; 593 | $man{$flag} = 1; 594 | 595 | # Keep track of them, in case we see 'Not implemented' below 596 | push @most_recent_flags, $flag; 597 | } 598 | 599 | # Options with no '=whatever' 600 | next LINE if !$line; 601 | 602 | # Anything remaining *must* be of the form '=' 603 | if ($line !~ /^=/) { 604 | warn "$ME: $subpath:$.: could not parse '$line' in option description\n"; 605 | ++$Errs; 606 | } 607 | 608 | # For some years it was traditional, albeit wrong, to write 609 | # **--foo**=*bar*, **-f** 610 | # The correct way is to add =*bar* at the end. 611 | if ($line =~ s/,\s\*\*(-[a-zA-Z])\*\*//) { 612 | $man{$1} = 1; 613 | warn "$ME: $subpath:$.: please rewrite as ', **$1**$line'\n"; 614 | ++$Errs; 615 | } 616 | 617 | # List of possibilities ('=*a* | *b*') must be space-separated 618 | if ($line =~ /\|/) { 619 | if ($line =~ /[^\s]\|[^\s]/) { 620 | # Sigh, except for this one special case 621 | if ($line !~ /SOURCE-VOLUME.*HOST-DIR.*CONTAINER-DIR/) { 622 | warn "$ME: $subpath:$.: values must be space-separated: '$line'\n"; 623 | ++$Errs; 624 | } 625 | } 626 | my $copy = $line; 627 | if ($copy =~ s/\**true\**//) { 628 | if ($copy =~ s/\**false\**//) { 629 | if ($copy !~ /[a-z]/) { 630 | warn "$ME: $subpath:$.: Do not enumerate true/false for boolean-only options\n"; 631 | ++$Errs; 632 | } 633 | } 634 | } 635 | } 636 | } 637 | 638 | # --format does not always mean a Go format! E.g., push --format=oci 639 | if ($previous_flag eq '--format') { 640 | # ...but if there's a table like '| .Foo | blah blah |' 641 | # then it's definitely a Go template. There are three cases: 642 | # .Foo - Scalar field. The usual case. 643 | # .Foo ... - Structure with subfields, e.g. .Foo.Xyz 644 | # .Foo ARG(s) - Function requiring one or more arguments 645 | # 646 | # 1 12 3 32 647 | if ($line =~ /^\|\s+(\.\S+)(\s+([^\|]+\S))?\s+\|/) { 648 | my ($format, $etc) = ($1, $3); 649 | 650 | # Confirmed: we have a table with '.Foo' strings, so 651 | # this is a Go template. Override previous (scalar) 652 | # setting of the --format flag with a hash, indicating 653 | # that we will recursively cross-check each param. 654 | if (! ref($man{$previous_flag})) { 655 | $man{$previous_flag} = { _path => $subpath }; 656 | } 657 | 658 | # ...and document this format option. $etc, if set, 659 | # will indicate if this is a struct ("...") or a 660 | # function. 661 | if ($etc) { 662 | if ($etc eq '...') { # ok 663 | ; 664 | } 665 | elsif ($etc =~ /^\*[a-z]+\*(\s+\*[a-z]+\*)*$/) { 666 | # a function. Preserve only the arg COUNT, not 667 | # their names. (command completion has no way 668 | # to give us arg names or types). 669 | $etc = scalar(split(' ', $etc)); 670 | } 671 | else { 672 | warn "$ME: $subpath:$.: unknown args '$etc' for '$format'. Valid args are '...' for nested structs or, for functions, one or more asterisk-wrapped argument names.\n"; 673 | ++$Errs; 674 | } 675 | } 676 | 677 | $man{$previous_flag}{$format} = $etc || 0; 678 | 679 | # Sort order check, case-insensitive 680 | if (lc($format) lt lc($previous_format)) { 681 | warn "$ME: $subpath:$.: format specifier '$format' should precede '$previous_format'\n"; 682 | ++$Errs; 683 | } 684 | 685 | # Dup check, would've caught #19462. 686 | if (lc($format) eq lc($previous_format)) { 687 | # Sigh. Allow preexisting exceptions, but no new ones. 688 | unless ($Format_Option_Dup_Allowed{$command}{lc $format}) { 689 | warn "$ME: $subpath:$.: format specifier '$format' is a dup\n"; 690 | ++$Errs; 691 | } 692 | } 693 | $previous_format = $format; 694 | } 695 | } 696 | # Same as above, but with --filter 697 | elsif ($previous_flag eq '--filter') { 698 | if ($line =~ /^\|\s+(\S+)\s+\|/) { 699 | my $filter = $1; 700 | 701 | # (Garbage: these are just table column titles & dividers) 702 | next LINE if $filter =~ /^\**Filter\**$/; 703 | next LINE if $filter =~ /---+/; 704 | 705 | # Special case: treat slash-separated options 706 | # ("after/since") as identical, and require that 707 | # each be documented. 708 | for my $f (split '/', $filter) { 709 | # Special case for negated options ("label!="): allow, 710 | # but only immediately after the positive case. 711 | if ($f =~ s/!$//) { 712 | if ($f ne $previous_filter) { 713 | warn "$ME: $subpath:$.: filter '$f!' only allowed immediately after its positive\n"; 714 | ++$Errs; 715 | } 716 | next LINE; 717 | } 718 | 719 | if (! ref($man{$previous_flag})) { 720 | $man{$previous_flag} = { _path => $subpath }; 721 | } 722 | $man{$previous_flag}{$f} = 1; 723 | } 724 | 725 | # Sort order check, case-insensitive 726 | # FIXME FIXME! Disabled for now because it would make 727 | # this PR completely impossible to review (as opposed to 728 | # only mostly-impossible) 729 | #if (lc($filter) lt lc($previous_filter)) { 730 | # warn "$ME: $subpath:$.: filter specifier '$filter' should precede '$previous_filter'\n"; 731 | # ++$Errs; 732 | #} 733 | 734 | # Dup check. Yes, it happens. 735 | if (lc($filter) eq lc($previous_filter)) { 736 | warn "$ME: $subpath:$.: filter specifier '$filter' is a dup\n"; 737 | ++$Errs; 738 | } 739 | $previous_filter = $filter; 740 | } 741 | } 742 | } 743 | 744 | # It's easy to make mistakes in the SEE ALSO elements. 745 | elsif ($section eq 'see-also') { 746 | _check_seealso_links( "$subpath:$.", $line ); 747 | } 748 | } 749 | close $fh; 750 | 751 | # Done reading man page. If there are any '--format' exceptions defined 752 | # for this command, flag them as seen, and as '...' so we don't 753 | # complain about any sub-fields. 754 | if (my $fields = $Format_Exceptions{$command}) { 755 | $man{"--format"}{$_} = '!' for @$fields; 756 | } 757 | 758 | # Special case: the 'image trust' man page tries hard to cover both set 759 | # and show, which means it ends up not being machine-readable. 760 | if ($command eq 'podman-bootc-image-trust') { 761 | my %set = %man; 762 | my %show = %man; 763 | $show{$_} = 1 for qw(--raw -j --json); 764 | return +{ set => \%set, show => \%show } 765 | } 766 | 767 | return \%man; 768 | } 769 | 770 | 771 | ################ 772 | # podman_bootc_rst # Parse contents of docs/source/*.rst 773 | ################ 774 | sub podman_bootc_rst { 775 | my %rst; 776 | 777 | # Read all .rst files, looking for ":doc:`subcmd ` description" 778 | for my $rst (glob "$Docs_Path/*.rst") { 779 | open my $fh, '<', $rst 780 | or die "$ME: Cannot read $rst: $!\n"; 781 | 782 | # The basename of foo.rst is usually, but not always, the name of 783 | # a podman-bootc subcommand. There are a few special cases: 784 | (my $command = $rst) =~ s!^.*/(.*)\.rst!$1!; 785 | 786 | my $subcommand_href = \%rst; 787 | if ($command eq 'Commands') { 788 | ; 789 | } 790 | else { 791 | $subcommand_href = $rst{$command} //= { _source => $rst }; 792 | } 793 | 794 | my $previous_subcommand = ''; 795 | while (my $line = <$fh>) { 796 | if ($line =~ /^:doc:`(\S+)\s+<(.*?)>`\s+(.*)/) { 797 | my ($subcommand, $target, $desc) = ($1, $2, $3); 798 | 799 | # Check that entries are in alphabetical order, and not dups 800 | if ($subcommand lt $previous_subcommand) { 801 | warn "$ME: $rst:$.: '$previous_subcommand' and '$subcommand' are out of order\n"; 802 | ++$Errs; 803 | } 804 | if ($subcommand eq $previous_subcommand) { 805 | warn "$ME: $rst:$.: duplicate '$subcommand'\n"; 806 | ++$Errs; 807 | } 808 | $previous_subcommand = $subcommand; 809 | 810 | # Mark this subcommand as documented. 811 | $subcommand_href->{$subcommand}{_desc} = $desc; 812 | $subcommand_href->{$subcommand}{_source} = $rst; 813 | 814 | # Check for invalid links. These will be one of two forms: 815 | # -> markdown/foo.1.md 816 | # -> foo.rst 817 | if ($target =~ m!^markdown/!) { 818 | if (! -e "$Docs_Path/$target.md") { 819 | warn "$ME: $rst:$.: '$subcommand' links to nonexistent $target\n"; 820 | ++$Errs; 821 | } 822 | 823 | my $expect = "markdown/podman-bootc-$subcommand.1"; 824 | if ($subcommand eq 'Podman-Bootc') { 825 | $expect = "markdown/podman-bootc.1"; 826 | } 827 | if ($target ne $expect) { 828 | warn "$ME: $rst:$.: '$subcommand' links to $target (expected '$expect')\n"; 829 | ++$Errs; 830 | } 831 | } 832 | else { 833 | if (! -e "$Docs_Path/$target.rst") { 834 | warn "$ME: $rst:$.: '$subcommand' links to nonexistent $target.rst\n"; 835 | ++$Errs; 836 | } 837 | } 838 | } 839 | } 840 | close $fh; 841 | } 842 | 843 | # Special case: 'image trust set/show' are documented in image-trust.1 844 | $rst{image}{trust}{$_} = { _desc => 'ok' } for (qw(set show)); 845 | 846 | return \%rst; 847 | } 848 | 849 | ################## 850 | # _completions # run ramalama __complete, return list of completions 851 | ################## 852 | sub _completions { 853 | my $kidpid = open my $ramalama_fh, '-|'; 854 | if (! defined $kidpid) { 855 | die "$ME: Could not fork: $!\n"; 856 | } 857 | 858 | if ($kidpid == 0) { 859 | # We are the child 860 | close STDERR; 861 | exec $PODMANBOOTC, '__complete', @_; 862 | die "$ME: Could not exec: $!\n"; 863 | } 864 | 865 | # We are the parent 866 | my @completions; 867 | while (my $line = <$ramalama_fh>) { 868 | chomp $line; 869 | push @completions, $line; 870 | 871 | # Recursively expand Go templates, like '{{.Server.Os}}' 872 | if ($line =~ /^\{\{\..*\.$/) { 873 | my @cmd_copy = @_; # clone of podman-bootc subcommands... 874 | pop @cmd_copy; # ...so we can recurse with new format 875 | my @subcompletions = _completions(@cmd_copy, $line); 876 | 877 | # A huge number of deep fields are time-related. Don't document them. 878 | my @is_time = grep { /Nanosecond|UnixNano|YearDay/ } @subcompletions; 879 | push @completions, @subcompletions 880 | unless @is_time >= 3; 881 | } 882 | } 883 | close $ramalama_fh 884 | or warn "$ME: Error running podman-bootc __complete @_\n"; 885 | return @completions; 886 | } 887 | 888 | # END data gathering 889 | ############################################################################### 890 | # BEGIN sanity checking of SEE ALSO links 891 | 892 | ########################## 893 | # _check_seealso_links # Check formatting and link validity. 894 | ########################## 895 | sub _check_seealso_links { 896 | my $path = shift; 897 | my $line = shift; 898 | 899 | return if ! $line; 900 | 901 | # Line must be a comma-separated list of man page references, e.g. 902 | # **foo(1)**, **[podman-bootc-bar(1)](podman-bootc-bar.1.md)**, **[xxx(8)](http...)** 903 | TOKEN: 904 | for my $token (split /,\s+/, $line) { 905 | # Elements must be separated by comma and space. (We don't do further 906 | # checks here, so it's possible for the dev to add the space and then 907 | # have us fail on the next iteration. I choose not to address that.) 908 | if ($token =~ /,/) { 909 | warn "$ME: $path: please add space after comma: '$token'\n"; 910 | ++$Errs; 911 | next TOKEN; 912 | } 913 | 914 | # Each token must be of the form '**something**' 915 | if ($token !~ s/^\*\*(.*)\*\*$/$1/) { 916 | if ($token =~ /\*\*/) { 917 | warn "$ME: $path: '$token' has asterisks in the wrong place\n"; 918 | } 919 | else { 920 | warn "$ME: $path: '$token' should be bracketed by '**'\n"; 921 | } 922 | ++$Errs; 923 | next TOKEN; 924 | } 925 | 926 | # Is it a markdown link? 927 | if ($token =~ /^\[(\S+)\]\((\S+)\)$/) { 928 | my ($name, $link) = ($1, $2); 929 | if ($name =~ /^(.*)\((\d)\)$/) { 930 | my ($base, $section) = ($1, $2); 931 | if (-e "$Markdown_Path/$base.$section.md") { 932 | if ($link ne "$base.$section.md") { 933 | warn "$ME: $path: inconsistent link $name -> $link, expected $base.$section.md\n"; 934 | ++$Errs; 935 | } 936 | } 937 | else { 938 | if (! _is_valid_external_link($base, $section, $link)) { 939 | warn "$ME: $path: invalid link $name -> $link\n"; 940 | ++$Errs; 941 | } 942 | } 943 | } 944 | else { 945 | warn "$ME: $path: could not parse '$name' as 'manpage(N)'\n"; 946 | ++$Errs; 947 | } 948 | } 949 | 950 | # Not a markdown link; it must be a plain man reference, e.g. 'foo(5)' 951 | elsif ($token =~ m!^(\S+)\((\d+)\)$!) { 952 | my ($base, $section) = ($1, $2); 953 | 954 | # Unadorned 'podman-bootc-foo(1)' must be a link. 955 | if (-e "$Markdown_Path/$base.$section.md") { 956 | warn "$ME: $path: '$token' should be '[$token]($base.$section.md)'\n"; 957 | ++$Errs; 958 | } 959 | 960 | # Aliases (non-canonical command names): never link to these 961 | if (-e "$Markdown_Path/links/$base.$section") { 962 | warn "$ME: $path: '$token' refers to a command alias; please use the canonical command name instead\n"; 963 | ++$Errs; 964 | } 965 | 966 | # Link to man page foo(5) but without a link. This is not an error 967 | # but Ed may sometimes want to see those on a manual test run. 968 | warn "$ME: $path: plain '$token' would be so much nicer as a link\n" 969 | if $verbose; 970 | } 971 | else { 972 | warn "$ME: $path: invalid token '$token'\n"; 973 | ++$Errs; 974 | } 975 | } 976 | } 977 | 978 | ############################# 979 | # _is_valid_external_link # Tries to validate links to external man pages 980 | ############################# 981 | # 982 | # This performs no actual fetches, so we can't actually check for 404. 983 | # All we do is ensure that links conform to standard patterns. This is 984 | # good for catching things like 'conmon(8)' pointing to a .5 URL, or 985 | # linking to .md instead of .html. 986 | # 987 | # FIXME: we could actually rewrite this so as to offer hints on what to fix. 988 | # That's a lot of work, and a lot of convoluted code, for questionable ROI. 989 | # 990 | sub _is_valid_external_link { 991 | my ($base, $section, $link) = @_; 992 | 993 | return 1 if $link =~ m!^https://github\.com/\S+/blob/(main|master)(/.*)?/\Q$base\E\.$section\.md!; 994 | 995 | return 1 if $link =~ m!^https://.*unix\.com/man-page/(linux|redhat)/$section/$base$!; 996 | return 1 if $link eq "https://man7\.org/linux/man-pages/man$section/$base\.$section\.html"; 997 | 998 | if ($base =~ /systemd/) { 999 | return 1 if $link eq "https://www.freedesktop.org/software/systemd/man/$base.html"; 1000 | } 1001 | 1002 | return; 1003 | } 1004 | 1005 | 1006 | 1007 | 1008 | # END sanity checking of SEE ALSO links 1009 | ############################################################################### 1010 | 1011 | 1; 1012 | --------------------------------------------------------------------------------