Skip to main content
Account
Tutorial

Packet Metadata for SaltStack

David Flanagan Aug 03, 2020 3 min read

In my last article, we spun up some bare metal compute on Packet with Pulumi and installed SaltStack.

In order to use SaltStack to provision our workloads on our servers, we need a way to identify which machines should be provisioned with what workload. SaltStack uses Grains to do this … and there’s a metadata grain that can read metadata from cloud providers; unfortunately, it doesn’t support Packet.

Drats ☹️

Happy news though, SaltStack is rather extensible; as long as you don’t mind getting your hands a little dirty with Python.

Writing a Custom Grain

Writing a SaltStack grain module is SUPER easy. Lets take a look at the simplest implementation I can put together.

def test():
return dict(test={
"name": "David",
"age": "18",
})

Yeah, yeah. I know I’m not 18 anymore. Shush.

Grain modules are Python functions that return key value pairs. This code above returns a grain named “test” with the key/value pairs name = David and age = 18. This means we can run salt minion-1 grains.item test and we’ll see:

minion-1:
----------
test:
----------
name:
David
age:
18

Of course, we don’t want to return hared coded key values! We want to return information about our servers from Packet’s metadata API.

The code to handle this isn’t particularly complicated. In-fact, performing a HTTP request in Python is really simple 😀

Lets take a look.

import json
import logging
import salt.utils.http as http
# Setup logging
log = logging.getLogger( __name__ )
# metadata server information
HOST = "https://metadata.packet.net/metadata"
def packet_metadata():
response = http.query(HOST)
metadata = json.loads(response["body"])
log.error(metadata)
grains = {}
grains["id"] = metadata["id"]
grains["iqn"] = metadata["iqn"]
grains["plan"] = metadata["plan"]
grains["class"] = metadata["class"]
grains["facility"] = metadata["facility"]
grains["tags"] = metadata["tags"]
return dict(packet_metadata=grains)

The important lines here are these three:

HOST = "https://metadata.packet.net/metadata"
response = http.query(HOST)
metadata = json.loads(response["body"])

We first query the metadata API endpoint, defined by the variable HOST. We then decode the body of the response into a Python dict, using json.loads.

This gives us access to every bit of metadata returned by the Packet metadata API. That looks like:

{
"id": "c5ce85c5-1eef-4581-90b6-88a91e47e207",
"hostname": "master-1",
"iqn": "iqn.2020-08.net.packet:device.c5ce85c5",
"operating_system": {
"slug": "debian_9",
"distro": "debian",
"version": "9",
"license_activation": {
"state": "unlicensed"
},
"image_tag": "b32a1f31b127ef631d6ae31af9c6d8b69dcaa9e9"
},
"plan": "c2.medium.x86",
"class": "c2.medium.x86",
"facility": "ams1",
"private_subnets": ["10.0.0.0/8"],
"tags": ["role/salt-master"],
"ssh_keys": [
"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGf0w9b+lPcZhsNHU8Sw5hJPBhpNICTNkjlBz9jxtLbWNGvHTE1lBeXU5VA2/7cuYw48apHmMURHFtK5AZx3srg="
],
"storage": {
"disks": [
{
"device": "/dev/sdd",
"wipeTable": true,
"partitions": [
{
"label": "BIOS",
"number": 1,
"size": "512M"
},
{
"label": "SWAP",
"number": 2,
"size": "3993600"
},
{
"label": "ROOT",
"number": 3,
"size": 0
}
]
}
],
"filesystems": [
{
"mount": {
"device": "/dev/sdd1",
"format": "vfat",
"point": "/boot/efi",
"create": {
"options": ["32", "-n", "EFI"]
}
}
},
{
"mount": {
"device": "/dev/sdd3",
"format": "ext4",
"point": "/",
"create": {
"options": ["-L", "ROOT"]
}
}
},
{
"mount": {
"device": "/dev/sdd2",
"format": "swap",
"point": "none",
"create": {
"options": ["-L", "SWAP"]
}
}
}
]
},
"network": {
"bonding": {
"mode": 4,
"link_aggregation": "bonded",
"mac": "50:6b:4b:b4:a9:3a"
},
"interfaces": [
{
"name": "eth0",
"mac": "50:6b:4b:b4:a9:3a",
"bond": "bond0"
},
{
"name": "eth1",
"mac": "50:6b:4b:b4:a9:3b",
"bond": "bond0"
}
],
"addresses": [
{
"id": "5d28837b-29c5-4505-bb05-930fd3760bac",
"address_family": 4,
"netmask": "255.255.255.252",
"created_at": "2020-08-03T14:07:50Z",
"public": true,
"cidr": 30,
"management": true,
"enabled": true,
"network": "147.75.84.128",
"address": "147.75.84.130",
"gateway": "147.75.84.129",
"parent_block": {
"network": "147.75.84.128",
"netmask": "255.255.255.252",
"cidr": 30,
"href": "/ips/7a30c2bf-f0e5-402c-b0c0-b8ab03359e63"
}
},
{
"id": "937552c6-cf1a-474d-9866-9fb1e0525503",
"address_family": 4,
"netmask": "255.255.255.254",
"created_at": "2020-08-03T14:07:49Z",
"public": false,
"cidr": 31,
"management": true,
"enabled": true,
"network": "10.80.76.4",
"address": "10.80.76.5",
"gateway": "10.80.76.4",
"parent_block": {
"network": "10.80.76.0",
"netmask": "255.255.255.128",
"cidr": 25,
"href": "/ips/8f8cd919-165a-4e62-b461-af7c15a25ec4"
}
}
]
},
"customdata": {},
"specs": {
"cpus": [
{
"count": 1,
"type": "AMD EPYC 7401P 24-Core Processor @ 2.0GHz"
}
],
"memory": {
"total": "64GB"
},
"drives": [
{
"count": 2,
"size": "120GB",
"type": "SSD",
"category": "boot"
},
{
"count": 2,
"size": "480GB",
"type": "SSD",
"category": "storage"
}
],
"nics": [
{
"count": 2,
"type": "10Gbps"
}
],
"features": {}
},
"switch_short_id": "f8dd5e3f",
"volumes": [],
"api_url": "https://metadata.packet.net",
"phone_home_url": "http://tinkerbell.ams1.packet.net/phone-home",
"user_state_url": "http://tinkerbell.ams1.packet.net/events"
}

I decided not to make all of this available within the grains system, as only a few data points make sense for scheduling workloads. Hence, I cherry pick out the attributes I want for the next demo. You can pick and choose whatever you want too.

grains = {}
grains["id"] = metadata["id"]
grains["iqn"] = metadata["iqn"]
grains["plan"] = metadata["plan"]
grains["class"] = metadata["class"]
grains["facility"] = metadata["facility"]
grains["tags"] = metadata["tags"]
return dict(packet_metadata=grains)

Provisioning the Custom Grain

Now that we have a custom grain, we need to update our Pulumi code to install this on our Salt master.

NB : We only need to make this grain available on our Salt master, as the Salt master takes responsibility for syncing custom grains to the minions.

I’ve updated our user-data.sh to create the directory we need and added the mustache template syntax that allows us to inject the Python script. We use & before the variable name to request that mustache doesn’t escape our quotes to HTML entities … I only learnt that today 😂

mkdir -p /srv/salt/_grains
cat <<EOF >/srv/salt/_grains/packet_metadata.py
{{ &PACKET_METADATA_PY }}
EOF

Next up, we provide the Python script at render time and provide some tags for our servers when we create them.

const pythonPacketMetadataGrain = fs
.readFileSync(path.join(__dirname, "..", "salt", "packet_metadata.py"))
.toString();
const saltMaster = new Device(`master-${name}`, {
// ... code omitted for brevity
userData: mustache.render(bootstrapString, {
PACKET_METADATA_PY: pythonPacketMetadataGrain,
}),
// Add tags to this server
tags: ["role/salt-master"],
});

Syncing Custom Grains

Finally, we need to tell our Salt master to sync the grains to our minions.

salt "*" saltutil.grains_sync

You can now confirm the custom grain is working with:

root@master-1:~# salt "*" grains.item packet_metadata
minion-1:
----------
packet_metadata:
----------
class:
c2.medium.x86
facility:
ams1
id:
ab0bc2ba-557b-4d99-a1eb-0beec02adff2
iqn:
iqn.2020-08.net.packet:device.ab0bc2ba
plan:
c2.medium.x86
tags:
- role/salt-minion
master-1:
----------
packet_metadata:
----------
class:
c2.medium.x86
facility:
ams1
id:
97ce9196-077d-4ce9-82a5-d58bf59d0dbc
iqn:
iqn.2020-08.net.packet:device.97ce9196
plan:
c2.medium.x86
tags:
- role/salt-master

That’s it! Next time we’ll take a look at using our tags to provision and schedule our workloads.

See you then.

Published Aug 03, 2020

Related Articles

Related Videos

Rawkode Academy newsletter

Academy publications by email

Get an email when Rawkode Academy publishes new tutorials, articles, and videos.

Loading email signup. If it does not load, manage email preferences in account settings.