Skip to content

Commit

Permalink
Add machine init --run-playbook
Browse files Browse the repository at this point in the history
Allow the user to provide an Ansible playbook file on init which will
then be run on boot.

Signed-off-by: Jake Correnti <[email protected]>
Signed-off-by: Brent Baude <[email protected]>
  • Loading branch information
jakecorrenti committed Jan 28, 2025
1 parent a3bb0a1 commit 9bbe2af
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 13 deletions.
4 changes: 4 additions & 0 deletions cmd/podman/machine/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions docs/source/markdown/podman-machine-init.1.md.in
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/machine/define/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type CreateVMOpts struct {
Dirs *MachineDirs
ReExec bool
UserModeNetworking bool
Playbook *PlaybookConfig
}

type MachineDirs struct {
Expand All @@ -29,3 +30,8 @@ type MachineDirs struct {
ImageCacheDir *VMFile
RuntimeDir *VMFile
}

type PlaybookConfig struct {
Dest string
Contents string
}
1 change: 1 addition & 0 deletions pkg/machine/define/initopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package define
import "net/url"

type InitOptions struct {
PlaybookPath string
CPUS uint64
DiskSize uint64
IgnitionPath string
Expand Down
32 changes: 21 additions & 11 deletions pkg/machine/e2e/config_init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions pkg/machine/e2e/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
51 changes: 51 additions & 0 deletions pkg/machine/ignition/ignition.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package ignition
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/url"
"os"
Expand Down Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions pkg/machine/shim/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion pkg/machine/wsl/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pkg/machine/wsl/stubber.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down

0 comments on commit 9bbe2af

Please sign in to comment.