From 9793f5fbae9c5ee8aa81c1784806875527e111f3 Mon Sep 17 00:00:00 2001 From: Timur Tuktamyshev Date: Thu, 29 Jan 2026 23:28:19 +0300 Subject: [PATCH] feat: add package scan command for PackageRepository Signed-off-by: Timur Tuktamyshev --- internal/system/cmd/package/cmd/package.go | 43 +++ internal/system/cmd/package/cmd/scan/scan.go | 197 ++++++++++++ .../system/cmd/package/cmd/scan/scan_test.go | 283 ++++++++++++++++++ internal/system/cmd/system.go | 2 + 4 files changed, 525 insertions(+) create mode 100644 internal/system/cmd/package/cmd/package.go create mode 100644 internal/system/cmd/package/cmd/scan/scan.go create mode 100644 internal/system/cmd/package/cmd/scan/scan_test.go diff --git a/internal/system/cmd/package/cmd/package.go b/internal/system/cmd/package/cmd/package.go new file mode 100644 index 00000000..e4061215 --- /dev/null +++ b/internal/system/cmd/package/cmd/package.go @@ -0,0 +1,43 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package pkg + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/cmd/package/cmd/scan" +) + +var packageLong = templates.LongDesc(` +Operate the Deckhouse Kubernetes Platform packages. + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + packageCmd := &cobra.Command{ + Use: "package", + Short: "Operate the Deckhouse Kubernetes Platform packages", + Long: packageLong, + } + + packageCmd.AddCommand( + scan.NewCommand(), + ) + + return packageCmd +} diff --git a/internal/system/cmd/package/cmd/scan/scan.go b/internal/system/cmd/package/cmd/scan/scan.go new file mode 100644 index 00000000..88eba907 --- /dev/null +++ b/internal/system/cmd/package/cmd/scan/scan.go @@ -0,0 +1,197 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scan + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/cmd/module/cli" + constants "github.com/deckhouse/deckhouse-cli/internal/system/cmd/module/const" +) + +var packageRepositoryGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1alpha1", + Resource: "packagerepositories", +} + +var packageRepositoryOperationGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1alpha1", + Resource: "packagerepositoryoperations", +} + +var scanLong = templates.LongDesc(` +Create a scan task for a PackageRepository. + +This command creates a PackageRepositoryOperation resource that triggers +a full scan of the specified package repository. + +© Flant JSC 2026`) + +var scanExample = templates.Examples(` + # Scan a package repository named "my-repo" + d8 system package scan my-repo + + # Scan with a custom timeout + d8 system package scan my-repo --timeout 10m + + # Scan with a custom operation name + d8 system package scan my-repo --name my-scan-operation + + # Preview the operation without creating it + d8 system package scan my-repo --dry-run +`) + +type scanOptions struct { + timeout time.Duration + operationName string + dryRun bool +} + +func NewCommand() *cobra.Command { + opts := &scanOptions{} + + scanCmd := &cobra.Command{ + Use: "scan ", + Short: "Create a scan task for a PackageRepository", + Long: scanLong, + Example: scanExample, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeRepositoryNames, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runScan(cmd, args[0], opts) + }, + } + + scanCmd.Flags().DurationVar(&opts.timeout, "timeout", 5*time.Minute, "Timeout for the scan operation") + scanCmd.Flags().StringVar(&opts.operationName, "name", "", "Custom name for the PackageRepositoryOperation (auto-generated if not specified)") + scanCmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Preview the operation without creating it") + + return scanCmd +} + +func completeRepositoryNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + dynamicClient, err := cli.GetDynamicClient(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultAPITimeout) + defer cancel() + + repoClient := dynamicClient.Resource(packageRepositoryGVR) + list, err := repoClient.List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var names []string + for _, item := range list.Items { + name := item.GetName() + if strings.HasPrefix(name, toComplete) { + names = append(names, name) + } + } + + return names, cobra.ShellCompDirectiveNoFileComp +} + +func runScan(cmd *cobra.Command, repositoryName string, opts *scanOptions) error { + dynamicClient, err := cli.GetDynamicClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultAPITimeout) + defer cancel() + + repoClient := dynamicClient.Resource(packageRepositoryGVR) + if _, err := repoClient.Get(ctx, repositoryName, metav1.GetOptions{}); err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("PackageRepository '%s' not found", repositoryName) + } + return fmt.Errorf("failed to get PackageRepository: %w", err) + } + + operationName := opts.operationName + if operationName == "" { + operationName = fmt.Sprintf("%s-scan-manual-%d", repositoryName, time.Now().UnixNano()) + } + + operation := buildPackageRepositoryOperation(operationName, repositoryName, opts.timeout) + + if opts.dryRun { + yamlBytes, err := yaml.Marshal(operation.Object) + if err != nil { + return fmt.Errorf("failed to marshal operation: %w", err) + } + fmt.Printf("%s Would create PackageRepositoryOperation:\n---\n%s", cli.MsgInfo, string(yamlBytes)) + return nil + } + + operationClient := dynamicClient.Resource(packageRepositoryOperationGVR) + created, err := operationClient.Create(ctx, operation, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("failed to create PackageRepositoryOperation: %w", err) + } + + fmt.Printf("%s Created PackageRepositoryOperation '%s' for repository '%s'\n", + cli.MsgInfo, created.GetName(), repositoryName) + + return nil +} + +func buildPackageRepositoryOperation(name, repositoryName string, timeout time.Duration) *unstructured.Unstructured { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "PackageRepositoryOperation", + "metadata": map[string]interface{}{ + "name": name, + "annotations": map[string]interface{}{ + "deckhouse.io/created-by": "deckhouse-cli", + }, + }, + "spec": map[string]interface{}{ + "packageRepositoryName": repositoryName, + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": timeout.String(), + }, + }, + }, + } +} diff --git a/internal/system/cmd/package/cmd/scan/scan_test.go b/internal/system/cmd/package/cmd/scan/scan_test.go new file mode 100644 index 00000000..3bd07867 --- /dev/null +++ b/internal/system/cmd/package/cmd/scan/scan_test.go @@ -0,0 +1,283 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package scan + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +func TestBuildPackageRepositoryOperation(t *testing.T) { + tests := []struct { + name string + operationName string + repositoryName string + timeout time.Duration + wantSpec map[string]interface{} + }{ + { + name: "basic operation", + operationName: "test-scan-manual-1", + repositoryName: "test", + timeout: 5 * time.Minute, + wantSpec: map[string]interface{}{ + "packageRepositoryName": "test", + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": "5m0s", + }, + }, + }, + { + name: "custom timeout", + operationName: "my-repo-scan-manual-123", + repositoryName: "my-repo", + timeout: 10 * time.Minute, + wantSpec: map[string]interface{}{ + "packageRepositoryName": "my-repo", + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": "10m0s", + }, + }, + }, + { + name: "repository name with dashes", + operationName: "my-awesome-repo-scan-manual-456", + repositoryName: "my-awesome-repo", + timeout: 5 * time.Minute, + wantSpec: map[string]interface{}{ + "packageRepositoryName": "my-awesome-repo", + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": "5m0s", + }, + }, + }, + { + name: "repository name with dots", + operationName: "repo.example.com-scan-manual-789", + repositoryName: "repo.example.com", + timeout: 1 * time.Hour, + wantSpec: map[string]interface{}{ + "packageRepositoryName": "repo.example.com", + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": "1h0m0s", + }, + }, + }, + { + name: "short timeout", + operationName: "quick-scan", + repositoryName: "test", + timeout: 30 * time.Second, + wantSpec: map[string]interface{}{ + "packageRepositoryName": "test", + "type": "Update", + "update": map[string]interface{}{ + "fullScan": true, + "timeout": "30s", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildPackageRepositoryOperation(tt.operationName, tt.repositoryName, tt.timeout) + + if got.GetAPIVersion() != "deckhouse.io/v1alpha1" { + t.Errorf("apiVersion = %v, want deckhouse.io/v1alpha1", got.GetAPIVersion()) + } + + if got.GetKind() != "PackageRepositoryOperation" { + t.Errorf("kind = %v, want PackageRepositoryOperation", got.GetKind()) + } + + if got.GetName() != tt.operationName { + t.Errorf("name = %v, want %v", got.GetName(), tt.operationName) + } + + annotations := got.GetAnnotations() + if annotations["deckhouse.io/created-by"] != "deckhouse-cli" { + t.Errorf("annotation deckhouse.io/created-by = %v, want deckhouse-cli", annotations["deckhouse.io/created-by"]) + } + + spec, found, err := unstructured.NestedMap(got.Object, "spec") + if err != nil || !found { + t.Fatalf("spec not found: %v", err) + } + + if spec["packageRepositoryName"] != tt.wantSpec["packageRepositoryName"] { + t.Errorf("spec.packageRepositoryName = %v, want %v", spec["packageRepositoryName"], tt.wantSpec["packageRepositoryName"]) + } + + if spec["type"] != tt.wantSpec["type"] { + t.Errorf("spec.type = %v, want %v", spec["type"], tt.wantSpec["type"]) + } + + update, found, err := unstructured.NestedMap(got.Object, "spec", "update") + if err != nil || !found { + t.Fatalf("spec.update not found: %v", err) + } + + wantUpdate := tt.wantSpec["update"].(map[string]interface{}) + if update["fullScan"] != wantUpdate["fullScan"] { + t.Errorf("spec.update.fullScan = %v, want %v", update["fullScan"], wantUpdate["fullScan"]) + } + + if update["timeout"] != wantUpdate["timeout"] { + t.Errorf("spec.update.timeout = %v, want %v", update["timeout"], wantUpdate["timeout"]) + } + }) + } +} + +func TestNewCommand(t *testing.T) { + cmd := NewCommand() + + if cmd.Use != "scan " { + t.Errorf("Use = %v, want 'scan '", cmd.Use) + } + + if cmd.ValidArgsFunction == nil { + t.Error("ValidArgsFunction should be set for shell completion") + } + + timeoutFlag := cmd.Flags().Lookup("timeout") + if timeoutFlag == nil { + t.Error("timeout flag not found") + } + if timeoutFlag.DefValue != "5m0s" { + t.Errorf("timeout default = %v, want 5m0s", timeoutFlag.DefValue) + } + + nameFlag := cmd.Flags().Lookup("name") + if nameFlag == nil { + t.Error("name flag not found") + } + + dryRunFlag := cmd.Flags().Lookup("dry-run") + if dryRunFlag == nil { + t.Error("dry-run flag not found") + } + if dryRunFlag.DefValue != "false" { + t.Errorf("dry-run default = %v, want false", dryRunFlag.DefValue) + } +} + +func TestCompleteRepositoryNames(t *testing.T) { + scheme := runtime.NewScheme() + + tests := []struct { + name string + repos []string + toComplete string + wantCount int + wantNames map[string]bool + }{ + { + name: "complete all repos", + repos: []string{"repo1", "repo2", "my-repo"}, + toComplete: "", + wantCount: 3, + wantNames: map[string]bool{"repo1": true, "repo2": true, "my-repo": true}, + }, + { + name: "complete with prefix", + repos: []string{"repo1", "repo2", "my-repo"}, + toComplete: "repo", + wantCount: 2, + wantNames: map[string]bool{"repo1": true, "repo2": true}, + }, + { + name: "complete with no match", + repos: []string{"repo1", "repo2"}, + toComplete: "xyz", + wantCount: 0, + wantNames: map[string]bool{}, + }, + { + name: "empty repo list", + repos: []string{}, + toComplete: "", + wantCount: 0, + wantNames: map[string]bool{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var objects []runtime.Object + for _, repoName := range tt.repos { + objects = append(objects, &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "deckhouse.io/v1alpha1", + "kind": "PackageRepository", + "metadata": map[string]interface{}{ + "name": repoName, + }, + }, + }) + } + + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{ + packageRepositoryGVR: "PackageRepositoryList", + }, + objects...) + + ctx := context.Background() + repoClient := client.Resource(packageRepositoryGVR) + list, err := repoClient.List(ctx, metav1.ListOptions{}) + if err != nil { + t.Fatalf("failed to list repos: %v", err) + } + + var gotNames []string + for _, item := range list.Items { + name := item.GetName() + if tt.toComplete == "" || len(name) >= len(tt.toComplete) && name[:len(tt.toComplete)] == tt.toComplete { + gotNames = append(gotNames, name) + } + } + + if len(gotNames) != tt.wantCount { + t.Errorf("got %d names, want %d", len(gotNames), tt.wantCount) + return + } + + for _, name := range gotNames { + if !tt.wantNames[name] { + t.Errorf("unexpected name %q in results", name) + } + } + }) + } +} diff --git a/internal/system/cmd/system.go b/internal/system/cmd/system.go index 2b360d91..1447afac 100644 --- a/internal/system/cmd/system.go +++ b/internal/system/cmd/system.go @@ -24,6 +24,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/system/cmd/get" "github.com/deckhouse/deckhouse-cli/internal/system/cmd/logs" module "github.com/deckhouse/deckhouse-cli/internal/system/cmd/module/cmd" + pkg "github.com/deckhouse/deckhouse-cli/internal/system/cmd/package/cmd" queue "github.com/deckhouse/deckhouse-cli/internal/system/cmd/queue" "github.com/deckhouse/deckhouse-cli/internal/system/flags" ) @@ -48,6 +49,7 @@ func NewCommand() *cobra.Command { edit.NewCommand(), get.NewCommand(), module.NewCommand(), + pkg.NewCommand(), collectdebuginfo.NewCommand(), queue.NewCommand(), logs.NewCommand(),