Injecting files in Ironic

This post is a follow-up to the earlier deploy steps tutorial, where I showed how to create a deploy step for injecting arbitrary files. I promised to bring that that particular deploy step upstream, and I've delivered on that promise!

Note

This post is talking about a recently (Feb 22, 2021) merged feature. If you don't have a master deployment, you may need to wait until the Wallaby release.

Deploy steps and templates

As explained in the deploy steps tutorial, the deployment process consistes of deploy steps, which are executed sequentially according to their priority. Deploy steps can be grouped into deploy templates, mapping a trait name to a list of steps. I explained traits in my post on bare metal scheduling.

Until Wallaby, deploy templates were the only way to use custom deploy steps. In the Wallaby release 16.2.0 Ironic gained an ability to accept deploy steps in the provisioning API, like this (using ironicclient 4.6.0 or newer):

$ baremetal node deploy <node> --deploy-steps 'JSON-or-file-name'

Which approach to take depends on your use case. If you're using Ironic behind the Compute API (Nova), you have to go with deploy templates, there is no way to pass deploy steps directly from Nova to Ironic.

Standalone users have both options. The benefit of deploy templates is that they're linked with traits. For example, if your deploy step depends on the hardware vendor, or model, or architecture, go with deploy templates and use traits to specify which nodes are compatible. If you use a pretty universal deploy step, like the one discussed here, you can go with the explicit argument, although a deploy template could simplify applying the same step over and over.

It is worth noting that you can use both approaches together, just not for the same step.

Inject files

The new inject_files deploy step is an in-band step and is documented in the ironic-python-agent hardware manager documentation. The deploy step accepts a files argument that can come explicitly from the deployment request, from the deploy template or from inject_files in the node's properties field. Deploy steps were demonstrated in the deploy steps tutorial, here I will use the newer explicit approach.

The files argument is a list of file objects. Each of them define the location of the file, its content (if any) and access rights (if needed). I'll concentrate only on the most aspects:

path

is the target file path. If the file is located on a non-root partition, this path may be different from the actual path on the final system!

For example, if you need to write to the EFI partition, you may end up having path equal to /EFI/centos/my.conf for it to end up in /boot/efi/EFI/centos/my.conf.

Any parent directories will be created on the way.

partition

is the partition on which the file is located.

The most reliable way is to use /dev/disk/by-uuid/<filesystem UUID>, where <filesystem UUID> is the UUID of the file system (not GUID of the partition, that would be /dev/disk/by-partuuid). You can analyze your image to figure out this UUID, I'll show it later on.

If your file systems or partitions have labels, you can use them. For example, my laptop has /dev/disk/by-partlabel/EFI\x20System\x20Partition (yes, exactly like this, with \x20 in the path).

Of course you can use standard Linux device names, like /dev/sda1, but these depend on the bus (SATA, NVMe, virtio), as well as the partition ordering. On the root device you can simply use numbers starting with 1.

If you don't provide a partition, ironic-python-agent will try a heuristic based on path: take the first component of the path and try to find a partition with it. For example, for /etc/sysctl.d/example.conf, it will look for a partition with etc directory on it.

If you want to write a file to the root of any partition or if you need all parent directories created, you have to specify partition.

content

is the content of the file in one of the two forms:

  • The actual binary content as a base64 encoded string (recommended only for small files).

  • A URL to the content (without base64 encoding).

A cool thing about URLs is that you can use some simple formatting to extract information from the node of the ports. I'll show an example later on.

Hello world

Let us start with the case from the tutorial. Imaging we want to change the message of the day to something positive:

Hello Ironic World!

For the list of deploy steps we'll use this file (inject_files.json):

[
  {
    "interface": "deploy",
    "step": "inject_files",
    "args": {
      "files": [
        {
          "path": "/etc/motd",
          "content": "SGVsbG8gSXJvbmljIFdvcmxkIQo="
        }
      ]
    },
    "priority": 50
  }
]

What is the priority and why 50? Read the deploy steps tutorial! Everything else should be more or less self-explanatory.

The metalsmith CLI does not have support for deploy steps yet, so start the deployment manually using the new --deploy-steps argument:

$ SSH_KEY=$(cat ~/.ssh/id_ed25519.pub)
$ baremetal node set <node> --instance-info image_source=file:///httpboot/centos8.qcow2
$ baremetal node deploy <node> \
    --config-drive '{"meta_data": {"public_keys": {"0": "'"$SSH_KEY"'"}}}' \
    --deploy-steps inject_files.json
$ watch baremetal node show <node> --fields provision_state deploy_step last_error

Note

The image location is specific to Bifrost. I created it with:

$ DIB_RELEASE=8 disk-image-create -o centos8-uefi centos vm openssh-server block-device-efi
$ sudo cp centos8-uefi.qcow2 /httpboot

Once the provision state is active, we can figure out the IP address and log into the instance:

$ ssh centos@<IP>
<..>
Hello Ironic World!
[centos@localhost ~]$

Target partition

What if we need to specify where to put the file? I'm struggling to invent an actual real-life example for it, so let us write a file to the EFI partition just because we can!

But how do we know where it will be located? Your image will probably give you a clue. We will use the virt-filesystems tool (which on my CentOS 8 system comes from the libguestfs-tools-c package):

$ virt-filesystems -a centos8-uefi.qcow2 --filesystems --long --uuids
Name      Type       VFS  Label           Size       Parent UUID
/dev/sda1 filesystem vfat MKFS_ESP        576716800  -      93C3-C214
/dev/sda3 filesystem ext4 cloudimg-rootfs 3293577216 -      3ef8f8c2-439f-4aad-86e1-6d2457364c24

Looking great! We can use both labels and UUIDs. Labels will probably be the same for any image created with diskimage-builder (I haven't verified this statement), UUIDs are more reliable within the same image.

Let us update our inject_files.json adding a new hello-world quality file and some parent directories to create:

[
  {
    "interface": "deploy",
    "step": "inject_files",
    "args": {
      "files": [
        {
          "path": "/etc/motd",
          "content": "SGVsbG8gSXJvbmljIFdvcmxkIQo="
        },
        {
          "path": "nonEFI/hello",
          "partition": "/dev/disk/by-uuid/93C3-C214",
          "content": "SGVsbG8gSXJvbmljIFdvcmxkIQo="
        }
      ]
    },
    "priority": 50
  }
]

After deploying and logging into the system, we see the file in place:

[centos@localhost ~]$ cat /boot/efi/nonEFI/hello
Hello Ironic World!

Node-specific content

Imagine you're using deploy templates, but still want the file content to be node-specific. How can we achieve that? Through passing a URL to content. Not only does it allow to fetch content remotely, it also supports simple Python formatting with node and ports parameters, for example:

  • Fetch file based on UUID: http://host/{node[uuid]}.bin.

  • Access a remote service with the first MAC: http://host/?mac={ports[0][address]}.

We will generate static files based on node names (which on a Bifrost testing environment look like testvm<N> where <N> is a number starting with 1):

$ find /httpboot/ -name testvm\* -print -exec cat {} \;
/httpboot/testvm1.txt
I am node 1
/httpboot/testvm2.txt
And I am node 2

Update the deploy steps again:

[
  {
    "interface": "deploy",
    "step": "inject_files",
    "args": {
      "files": [
        {
          "path": "/etc/motd",
          "content": "http://192.168.122.1:8080/{node[name]}.txt"
        }
      ]
    },
    "priority": 50
  }
]

Then create a deploy template and declare all nodes compatible with it:

$ baremetal deploy template create CUSTOM_INJECT_FILES --steps inject_files.json --max-width 100
+------------+-------------------------------------------------------------------------------------+
| Field      | Value                                                                               |
+------------+-------------------------------------------------------------------------------------+
| created_at | 2021-02-17T15:43:34.660443+00:00                                                    |
| extra      | {}                                                                                  |
| name       | CUSTOM_INJECT_FILES                                                                 |
| steps      | [{'interface': 'deploy', 'step': 'inject_files', 'args': {'files': [{'path':        |
|            | '/etc/motd', 'content': 'http://192.168.122.1:8080/{node[name]}.txt'}]},            |
|            | 'priority': 50}]                                                                    |
| updated_at | None                                                                                |
| uuid       | 3ebf8dac-896b-4633-b965-d6e2830ff5cc                                                |
+------------+-------------------------------------------------------------------------------------+
$ for node in $(baremetal node list -f value --fields uuid); do baremetal node add trait $node CUSTOM_INJECT_FILES; done
Added trait CUSTOM_INJECT_FILES
Added trait CUSTOM_INJECT_FILES

Note

I'm using --max-width 100 purely for readability, you can omit it.

Now we can use metalsmith to pick any suitable node and deploy on it:

$ metalsmith deploy --resource-class baremetal --trait CUSTOM_INJECT_FILES \
      --image file:///httpboot/centos8.qcow2 --ssh-public-key ~/.ssh/id_ed25519.pub
+--------------------------------------+-----------+--------------------------------------+----------+--------+--------------+
| UUID                                 | Node Name | Allocation UUID                      | Hostname | State  | IP Addresses |
+--------------------------------------+-----------+--------------------------------------+----------+--------+--------------+
| 4e41df61-84b1-5856-bfb6-6b5f2cd3dd11 | testvm1   | f1be9ff3-965b-4500-bb7b-59673faba68a | testvm1  | ACTIVE |              |
+--------------------------------------+-----------+--------------------------------------+----------+--------+--------------+

As you see, this is node number 1, and as expected it greets us with:

I am node 1

Conclusion

Deploy steps are incredibly powerful, and the new inject_files deploy step is capable of demonstrating this power, as well as enriching the Ironic operator's toolset.