Summary
When urunc loads a unikernel configuration via GetUnikernelConfig(), any parsing, validation, or base64 decoding error is collapsed into ErrNotUnikernel.
As a result, urunc assumes the workload is a normal OCI container and silently falls back to runc. Since the rootfs only contains a unikernel binary, runc eventually fails with an unrelated error (missing executable, missing filesystem, rootless configuration, etc.), hiding the actual configuration problem.
Code references
Error masking (pkg/unikontainers/unikontainers.go)
config, err := GetUnikernelConfig(bundlePath, spec)
if err != nil {
return nil, ErrNotUnikernel
}
GetUnikernelConfig() already distinguishes between several failure modes:
- missing configuration
- malformed JSON
- invalid base64
- validation failures
- filesystem errors
However, all of these are discarded here.
Silent fallback (cmd/urunc/create.go)
unikontainer, err := unikontainers.New(...)
if err != nil {
if errors.Is(err, unikontainers.ErrQueueProxy) ||
errors.Is(err, unikontainers.ErrNotUnikernel) {
return runcExec()
}
return err
}
Since every configuration error becomes ErrNotUnikernel, the runtime silently executes runc instead.
Reproduction 1: invalid base64 annotations
Example urunc.json:
{
"com.urunc.unikernel.unikernelType": "not-base64-!!!",
"com.urunc.unikernel.hypervisor": "not-base64-!!!",
"com.urunc.unikernel.binary": "not-base64-!!!"
}
./dist/urunc_static_amd64 create --bundle ./repro_bundle test-container
Observed output:
INFO failed to fetch urunc annotations from spec, fallback to urunc.json
ERRO Failed to decode string: not-base64-!!!
ERRO Failed to decode string: not-base64-!!!
ERRO Failed to decode string: not-base64-!!!
ERRO runc create failed: rootless container requires user namespaces
The important observation is not the final runc error itself (the exact message depends on the environment), but that runcExec() is reached at all after a configuration decoding failure.
Note on the decode logs
The three Failed to decode string messages come from tryDecode(), a helper used only for preview logging.
They are not the decode operation that determines container creation.
The real decoding happens later inside UnikernelConfig.decode(), which returns an error such as:
failed to decode Hypervisor:
illegal base64 data at input byte 3
That error is immediately discarded by New() and never reaches the user.
Reproduction 2: missing mandatory field
urunc.json:
{
"com.urunc.unikernel.unikernelType": "dW5pa3JhZnQ=",
"com.urunc.unikernel.binary": "L2tlcm5lbA=="
}
Observed output:
INFO failed to fetch urunc annotations from spec, fallback to urunc.json
ERRO runc create failed: rootless container requires user namespaces
Here no base64 decoding fails.
Instead, jsonConf.validate() returns:
unikernel configuration is missing mandatory field:
com.urunc.unikernel.hypervisor
That validation error is also discarded and replaced by the unrelated runc failure.
This demonstrates that the problem is not specific to base64 decoding it affects all configuration errors.
Impact
Malformed unikernel configurations are treated as ordinary OCI containers.
Instead of reporting the actual configuration error, urunc falls back to runc, producing misleading runtime failures that make debugging unnecessarily difficult.
Proposed solution
Only fall back to runc when no unikernel configuration exists.
If configuration is present but invalid, propagate the original error.
config, err := GetUnikernelConfig(bundlePath, spec)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotUnikernel
}
return nil, err
}
This preserves the existing behavior for ordinary OCI containers while allowing malformed unikernel configurations to fail with descriptive errors.
Behavior after the change
| Scenario |
Current |
Proposed |
No annotations + no urunc.json |
Fall back to runc |
Fall back to runc |
| Invalid JSON |
Fall back to runc |
Return JSON parse error |
| Invalid base64 |
Fall back to runc |
Return decode error |
| Missing mandatory field |
Fall back to runc |
Return validation error |
urunc.json is a directory |
Fall back to runc |
Return descriptive error |
Known limitation
A partial annotation set (for example, only unikernelType with no urunc.json) still eventually results in os.ErrNotExist and therefore falls back to runc.
Whether partial annotations should be considered sufficient evidence of unikernel intent is a separate design question and intentionally left out of this change to keep the fix focused.
LLM Disclosure
Used GPT to draft and refine the issue report, and Gemini to help verify the issue and reproduction.
Summary
When
uruncloads a unikernel configuration viaGetUnikernelConfig(), any parsing, validation, or base64 decoding error is collapsed intoErrNotUnikernel.As a result,
uruncassumes the workload is a normal OCI container and silently falls back torunc. Since the rootfs only contains a unikernel binary,runceventually fails with an unrelated error (missing executable, missing filesystem, rootless configuration, etc.), hiding the actual configuration problem.Code references
Error masking (
pkg/unikontainers/unikontainers.go)GetUnikernelConfig()already distinguishes between several failure modes:However, all of these are discarded here.
Silent fallback (
cmd/urunc/create.go)Since every configuration error becomes
ErrNotUnikernel, the runtime silently executesruncinstead.Reproduction 1: invalid base64 annotations
Example
urunc.json:{ "com.urunc.unikernel.unikernelType": "not-base64-!!!", "com.urunc.unikernel.hypervisor": "not-base64-!!!", "com.urunc.unikernel.binary": "not-base64-!!!" }Observed output:
The important observation is not the final
runcerror itself (the exact message depends on the environment), but thatruncExec()is reached at all after a configuration decoding failure.Note on the decode logs
The three
Failed to decode stringmessages come fromtryDecode(), a helper used only for preview logging.They are not the decode operation that determines container creation.
The real decoding happens later inside
UnikernelConfig.decode(), which returns an error such as:That error is immediately discarded by
New()and never reaches the user.Reproduction 2: missing mandatory field
urunc.json:{ "com.urunc.unikernel.unikernelType": "dW5pa3JhZnQ=", "com.urunc.unikernel.binary": "L2tlcm5lbA==" }Observed output:
Here no base64 decoding fails.
Instead,
jsonConf.validate()returns:That validation error is also discarded and replaced by the unrelated
runcfailure.This demonstrates that the problem is not specific to base64 decoding it affects all configuration errors.
Impact
Malformed unikernel configurations are treated as ordinary OCI containers.
Instead of reporting the actual configuration error,
uruncfalls back torunc, producing misleading runtime failures that make debugging unnecessarily difficult.Proposed solution
Only fall back to
runcwhen no unikernel configuration exists.If configuration is present but invalid, propagate the original error.
This preserves the existing behavior for ordinary OCI containers while allowing malformed unikernel configurations to fail with descriptive errors.
Behavior after the change
urunc.jsonruncruncruncruncruncurunc.jsonis a directoryruncKnown limitation
A partial annotation set (for example, only
unikernelTypewith nourunc.json) still eventually results inos.ErrNotExistand therefore falls back torunc.Whether partial annotations should be considered sufficient evidence of unikernel intent is a separate design question and intentionally left out of this change to keep the fix focused.
LLM Disclosure
Used GPT to draft and refine the issue report, and Gemini to help verify the issue and reproduction.