From d3f1b821f76ca71a2a606ac22cd2d03ea9631f6c Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 24 Dec 2025 21:33:52 +0100 Subject: [PATCH] [cozypkg] Add tool for managing Package and PackageSources Signed-off-by: Andrei Kvapil --- cmd/cozypkg/cmd/add.go | 549 +++++++++++++++++++++++++++++++ cmd/cozypkg/cmd/del.go | 396 ++++++++++++++++++++++ cmd/cozypkg/cmd/dependencies.go | 564 ++++++++++++++++++++++++++++++++ cmd/cozypkg/cmd/list.go | 220 +++++++++++++ cmd/cozypkg/cmd/root.go | 49 +++ cmd/cozypkg/main.go | 30 ++ go.mod | 1 + go.sum | 2 + 8 files changed, 1811 insertions(+) create mode 100644 cmd/cozypkg/cmd/add.go create mode 100644 cmd/cozypkg/cmd/del.go create mode 100644 cmd/cozypkg/cmd/dependencies.go create mode 100644 cmd/cozypkg/cmd/list.go create mode 100644 cmd/cozypkg/cmd/root.go create mode 100644 cmd/cozypkg/main.go diff --git a/cmd/cozypkg/cmd/add.go b/cmd/cozypkg/cmd/add.go new file mode 100644 index 00000000..055da3f0 --- /dev/null +++ b/cmd/cozypkg/cmd/add.go @@ -0,0 +1,549 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/spf13/cobra" + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + 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/serializer/yaml" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + _ "k8s.io/client-go/plugin/pkg/client/auth" +) + +var addCmdFlags struct { + files []string + kubeconfig string +} + +var addCmd = &cobra.Command{ + Use: "add [package]...", + Short: "Install PackageSource and its dependencies interactively", + Long: `Install PackageSource and its dependencies interactively. + +You can specify packages as arguments or use -f flag to read from files. +Multiple -f flags can be specified, and they can point to files or directories.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Collect package names from arguments and files + packageNames := make(map[string]bool) + packagesFromFiles := make(map[string]string) // packageName -> filePath + + for _, arg := range args { + packageNames[arg] = true + } + + // Read packages from files + for _, filePath := range addCmdFlags.files { + packages, err := readPackagesFromFile(filePath) + if err != nil { + return fmt.Errorf("failed to read packages from %s: %w", filePath, err) + } + for _, pkg := range packages { + packageNames[pkg] = true + if oldPath, ok := packagesFromFiles[pkg]; ok { + fmt.Fprintf(os.Stderr, "warning: package %q is defined in both %s and %s, using the latter\n", pkg, oldPath, filePath) + } + packagesFromFiles[pkg] = filePath + } + } + + if len(packageNames) == 0 { + return fmt.Errorf("no packages specified") + } + + // Create Kubernetes client config + var config *rest.Config + var err error + + if addCmdFlags.kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags("", addCmdFlags.kubeconfig) + if err != nil { + return fmt.Errorf("failed to load kubeconfig from %s: %w", addCmdFlags.kubeconfig, err) + } + } else { + config, err = ctrl.GetConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + } + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(cozyv1alpha1.AddToScheme(scheme)) + + k8sClient, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create k8s client: %w", err) + } + + // Process each package + for packageName := range packageNames { + // Check if package comes from a file + if filePath, fromFile := packagesFromFiles[packageName]; fromFile { + // Try to create Package directly from file + if err := createPackageFromFile(ctx, k8sClient, filePath, packageName); err == nil { + fmt.Fprintf(os.Stderr, "✓ Added Package %s\n", packageName) + continue + } + // If failed, fall back to interactive installation + } + + // Interactive installation from PackageSource + if err := installPackage(ctx, k8sClient, packageName); err != nil { + return err + } + } + + return nil + }, +} + +func readPackagesFromFile(filePath string) ([]string, error) { + info, err := os.Stat(filePath) + if err != nil { + return nil, err + } + + var packages []string + + if info.IsDir() { + // Read all YAML files from directory + err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { + return nil + } + + pkgs, err := readPackagesFromYAMLFile(path) + if err != nil { + return fmt.Errorf("failed to read %s: %w", path, err) + } + packages = append(packages, pkgs...) + return nil + }) + if err != nil { + return nil, err + } + } else { + packages, err = readPackagesFromYAMLFile(filePath) + if err != nil { + return nil, err + } + } + + return packages, nil +} + +func readPackagesFromYAMLFile(filePath string) ([]string, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var packages []string + + // Split YAML documents (in case of multiple resources) + documents := strings.Split(string(data), "---") + + for _, doc := range documents { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + // Parse using Kubernetes decoder + decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + obj := &unstructured.Unstructured{} + _, _, err := decoder.Decode([]byte(doc), nil, obj) + if err != nil { + continue + } + + // Check if it's a Package + if obj.GetKind() == "Package" { + name := obj.GetName() + if name != "" { + packages = append(packages, name) + } + continue + } + + // Check if it's a PackageSource + if obj.GetKind() == "PackageSource" { + name := obj.GetName() + if name != "" { + packages = append(packages, name) + } + continue + } + + // Try to parse as PackageList or PackageSourceList + if obj.GetKind() == "PackageList" || obj.GetKind() == "PackageSourceList" { + items, found, err := unstructured.NestedSlice(obj.Object, "items") + if err == nil && found { + for _, item := range items { + if itemMap, ok := item.(map[string]interface{}); ok { + if metadata, ok := itemMap["metadata"].(map[string]interface{}); ok { + if name, ok := metadata["name"].(string); ok && name != "" { + packages = append(packages, name) + } + } + } + } + } + continue + } + } + + // Return empty list if no packages found - don't error out + // The check for whether any packages were specified at all is handled later in RunE + return packages, nil +} + +// buildDependencyTree builds a dependency tree starting from the root PackageSource +// Returns both the dependency tree and a map of dependencies to their requesters +func buildDependencyTree(ctx context.Context, k8sClient client.Client, rootName string) (map[string][]string, map[string]string, error) { + tree := make(map[string][]string) + dependencyRequesters := make(map[string]string) // dep -> requester + visited := make(map[string]bool) + + // Ensure root is in tree even if it has no dependencies + tree[rootName] = []string{} + + var buildTree func(string) error + buildTree = func(pkgName string) error { + if visited[pkgName] { + return nil + } + visited[pkgName] = true + + // Get PackageSource + ps := &cozyv1alpha1.PackageSource{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: pkgName}, ps); err != nil { + // If PackageSource doesn't exist, just skip it + return nil + } + + // Collect all dependencies from all variants + deps := make(map[string]bool) + for _, variant := range ps.Spec.Variants { + for _, dep := range variant.DependsOn { + deps[dep] = true + } + } + + // Add dependencies to tree + for dep := range deps { + if _, exists := tree[pkgName]; !exists { + tree[pkgName] = []string{} + } + tree[pkgName] = append(tree[pkgName], dep) + // Track who requested this dependency + dependencyRequesters[dep] = pkgName + // Recursively build tree for dependencies + if err := buildTree(dep); err != nil { + return err + } + } + + return nil + } + + if err := buildTree(rootName); err != nil { + return nil, nil, err + } + + return tree, dependencyRequesters, nil +} + +// topologicalSort performs topological sort on the dependency tree +// Returns order from root to leaves (dependencies first) +func topologicalSort(tree map[string][]string) ([]string, error) { + // Build reverse graph (dependencies -> dependents) + reverseGraph := make(map[string][]string) + allNodes := make(map[string]bool) + + for node, deps := range tree { + allNodes[node] = true + for _, dep := range deps { + allNodes[dep] = true + reverseGraph[dep] = append(reverseGraph[dep], node) + } + } + + // Calculate in-degrees (how many dependencies a node has) + inDegree := make(map[string]int) + for node := range allNodes { + inDegree[node] = 0 + } + for node, deps := range tree { + inDegree[node] = len(deps) + } + + // Kahn's algorithm - start with nodes that have no dependencies + var queue []string + for node, degree := range inDegree { + if degree == 0 { + queue = append(queue, node) + } + } + + var result []string + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + result = append(result, node) + + // Process dependents (nodes that depend on this node) + for _, dependent := range reverseGraph[node] { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + queue = append(queue, dependent) + } + } + } + + // Check for cycles + if len(result) != len(allNodes) { + return nil, fmt.Errorf("dependency cycle detected") + } + + return result, nil +} + +// createPackageFromFile creates a Package resource directly from a YAML file +func createPackageFromFile(ctx context.Context, k8sClient client.Client, filePath string, packageName string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return err + } + + // Split YAML documents + documents := strings.Split(string(data), "---") + + for _, doc := range documents { + doc = strings.TrimSpace(doc) + if doc == "" { + continue + } + + // Parse using Kubernetes decoder + decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + obj := &unstructured.Unstructured{} + _, _, err := decoder.Decode([]byte(doc), nil, obj) + if err != nil { + continue + } + + // Check if it's a Package with matching name + if obj.GetKind() == "Package" && obj.GetName() == packageName { + // Convert to Package + var pkg cozyv1alpha1.Package + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &pkg); err != nil { + return fmt.Errorf("failed to convert Package: %w", err) + } + + // Create Package + if err := k8sClient.Create(ctx, &pkg); err != nil { + return fmt.Errorf("failed to create Package: %w", err) + } + + return nil + } + } + + return fmt.Errorf("Package %s not found in file", packageName) +} + +func installPackage(ctx context.Context, k8sClient client.Client, packageSourceName string) error { + // Get PackageSource + packageSource := &cozyv1alpha1.PackageSource{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: packageSourceName}, packageSource); err != nil { + return fmt.Errorf("failed to get PackageSource %s: %w", packageSourceName, err) + } + + // Build dependency tree + dependencyTree, dependencyRequesters, err := buildDependencyTree(ctx, k8sClient, packageSourceName) + if err != nil { + return fmt.Errorf("failed to build dependency tree: %w", err) + } + + // Topological sort (install from root to leaves) + installOrder, err := topologicalSort(dependencyTree) + if err != nil { + return fmt.Errorf("failed to sort dependencies: %w", err) + } + + // Get all PackageSources for variant selection + var allPackageSources cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &allPackageSources); err != nil { + return fmt.Errorf("failed to list PackageSources: %w", err) + } + + packageSourceMap := make(map[string]*cozyv1alpha1.PackageSource) + for i := range allPackageSources.Items { + packageSourceMap[allPackageSources.Items[i].Name] = &allPackageSources.Items[i] + } + + // Get all installed Packages + var installedPackages cozyv1alpha1.PackageList + if err := k8sClient.List(ctx, &installedPackages); err != nil { + return fmt.Errorf("failed to list Packages: %w", err) + } + + installedMap := make(map[string]*cozyv1alpha1.Package) + for i := range installedPackages.Items { + installedMap[installedPackages.Items[i].Name] = &installedPackages.Items[i] + } + + // First, collect all variant selections + fmt.Fprintf(os.Stderr, "Installing %s and its dependencies...\n\n", packageSourceName) + packageVariants := make(map[string]string) // packageName -> variant + + for _, pkgName := range installOrder { + // Check if already installed + if installed, exists := installedMap[pkgName]; exists { + variant := installed.Spec.Variant + if variant == "" { + variant = "default" + } + fmt.Fprintf(os.Stderr, "✓ %s (already installed, variant: %s)\n", pkgName, variant) + packageVariants[pkgName] = variant + continue + } + + // Get PackageSource for this dependency + ps, exists := packageSourceMap[pkgName] + if !exists { + requester := dependencyRequesters[pkgName] + if requester != "" { + return fmt.Errorf("PackageSource %s not found (required by %s)", pkgName, requester) + } + return fmt.Errorf("PackageSource %s not found", pkgName) + } + + // Select variant interactively + variant, err := selectVariantInteractive(ps) + if err != nil { + return fmt.Errorf("failed to select variant for %s: %w", pkgName, err) + } + + packageVariants[pkgName] = variant + } + + // Now create all Package resources + for _, pkgName := range installOrder { + // Skip if already installed + if _, exists := installedMap[pkgName]; exists { + continue + } + + variant := packageVariants[pkgName] + + // Create Package + pkg := &cozyv1alpha1.Package{ + ObjectMeta: metav1.ObjectMeta{ + Name: pkgName, + }, + Spec: cozyv1alpha1.PackageSpec{ + Variant: variant, + }, + } + + if err := k8sClient.Create(ctx, pkg); err != nil { + return fmt.Errorf("failed to create Package %s: %w", pkgName, err) + } + + fmt.Fprintf(os.Stderr, "✓ Added Package %s\n", pkgName) + } + + return nil +} + +// selectVariantInteractive prompts user to select a variant +func selectVariantInteractive(ps *cozyv1alpha1.PackageSource) (string, error) { + if len(ps.Spec.Variants) == 0 { + return "", fmt.Errorf("no variants available for PackageSource %s", ps.Name) + } + + reader := bufio.NewReader(os.Stdin) + + fmt.Fprintf(os.Stderr, "\nPackageSource: %s\n", ps.Name) + fmt.Fprintf(os.Stderr, "Available variants:\n") + for i, variant := range ps.Spec.Variants { + fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, variant.Name) + } + + // If only one variant, use it as default + defaultVariant := ps.Spec.Variants[0].Name + var prompt string + if len(ps.Spec.Variants) == 1 { + prompt = "Select variant [1]: " + } else { + prompt = fmt.Sprintf("Select variant (1-%d): ", len(ps.Spec.Variants)) + } + + for { + fmt.Fprintf(os.Stderr, prompt) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(input) + + // If input is empty and there's a default variant, use it + if input == "" && len(ps.Spec.Variants) == 1 { + return defaultVariant, nil + } + + choice, err := strconv.Atoi(input) + if err != nil || choice < 1 || choice > len(ps.Spec.Variants) { + fmt.Fprintf(os.Stderr, "Invalid choice. Please enter a number between 1 and %d.\n", len(ps.Spec.Variants)) + continue + } + + return ps.Spec.Variants[choice-1].Name, nil + } +} + +func init() { + rootCmd.AddCommand(addCmd) + addCmd.Flags().StringArrayVarP(&addCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)") + addCmd.Flags().StringVar(&addCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)") +} + diff --git a/cmd/cozypkg/cmd/del.go b/cmd/cozypkg/cmd/del.go new file mode 100644 index 00000000..6b2e37f5 --- /dev/null +++ b/cmd/cozypkg/cmd/del.go @@ -0,0 +1,396 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + _ "k8s.io/client-go/plugin/pkg/client/auth" +) + +var delCmdFlags struct { + files []string + kubeconfig string +} + +var delCmd = &cobra.Command{ + Use: "del [package]...", + Short: "Delete Package resources", + Long: `Delete Package resources. + +You can specify packages as arguments or use -f flag to read from files. +Multiple -f flags can be specified, and they can point to files or directories.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Collect package names from arguments and files + packageNames := make(map[string]bool) + packagesFromFiles := make(map[string]string) // packageName -> filePath + + for _, arg := range args { + packageNames[arg] = true + } + + // Read packages from files (reuse function from add.go) + for _, filePath := range delCmdFlags.files { + packages, err := readPackagesFromFile(filePath) + if err != nil { + return fmt.Errorf("failed to read packages from %s: %w", filePath, err) + } + for _, pkg := range packages { + packageNames[pkg] = true + if oldPath, ok := packagesFromFiles[pkg]; ok { + fmt.Fprintf(os.Stderr, "warning: package %q is defined in both %s and %s, using the latter\n", pkg, oldPath, filePath) + } + packagesFromFiles[pkg] = filePath + } + } + + if len(packageNames) == 0 { + return fmt.Errorf("no packages specified") + } + + // Create Kubernetes client config + var config *rest.Config + var err error + + if delCmdFlags.kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags("", delCmdFlags.kubeconfig) + if err != nil { + return fmt.Errorf("failed to load kubeconfig from %s: %w", delCmdFlags.kubeconfig, err) + } + } else { + config, err = ctrl.GetConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + } + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(cozyv1alpha1.AddToScheme(scheme)) + + k8sClient, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create k8s client: %w", err) + } + + // Check which requested packages are installed + var installedPackages cozyv1alpha1.PackageList + if err := k8sClient.List(ctx, &installedPackages); err != nil { + return fmt.Errorf("failed to list Packages: %w", err) + } + installedMap := make(map[string]bool) + for _, pkg := range installedPackages.Items { + installedMap[pkg.Name] = true + } + + // Warn about requested packages that are not installed + for pkgName := range packageNames { + if !installedMap[pkgName] { + fmt.Fprintf(os.Stderr, "⚠ Package %s is not installed, skipping\n", pkgName) + } + } + + // Find all packages to delete (including dependents) + packagesToDelete, err := findPackagesToDelete(ctx, k8sClient, packageNames) + if err != nil { + return fmt.Errorf("failed to analyze dependencies: %w", err) + } + + if len(packagesToDelete) == 0 { + fmt.Fprintf(os.Stderr, "No packages found to delete\n") + return nil + } + + // Show packages to be deleted and ask for confirmation + if err := confirmDeletion(packagesToDelete, packageNames); err != nil { + return err + } + + // Delete packages in reverse topological order (dependents first, then dependencies) + deleteOrder, err := getDeleteOrder(ctx, k8sClient, packagesToDelete) + if err != nil { + return fmt.Errorf("failed to determine delete order: %w", err) + } + + // Delete each package + for _, packageName := range deleteOrder { + pkg := &cozyv1alpha1.Package{} + pkg.Name = packageName + if err := k8sClient.Delete(ctx, pkg); err != nil { + if apierrors.IsNotFound(err) { + fmt.Fprintf(os.Stderr, "⚠ Package %s not found, skipping\n", packageName) + continue + } + return fmt.Errorf("failed to delete Package %s: %w", packageName, err) + } + fmt.Fprintf(os.Stderr, "✓ Deleted Package %s\n", packageName) + } + + return nil + }, +} + +// findPackagesToDelete finds all packages that need to be deleted, including dependents +func findPackagesToDelete(ctx context.Context, k8sClient client.Client, requestedPackages map[string]bool) (map[string]bool, error) { + // Get all installed Packages + var installedPackages cozyv1alpha1.PackageList + if err := k8sClient.List(ctx, &installedPackages); err != nil { + return nil, fmt.Errorf("failed to list Packages: %w", err) + } + + installedMap := make(map[string]bool) + for _, pkg := range installedPackages.Items { + installedMap[pkg.Name] = true + } + + // Get all PackageSources to build dependency graph + var packageSources cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &packageSources); err != nil { + return nil, fmt.Errorf("failed to list PackageSources: %w", err) + } + + // Build reverse dependency graph (dependents -> dependencies) + // This tells us: for each package, which packages depend on it + reverseDeps := make(map[string][]string) + for _, ps := range packageSources.Items { + // Only consider installed packages + if !installedMap[ps.Name] { + continue + } + for _, variant := range ps.Spec.Variants { + for _, dep := range variant.DependsOn { + // Only consider installed dependencies + if installedMap[dep] { + reverseDeps[dep] = append(reverseDeps[dep], ps.Name) + } + } + } + } + + // Find all packages to delete (requested + their dependents) + packagesToDelete := make(map[string]bool) + visited := make(map[string]bool) + + var findDependents func(string) + findDependents = func(pkgName string) { + if visited[pkgName] { + return + } + visited[pkgName] = true + + // Only add if it's installed + if installedMap[pkgName] { + packagesToDelete[pkgName] = true + } + + // Recursively find all dependents + for _, dependent := range reverseDeps[pkgName] { + if installedMap[dependent] { + findDependents(dependent) + } + } + } + + // Start from requested packages + for pkgName := range requestedPackages { + if !installedMap[pkgName] { + continue + } + findDependents(pkgName) + } + + return packagesToDelete, nil +} + +// confirmDeletion shows the list of packages to be deleted and asks for user confirmation +func confirmDeletion(packagesToDelete map[string]bool, requestedPackages map[string]bool) error { + // Separate requested packages from dependents + var requested []string + var dependents []string + + for pkg := range packagesToDelete { + if requestedPackages[pkg] { + requested = append(requested, pkg) + } else { + dependents = append(dependents, pkg) + } + } + + fmt.Fprintf(os.Stderr, "\nThe following packages will be deleted:\n\n") + + if len(requested) > 0 { + fmt.Fprintf(os.Stderr, "Requested packages:\n") + for _, pkg := range requested { + fmt.Fprintf(os.Stderr, " - %s\n", pkg) + } + fmt.Fprintf(os.Stderr, "\n") + } + + if len(dependents) > 0 { + fmt.Fprintf(os.Stderr, "Dependent packages (will also be deleted):\n") + for _, pkg := range dependents { + fmt.Fprintf(os.Stderr, " - %s\n", pkg) + } + fmt.Fprintf(os.Stderr, "\n") + } + + fmt.Fprintf(os.Stderr, "Total: %d package(s)\n\n", len(packagesToDelete)) + fmt.Fprintf(os.Stderr, "Do you want to continue? [y/N]: ") + + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(strings.ToLower(input)) + if input != "y" && input != "yes" { + return fmt.Errorf("deletion cancelled") + } + + return nil +} + +// getDeleteOrder returns packages in reverse topological order (dependents first, then dependencies) +// This ensures we delete dependents before their dependencies +func getDeleteOrder(ctx context.Context, k8sClient client.Client, packagesToDelete map[string]bool) ([]string, error) { + // Get all PackageSources to build dependency graph + var packageSources cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &packageSources); err != nil { + return nil, fmt.Errorf("failed to list PackageSources: %w", err) + } + + // Build forward dependency graph (package -> dependencies) + dependencyGraph := make(map[string][]string) + for _, ps := range packageSources.Items { + if !packagesToDelete[ps.Name] { + continue + } + deps := make(map[string]bool) + for _, variant := range ps.Spec.Variants { + for _, dep := range variant.DependsOn { + if packagesToDelete[dep] { + deps[dep] = true + } + } + } + var depList []string + for dep := range deps { + depList = append(depList, dep) + } + dependencyGraph[ps.Name] = depList + } + + // Build reverse graph for topological sort + reverseGraph := make(map[string][]string) + allNodes := make(map[string]bool) + + for node, deps := range dependencyGraph { + allNodes[node] = true + for _, dep := range deps { + allNodes[dep] = true + reverseGraph[dep] = append(reverseGraph[dep], node) + } + } + + // Add nodes that have no dependencies + for pkg := range packagesToDelete { + if !allNodes[pkg] { + allNodes[pkg] = true + dependencyGraph[pkg] = []string{} + } + } + + // Calculate in-degrees + inDegree := make(map[string]int) + for node := range allNodes { + inDegree[node] = 0 + } + for node, deps := range dependencyGraph { + inDegree[node] = len(deps) + } + + // Kahn's algorithm - start with nodes that have no dependencies + var queue []string + for node, degree := range inDegree { + if degree == 0 { + queue = append(queue, node) + } + } + + var result []string + for len(queue) > 0 { + node := queue[0] + queue = queue[1:] + result = append(result, node) + + // Process dependents + for _, dependent := range reverseGraph[node] { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + queue = append(queue, dependent) + } + } + } + + // Check for cycles: if not all nodes were processed, there's a cycle + if len(result) != len(allNodes) { + // Find unprocessed nodes + processed := make(map[string]bool) + for _, node := range result { + processed[node] = true + } + var unprocessed []string + for node := range allNodes { + if !processed[node] { + unprocessed = append(unprocessed, node) + } + } + return nil, fmt.Errorf("dependency cycle detected: the following packages form a cycle and cannot be deleted: %v", unprocessed) + } + + // Reverse the result to get dependents first, then dependencies + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + + return result, nil +} + +func init() { + rootCmd.AddCommand(delCmd) + delCmd.Flags().StringArrayVarP(&delCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)") + delCmd.Flags().StringVar(&delCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)") +} + diff --git a/cmd/cozypkg/cmd/dependencies.go b/cmd/cozypkg/cmd/dependencies.go new file mode 100644 index 00000000..7db71d4a --- /dev/null +++ b/cmd/cozypkg/cmd/dependencies.go @@ -0,0 +1,564 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 cmd + +import ( + "context" + "fmt" + "os" + "strings" + + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + "github.com/emicklei/dot" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var dotCmdFlags struct { + installed bool + components bool + files []string + kubeconfig string +} + +var dotCmd = &cobra.Command{ + Use: "dot [package]...", + Short: "Generate dependency graph as graphviz DOT format", + Long: `Generate dependency graph as graphviz DOT format. + +Pipe the output through the "dot" program (part of graphviz package) to render the graph: + + cozypkg dot | dot -Tpng > graph.png + +By default, shows dependencies for all PackageSource resources. +Use --installed to show only installed Package resources. +Specify packages as arguments or use -f flag to read from files.`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Collect package names from arguments and files + packageNames := make(map[string]bool) + for _, arg := range args { + packageNames[arg] = true + } + + // Read packages from files (reuse function from add.go) + for _, filePath := range dotCmdFlags.files { + packages, err := readPackagesFromFile(filePath) + if err != nil { + return fmt.Errorf("failed to read packages from %s: %w", filePath, err) + } + for _, pkg := range packages { + packageNames[pkg] = true + } + } + + // Convert to slice, empty means all packages + var selectedPackages []string + if len(packageNames) > 0 { + for pkg := range packageNames { + selectedPackages = append(selectedPackages, pkg) + } + } + + // If multiple packages specified, show graph for all of them + // If single package, use packageName for backward compatibility + var packageName string + if len(selectedPackages) == 1 { + packageName = selectedPackages[0] + } else if len(selectedPackages) > 1 { + // Multiple packages - pass empty string to packageName, use selectedPackages + packageName = "" + } + + // packagesOnly is inverse of components flag (if components=false, then packagesOnly=true) + packagesOnly := !dotCmdFlags.components + graph, allNodes, edgeVariants, packageNames, err := buildGraphFromCluster(ctx, dotCmdFlags.kubeconfig, packagesOnly, dotCmdFlags.installed, packageName, selectedPackages) + if err != nil { + return fmt.Errorf("error getting PackageSource dependencies: %w", err) + } + + dotGraph := generateDOTGraph(graph, allNodes, packagesOnly, edgeVariants, packageNames) + dotGraph.Write(os.Stdout) + + return nil + }, +} + +func init() { + rootCmd.AddCommand(dotCmd) + dotCmd.Flags().BoolVarP(&dotCmdFlags.installed, "installed", "i", false, "show dependencies only for installed Package resources") + dotCmd.Flags().BoolVar(&dotCmdFlags.components, "components", false, "show component-level dependencies") + dotCmd.Flags().StringArrayVarP(&dotCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)") + dotCmd.Flags().StringVar(&dotCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)") +} + +var ( + dependenciesScheme = runtime.NewScheme() +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(dependenciesScheme)) + utilruntime.Must(cozyv1alpha1.AddToScheme(dependenciesScheme)) +} + +// buildGraphFromCluster builds a dependency graph from PackageSource resources in the cluster. +// Returns: graph, allNodes, edgeVariants (map[edgeKey]variants), packageNames, error +func buildGraphFromCluster(ctx context.Context, kubeconfig string, packagesOnly bool, installedOnly bool, packageName string, selectedPackages []string) (map[string][]string, map[string]bool, map[string][]string, map[string]bool, error) { + // Create Kubernetes client config + var config *rest.Config + var err error + + if kubeconfig != "" { + // Load kubeconfig from explicit path + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load kubeconfig from %s: %w", kubeconfig, err) + } + } else { + // Use default kubeconfig loading (from env var or ~/.kube/config) + config, err = ctrl.GetConfig() + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get kubeconfig: %w", err) + } + } + + k8sClient, err := client.New(config, client.Options{Scheme: dependenciesScheme}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to create k8s client: %w", err) + } + + // Get installed Packages if needed + installedPackages := make(map[string]bool) + if installedOnly { + var packageList cozyv1alpha1.PackageList + if err := k8sClient.List(ctx, &packageList); err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to list Packages: %w", err) + } + for _, pkg := range packageList.Items { + installedPackages[pkg.Name] = true + } + } + + // List all PackageSource resources + var packageSourceList cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &packageSourceList); err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to list PackageSources: %w", err) + } + + // Build map of existing packages and components + packageNames := make(map[string]bool) + allExistingComponents := make(map[string]bool) // "package.component" -> true + for _, ps := range packageSourceList.Items { + if ps.Name != "" { + packageNames[ps.Name] = true + for _, variant := range ps.Spec.Variants { + for _, component := range variant.Components { + if component.Install != nil { + componentFullName := fmt.Sprintf("%s.%s", ps.Name, component.Name) + allExistingComponents[componentFullName] = true + } + } + } + } + } + + graph := make(map[string][]string) + allNodes := make(map[string]bool) + edgeVariants := make(map[string][]string) // key: "source->target", value: list of variant names + existingEdges := make(map[string]bool) // key: "source->target" to avoid duplicates + componentHasLocalDeps := make(map[string]bool) // componentName -> has local component dependencies + + // Process each PackageSource + for _, ps := range packageSourceList.Items { + psName := ps.Name + if psName == "" { + continue + } + + // Filter by package name if specified + if packageName != "" && psName != packageName { + continue + } + + // Filter by selected packages if specified + if len(selectedPackages) > 0 { + found := false + for _, selected := range selectedPackages { + if psName == selected { + found = true + break + } + } + if !found { + continue + } + } + + // Filter by installed packages if flag is set + if installedOnly && !installedPackages[psName] { + continue + } + + allNodes[psName] = true + + // Track package dependencies per variant + packageDepVariants := make(map[string]map[string]bool) // dep -> variant -> true + allVariantNames := make(map[string]bool) + for _, v := range ps.Spec.Variants { + allVariantNames[v.Name] = true + } + + // Track component dependencies per variant + componentDepVariants := make(map[string]map[string]map[string]bool) // componentName -> dep -> variant -> true + componentVariants := make(map[string]map[string]bool) // componentName -> variant -> true + + // Extract dependencies from variants + for _, variant := range ps.Spec.Variants { + // Variant-level dependencies (package-level) + for _, dep := range variant.DependsOn { + // If installedOnly is set, only include dependencies that are installed + if installedOnly && !installedPackages[dep] { + continue + } + + // Track which variant this dependency comes from + if packageDepVariants[dep] == nil { + packageDepVariants[dep] = make(map[string]bool) + } + packageDepVariants[dep][variant.Name] = true + + edgeKey := fmt.Sprintf("%s->%s", psName, dep) + if !existingEdges[edgeKey] { + graph[psName] = append(graph[psName], dep) + existingEdges[edgeKey] = true + } + + // Add to allNodes only if package exists + if packageNames[dep] { + allNodes[dep] = true + } + // If package doesn't exist, don't add to allNodes - it will be shown as missing (red) + } + + // Component-level dependencies + if !packagesOnly { + for _, component := range variant.Components { + // Skip components without install section + if component.Install == nil { + continue + } + + componentName := fmt.Sprintf("%s.%s", psName, component.Name) + allNodes[componentName] = true + + // Track which variants this component appears in + if componentVariants[componentName] == nil { + componentVariants[componentName] = make(map[string]bool) + } + componentVariants[componentName][variant.Name] = true + + if component.Install != nil { + if componentDepVariants[componentName] == nil { + componentDepVariants[componentName] = make(map[string]map[string]bool) + } + + for _, dep := range component.Install.DependsOn { + // Track which variant this dependency comes from + if componentDepVariants[componentName][dep] == nil { + componentDepVariants[componentName][dep] = make(map[string]bool) + } + componentDepVariants[componentName][dep][variant.Name] = true + + // Check if it's a local component dependency or external + if strings.Contains(dep, ".") { + // External component dependency (package.component format) + // Mark that this component has local dependencies (for edge to package logic) + componentHasLocalDeps[componentName] = true + + // Check if target component exists + if allExistingComponents[dep] { + // Component exists + edgeKey := fmt.Sprintf("%s->%s", componentName, dep) + if !existingEdges[edgeKey] { + graph[componentName] = append(graph[componentName], dep) + existingEdges[edgeKey] = true + } + allNodes[dep] = true + } else { + // Component doesn't exist - create missing component node + edgeKey := fmt.Sprintf("%s->%s", componentName, dep) + if !existingEdges[edgeKey] { + graph[componentName] = append(graph[componentName], dep) + existingEdges[edgeKey] = true + } + // Don't add to allNodes - will be shown as missing (red) + + // Add edge from missing component to its package + parts := strings.SplitN(dep, ".", 2) + if len(parts) == 2 { + depPackageName := parts[0] + missingEdgeKey := fmt.Sprintf("%s->%s", dep, depPackageName) + if !existingEdges[missingEdgeKey] { + graph[dep] = append(graph[dep], depPackageName) + existingEdges[missingEdgeKey] = true + } + // Add package to allNodes only if it exists + if packageNames[depPackageName] { + allNodes[depPackageName] = true + } + // If package doesn't exist, it will be shown as missing (red) + } + } + } else { + // Local component dependency (same package) + // Mark that this component has local dependencies + componentHasLocalDeps[componentName] = true + + localDep := fmt.Sprintf("%s.%s", psName, dep) + + // Check if target component exists + if allExistingComponents[localDep] { + // Component exists + edgeKey := fmt.Sprintf("%s->%s", componentName, localDep) + if !existingEdges[edgeKey] { + graph[componentName] = append(graph[componentName], localDep) + existingEdges[edgeKey] = true + } + allNodes[localDep] = true + } else { + // Component doesn't exist - create missing component node + edgeKey := fmt.Sprintf("%s->%s", componentName, localDep) + if !existingEdges[edgeKey] { + graph[componentName] = append(graph[componentName], localDep) + existingEdges[edgeKey] = true + } + // Don't add to allNodes - will be shown as missing (red) + + // Add edge from missing component to its package + missingEdgeKey := fmt.Sprintf("%s->%s", localDep, psName) + if !existingEdges[missingEdgeKey] { + graph[localDep] = append(graph[localDep], psName) + existingEdges[missingEdgeKey] = true + } + } + } + } + } + } + } + } + + // Store variant information for package dependencies that are not in all variants + for dep, variants := range packageDepVariants { + if len(variants) < len(allVariantNames) { + var variantList []string + for v := range variants { + variantList = append(variantList, v) + } + edgeKey := fmt.Sprintf("%s->%s", psName, dep) + edgeVariants[edgeKey] = variantList + } + } + + // Add component->package edges for components without local dependencies + if !packagesOnly { + for componentName := range componentVariants { + // Only add edge to package if component has no local component dependencies + if !componentHasLocalDeps[componentName] { + edgeKey := fmt.Sprintf("%s->%s", componentName, psName) + if !existingEdges[edgeKey] { + graph[componentName] = append(graph[componentName], psName) + existingEdges[edgeKey] = true + } + + // If component is not in all variants, store variant info for component->package edge + componentAllVariants := componentVariants[componentName] + if len(componentAllVariants) < len(allVariantNames) { + var variantList []string + for v := range componentAllVariants { + variantList = append(variantList, v) + } + edgeVariants[edgeKey] = variantList + } + } + } + } + + // Store variant information for component dependencies that are not in all variants + for componentName, deps := range componentDepVariants { + componentAllVariants := componentVariants[componentName] + for dep, variants := range deps { + if len(variants) < len(componentAllVariants) { + var variantList []string + for v := range variants { + variantList = append(variantList, v) + } + // Determine the actual target name + var targetName string + if strings.Contains(dep, ".") { + targetName = dep + } else { + targetName = fmt.Sprintf("%s.%s", psName, dep) + } + edgeKey := fmt.Sprintf("%s->%s", componentName, targetName) + edgeVariants[edgeKey] = variantList + } + } + } + } + + return graph, allNodes, edgeVariants, packageNames, nil +} + +// generateDOTGraph generates a DOT graph from the dependency graph. +func generateDOTGraph(graph map[string][]string, allNodes map[string]bool, packagesOnly bool, edgeVariants map[string][]string, packageNames map[string]bool) *dot.Graph { + g := dot.NewGraph(dot.Directed) + g.Attr("rankdir", "RL") + g.Attr("nodesep", "0.5") + g.Attr("ranksep", "1.0") + + // Helper function to check if a node is a package + // A node is a package if: + // 1. It's directly in packageNames + // 2. It doesn't contain a dot (simple package name) + // 3. It contains a dot but the part before the first dot is a package name + isPackage := func(nodeName string) bool { + if packageNames[nodeName] { + return true + } + if !strings.Contains(nodeName, ".") { + return true + } + // If it contains a dot, check if the part before the first dot is a package + parts := strings.SplitN(nodeName, ".", 2) + if len(parts) > 0 { + return packageNames[parts[0]] + } + return false + } + + // Add nodes + for node := range allNodes { + if packagesOnly && !isPackage(node) { + // Skip component nodes when packages-only is enabled + continue + } + + n := g.Node(node) + + // Style nodes based on type + if isPackage(node) { + // Package node + n.Attr("shape", "box") + n.Attr("style", "rounded,filled") + n.Attr("fillcolor", "lightblue") + n.Attr("label", node) + } else { + // Component node + n.Attr("shape", "box") + n.Attr("style", "rounded,filled") + n.Attr("fillcolor", "lightyellow") + // Extract component name (part after last dot) + parts := strings.Split(node, ".") + if len(parts) > 0 { + n.Attr("label", parts[len(parts)-1]) + } else { + n.Attr("label", node) + } + } + } + + // Add edges + for source, targets := range graph { + if packagesOnly && !isPackage(source) { + // Skip component edges when packages-only is enabled + continue + } + + for _, target := range targets { + if packagesOnly && !isPackage(target) { + // Skip component edges when packages-only is enabled + continue + } + + // Check if target exists + targetExists := allNodes[target] + + // Determine edge type for coloring + sourceIsPackage := isPackage(source) + targetIsPackage := isPackage(target) + + // Add edge + edge := g.Edge(g.Node(source), g.Node(target)) + + // Set edge color based on type (if target exists) + if targetExists { + if sourceIsPackage && targetIsPackage { + // Package -> Package: black (default) + edge.Attr("color", "black") + } else { + // Component -> Package or Component -> Component: green + edge.Attr("color", "green") + } + } + + // If target doesn't exist, mark it as missing (red color) + if !targetExists { + edge.Attr("color", "red") + edge.Attr("style", "dashed") + + // Also add the missing node with red color + missingNode := g.Node(target) + missingNode.Attr("shape", "box") + missingNode.Attr("style", "rounded,filled,dashed") + missingNode.Attr("fillcolor", "lightcoral") + + // Determine label based on node type + if isPackage(target) { + // Package node + missingNode.Attr("label", target) + } else { + // Component node - extract component name + parts := strings.Split(target, ".") + if len(parts) > 0 { + missingNode.Attr("label", parts[len(parts)-1]) + } else { + missingNode.Attr("label", target) + } + } + } else { + // Check if this edge has variant information (dependency not in all variants) + edgeKey := fmt.Sprintf("%s->%s", source, target) + if variants, hasVariants := edgeVariants[edgeKey]; hasVariants { + // Add label with variant names + edge.Attr("label", strings.Join(variants, ",")) + } + } + } + } + + return g +} diff --git a/cmd/cozypkg/cmd/list.go b/cmd/cozypkg/cmd/list.go new file mode 100644 index 00000000..6f737502 --- /dev/null +++ b/cmd/cozypkg/cmd/list.go @@ -0,0 +1,220 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 cmd + +import ( + "context" + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + _ "k8s.io/client-go/plugin/pkg/client/auth" +) + +var listCmdFlags struct { + installed bool + components bool + kubeconfig string +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List PackageSource or Package resources", + Long: `List PackageSource or Package resources in table format. + +By default, lists PackageSource resources. Use --installed flag to list installed Package resources. +Use --components flag to show components on separate lines.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Create Kubernetes client config + var config *rest.Config + var err error + + if listCmdFlags.kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags("", listCmdFlags.kubeconfig) + if err != nil { + return fmt.Errorf("failed to load kubeconfig from %s: %w", listCmdFlags.kubeconfig, err) + } + } else { + config, err = ctrl.GetConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + } + + scheme := runtime.NewScheme() + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(cozyv1alpha1.AddToScheme(scheme)) + + k8sClient, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create k8s client: %w", err) + } + + if listCmdFlags.installed { + return listPackages(ctx, k8sClient, listCmdFlags.components) + } + return listPackageSources(ctx, k8sClient, listCmdFlags.components) + }, +} + +func listPackageSources(ctx context.Context, k8sClient client.Client, showComponents bool) error { + var psList cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &psList); err != nil { + return fmt.Errorf("failed to list PackageSources: %w", err) + } + + // Use tabwriter for better column alignment + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + defer w.Flush() + + // Print header + fmt.Fprintln(w, "NAME\tVARIANTS\tREADY\tSTATUS") + + // Print rows + for _, ps := range psList.Items { + // Get variants + var variants []string + for _, variant := range ps.Spec.Variants { + variants = append(variants, variant.Name) + } + variantsStr := strings.Join(variants, ",") + if len(variantsStr) > 28 { + variantsStr = variantsStr[:25] + "..." + } + + // Get Ready condition + ready := "Unknown" + status := "" + for _, condition := range ps.Status.Conditions { + if condition.Type == "Ready" { + ready = string(condition.Status) + status = condition.Message + if len(status) > 48 { + status = status[:45] + "..." + } + break + } + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ps.Name, variantsStr, ready, status) + + // Show components if requested + if showComponents { + for _, variant := range ps.Spec.Variants { + for _, component := range variant.Components { + fmt.Fprintf(w, " %s\t%s\t\t\n", + fmt.Sprintf("%s.%s", ps.Name, component.Name), + variant.Name) + } + } + } + } + + return nil +} + +func listPackages(ctx context.Context, k8sClient client.Client, showComponents bool) error { + var pkgList cozyv1alpha1.PackageList + if err := k8sClient.List(ctx, &pkgList); err != nil { + return fmt.Errorf("failed to list Packages: %w", err) + } + + // Fetch all PackageSource resources once if components are requested + var psMap map[string]*cozyv1alpha1.PackageSource + if showComponents { + var psList cozyv1alpha1.PackageSourceList + if err := k8sClient.List(ctx, &psList); err != nil { + return fmt.Errorf("failed to list PackageSources: %w", err) + } + psMap = make(map[string]*cozyv1alpha1.PackageSource) + for i := range psList.Items { + psMap[psList.Items[i].Name] = &psList.Items[i] + } + } + + // Use tabwriter for better column alignment + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + defer w.Flush() + + // Print header + fmt.Fprintln(w, "NAME\tVARIANT\tREADY\tSTATUS") + + // Print rows + for _, pkg := range pkgList.Items { + variant := pkg.Spec.Variant + if variant == "" { + variant = "default" + } + + // Get Ready condition + ready := "Unknown" + status := "" + for _, condition := range pkg.Status.Conditions { + if condition.Type == "Ready" { + ready = string(condition.Status) + status = condition.Message + if len(status) > 48 { + status = status[:45] + "..." + } + break + } + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", pkg.Name, variant, ready, status) + + // Show components if requested + if showComponents { + // Look up PackageSource from map instead of making API call + if ps, exists := psMap[pkg.Name]; exists { + // Find the variant + for _, v := range ps.Spec.Variants { + if v.Name == variant { + for _, component := range v.Components { + fmt.Fprintf(w, " %s\t%s\t\t\n", + fmt.Sprintf("%s.%s", pkg.Name, component.Name), + variant) + } + break + } + } + } + } + } + + return nil +} + +func init() { + rootCmd.AddCommand(listCmd) + listCmd.Flags().BoolVarP(&listCmdFlags.installed, "installed", "i", false, "list installed Package resources instead of PackageSource resources") + listCmd.Flags().BoolVar(&listCmdFlags.components, "components", false, "show components on separate lines") + listCmd.Flags().StringVar(&listCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)") +} + diff --git a/cmd/cozypkg/cmd/root.go b/cmd/cozypkg/cmd/root.go new file mode 100644 index 00000000..62bd54aa --- /dev/null +++ b/cmd/cozypkg/cmd/root.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// rootCmd represents the base command when called without any subcommands. +var rootCmd = &cobra.Command{ + Use: "cozypkg", + Short: "A CLI for managing Cozystack packages", + Long: ``, + SilenceErrors: true, + SilenceUsage: true, + DisableAutoGenTag: true, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() error { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + return err + } + return nil +} + +func init() { + // Commands are registered in their respective init() functions +} + diff --git a/cmd/cozypkg/main.go b/cmd/cozypkg/main.go new file mode 100644 index 00000000..7f6b9ead --- /dev/null +++ b/cmd/cozypkg/main.go @@ -0,0 +1,30 @@ +/* +Copyright 2025 The Cozystack Authors. + +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 main + +import ( + "os" + + "github.com/cozystack/cozystack/cmd/cozypkg/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} + diff --git a/go.mod b/go.mod index f211d737..04436899 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ module github.com/cozystack/cozystack go 1.25.0 require ( + github.com/emicklei/dot v1.10.0 github.com/fluxcd/helm-controller/api v1.4.3 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 diff --git a/go.sum b/go.sum index 2e0df1a0..655f73fd 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/dot v1.10.0 h1:z17n0ce/FBMz3QbShSzVGhiW447Qhu7fljzvp3Gs6ig= +github.com/emicklei/dot v1.10.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=