-
Notifications
You must be signed in to change notification settings - Fork 4
Add lint-package debug command for engines, runtimes and models #394
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
cb14593
4d37797
2bd80ef
22ff171
e6f3aa0
deac7c5
a36ebd6
449b7e0
3b47fab
fb44cb0
7ffa0e0
2796667
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| ) | ||
|
|
||
|
|
@@ -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), | ||
| ValidArgsFunction: cobra.NoFileCompletions, | ||
| RunE: cmd.run, | ||
| } | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||
| } | ||
|
mrmara marked this conversation as resolved.
|
||
| return nil | ||
| } | ||
| 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) | ||
| } | ||
|
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") | ||
| } | ||
|
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) | ||
| } | ||
|
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") | ||
| } | ||
|
mrmara marked this conversation as resolved.
|
||
| if !diskSizePattern.MatchString(manifest.DiskSize) { | ||
| return fmt.Errorf("invalid disk-size format: %s", manifest.DiskSize) | ||
| } | ||
|
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 | ||
|
mrmara marked this conversation as resolved.
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.