Skip to content

Commit d1e04b1

Browse files
committed
test: add CNCF format tests for artifact validation and media type checks
1 parent 9fd1811 commit d1e04b1

1 file changed

Lines changed: 333 additions & 0 deletions

File tree

pkg/distribution/builder/builder_test.go

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package builder_test
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"io"
78
"path/filepath"
@@ -11,6 +12,8 @@ import (
1112

1213
"github.com/docker/model-runner/pkg/distribution/builder"
1314
"github.com/docker/model-runner/pkg/distribution/internal/testutil"
15+
"github.com/docker/model-runner/pkg/distribution/modelpack"
16+
"github.com/docker/model-runner/pkg/distribution/oci"
1417
"github.com/docker/model-runner/pkg/distribution/types"
1518
)
1619

@@ -422,6 +425,336 @@ func TestFromModelErrorHandling(t *testing.T) {
422425
}
423426
}
424427

428+
// TestFromPathCNCFFormat verifies that FromPath with WithFormat(BuildFormatCNCF) produces
429+
// a valid CNCF ModelPack artifact with correct media types, artifact type, and config.
430+
func TestFromPathCNCFFormat(t *testing.T) {
431+
ggufPath := filepath.Join("..", "assets", "dummy.gguf")
432+
fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
433+
434+
b, err := builder.FromPath(ggufPath,
435+
builder.WithFormat(builder.BuildFormatCNCF),
436+
builder.WithCreated(fixedTime),
437+
)
438+
if err != nil {
439+
t.Fatalf("FromPath with CNCF format failed: %v", err)
440+
}
441+
442+
target := &fakeTarget{}
443+
if err := b.Build(t.Context(), target, nil); err != nil {
444+
t.Fatalf("Build failed: %v", err)
445+
}
446+
447+
// 1. Verify manifest has CNCF artifact type.
448+
manifest, err := target.artifact.Manifest()
449+
if err != nil {
450+
t.Fatalf("Failed to get manifest: %v", err)
451+
}
452+
if manifest.ArtifactType != modelpack.ArtifactTypeModelManifest {
453+
t.Errorf("Expected artifactType %q, got %q",
454+
modelpack.ArtifactTypeModelManifest, manifest.ArtifactType)
455+
}
456+
457+
// 2. Verify config media type is CNCF model config.
458+
if manifest.Config.MediaType != oci.MediaType(modelpack.MediaTypeModelConfigV1) {
459+
t.Errorf("Expected config media type %q, got %q",
460+
modelpack.MediaTypeModelConfigV1, manifest.Config.MediaType)
461+
}
462+
463+
// 3. Verify all layers have CNCF media types (not Docker media types).
464+
for i, layer := range manifest.Layers {
465+
mt := string(layer.MediaType)
466+
if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) {
467+
t.Errorf("Layer %d has non-CNCF media type %q (expected prefix %q)",
468+
i, mt, modelpack.MediaTypePrefix)
469+
}
470+
}
471+
472+
// 4. Verify the weight layer specifically uses the CNCF weight media type.
473+
if len(manifest.Layers) == 0 {
474+
t.Fatal("Expected at least one layer")
475+
}
476+
weightMT := manifest.Layers[0].MediaType
477+
if weightMT != oci.MediaType(modelpack.MediaTypeWeightRaw) {
478+
t.Errorf("Expected weight layer media type %q, got %q",
479+
modelpack.MediaTypeWeightRaw, weightMT)
480+
}
481+
482+
// 5. Verify the raw config is valid ModelPack JSON with correct fields.
483+
rawCfg, err := target.artifact.RawConfigFile()
484+
if err != nil {
485+
t.Fatalf("Failed to get raw config: %v", err)
486+
}
487+
var mp modelpack.Model
488+
if err := json.Unmarshal(rawCfg, &mp); err != nil {
489+
t.Fatalf("Failed to unmarshal CNCF config: %v", err)
490+
}
491+
if mp.Config.Format != "gguf" {
492+
t.Errorf("Expected config.format %q, got %q", "gguf", mp.Config.Format)
493+
}
494+
if mp.ModelFS.Type != "layers" {
495+
t.Errorf("Expected modelfs.type %q, got %q", "layers", mp.ModelFS.Type)
496+
}
497+
if len(mp.ModelFS.DiffIDs) == 0 {
498+
t.Error("Expected at least one diffId in modelfs")
499+
}
500+
if mp.Descriptor.CreatedAt == nil {
501+
t.Error("Expected descriptor.createdAt to be set")
502+
} else if !mp.Descriptor.CreatedAt.Equal(fixedTime) {
503+
t.Errorf("Expected descriptor.createdAt %v, got %v", fixedTime, *mp.Descriptor.CreatedAt)
504+
}
505+
506+
// 6. Verify the JSON tags are camelCase (spec-compliant).
507+
var rawMap map[string]json.RawMessage
508+
if err := json.Unmarshal(rawCfg, &rawMap); err != nil {
509+
t.Fatalf("Failed to unmarshal config to map: %v", err)
510+
}
511+
// Must have "modelfs" (not "model_fs").
512+
if _, ok := rawMap["modelfs"]; !ok {
513+
t.Error("Config JSON missing 'modelfs' key")
514+
}
515+
// Verify modelfs contains "diffIds" (camelCase, not "diff_ids").
516+
if modelfsRaw, ok := rawMap["modelfs"]; ok {
517+
var modelfsMap map[string]json.RawMessage
518+
if err := json.Unmarshal(modelfsRaw, &modelfsMap); err != nil {
519+
t.Fatalf("Failed to unmarshal modelfs: %v", err)
520+
}
521+
if _, ok := modelfsMap["diffIds"]; !ok {
522+
t.Error("modelfs JSON missing 'diffIds' key (expected camelCase)")
523+
}
524+
if _, ok := modelfsMap["diff_ids"]; ok {
525+
t.Error("modelfs JSON has 'diff_ids' (snake_case) — should be 'diffIds' (camelCase)")
526+
}
527+
}
528+
// Verify config contains "paramSize" (not "param_size").
529+
if configRaw, ok := rawMap["config"]; ok {
530+
var configMap map[string]json.RawMessage
531+
if err := json.Unmarshal(configRaw, &configMap); err != nil {
532+
t.Fatalf("Failed to unmarshal config section: %v", err)
533+
}
534+
if _, ok := configMap["param_size"]; ok {
535+
t.Error("config JSON has 'param_size' (snake_case) — should be 'paramSize' (camelCase)")
536+
}
537+
}
538+
}
539+
540+
// TestFromPathCNCFWithAdditionalLayers verifies that additional layers added
541+
// to a CNCF builder get CNCF media types, not Docker media types.
542+
func TestFromPathCNCFWithAdditionalLayers(t *testing.T) {
543+
ggufPath := filepath.Join("..", "assets", "dummy.gguf")
544+
545+
b, err := builder.FromPath(ggufPath, builder.WithFormat(builder.BuildFormatCNCF))
546+
if err != nil {
547+
t.Fatalf("FromPath failed: %v", err)
548+
}
549+
550+
// Add license
551+
b, err = b.WithLicense(filepath.Join("..", "assets", "license.txt"))
552+
if err != nil {
553+
t.Fatalf("Failed to add license: %v", err)
554+
}
555+
556+
// Add multimodal projector
557+
b, err = b.WithMultimodalProjector(filepath.Join("..", "assets", "dummy.mmproj"))
558+
if err != nil {
559+
t.Fatalf("Failed to add multimodal projector: %v", err)
560+
}
561+
562+
// Add chat template
563+
b, err = b.WithChatTemplateFile(filepath.Join("..", "assets", "template.jinja"))
564+
if err != nil {
565+
t.Fatalf("Failed to add chat template: %v", err)
566+
}
567+
568+
target := &fakeTarget{}
569+
if err := b.Build(t.Context(), target, nil); err != nil {
570+
t.Fatalf("Build failed: %v", err)
571+
}
572+
573+
manifest, err := target.artifact.Manifest()
574+
if err != nil {
575+
t.Fatalf("Failed to get manifest: %v", err)
576+
}
577+
578+
// Should have 4 layers: weight + license + mmproj + chat template
579+
if len(manifest.Layers) != 4 {
580+
t.Fatalf("Expected 4 layers, got %d", len(manifest.Layers))
581+
}
582+
583+
// ALL layers must have CNCF media type prefix.
584+
for i, layer := range manifest.Layers {
585+
mt := string(layer.MediaType)
586+
if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) {
587+
t.Errorf("Layer %d has non-CNCF media type %q", i, mt)
588+
}
589+
}
590+
591+
// No Docker media types should appear.
592+
dockerMTs := []oci.MediaType{
593+
types.MediaTypeGGUF,
594+
types.MediaTypeLicense,
595+
types.MediaTypeMultimodalProjector,
596+
types.MediaTypeChatTemplate,
597+
}
598+
for _, layer := range manifest.Layers {
599+
for _, dmt := range dockerMTs {
600+
if layer.MediaType == dmt {
601+
t.Errorf("Found Docker media type %q in CNCF artifact", dmt)
602+
}
603+
}
604+
}
605+
}
606+
607+
// TestFromPathCNCFContextSizeError verifies that WithContextSize returns an error
608+
// when the output format is CNCF (context size is not in the CNCF spec).
609+
func TestFromPathCNCFContextSizeError(t *testing.T) {
610+
ggufPath := filepath.Join("..", "assets", "dummy.gguf")
611+
612+
b, err := builder.FromPath(ggufPath, builder.WithFormat(builder.BuildFormatCNCF))
613+
if err != nil {
614+
t.Fatalf("FromPath failed: %v", err)
615+
}
616+
617+
_, err = b.WithContextSize(4096)
618+
if err == nil {
619+
t.Fatal("Expected error when setting context size with CNCF format, got nil")
620+
}
621+
if !strings.Contains(err.Error(), "--context-size is not supported") {
622+
t.Errorf("Expected error about context-size not supported, got: %v", err)
623+
}
624+
}
625+
626+
// TestFromModelToCNCF verifies that FromModel with WithFormat(BuildFormatCNCF) correctly
627+
// converts a Docker-format model to CNCF ModelPack format.
628+
func TestFromModelToCNCF(t *testing.T) {
629+
// Step 1: Create a Docker-format model with a license layer.
630+
dockerBuilder, err := builder.FromPath(filepath.Join("..", "assets", "dummy.gguf"))
631+
if err != nil {
632+
t.Fatalf("FromPath failed: %v", err)
633+
}
634+
dockerBuilder, err = dockerBuilder.WithLicense(filepath.Join("..", "assets", "license.txt"))
635+
if err != nil {
636+
t.Fatalf("WithLicense failed: %v", err)
637+
}
638+
639+
dockerTarget := &fakeTarget{}
640+
if err := dockerBuilder.Build(t.Context(), dockerTarget, nil); err != nil {
641+
t.Fatalf("Build Docker model failed: %v", err)
642+
}
643+
644+
// Verify the Docker model has Docker media types.
645+
dockerManifest, err := dockerTarget.artifact.Manifest()
646+
if err != nil {
647+
t.Fatalf("Failed to get Docker manifest: %v", err)
648+
}
649+
for _, layer := range dockerManifest.Layers {
650+
if strings.HasPrefix(string(layer.MediaType), modelpack.MediaTypePrefix) {
651+
t.Fatalf("Docker model should not have CNCF media types, found %q", layer.MediaType)
652+
}
653+
}
654+
655+
// Step 2: Convert Docker model to CNCF format.
656+
cncfBuilder, err := builder.FromModel(dockerTarget.artifact, builder.WithFormat(builder.BuildFormatCNCF))
657+
if err != nil {
658+
t.Fatalf("FromModel with CNCF format failed: %v", err)
659+
}
660+
661+
cncfTarget := &fakeTarget{}
662+
if err := cncfBuilder.Build(t.Context(), cncfTarget, nil); err != nil {
663+
t.Fatalf("Build CNCF model failed: %v", err)
664+
}
665+
666+
// Step 3: Verify the CNCF model.
667+
cncfManifest, err := cncfTarget.artifact.Manifest()
668+
if err != nil {
669+
t.Fatalf("Failed to get CNCF manifest: %v", err)
670+
}
671+
672+
// Artifact type must be set.
673+
if cncfManifest.ArtifactType != modelpack.ArtifactTypeModelManifest {
674+
t.Errorf("Expected artifactType %q, got %q",
675+
modelpack.ArtifactTypeModelManifest, cncfManifest.ArtifactType)
676+
}
677+
678+
// Config media type must be CNCF.
679+
if cncfManifest.Config.MediaType != oci.MediaType(modelpack.MediaTypeModelConfigV1) {
680+
t.Errorf("Expected config media type %q, got %q",
681+
modelpack.MediaTypeModelConfigV1, cncfManifest.Config.MediaType)
682+
}
683+
684+
// Same number of layers must be preserved.
685+
if len(cncfManifest.Layers) != len(dockerManifest.Layers) {
686+
t.Fatalf("Expected %d layers, got %d", len(dockerManifest.Layers), len(cncfManifest.Layers))
687+
}
688+
689+
// All layers must have CNCF media types.
690+
for i, layer := range cncfManifest.Layers {
691+
mt := string(layer.MediaType)
692+
if !strings.HasPrefix(mt, modelpack.MediaTypePrefix) {
693+
t.Errorf("Layer %d has non-CNCF media type %q after conversion", i, mt)
694+
}
695+
}
696+
697+
// Layer digests should be preserved (same content, different media type).
698+
for i := range dockerManifest.Layers {
699+
if dockerManifest.Layers[i].Digest != cncfManifest.Layers[i].Digest {
700+
t.Errorf("Layer %d digest changed after conversion: %v → %v",
701+
i, dockerManifest.Layers[i].Digest, cncfManifest.Layers[i].Digest)
702+
}
703+
}
704+
705+
// Config should have the model architecture and format.
706+
cfg, err := cncfTarget.artifact.Config()
707+
if err != nil {
708+
t.Fatalf("Failed to get config: %v", err)
709+
}
710+
if cfg.GetFormat() != types.FormatGGUF {
711+
t.Errorf("Expected format %q, got %q", types.FormatGGUF, cfg.GetFormat())
712+
}
713+
}
714+
715+
// TestFromPathCNCFDeterministicDigest verifies that CNCF format builds
716+
// with the same inputs produce the same digests.
717+
func TestFromPathCNCFDeterministicDigest(t *testing.T) {
718+
ggufPath := filepath.Join("..", "assets", "dummy.gguf")
719+
fixedTime := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
720+
721+
b1, err := builder.FromPath(ggufPath,
722+
builder.WithFormat(builder.BuildFormatCNCF),
723+
builder.WithCreated(fixedTime),
724+
)
725+
if err != nil {
726+
t.Fatalf("FromPath (first) failed: %v", err)
727+
}
728+
b2, err := builder.FromPath(ggufPath,
729+
builder.WithFormat(builder.BuildFormatCNCF),
730+
builder.WithCreated(fixedTime),
731+
)
732+
if err != nil {
733+
t.Fatalf("FromPath (second) failed: %v", err)
734+
}
735+
736+
target1 := &fakeTarget{}
737+
target2 := &fakeTarget{}
738+
if err := b1.Build(t.Context(), target1, nil); err != nil {
739+
t.Fatalf("Build (first) failed: %v", err)
740+
}
741+
if err := b2.Build(t.Context(), target2, nil); err != nil {
742+
t.Fatalf("Build (second) failed: %v", err)
743+
}
744+
745+
digest1, err := target1.artifact.Digest()
746+
if err != nil {
747+
t.Fatalf("Digest (first) failed: %v", err)
748+
}
749+
digest2, err := target2.artifact.Digest()
750+
if err != nil {
751+
t.Fatalf("Digest (second) failed: %v", err)
752+
}
753+
if digest1 != digest2 {
754+
t.Errorf("Expected identical digests for CNCF format with same inputs, got %v and %v", digest1, digest2)
755+
}
756+
}
757+
425758
var _ builder.Target = &fakeTarget{}
426759

427760
type fakeTarget struct {

0 commit comments

Comments
 (0)