Luther Monson, Staff Software Engineer at SUSE

Luther Monson

Go by way of PHP · Containers · VMs · Windows · Kubernetes

Proxmox JSON, Part 2: null PIDs, 1.12589990684262e+15 and net10

The Proxmox VE API found three more ways for its JSON not to fit Go's encoding/json. There's a null I fixed and then broke, petabyte storage in scientific notation, and devices past index 9 that were silently dropped.

7-Minute Read

In 2023 I wrote about why go-proxmox has StringOrInt and friends. Proxmox sends the same number quoted in one place and bare in another, so the client has types that accept both. That post ended on a pretty confident note. Since then three more shapes of “the JSON is valid and Go still won’t take it” have come in. One of them is a bug I’d already fixed once and then reintroduced myself.

null, twice

#198, June 2025: listing VMs on a node fails with

failed to match ^[0-9.]*$: null

if any of the VMs is a template. Templates never run, so /nodes/{node}/qemu sends "pid": null for them. A running VM on the same list gets "pid": "14558", a quoted string. PID is a StringOrUint64. Its unmarshaller strips quotes and requires what’s left to match ^[0-9.]*$. null doesn’t.

The embarrassing part is that this worked in May 2023. A contributor had sent a fix for stopped VMs returning null. It had the unmarshaller treat anything without a digit in it as zero:

numeric := numericRegex.MatchString(str) // `\d`
if !numeric {
	*d = StringOrUint64(0)
	return nil
}

Three months later I rewrote all the StringOr types to parse floats and truncate. That’s the version in the 2023 post. “Non-numeric means zero” became “non-numeric is an error, except the empty string”. I wanted garbage to fail loudly. null is not garbage. It’s Proxmox saying the field doesn’t apply. No test fixture had a null in it, so nothing noticed for two years. The fix in #265 is one condition on each of the three types:

str := strings.ReplaceAll(string(b), "\"", "")
// Empty string and JSON null both yield the zero value. Proxmox returns
// null for fields that are simply absent on the resource (e.g. PID on a
// stopped VM template — see issue #198).
if str == "" || str == "null" {
	*d = StringOrInt(0)
	return nil
}

Note that encoding/json calls UnmarshalJSON with the literal bytes null for a custom type rather than skipping it. That’s why this has to be handled in the type at all.

A petabyte in scientific notation

#191: a directory storage backed by a MinIO bucket over a FUSE mount. The FUSE driver can’t tell how big the bucket is, so it reports 1 PiB. pvesh shows exactly that:

content   storage        type active avail           enabled shared total           used
backup    minio_backup   dir  1      1125899906842624 1       0      1125899906842624 0

The API sends something else:

json: cannot unmarshal number 1.12589990684262e+15 into Go struct field Storage.Total of type uint64

Somewhere in Proxmox’s storage code that value became a Perl floating point number. A float that big gets printed with 15 significant digits in exponent form. It’s valid JSON. It’s not a valid uint64, and json.Number.Int64() won’t parse it either:

uint64: json: cannot unmarshal number 1.12589990684262e+15 into Go struct field .total of type uint64
json.Number Int64 err: strconv.ParseInt: parsing "1.12589990684262e+15": invalid syntax
json.Number Float64 -> uint64: 1125899906842620
actual bytes (2^50):           1125899906842624

The fix (#237, from a contributor’s earlier attempt in #195) is a custom UnmarshalJSON on Storage. It decodes the numeric fields as json.Number. It tries Int64 first and falls back to Float64:

if aux.Total != "" {
	// Try int64 first, then fall back to float64 for scientific notation
	if val, err := aux.Total.Int64(); err == nil {
		s.Total = uint64(val)
	} else if val, err := aux.Total.Float64(); err == nil {
		s.Total = uint64(val)
	}
}

Look at the last two lines of that output though. The client now reads 1,125,899,906,842,620 for a storage that is 1,125,899,906,842,624 bytes. Those four bytes were gone before the response left the server. They went when the number was printed with 15 digits. No amount of client-side parsing brings them back. For a fake petabyte nobody cares. It’s worth knowing that Proxmox capacity numbers above 10^15 are approximate.

A 1 PiB storage size of 1125899906842624 bytes becomes 1.12589990684262e+15 when Proxmox prints it as a 15-digit float. Decoding into uint64 fails; after the fix Storage.UnmarshalJSON falls back from Int64 to Float64 and reads 1125899906842620, four bytes short

That fix then broke something else in a way I would not have guessed. #241 came from running the cluster-api-provider-proxmox test suite against main. Marshal a Storage, unmarshal it again, and Name comes back empty. The struct had two fields that map to the same key. That’s easy to miss until you remember encoding/json matches keys case-insensitively:

Name    string `json:"storage"` // marshals as "storage"
Storage string                  // marshals as "Storage"

Marshal writes both "storage":"local" and "Storage":"". On the way back in, the new unmarshaller’s aux struct has a single json:"storage" field. Both keys match it and the later one wins. It’s the empty one. The old Storage field was a leftover from before Name existed. #243 tagged it json:"-" and marked it deprecated. The round trip works again.

net10 and the fields that weren’t there

#211: a VM with eleven or more network interfaces only shows the first ten. VirtualMachineConfig had explicit fields for indexed devices. Those were Net0 through Net9, SCSI0 through SCSI30, Unused0 through Unused9 and so on. It also had helper maps (Nets, SCSIs, …) that a custom UnmarshalJSON filled in by reflecting over those fields. Proxmox allows net0 to net31, unused0 to unused255, and so on. encoding/json throws away any key that doesn’t match a struct field before your code ever gets a look at it. So net10 was dropped on the floor, silently.

The fix in #260 unmarshals the body a second time into map[string]json.RawMessage. Then it routes every <prefix><digits> key into the matching map:

routes := vmc.indexedDeviceMaps() // "net" -> &vmc.Nets, "scsi" -> &vmc.SCSIs, ...
for k, v := range raw {
	prefix, ok := indexedDeviceKey(k)
	if !ok {
		continue
	}
	target, ok := routes[prefix]
	if !ok {
		continue
	}
	var s string
	if err := json.Unmarshal(v, &s); err != nil {
		// non-string indexed value — skip rather than fail the whole config
		continue
	}
	if *target == nil {
		*target = make(map[string]string)
	}
	(*target)[k] = s
}

The obvious version of this is strings.HasPrefix(k, "scsi"). An earlier PR for the same issue did exactly that. It’s wrong, because scsihw (the SCSI controller type) starts with scsi. The VM config also has a plain numa flag next to numa0..numa9. So the key parser requires the prefix to be followed by digits and only digits:

// indexedDeviceKey returns ("net", true) for "net10" and ("", false) for keys
// like "scsihw" or "numa" that share a prefix but lack a pure-digit suffix.
func indexedDeviceKey(k string) (prefix string, ok bool) {
	for i := 0; i < len(k); i++ {
		c := k[i]
		if c >= '0' && c <= '9' {
			if i == 0 {
				return "", false
			}
			// rest of the string must be all digits
			for j := i + 1; j < len(k); j++ {
				if k[j] < '0' || k[j] > '9' {
					return "", false
				}
			}
			return k[:i], true
		}
	}
	return "", false
}

There are unit tests for exactly those two collisions. #260 kept the explicit Net0..Net9 fields populated for compatibility. v0.7.0 (#261) removed them. The maps are now the only shape: cfg.Nets["net10"]. It’s one of the bigger sections of the v0.7.0 migration guide because a lot of code reads cfg.Net0.

The pattern

All three of these are the same bug as the 2023 post wearing a different hat. The JSON is fine. The Go type was written from the docs or from one sample response. The real API sends a shape nobody put in a test fixture: a null, a float, an eleventh NIC. Each fix is small. Finding them is the hard part. That’s why I’m now more interested in testing against recorded responses from a real cluster than in typing more fixtures by hand.

Recent Posts

Categories

About

Luther is a 20+ year software veteran specializing in backend APIs and architecting products. Employed at SUSE as the Team Lead for the Containers on Windows Team working on Rancher + Kubernetes