fix(server): base64-encode --user-data; add rebuild --user-data/--no-user-data - #29
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the
--user-dataparity gap against upstreampython-openstackclient8.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 createhanded the file's raw bytes to gophercloud'sservers.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-datafile#cloud-config\npackages: [fio]\nI2Nsb3VkLWNvbmZpZwpw…runcmd\nls\nruncmd\nls\n❌cnVuY21kCmxzCg==hostname\nhostname\n❌aG9zdG5hbWUKdeadbeefdeadbeef❌ZGVhZGJlZWY=Nothing caught it downstream. Nova's
"format": "base64"check isoslo_serialization'sb64decode, 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, andserver show --user-datadecoded 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.readUserDatanow 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:
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.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 rebuildgains--user-dataand--no-user-data, mutually exclusive, mirroring OSC's argparse group. Nova addeduser_datato rebuild at microversion 2.57 as a nullable string: a base64 payload replaces the server's user data, JSONnullclears 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_nameto rebuild, and nova's schema rejectsuser_databelow 2.57 as an unexpected property. Zed's nova caps at 2.93, so the flags reach the whole supported fleet.gophercloud's
servers.RebuildOptshas no field for this — it predates the change and still models the personality files 2.57 removed — soserverRebuildOptsExtsplices the field into the body, the wayserverCreateOptsExtsplices nova 2.74'shostinto a create.Verification
Captured from the built binary against a mock nova:
New tests assert the exact
user_datastring 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-raceare green.golangci-lintcould 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.mdis unchanged on purpose: no new leaf command, only flags. The analysis behind both changes is indocs/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