The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

cloud-config(5)

NAME

cloud-config - instance configuration format read by instance-setup(8)

SYNOPSIS

user-data on a cidata seed, or openstack/latest/user_data on a config-2 seed, conventionally beginning with:

#cloud-config

DESCRIPTION

A cloud-config document is how you tell a freshly booted image who it is: its hostname, who can log in, with which key, and which repositories it pulls packages from. It's a YAML mapping, conventionally starting with the line #cloud-config, and it arrives as the user-data document on a config drive. instance-setup(8) reads it on every boot.

This page covers the keywords instance-setup(8) implements. It started from micro-config-drive's cloud-config.5, with everything out of scope taken out. cloud-init grew around ninety modules. This is a small fraction of that, on purpose, and it's not meant to grow much.

What happens to a key it doesn't know?

It's reported and skipped. So is a malformed value for a key it does know. Neither fails the boot. If your tooling emits a key out of habit, that must not cost you the instance. The warnings go to the journal and the console.

A few things do refuse the whole document, because there's no sensible way to read the rest of it: a top level that isn't a mapping, user-data that isn't UTF-8, and YAML aliases (&anchor and *alias). Aliases are refused because a tiny document full of nested aliases expands to more memory than the machine has, and cloud-config has no use for them. Anything after the first YAML document in the file is ignored, with a warning. An empty document is fine and does nothing.

User-data starting with #! is a script, not cloud-config. Scripts aren't implemented yet, so that's a warning and nothing else happens.

The seed is untrusted

Keep this in mind while reading the rest of the page. Nothing authenticates a config drive: anyone who can attach a disk to the machine can write one, and instance-setup(8) runs as root. Most of the restrictions below exist because a name or a path from the seed ends up as a file name, a field in a config line, or an argument to a command.

USERS

users is a list of accounts. Each entry is a mapping with these keys. A - and a _ in a key are the same thing, so lock_passwd and lock-passwd both work. An unknown key refuses that account, with a warning, and the rest of the seed still lands.

name
Account name. Required, and an entry without one is skipped. It must be 32 characters or fewer of [A-Za-z0-9._-], and can't begin with a dot or a dash. The name becomes a credential file name and a path under /etc/sudoers.d/. A name outside that set is reported and the account is skipped.
gecos
Real name. It becomes the realName field of the user record.
homedir
Home directory path.
shell
Login shell.
primary-group
Group name. When the document also defines that group under groups, the account gets its gid. See "Which group does an account get?" below.
groups
Supplementary groups, as a list or a comma-separated string.
uid, gid
Explicit numeric ids, as an integer or a numeric string. Anything else, or a value outside 0 to 4294967294 or equal to 65535, refuses the account, instead of quietly allocating a different id. systemd refuses 65535, and a record it refuses fails every account in the seed. A uid the image already uses is honoured, with a warning. Two accounts with one uid own each other's files.
passwd
A password hash. Never a plaintext password.
lock-passwd
Boolean, default true. A locked account has no usable password. An account only gets the hash from passwd when this is false.
ssh-authorized-keys
A list of public key lines, or a single line. A single line is never split on commas, because key options such as no-pty,from="a,b" use them as syntax.
sudo
A list of sudoers lines, or a single line, never split on commas: a sudoers command list uses them. The account name is put in front automatically, so ALL=(ALL) NOPASSWD:ALL becomes <name> ALL=(ALL) NOPASSWD:ALL. A name with a dot in it gets no grant, with a warning: sudo skips any drop-in whose file name holds a dot.
system
Boolean. Makes it a system account.

Both booleans are read the way cloud-init reads them: yes, no, on and off count, in any case. Any other non-boolean refuses the account.

The keys no-create-home, no-user-group, no-log-init, inactive, expiredate, ssh-import-id and selinux-user are accepted and ignored. They're valid cloud-config, and a systemd user record has nothing to map them to. instance-setup(8) refuses them in --offline mode, where nothing is ignored.

The literal entry default is reported and skipped. There's no baked-in default user here for it to refer to.

A primary-group or groups entry that isn't a usable name, by the same rules as name, is reported and dropped, and the account itself is kept. That's not fussiness: systemd rejects a record that names such a group, and one rejected record fails every other record in the same load.

#cloud-config users:

  • name: operator gecos: Cloud Operator groups: wheel shell: /bin/bash sudo: ALL=(ALL) NOPASSWD:ALL ssh-authorized-keys:
    • ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... operator@example

Where do the uids come from?

Usually you don't give one, and they're allocated. An account the image already has keeps its own uid. Everything else is allocated upward from 1000, in the order the accounts appear, skipping every id that /etc/passwd, /etc/group or a record in /etc/userdb already uses.

That makes the uid a function of the seed plus the image, so the same seed gets the same uid on every boot. It has to: /home outlives /run, and files are owned by number.

Which group does an account get?

The first of these that applies:

  • the gid given in the seed;
  • the gid of its primary-group, if the seed defines that group;
  • a group under groups with the same name as the account;
  • a group the image already has with the same name as the account;
  • a new private group with the same number as its uid, which is what useradd does.

Why is there no default sudo?

Because it should be chosen, not inherited. An account gets no sudo access at all unless the seed asks for it. micro-config-drive gave its default user NOPASSWD:ALL. That's a deliberate weakening of the "no local user can escalate" posture, and it's not carried over.

The drop-in is checked with visudo -c before it's written. One that fails is dropped, the reason is logged, and the account is still created. Losing the escalation is better than losing the account, and a broken sudoers file breaks sudo for everyone on the machine. If the image has no visudo at all, the drop-in is written unchecked, with a warning.

How do I take a grant back?

Drop it from the seed. Any /etc/sudoers.d/50-instance-setup-* that an earlier boot wrote and the current seed doesn't produce is removed on that boot, including one whose new body visudo refused. A boot with no seed at all changes nothing. A grant baked with --offline at install time is never removed by a seed found at boot; edit or delete that file by hand.

GROUPS

groups is a list of group names, or of single-key mappings from a group name to its members. Both spellings work, and can be mixed:

groups:

  • admins
  • devs: [alice, bob]

Group names and member names follow the same rules as an account name, for the same reason. One outside them is reported and skipped.

SSH_AUTHORIZED_KEYS

The top-level ssh_authorized_keys is a list of public key lines, or a single line. The keys go to the first account in users. With no accounts to attach them to, they're reported, not silently dropped.

HOSTNAME, FQDN

hostname and fqdn are strings, and both spellings mean the same thing here. The first valid one wins.

A valid name is at most 64 bytes of dot-separated labels made of letters, digits and -, with no label empty, and none starting or ending with -. Anything else is reported and skipped, since PID 1 would refuse it in /etc/hostname anyway.

Why both a file and a command?

Because neither one alone is right. The hostname is written to /etc/hostname and also applied to the running system. The kernel and PID 1 read /etc/hostname long before this runs, so the file alone only takes effect on the next boot. Applying it live alone leaves nothing behind for the next boot.

REPOS

repos is a list of pkg repositories to configure. Each entry becomes a drop-in under /etc/pkg/repos.d/, and a remote repository's trust anchor becomes a file next to it in /etc/pkg/. The drop-in format, its keys and its naming convention are in pkg-repos.d(5). The keys below are that page's keys, with one deliberate difference, in tuf_root.

name
Required. The repository name, as the chain and other drop-ins' consumes refer to it. Restricted here to letters, digits, ., _ and -, and it can't begin with a dot, since it becomes part of a file name. A name outside that set is reported and the repository is skipped. Two names in one seed that differ only in case are refused too, because the file names are lowercased and they'd land on the same file.
url
Base URL of a remote repository. Needs tuf_root. Can't be combined with path.
path
Filesystem path to a local repository root. Can't be combined with url. No trust anchor is involved.
tuf_root
The TUF trust anchor's content, not its path. This is the one place this page differs from pkg-repos.d(5), where tuf_root is a path. A seed can't name a file that doesn't exist on the target yet, so it carries the bytes, and instance-setup(8) writes them to /etc/pkg/<lowercased-name>.root.json and puts that path in the drop-in. Required with url, and an error with path.
priority
Integer chain priority, default 0. Higher sorts earlier. It also picks the drop-in's file name: /etc/pkg/repos.d/<priority>-<name>.toml, with the priority zero-padded to two digits and the name lowercased. A repository the target already configures under the same name, at another file name, is refused while both are enabled, because pkg would load it twice.
enabled
true or false, default true. A disabled repository keeps its URL, anchor, priority and edges.
consumes
A list of repository names this one sits on top of. Every name has to be an enabled repository, either elsewhere in the same document or already in /etc/pkg/repos.d/ on the target. An image bake writes Base, and a seed adding Common on top of it is the ordinary case. A repository whose provider is refused is refused too, and so is every member of a consumes cycle, counting the edges of drop-ins already on the target.
stream
Optional. The product line the repository belongs to, written through as pkg-repos.d(5)'s record of it.

An unknown key refuses that entry, just as it's an error in the drop-in format itself.

repos:

  • name: Base url: http://repo.example:8082/Base priority: 10 tuf_root: | {"signed": { ... }, "signatures": [ ... ]}
  • name: Common url: http://repo.example:8082/Common priority: 20 consumes: [Base] tuf_root: | {"signed": { ... }, "signatures": [ ... ]}

Why ship the anchor in the seed?

Because the anchor has to arrive some other way than over the URL it authenticates. An anchor fetched from the repository it's supposed to vouch for is worthless. A config drive is out-of-band with respect to that URL, which is exactly what an anchor needs. The TRUST ANCHORS section of pkg-repos.d(5) goes into this.

Why skip a bad repository instead of writing it?

Because one bad drop-in takes down all of them. A repository that pkg would refuse is reported and skipped, not written. Every one of pkg's drop-in errors -- a duplicate name, a dangling consumes edge, url without tuf_root, both or neither of url and path, an unknown key -- is a load-time error. A load-time error isn't scoped to the drop-in that caused it: it refuses every later pkg command, for every repository. Writing one bad entry through would cost you the repositories that were fine.

A drop-in that an earlier boot's seed wrote, and the current one no longer produces, is removed with its trust anchor, the same way sudo grants are, unless another drop-in still consumes it: then it is kept, with a warning. Drop-ins that instance-setup(8) didn't write, or that --offline baked, are never touched.

A trust anchor over 4095 bytes can't be carried, and is reported. See NOTES in instance-setup(8).

WRITE_FILES

write_files is a list of files to put on the instance. It's for configuration that has no record type and no keyword of its own. The case it was built for is pointing gdm(8) at an autologin account: gdm reads exactly /etc/gdm/custom.conf, and has no drop-in directory.

#cloud-config write_files:

  • path: /etc/gdm/custom.conf permissions: "0644" owner: root:root content: | [daemon] AutomaticLoginEnable=True AutomaticLogin=live-usb-user
path
Required, and absolute. Leading directories are created, so a path under a directory no package ships still works. See "Which paths are refused?" below.
content
The file's contents. Defaults to empty. A NUL byte is refused, and so is one in a sudo rule or a tuf_root: systemd reads a NUL as the end of a tmpfiles.extra line, and the rest would run as a line of its own.
permissions
Defaults to 0644. It must be a quoted string of three or four octal digits. An unquoted 0600 is refused: YAML 1.2 reads it as the decimal number 600, and guessing what you meant could leave a secret world-readable.
owner
Defaults to root:root. A bare name means that user, and a group of the same name. Both halves have to be account names, by the rules in USERS, because they become fields of the same line as the path.

An existing file is truncated and rewritten. The seed is the source of truth on every boot, and a stale file from an earlier instance would otherwise live forever.

Files are written through tmpfiles.extra, so the 4095-byte limit in NOTES in instance-setup(8) applies to each file's contents. A file too big to carry is reported and skipped, and the others are still written.

Why are encoding, append and defer refused?

Because each of them changes what ends up in the file. Honouring the entry and dropping the key would write something the seed didn't ask for, and that's worse than writing nothing. An entry using one of them is reported and skipped.

Which paths are refused?

A relative path, or one with a .. component, is refused, not resolved. A seed isn't entitled to name a file outside the tree it was pointed at.

A path with whitespace or a control character in it is refused too, and that's not cosmetic. The file is written through a tmpfiles.d line whose leading fields are separated by whitespace and have no escaping at all. A space shifts every later field along by one. A newline ends the line, and leaves the rest of the value standing as a directive of its own: any file, mode and owner the seed liked, created as root.

For the same reason a path can't contain %, \, " or '. tmpfiles expands specifiers and decodes quotes and escapes in that field, so the file would land somewhere other than the path you named.

NOT YET IMPLEMENTED

envar, service, runcmd, fbootcmd, packages, package_upgrade and wait-for-network are recognised, and reported as not implemented. They're in scope and will come in later releases. A seed that uses them isn't rejected.

Why can't service enable a unit?

It looks like the obvious way to enable a unit from a seed, and it can't be. Units are pulled in by the boot transaction for default.target, which systemd works out at the start of the boot, long before this runs in the sysinit phase. A .wants symlink written here only takes effect on the next boot. The sources document the same constraint for runcmd. Enabling units belongs in the image, not the seed.

So repos without packages?

For now, yes. repos configures repositories without installing anything from them. That's still the useful half on an instance whose pkg has no repository in its chain. The two are independent anyway: repositories are configured in the sysinit phase, and package installation will run after the network is up.

NOT SUPPORTED

Left out on purpose, and not planned: Ansible, Chef, Puppet, apt, snap, disk partitioning, and the rest of cloud-init's module surface. Growing the disk is systemd-repart and x-systemd.growfs, declared in the image.

A network-config document is ignored for now, with a warning, and refused by --offline. It will be translated, for addressing only. There will be no netplan translator here.

SEE ALSO

instance-setup(8), pkg-repos.d(5)