Skip to content
3 changes: 2 additions & 1 deletion cmd/modelctl/commands/debug/serve-webui.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"

"github.com/canonical/inference-snaps-cli/v2/cmd/modelctl/common"
"github.com/canonical/inference-snaps-cli/v2/pkg/models"
"github.com/canonical/inference-snaps-cli/v2/pkg/webui"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -45,7 +46,7 @@ func (cmd *serveWebUiCommand) serveWebUi(_ *cobra.Command, args []string) error

config := webui.Config{
OpenAIBaseURL: cmd.baseUrl,
Capabilities: common.SupportedCapabilities(), // set all capabilities for debugging
Capabilities: models.SupportedCapabilities(), // set all capabilities for debugging
InstanceName: "debug",
EngineName: "unset",
}
Expand Down
48 changes: 36 additions & 12 deletions cmd/modelctl/commands/debug/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package debug

import (
"fmt"
"path/filepath"

"github.com/canonical/inference-snaps-cli/v2/cmd/modelctl/common"
"github.com/canonical/inference-snaps-cli/v2/pkg/engines"
"github.com/canonical/inference-snaps-cli/v2/pkg/models"
"github.com/canonical/inference-snaps-cli/v2/pkg/runtimes"
"github.com/spf13/cobra"
)

Expand All @@ -17,9 +20,9 @@ func ValidateCommand(ctx *common.Context) *cobra.Command {
cmd.Context = ctx

cobraCmd := &cobra.Command{
Use: "validate-engines",
Short: "Validate engine manifest files",
Args: cobra.MinimumNArgs(1),
Use: "lint-package",
Short: "Validate engines, models, and runtimes manifest files",
Args: cobra.ExactArgs(1),
Comment thread
mrmara marked this conversation as resolved.
ValidArgsFunction: cobra.NoFileCompletions,
RunE: cmd.run,
}
Expand All @@ -28,23 +31,44 @@ func ValidateCommand(ctx *common.Context) *cobra.Command {
}

func (cmd *validateCommand) run(_ *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("no engine manifest specified")
allManifestsValid := true
snapDir := args[0]

validators := []struct {
subdir string
manifestFilename string
validate func(string) error
}{
{"engines", engines.ManifestFilename, engines.Validate},
{"models", models.ManifestFilename, models.Validate},
{"runtimes", runtimes.ManifestFilename, runtimes.Validate},
}

allManifestsValid := true
for _, manifestPath := range args {
err := engines.Validate(manifestPath)
for _, v := range validators {
pattern := filepath.Join(snapDir, v.subdir, "*", v.manifestFilename)
manifestPaths, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("invalid glob pattern %s: %w", pattern, err)
}

if len(manifestPaths) == 0 {
allManifestsValid = false
fmt.Printf("❌ %s: %s\n", manifestPath, err)
} else {
fmt.Printf("✅ %s\n", manifestPath)
fmt.Printf("❌ %s: no manifests found\n", filepath.Join(snapDir, v.subdir))
continue
}
Comment on lines +47 to +58

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider both runtimes and models optional. There could be an engine without a runtime, delivering just optimized weights or an engine with a combined runtime+model, using llamafiles. I see runtimes and models treated as an expected fields for engines elsewhere in the code so there has been an architectural regression. In this case, let's leave this for now.


for _, manifestPath := range manifestPaths {
if err := v.validate(manifestPath); err != nil {
allManifestsValid = false
fmt.Printf("❌ %s: %s\n", manifestPath, err)
} else {
fmt.Printf("✅ %s\n", manifestPath)
}
}
}

if !allManifestsValid {
return fmt.Errorf("not all manifests are valid")
return fmt.Errorf("some manifests are invalid")
}
Comment thread
mrmara marked this conversation as resolved.
return nil
}
11 changes: 0 additions & 11 deletions cmd/modelctl/common/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ import (
"github.com/canonical/inference-snaps-cli/v2/pkg/utils"
)

const (
capabilityText string = "text"
capabilityVision string = "vision"
capabilityTools string = "tools"
capabilityThinking string = "thinking"
)

type ModelDetails struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Expand All @@ -31,10 +24,6 @@ type ModelDetails struct {
Components []string `json:"components" yaml:"components"`
}

func SupportedCapabilities() []string {
return []string{capabilityText, capabilityVision, capabilityTools, capabilityThinking}
}

func NewModelDetails(manifest *models.Manifest) (ModelDetails, error) {
var modelDetails ModelDetails
modelDetails.ID = manifest.ID
Expand Down
11 changes: 11 additions & 0 deletions pkg/models/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import (
"github.com/canonical/inference-snaps-cli/v2/pkg/engines"
)

const (
capabilityText string = "text"
capabilityVision string = "vision"
capabilityTools string = "tools"
capabilityThinking string = "thinking"
)

type Manifest struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name" yaml:"name"`
Expand All @@ -21,3 +28,7 @@ type Manifest struct {

Layout map[string]engines.Layout `json:"layout,omitempty" yaml:"layout,omitempty"`
}

func SupportedCapabilities() []string {
return []string{capabilityText, capabilityVision, capabilityTools, capabilityThinking}
}
Comment thread
mrmara marked this conversation as resolved.
135 changes: 135 additions & 0 deletions pkg/models/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package models

import (
"bytes"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"slices"

"github.com/canonical/inference-snaps-cli/v2/pkg/utils"
"go.yaml.in/yaml/v4"
)

// diskSizePattern matches a human-readable size such as "6G" or "512M".
var diskSizePattern = regexp.MustCompile(`^\d+(\.\d+)?(K|M|G|T)i?B?$`)

func Validate(manifestFilePath string) error {

if filepath.Base(manifestFilePath) != ManifestFilename {
return fmt.Errorf("manifest file must be called %s: %s", ManifestFilename, manifestFilePath)
}
Comment thread
Copilot marked this conversation as resolved.

_, err := os.Stat(manifestFilePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("manifest file does not exist: %s", manifestFilePath)
}
return fmt.Errorf("getting file info: %v", err)
}

yamlData, err := os.ReadFile(manifestFilePath)
if err != nil {
return fmt.Errorf("reading file: %v", err)
}

// Get model ID from the directory name
modelId := modelIdFromPath(manifestFilePath)

return validateManifestYaml(modelId, yamlData)
}

func modelIdFromPath(manifestFilePath string) string {
parts := utils.SplitPathIntoDirectories(manifestFilePath)
if len(parts) < 2 {
return ""
}
return parts[len(parts)-2] // second last part: model-id/model.yaml
}

func validateManifestYaml(expectedName string, yamlData []byte) error {
yamlData = bytes.TrimSpace(yamlData)
if len(yamlData) == 0 {
return errors.New("empty yaml data")
}

var manifest Manifest

yamlDecoder := yaml.NewDecoder(bytes.NewReader(yamlData))

// Error if there are unknown fields in the yaml
yamlDecoder.KnownFields(true)

// We depend on the yaml unmarshal to check field types
if err := yamlDecoder.Decode(&manifest); err != nil {
return fmt.Errorf("decoding manifest: %v", err)
}

return manifest.validate(expectedName)
}

func (manifest Manifest) validate(expectedModelId string) error {
if manifest.ID == "" {
return fmt.Errorf("required field is not set: id")
}

if expectedModelId != "" {
if manifest.ID != expectedModelId {
return fmt.Errorf("model directory name should match id in manifest: %s != %s", expectedModelId, manifest.ID)
}
}

if manifest.Name == "" {
return fmt.Errorf("required field is not set: name")
}

if manifest.Description == "" {
return fmt.Errorf("required field is not set: description")
}

if manifest.ModelCardUrl == "" {
return fmt.Errorf("required field is not set: model-card-url")
}
Comment thread
mrmara marked this conversation as resolved.
if u, err := url.ParseRequestURI(manifest.ModelCardUrl); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("invalid model-card-url: %s", manifest.ModelCardUrl)
}
Comment thread
mrmara marked this conversation as resolved.

if manifest.Quantization == "" {
return fmt.Errorf("required field is not set: quantization")
}

if len(manifest.Capabilities) == 0 {
return fmt.Errorf("required field is not set: capabilities")
}
for _, cap := range manifest.Capabilities {
if !slices.Contains(SupportedCapabilities(), cap) {
return fmt.Errorf("unsupported capability: %q", cap)
}
}

if manifest.DiskSize == "" {
return fmt.Errorf("required field is not set: disk-size")
}
Comment thread
mrmara marked this conversation as resolved.
if !diskSizePattern.MatchString(manifest.DiskSize) {
return fmt.Errorf("invalid disk-size format: %s", manifest.DiskSize)
}
Comment thread
mrmara marked this conversation as resolved.

if len(manifest.Components) == 0 {
return fmt.Errorf("required field is not set: components")
}

if len(manifest.Environment) == 0 {
return fmt.Errorf("required field is not set: environment")
}

for target, layout := range manifest.Layout {
if layout.Symlink == "" {
return fmt.Errorf("layout %q: required field is not set: symlink", target)
}
}

return nil
Comment thread
mrmara marked this conversation as resolved.
}
Loading
Loading