[cozypkg] Add tool for managing Package and PackageSources

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
This commit is contained in:
Andrei Kvapil 2025-12-24 21:33:52 +01:00
parent 59f62b7834
commit d3f1b821f7
No known key found for this signature in database
GPG key ID: 931CF7FEACEAF765
8 changed files with 1811 additions and 0 deletions

549
cmd/cozypkg/cmd/add.go Normal file
View file

@ -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)")
}

396
cmd/cozypkg/cmd/del.go Normal file
View file

@ -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)")
}

View file

@ -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
}

220
cmd/cozypkg/cmd/list.go Normal file
View file

@ -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)")
}

49
cmd/cozypkg/cmd/root.go Normal file
View file

@ -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
}

30
cmd/cozypkg/main.go Normal file
View file

@ -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)
}
}

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=