Skip to content

fix(server): base64-encode --user-data; add rebuild --user-data/--no-user-data - #29

Merged
ftarasenko merged 3 commits into
masterfrom
claude/elegant-ptolemy-4lmsb2
Sep 22, 2026
Merged

ftarasenko merged 3 commits into
masterfrom
claude/elegant-ptolemy-4lmsb2

Conversation

@ftarasenko

Copy link
Copy Markdown
Owner

Closes the --user-data parity gap against upstream python-openstackclient 8.2.0, measured against nova 26.3.0 (the Zed floor). One of the two findings is a silent data-corruption bug.

The bug (5756d22)

server create handed the file's raw bytes to gophercloud's servers.CreateOpts.UserData, which base64-encodes them only when they do not already decode as base64. Go's decoder ignores newlines, so an ordinary file whose remaining bytes all fall in the base64 alphabet with a length divisible by four took the pass-through branch and was sent verbatim:

--user-data file sent before correct
#cloud-config\npackages: [fio]\n I2Nsb3VkLWNvbmZpZwpw… same ✅
runcmd\nls\n runcmd\nls\n cnVuY21kCmxzCg==
hostname\n hostname\n aG9zdG5hbWUK
deadbeef deadbeef ZGVhZGJlZWY=

Nothing caught it downstream. Nova's "format": "base64" check is oslo_serialization's b64decode, which discards characters outside the alphabet rather than rejecting them, so the request succeeded and the guest was served the decoded bytes instead of the file. The instance reached ACTIVE having run nothing, and server show --user-data decoded the same garbage, so the client agreed with itself. Files containing #, :, -, = or a space are unaffected, which covers most real cloud-configs and is why this had not surfaced.

readUserData now encodes. Handing gophercloud text that is already valid base64 pins the pass-through branch, so the request body matches upstream OSC, which encodes unconditionally.

Two smaller divergences from OSC ride along:

  • An empty file is no longer rejected. Upstream's if user_data: drops the field from the request, so koc now sends the same body — but warns on stderr rather than doing it silently, since a template that rendered to nothing is usually a mistake.
  • The file is read before the flavor and scheduler-hint lookups, so a mistyped path costs no round-trip.

Affected releases are v0.28.0 through v0.32.1. README → "User data (--user-data)" carries the pre-encoding workaround for them, and the note to remove it after upgrading.

The feature (9ebf211)

server rebuild gains --user-data and --no-user-data, mutually exclusive, mirroring OSC's argparse group. Nova added user_data to rebuild at microversion 2.57 as a nullable string: a base64 payload replaces the server's user data, JSON null clears it.

The gate is nova's 2.57, not OSC's 2.54 — upstream checks 2.54 while its own help text says 2.57; 2.54 is the microversion that added key_name to rebuild, and nova's schema rejects user_data below 2.57 as an unexpected property. Zed's nova caps at 2.93, so the flags reach the whole supported fleet.

gophercloud's servers.RebuildOpts has no field for this — it predates the change and still models the personality files 2.57 removed — so serverRebuildOptsExt splices the field into the body, the way serverCreateOptsExt splices nova 2.74's host into a create.

Verification

Captured from the built binary against a mock nova:

create   user_data = 'cnVuY21kCmxzCg=='  -> b'runcmd\nls\n'
create   user_data ABSENT                                     # empty file, + stderr warning
rebuild  user_data = 'cnVuY21kCmxzCg=='  -> b'runcmd\nls\n'
rebuild  user_data = null                                     # --no-user-data
rebuild  user_data ABSENT                                     # untouched, server keeps its own

New tests assert the exact user_data string on the wire for each payload, driven from real files on disk, which also pins gophercloud's behaviour across a vendor bump. Also covered: missing file fails before anything is sent, the 2.57 gate sends nothing below it, and cobra refuses both rebuild flags together.

gofmt, go vet ./..., the offline static build, go test ./... and -race are green. golangci-lint could not run in the authoring sandbox (v2.5.0 there, built with go1.25, refuses a go1.26 target) — CI's pinned version is the gate.

docs/coverage.md is unchanged on purpose: no new leaf command, only flags. The analysis behind both changes is in docs/proposals/user-data-parity.md, and the gophercloud trap is recorded in AGENTS.md → "gophercloud v2 gotchas".

🤖 Generated with Claude Code

https://claude.ai/code/session_01EkWzWSEyrjJ1gd1v5zJNtE


Generated by Claude Code

Measured `koc server create --user-data` against python-openstackclient
8.2.0 and nova 26.3.0 (the Zed floor).

The headline finding is a bug, not a missing flag: koc hands the file's
raw bytes to gophercloud's servers.CreateOpts.UserData, which
base64-encodes them only if they do not already decode as base64. Go's
decoder ignores newlines, so an ordinary file of alphanumerics whose
length is a multiple of four ("runcmd\nls\n", "hostname\n") takes the
pass-through branch. Nova's base64 format checker is lenient
(oslo_serialization's b64decode discards non-alphabet characters), so it
accepts the value, and the guest is served the decoded garbage. Nothing
errors at any layer. Upstream OSC unconditionally b64encodes the file.

Also recorded: an empty file is a hard error here and a silent no-op
upstream; `server rebuild` is missing --user-data/--no-user-data (nova
2.57 — note OSC's own gate says 2.54, which its help text contradicts and
nova's schema rejects); and a bad path is only reported after two API
calls.

Proposal is docs-only; no behaviour changes in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkWzWSEyrjJ1gd1v5zJNtE
server create handed the file's raw bytes to gophercloud's
servers.CreateOpts.UserData, which base64-encodes them only when they do
not already decode as base64. Go's decoder ignores newlines, so an
ordinary file whose remaining bytes all fall in the base64 alphabet with
a length divisible by four -- "runcmd\nls\n", "hostname\n", "deadbeef" --
took the pass-through branch and was sent verbatim.

Nothing caught it downstream. Nova's "format": "base64" check is
oslo_serialization's b64decode, which discards characters outside the
alphabet rather than rejecting them, so the request succeeded and the
guest was served the decoded garbage instead of the file. The instance
reached ACTIVE having run nothing, and `server show --user-data` decoded
the same garbage, so the client agreed with itself.

Encode in readUserData instead. Handing gophercloud text that is already
valid base64 pins the pass-through branch, so the request body now
matches upstream OSC, which encodes unconditionally. The regression test
asserts the exact user_data string on the wire for each payload, which
also pins gophercloud's behaviour across a vendor bump.

Two smaller divergences from OSC go with it. An empty file is no longer
rejected: upstream's `if user_data:` drops the field from the request, so
koc sends the same body -- but warns on stderr rather than doing it
silently, since a template that rendered to nothing is usually a mistake.
And the file is read before the flavor and scheduler-hint lookups, so a
mistyped path costs no round-trip.

Affected releases are v0.28.0 through v0.32.1; README "User data" carries
the pre-encoding workaround for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkWzWSEyrjJ1gd1v5zJNtE
Nova added user_data to the rebuild request at microversion 2.57, as a
nullable string: a base64 payload replaces the server's user data, JSON
null clears it (rebuild_v257 in
nova/api/openstack/compute/schemas/servers.py). koc rebuild carried only
--image and --name, so re-provisioning a guest meant deleting and
recreating it to change the cloud-init payload.

The gate is nova's 2.57, not upstream OSC's 2.54. OSC checks 2.54 while
its own help text says 2.57; 2.54 is the microversion that added key_name
to rebuild, and nova's schema rejects user_data below 2.57 as an
unexpected property. Zed's nova caps at 2.93, so the flags reach the whole
supported fleet.

gophercloud's servers.RebuildOpts has no field for this -- it predates the
change and still models the personality files 2.57 removed -- so
serverRebuildOptsExt splices the field into the body, the way
serverCreateOptsExt splices nova 2.74's host into a create. The file is
read through the same encoder as create, so the payloads that used to be
passed through unencoded are correct here from the start. An empty file is
refused rather than sent as "": clearing is what --no-user-data spells.

Flag surface only; no new leaf command, so docs/coverage.md is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkWzWSEyrjJ1gd1v5zJNtE
@ftarasenko
ftarasenko merged commit 0476697 into master Sep 22, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants