From 10753faa9b51ffee43d9aa79c46c23207b3af6ce Mon Sep 17 00:00:00 2001 From: Jake Correnti Date: Fri, 17 Jan 2025 11:34:06 -0500 Subject: [PATCH] Add `machine init --run-playbook` Allow the user to provide an Ansible playbook file on init which will then be run on boot. Signed-off-by: Jake Correnti Signed-off-by: Brent Baude --- cmd/podman/machine/init.go | 4 ++ .../markdown/podman-machine-init.1.md.in | 10 +++ pkg/machine/define/initopts.go | 1 + pkg/machine/e2e/config_init_test.go | 32 ++++++--- pkg/machine/e2e/init_test.go | 67 +++++++++++++++++++ pkg/machine/ignition/ignition.go | 44 ++++++++++++ pkg/machine/shim/host.go | 33 +++++++++ pkg/machine/vmconfigs/config.go | 8 +++ pkg/machine/wsl/machine.go | 8 ++- pkg/machine/wsl/stubber.go | 2 +- 10 files changed, 196 insertions(+), 13 deletions(-) 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/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..8e3bc72b92 100644 --- a/pkg/machine/e2e/init_test.go +++ b/pkg/machine/e2e/init_test.go @@ -98,6 +98,73 @@ 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 + playbookFinished := false + for range 900 { + sshSession, err = mb.setName(name).setCmd(ssh.withSSHCommand([]string{"systemctl", "is-active", "playbook.service"})).run() + Expect(err).ToNot(HaveOccurred()) + + if sshSession.outputToString() == "inactive" { + playbookFinished = true + break + } + + time.Sleep(10 * time.Millisecond) + } + + if !playbookFinished { + Fail("playbook.service did not finish") + } + + // 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..ac56f36fb5 100644 --- a/pkg/machine/ignition/ignition.go +++ b/pkg/machine/ignition/ignition.go @@ -685,6 +685,50 @@ done ` } +func (i *IgnitionBuilder) AddPlaybook(contents string, destPath string, username string) error { + // 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(contents), + }, + 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 1315c9adb9..c0fe6b0ead 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 + } + s, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("read playbook: %w", err) + } + + playbookDest := fmt.Sprintf("/home/%s/%s", userName, "playbook.yaml") + + if mp.VMType() != machineDefine.WSLVirt { + err = ignBuilder.AddPlaybook(string(s), playbookDest, userName) + if err != nil { + return err + } + } + + mc.Ansible = &vmconfigs.AnsibleConfig{ + PlaybookPath: playbookDest, + Contents: string(s), + User: userName, + } + } + readyIgnOpts, err := mp.PrepareIgnition(mc, &ignBuilder) if err != nil { return err @@ -543,6 +570,12 @@ func Start(mc *vmconfigs.MachineConfig, mp vmconfigs.VMProvider, dirs *machineDe } } + if mp.VMType() == machineDefine.WSLVirt && mc.Ansible != nil { + if err := machine.CommonSSHSilent(mc.Ansible.User, mc.SSH.IdentityPath, mc.Name, mc.SSH.Port, []string{"ansible-playbook", mc.Ansible.PlaybookPath}); err != nil { + logrus.Error(err) + } + } + // Provider is responsible for waiting if mp.UseProviderNetworkSetup() { return nil diff --git a/pkg/machine/vmconfigs/config.go b/pkg/machine/vmconfigs/config.go index 417d5b3fe1..a45fee6432 100644 --- a/pkg/machine/vmconfigs/config.go +++ b/pkg/machine/vmconfigs/config.go @@ -53,6 +53,8 @@ type MachineConfig struct { Starting bool Rosetta bool + + Ansible *AnsibleConfig } type machineImage interface { //nolint:unused @@ -148,3 +150,9 @@ type VMStats struct { // LastUp contains the last recorded uptime LastUp time.Time } + +type AnsibleConfig struct { + PlaybookPath string + Contents string + User string +} diff --git a/pkg/machine/wsl/machine.go b/pkg/machine/wsl/machine.go index 38c1a91261..3355f6b2b5 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, ansibleConfig *vmconfigs.AnsibleConfig) 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 ansibleConfig != nil { + if err := wslPipe(ansibleConfig.Contents, dist, "sh", "-c", fmt.Sprintf("cat > %s", ansibleConfig.PlaybookPath)); 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..75b12fad03 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, mc.Ansible); err != nil { return err }