Skip to content

Commit 44a4cea

Browse files
author
Shreyansh Sancheti
committed
parity: add LCOW HCS document parity tests
Adds test infrastructure to validate that the v2 LCOW document builder (lcow.BuildSandboxConfig) produces the same HCS ComputeSystem documents as the legacy shim pipeline. Both paths receive identical inputs (annotations, shim options, devices): Legacy: OCI spec → oci.UpdateSpecFromOptions → oci.ProcessAnnotations → oci.SpecToUVMCreateOpts → uvm.MakeLCOWDocument V2: vm.Spec → lcow.BuildSandboxConfig Documents are normalized (GUID map keys sorted, owner zeroed, nil-vs-empty-struct equalized) and compared with go-cmp. Changes: - internal/uvm: export MakeLCOWDocument() to generate HCS document without creating a VM. Supports both non-SNP and SNP paths. - test/parity: new package with separated concerns: - doc.go: package docs with pipeline diagrams and run instructions - helpers_test.go: boot file setup, normalization, JSON logging - legacy_pipeline_test.go: wires the 4-step legacy pipeline - v2_pipeline_test.go: wraps BuildSandboxConfig - lcow_doc_test.go: 3 document parity tests + 5 field parity checks All 8 tests pass on Windows with Hyper-V + admin privileges. Signed-off-by: Shreyansh Sancheti <shsancheti@microsoft.com>
1 parent 3b9a4af commit 44a4cea

6 files changed

Lines changed: 433 additions & 0 deletions

File tree

internal/uvm/create_lcow.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,58 @@ func makeLCOWDoc(ctx context.Context, opts *OptionsLCOW, uvm *UtilityVM) (_ *hcs
876876
return doc, nil
877877
}
878878

879+
// MakeLCOWDocument generates the HCS compute system document for an LCOW VM
880+
// without actually creating the VM or submitting to HCS. This is useful for
881+
// parity testing between the legacy and v2 shim document builders.
882+
//
883+
// It mirrors the initialization logic of CreateLCOW (UtilityVM struct setup,
884+
// SCSI controller adjustment, option verification) then calls makeLCOWDoc
885+
// or makeLCOWSecurityDoc to produce the document depending on whether
886+
// SecurityPolicyEnabled is set.
887+
func MakeLCOWDocument(ctx context.Context, opts *OptionsLCOW) (*hcsschema.ComputeSystem, error) {
888+
if opts.ID == "" {
889+
g, err := guid.NewV4()
890+
if err != nil {
891+
return nil, err
892+
}
893+
opts.ID = g.String()
894+
}
895+
896+
if opts.OutputHandlerCreator == nil {
897+
opts.OutputHandlerCreator = vmutils.ParseGCSLogrus
898+
}
899+
900+
uvm := &UtilityVM{
901+
id: opts.ID,
902+
owner: opts.Owner,
903+
operatingSystem: "linux",
904+
scsiControllerCount: opts.SCSIControllerCount,
905+
vpmemMaxCount: opts.VPMemDeviceCount,
906+
vpmemMaxSizeBytes: opts.VPMemSizeBytes,
907+
vpciDevices: make(map[VPCIDeviceID]*VPCIDevice),
908+
physicallyBacked: !opts.AllowOvercommit,
909+
devicesPhysicallyBacked: opts.FullyPhysicallyBacked,
910+
createOpts: opts,
911+
vpmemMultiMapping: !opts.VPMemNoMultiMapping,
912+
encryptScratch: opts.EnableScratchEncryption,
913+
noWritableFileShares: opts.NoWritableFileShares,
914+
policyBasedRouting: opts.PolicyBasedRouting,
915+
}
916+
917+
if osversion.Build() >= osversion.RS5 && uvm.vpmemMaxCount == 0 {
918+
uvm.scsiControllerCount = 4
919+
}
920+
921+
if err := verifyOptions(ctx, opts); err != nil {
922+
return nil, errors.Wrap(err, errBadUVMOpts.Error())
923+
}
924+
925+
if opts.SecurityPolicyEnabled {
926+
return makeLCOWSecurityDoc(ctx, opts, uvm)
927+
}
928+
return makeLCOWDoc(ctx, opts, uvm)
929+
}
930+
879931
// CreateLCOW creates an HCS compute system representing a utility VM. It
880932
// consumes a set of options derived from various defaults and options
881933
// expressed as annotations.

test/parity/doc.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//go:build windows
2+
3+
// Package parity validates that the v2 LCOW document builder
4+
// (lcow.BuildSandboxConfig) produces HCS ComputeSystem documents equivalent
5+
// to the legacy shim pipeline (oci → uvm.MakeLCOWDocument).
6+
//
7+
// # How it works
8+
//
9+
// Each test case defines a set of annotations, shim options, and devices.
10+
// These inputs are fed identically to both pipelines:
11+
//
12+
// Legacy: specs.Spec + runhcsopts.Options
13+
// → oci.UpdateSpecFromOptions
14+
// → oci.ProcessAnnotations
15+
// → oci.SpecToUVMCreateOpts → *OptionsLCOW
16+
// → uvm.MakeLCOWDocument → *hcsschema.ComputeSystem
17+
//
18+
// V2: vm.Spec + runhcsopts.Options
19+
// → lcow.BuildSandboxConfig → *hcsschema.ComputeSystem + *SandboxOptions
20+
//
21+
// The resulting documents are normalized (random GUID map keys sorted,
22+
// owner zeroed, nil-vs-empty-struct equalized) then compared with go-cmp.
23+
//
24+
// The test also compares legacy OptionsLCOW fields against v2 SandboxOptions
25+
// to verify configuration semantics are preserved.
26+
//
27+
// # File layout
28+
//
29+
// doc.go — this file
30+
// lcow_doc_test.go — test cases and input construction (inline)
31+
// legacy_pipeline_test.go — buildLegacyDocument: wires the 4-step legacy pipeline
32+
// v2_pipeline_test.go — buildV2Document: wraps lcow.BuildSandboxConfig
33+
// helpers_test.go — setupBootFiles, normalizeDoc, sorted map helpers, mustJSON
34+
//
35+
// # Running
36+
//
37+
// Requires: Windows with Hyper-V enabled, admin (elevated) PowerShell.
38+
//
39+
// cd test
40+
// go test -tags functional -v -count=1 ./parity/
41+
package parity

test/parity/helpers_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//go:build windows && functional
2+
3+
package parity
4+
5+
import (
6+
"encoding/json"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
"testing"
11+
12+
hcsschema "github.com/Microsoft/hcsshim/internal/hcs/schema2"
13+
"github.com/Microsoft/hcsshim/internal/vm/vmutils"
14+
)
15+
16+
// setupBootFiles creates a temp directory with the dummy boot files that both
17+
// builders probe for when resolving kernel and rootfs paths.
18+
func setupBootFiles(t *testing.T) string {
19+
t.Helper()
20+
dir := t.TempDir()
21+
for _, name := range []string{
22+
vmutils.KernelFile,
23+
vmutils.UncompressedKernelFile,
24+
vmutils.InitrdFile,
25+
vmutils.VhdFile,
26+
} {
27+
if err := os.WriteFile(filepath.Join(dir, name), []byte("test"), 0644); err != nil {
28+
t.Fatalf("failed to create boot file %s: %v", name, err)
29+
}
30+
}
31+
return dir
32+
}
33+
34+
// normalizeDoc makes both documents comparable by zeroing nondeterministic
35+
// fields.
36+
func normalizeDoc(doc *hcsschema.ComputeSystem) {
37+
if doc == nil {
38+
return
39+
}
40+
doc.Owner = ""
41+
42+
vm := doc.VirtualMachine
43+
if vm == nil {
44+
return
45+
}
46+
47+
// Empty StorageQoS is the same as nil (no QoS configured).
48+
if vm.StorageQoS != nil && vm.StorageQoS.IopsMaximum == 0 && vm.StorageQoS.BandwidthMaximum == 0 {
49+
vm.StorageQoS = nil
50+
}
51+
52+
// Empty CpuGroup is the same as nil (no CPU group assigned).
53+
if vm.ComputeTopology != nil && vm.ComputeTopology.Processor != nil {
54+
if cg := vm.ComputeTopology.Processor.CpuGroup; cg != nil && cg.Id == "" {
55+
vm.ComputeTopology.Processor.CpuGroup = nil
56+
}
57+
}
58+
59+
if vm.Devices == nil {
60+
return
61+
}
62+
63+
// SCSI and vPCI maps use random GUID keys. Sort and re-index for
64+
// deterministic comparison.
65+
if scsi := vm.Devices.Scsi; scsi != nil {
66+
vm.Devices.Scsi = sortedMapKeys(scsi)
67+
}
68+
if vpci := vm.Devices.VirtualPci; vpci != nil {
69+
vm.Devices.VirtualPci = sortedVPCIKeys(vpci)
70+
}
71+
}
72+
73+
func sortedMapKeys(m map[string]hcsschema.Scsi) map[string]hcsschema.Scsi {
74+
keys := make([]string, 0, len(m))
75+
for k := range m {
76+
keys = append(keys, k)
77+
}
78+
sort.Strings(keys)
79+
out := make(map[string]hcsschema.Scsi, len(m))
80+
for i, k := range keys {
81+
out[string(rune('0'+i))] = m[k]
82+
}
83+
return out
84+
}
85+
86+
func sortedVPCIKeys(m map[string]hcsschema.VirtualPciDevice) map[string]hcsschema.VirtualPciDevice {
87+
keys := make([]string, 0, len(m))
88+
for k := range m {
89+
keys = append(keys, k)
90+
}
91+
sort.Strings(keys)
92+
out := make(map[string]hcsschema.VirtualPciDevice, len(m))
93+
for i, k := range keys {
94+
out[string(rune('0'+i))] = m[k]
95+
}
96+
return out
97+
}
98+
99+
// mustJSON returns indented JSON for logging.
100+
func mustJSON(v interface{}) string {
101+
b, err := json.MarshalIndent(v, "", " ")
102+
if err != nil {
103+
panic(err)
104+
}
105+
return string(b)
106+
}

test/parity/lcow_doc_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//go:build windows && functional
2+
3+
package parity
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
"github.com/opencontainers/runtime-spec/specs-go"
11+
12+
runhcsopts "github.com/Microsoft/hcsshim/cmd/containerd-shim-runhcs-v1/options"
13+
shimannotations "github.com/Microsoft/hcsshim/pkg/annotations"
14+
vm "github.com/Microsoft/hcsshim/sandbox-spec/vm/v2"
15+
)
16+
17+
// TestLCOWDocumentParity feeds identical inputs to both the legacy and v2
18+
// pipelines and verifies the resulting HCS documents match.
19+
func TestLCOWDocumentParity(t *testing.T) {
20+
t.Parallel()
21+
bootDir := setupBootFiles(t)
22+
23+
tests := []struct {
24+
name string
25+
annotations map[string]string // applied to both pipelines
26+
devices []specs.WindowsDevice // vPCI devices for both pipelines
27+
shimOpts func() *runhcsopts.Options // nil = use default
28+
}{
29+
{
30+
name: "default config",
31+
},
32+
{
33+
name: "custom CPU and memory",
34+
annotations: map[string]string{
35+
shimannotations.ProcessorCount: "2",
36+
shimannotations.MemorySizeInMB: "2048",
37+
},
38+
},
39+
{
40+
name: "storage QoS",
41+
annotations: map[string]string{
42+
shimannotations.StorageQoSIopsMaximum: "5000",
43+
shimannotations.StorageQoSBandwidthMaximum: "1000000",
44+
},
45+
},
46+
}
47+
48+
for _, tc := range tests {
49+
t.Run(tc.name, func(t *testing.T) {
50+
t.Parallel()
51+
ctx := context.Background()
52+
53+
// Shim options — identical for both paths.
54+
shimOpts := &runhcsopts.Options{
55+
SandboxPlatform: "linux/amd64",
56+
BootFilesRootPath: bootDir,
57+
}
58+
if tc.shimOpts != nil {
59+
shimOpts = tc.shimOpts()
60+
}
61+
62+
// --- Legacy path ---
63+
// Build OCI spec with annotations and devices, then run the full
64+
// old shim pipeline: UpdateSpecFromOptions → ProcessAnnotations →
65+
// SpecToUVMCreateOpts → MakeLCOWDocument.
66+
legacySpec := specs.Spec{
67+
Annotations: make(map[string]string),
68+
Linux: &specs.Linux{},
69+
Windows: &specs.Windows{
70+
HyperV: &specs.WindowsHyperV{},
71+
Devices: tc.devices,
72+
},
73+
}
74+
for k, v := range tc.annotations {
75+
legacySpec.Annotations[k] = v
76+
}
77+
legacyDoc, legacyOpts, err := buildLegacyDocument(ctx, legacySpec, shimOpts, bootDir)
78+
if err != nil {
79+
t.Fatalf("legacy: %v", err)
80+
}
81+
82+
// --- V2 path ---
83+
// Build vm.Spec with the same annotations and devices, then call
84+
// the v2 builder: BuildSandboxConfig.
85+
v2Spec := &vm.Spec{
86+
Annotations: make(map[string]string),
87+
Devices: tc.devices,
88+
}
89+
for k, v := range tc.annotations {
90+
v2Spec.Annotations[k] = v
91+
}
92+
v2Doc, sandboxOpts, err := buildV2Document(ctx, shimOpts, v2Spec, bootDir)
93+
if err != nil {
94+
t.Fatalf("v2: %v", err)
95+
}
96+
97+
// Log both config structs side by side.
98+
t.Logf("Legacy opts: AllowOvercommit=%v PhysicallyBacked=%v ScratchEncrypt=%v "+
99+
"NoWriteShares=%v PolicyRouting=%v VPMemNoMultiMap=%v",
100+
legacyOpts.AllowOvercommit, legacyOpts.FullyPhysicallyBacked,
101+
legacyOpts.EnableScratchEncryption, legacyOpts.NoWritableFileShares,
102+
legacyOpts.PolicyBasedRouting, legacyOpts.VPMemNoMultiMapping)
103+
t.Logf("V2 sandbox: PhysicallyBacked=%v ScratchEncrypt=%v "+
104+
"NoWriteShares=%v PolicyRouting=%v VPMEMMultiMap=%v Arch=%s",
105+
sandboxOpts.FullyPhysicallyBacked, sandboxOpts.EnableScratchEncryption,
106+
sandboxOpts.NoWritableFileShares, sandboxOpts.PolicyBasedRouting,
107+
sandboxOpts.VPMEMMultiMapping, sandboxOpts.Architecture)
108+
109+
// Normalize and compare.
110+
normalizeDoc(legacyDoc)
111+
normalizeDoc(v2Doc)
112+
113+
if diff := cmp.Diff(legacyDoc, v2Doc); diff != "" {
114+
t.Logf("Legacy doc:\n%s", mustJSON(legacyDoc))
115+
t.Logf("V2 doc:\n%s", mustJSON(v2Doc))
116+
t.Errorf("Document mismatch (-legacy +v2):\n%s", diff)
117+
}
118+
})
119+
}
120+
}
121+
122+
// TestSandboxOptionsFieldParity verifies that config fields match between
123+
// legacy OptionsLCOW and v2 SandboxOptions for default inputs.
124+
func TestSandboxOptionsFieldParity(t *testing.T) {
125+
t.Parallel()
126+
bootDir := setupBootFiles(t)
127+
ctx := context.Background()
128+
129+
shimOpts := &runhcsopts.Options{
130+
SandboxPlatform: "linux/amd64",
131+
BootFilesRootPath: bootDir,
132+
}
133+
134+
// Legacy: build OCI spec inline, run pipeline.
135+
legacySpec := specs.Spec{
136+
Annotations: map[string]string{},
137+
Linux: &specs.Linux{},
138+
Windows: &specs.Windows{HyperV: &specs.WindowsHyperV{}},
139+
}
140+
_, legacyOpts, err := buildLegacyDocument(ctx, legacySpec, shimOpts, bootDir)
141+
if err != nil {
142+
t.Fatalf("legacy: %v", err)
143+
}
144+
145+
// V2: build vm.Spec inline, run builder.
146+
v2Spec := &vm.Spec{Annotations: map[string]string{}}
147+
_, sandboxOpts, err := buildV2Document(ctx, shimOpts, v2Spec, bootDir)
148+
if err != nil {
149+
t.Fatalf("v2: %v", err)
150+
}
151+
152+
checks := []struct {
153+
name string
154+
legacy interface{}
155+
v2 interface{}
156+
}{
157+
{"NoWritableFileShares", legacyOpts.NoWritableFileShares, sandboxOpts.NoWritableFileShares},
158+
{"EnableScratchEncryption", legacyOpts.EnableScratchEncryption, sandboxOpts.EnableScratchEncryption},
159+
{"PolicyBasedRouting", legacyOpts.PolicyBasedRouting, sandboxOpts.PolicyBasedRouting},
160+
{"FullyPhysicallyBacked", legacyOpts.FullyPhysicallyBacked, sandboxOpts.FullyPhysicallyBacked},
161+
{"VPMEMMultiMapping", !legacyOpts.VPMemNoMultiMapping, sandboxOpts.VPMEMMultiMapping},
162+
}
163+
164+
for _, c := range checks {
165+
t.Run(c.name, func(t *testing.T) {
166+
if c.legacy != c.v2 {
167+
t.Errorf("%s mismatch: legacy=%v v2=%v", c.name, c.legacy, c.v2)
168+
}
169+
})
170+
}
171+
}

0 commit comments

Comments
 (0)