@@ -2,6 +2,7 @@ package builder_test
22
33import (
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+
425758var _ builder.Target = & fakeTarget {}
426759
427760type fakeTarget struct {
0 commit comments