diff --git a/cmd/podman/machine/init.go b/cmd/podman/machine/init.go index 69afdc02c6..c387caf3c4 100644 --- a/cmd/podman/machine/init.go +++ b/cmd/podman/machine/init.go @@ -62,6 +62,10 @@ func init() { ) _ = initCmd.RegisterFlagCompletionFunc(cpusFlagName, completion.AutocompleteNone) + runPlaybookFlagName := "run-playbook" + flags.StringVar(&initOpts.PlaybookPath, runPlaybookFlagName, "", "Run an Ansible playbook after first boot") + _ = initCmd.RegisterFlagCompletionFunc(runPlaybookFlagName, completion.AutocompleteDefault) + diskSizeFlagName := "disk-size" flags.Uint64Var( &initOpts.DiskSize, diff --git a/docs/source/markdown/podman-machine-init.1.md.in b/docs/source/markdown/podman-machine-init.1.md.in index af603e0d4e..368874e773 100644 --- a/docs/source/markdown/podman-machine-init.1.md.in +++ b/docs/source/markdown/podman-machine-init.1.md.in @@ -80,6 +80,8 @@ is copied into the user's CONF_DIR and renamed. Additionally, no SSH keys are g Fully qualified registry, path, or URL to a VM image. Registry target must be in the form of `docker://registry/repo/image:version`. +Note: Only images provided by podman will be supported. + #### **--memory**, **-m**=*number* Memory (in MiB). Note: 1024MiB = 1GiB. @@ -96,6 +98,14 @@ if there is no existing remote connection configurations. API forwarding, if available, follows this setting. +#### **--run-playbook** + +Add the provided Ansible playbook to the machine and execute it after the first boot. + +Note: The playbook will be executed with the same privileges given to the user in the virtual machine. The playbook provided cannot include other files from the host system, as they will not be copied. +Use of the `--run-playbook` flag will require the image to include Ansible. The default image provided will have Ansible included. + + #### **--timezone** Set the timezone for the machine and containers. Valid values are `local` or diff --git a/pkg/machine/define/config.go b/pkg/machine/define/config.go index b6c07342c1..e169f2f43a 100644 --- a/pkg/machine/define/config.go +++ b/pkg/machine/define/config.go @@ -21,6 +21,7 @@ type CreateVMOpts struct { Dirs *MachineDirs ReExec bool UserModeNetworking bool + Playbook *PlaybookConfig } type MachineDirs struct { @@ -29,3 +30,8 @@ type MachineDirs struct { ImageCacheDir *VMFile RuntimeDir *VMFile } + +type PlaybookConfig struct { + Dest string + Contents string +} diff --git a/pkg/machine/define/initopts.go b/pkg/machine/define/initopts.go index 4ddf87ca19..b1ccaaa6b7 100644 --- a/pkg/machine/define/initopts.go +++ b/pkg/machine/define/initopts.go @@ -3,6 +3,7 @@ package define import "net/url" type InitOptions struct { + PlaybookPath string CPUS uint64 DiskSize uint64 IgnitionPath string diff --git a/pkg/machine/e2e/config_init_test.go b/pkg/machine/e2e/config_init_test.go index 0423010477..0c600c5d46 100644 --- a/pkg/machine/e2e/config_init_test.go +++ b/pkg/machine/e2e/config_init_test.go @@ -11,19 +11,21 @@ import ( type initMachine struct { /* - --cpus uint Number of CPUs (default 1) - --disk-size uint Disk size in GiB (default 100) - --ignition-path string Path to ignition file - --username string Username of the remote user (default "core" for FCOS, "user" for Fedora) - --image-path string Path to bootable image (default "testing") - -m, --memory uint Memory in MiB (default 2048) - --now Start machine now - --rootful Whether this machine should prefer rootful container execution - --timezone string Set timezone (default "local") - -v, --volume stringArray Volumes to mount, source:target - --volume-driver string Optional volume driver + --cpus uint Number of CPUs (default 1) + --disk-size uint Disk size in GiB (default 100) + --ignition-path string Path to ignition file + --username string Username of the remote user (default "core" for FCOS, "user" for Fedora) + --image-path string Path to bootable image (default "testing") + -m, --memory uint Memory in MiB (default 2048) + --now Start machine now + --rootful Whether this machine should prefer rootful container execution + --run-playbook string Run an ansible playbook after first boot + --timezone string Set timezone (default "local") + -v, --volume stringArray Volumes to mount, source:target + --volume-driver string Optional volume driver */ + playbook string cpus *uint diskSize *uint ignitionPath string @@ -73,6 +75,9 @@ func (i *initMachine) buildCmd(m *machineTestBuilder) []string { if i.rootful { cmd = append(cmd, "--rootful") } + if l := len(i.playbook); l > 0 { + cmd = append(cmd, "--run-playbook", i.playbook) + } if i.userModeNetworking { cmd = append(cmd, "--user-mode-networking") } @@ -152,6 +157,11 @@ func (i *initMachine) withRootful(r bool) *initMachine { return i } +func (i *initMachine) withRunPlaybook(p string) *initMachine { + i.playbook = p + return i +} + func (i *initMachine) withUserModeNetworking(r bool) *initMachine { //nolint:unused i.userModeNetworking = r return i diff --git a/pkg/machine/e2e/init_test.go b/pkg/machine/e2e/init_test.go index bd5fe683c5..387a47b008 100644 --- a/pkg/machine/e2e/init_test.go +++ b/pkg/machine/e2e/init_test.go @@ -98,6 +98,67 @@ var _ = Describe("podman machine init", func() { } }) + It("run playbook", func() { + str := randomString() + + // ansible playbook file to create a text file containing a random string + playbookContents := fmt.Sprintf(`- name: Simple podman machine example + hosts: localhost + tasks: + - name: create a file + ansible.builtin.copy: + dest: ~/foobar.txt + content: "%s\n"`, str) + + playbookPath := filepath.Join(GinkgoT().TempDir(), "playbook.yaml") + + // create the playbook file + playbookFile, err := os.Create(playbookPath) + Expect(err).ToNot(HaveOccurred()) + defer playbookFile.Close() + + // write the desired contents into the file + _, err = playbookFile.WriteString(playbookContents) + Expect(err).To(Not(HaveOccurred())) + + name := randomString() + i := new(initMachine) + session, err := mb.setName(name).setCmd(i.withImage(mb.imagePath).withRunPlaybook(playbookPath).withNow()).run() + Expect(err).ToNot(HaveOccurred()) + Expect(session).To(Exit(0)) + + // ensure the contents of the playbook file didn't change when getting copied + ssh := new(sshMachine) + sshSession, err := mb.setName(name).setCmd(ssh.withSSHCommand([]string{"cat", "playbook.yaml"})).run() + Expect(err).ToNot(HaveOccurred()) + Expect(sshSession).To(Exit(0)) + Expect(sshSession.outputToStringSlice()).To(Equal(strings.Split(playbookContents, "\n"))) + + // wait until the playbook.service is done before checking to make sure the playbook was a success + for { + sshSession, err = mb.setName(name).setCmd(ssh.withSSHCommand([]string{"systemctl", "is-active", "playbook.service"})).run() + Expect(err).ToNot(HaveOccurred()) + + if sshSession.outputToString() == "inactive" { + break + } + + time.Sleep(10 * time.Millisecond) + } + + // output the contents of the file generated by the playbook + guestUser := "core" + if isWSL() { + guestUser = "user" + } + sshSession, err = mb.setName(name).setCmd(ssh.withSSHCommand([]string{"cat", fmt.Sprintf("/home/%s/foobar.txt", guestUser)})).run() + Expect(err).ToNot(HaveOccurred()) + Expect(sshSession).To(Exit(0)) + + // check its the same as the random number or string that we generated + Expect(sshSession.outputToString()).To(Equal(str)) + }) + It("simple init with start", func() { i := initMachine{} session, err := mb.setCmd(i.withImage(mb.imagePath)).run() diff --git a/pkg/machine/ignition/ignition.go b/pkg/machine/ignition/ignition.go index 62bf7a872f..7aa73b2189 100644 --- a/pkg/machine/ignition/ignition.go +++ b/pkg/machine/ignition/ignition.go @@ -5,6 +5,7 @@ package ignition import ( "encoding/json" "fmt" + "io" "io/fs" "net/url" "os" @@ -685,6 +686,56 @@ done ` } +func (i *IgnitionBuilder) AddPlaybook(input *os.File, destPath string, username string) error { + // read the config file to a string + s, err := io.ReadAll(input) + if err != nil { + return fmt.Errorf("read playbook: %w", err) + } + + // create the ignition file object + f := File{ + Node: Node{ + Group: GetNodeGrp(username), + Path: destPath, + User: GetNodeUsr(username), + }, + FileEmbedded1: FileEmbedded1{ + Append: nil, + Contents: Resource{ + Source: EncodeDataURLPtr(string(s)), + }, + Mode: IntToPtr(0744), + }, + } + + // call ignitionBuilder.WithFile + // add the config file to the ignition object + i.WithFile(f) + + unit := parser.NewUnitFile() + unit.Add("Unit", "After", "ready.service") + unit.Add("Service", "Type", "oneshot") + unit.Add("Service", "User", username) + unit.Add("Service", "Group", username) + unit.Add("Service", "ExecStart", fmt.Sprintf("ansible-playbook %s", destPath)) + unit.Add("Install", "WantedBy", "default.target") + unitContents, err := unit.ToString() + if err != nil { + return err + } + + // create a systemd service + playbookUnit := Unit{ + Enabled: BoolToPtr(true), + Name: "playbook.service", + Contents: &unitContents, + } + i.WithUnit(playbookUnit) + + return nil +} + func GetNetRecoveryUnitFile() *parser.UnitFile { recoveryUnit := parser.NewUnitFile() recoveryUnit.Add("Unit", "Description", "Verifies health of network and recovers if necessary") diff --git a/pkg/machine/shim/host.go b/pkg/machine/shim/host.go index b0daa19ba9..8017f82d8c 100644 --- a/pkg/machine/shim/host.go +++ b/pkg/machine/shim/host.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "io" "os" "path/filepath" "runtime" @@ -207,6 +208,32 @@ func Init(opts machineDefine.InitOptions, mp vmconfigs.VMProvider) error { } } + if len(opts.PlaybookPath) > 0 { + f, err := os.Open(opts.PlaybookPath) + if err != nil { + return err + } + + playbookDest := fmt.Sprintf("/home/%s/%s", userName, "playbook.yaml") + + if mp.VMType() == machineDefine.WSLVirt { + s, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("read playbook: %w", err) + } + + createOpts.Playbook = &machineDefine.PlaybookConfig{ + Dest: playbookDest, + Contents: string(s), + } + } else { + err = ignBuilder.AddPlaybook(f, playbookDest, userName) + if err != nil { + return err + } + } + } + readyIgnOpts, err := mp.PrepareIgnition(mc, &ignBuilder) if err != nil { return err diff --git a/pkg/machine/wsl/machine.go b/pkg/machine/wsl/machine.go index 38c1a91261..adbae4925d 100644 --- a/pkg/machine/wsl/machine.go +++ b/pkg/machine/wsl/machine.go @@ -148,7 +148,7 @@ func createKeys(mc *vmconfigs.MachineConfig, dist string) error { return nil } -func configureSystem(mc *vmconfigs.MachineConfig, dist string) error { +func configureSystem(mc *vmconfigs.MachineConfig, dist string, playbookConfig *define.PlaybookConfig) error { user := mc.SSH.RemoteUsername if err := wslInvoke(dist, "sh", "-c", fmt.Sprintf(appendPort, mc.SSH.Port, mc.SSH.Port)); err != nil { return fmt.Errorf("could not configure SSH port for guest OS: %w", err) @@ -167,6 +167,12 @@ func configureSystem(mc *vmconfigs.MachineConfig, dist string) error { return fmt.Errorf("could not generate systemd-sysusers override for guest OS: %w", err) } + if playbookConfig != nil { + if err := wslPipe(playbookConfig.Contents, dist, "sh", "-c", fmt.Sprintf("cat > %s", playbookConfig.Dest)); err != nil { + return fmt.Errorf("could not generate playbook file for guest os: %w", err) + } + } + lingerCmd := withUser("cat > /home/[USER]/.config/systemd/[USER]/linger-example.service", user) if err := wslPipe(lingerService, dist, "sh", "-c", lingerCmd); err != nil { return fmt.Errorf("could not generate linger service for guest OS: %w", err) diff --git a/pkg/machine/wsl/stubber.go b/pkg/machine/wsl/stubber.go index 073b0b04bf..dc302b5029 100644 --- a/pkg/machine/wsl/stubber.go +++ b/pkg/machine/wsl/stubber.go @@ -68,7 +68,7 @@ func (w WSLStubber) CreateVM(opts define.CreateVMOpts, mc *vmconfigs.MachineConf } fmt.Println("Configuring system...") - if err = configureSystem(mc, dist); err != nil { + if err = configureSystem(mc, dist, opts.Playbook); err != nil { return err }