Skip to content

Commit 8f89395

Browse files
committed
[cozypkg] Add tool for managing Package and PackageSources
Signed-off-by: Andrei Kvapil <[email protected]>
1 parent 59f62b7 commit 8f89395

8 files changed

Lines changed: 1770 additions & 0 deletions

File tree

‎cmd/cozypkg/cmd/add.go‎

Lines changed: 549 additions & 0 deletions
Large diffs are not rendered by default.

‎cmd/cozypkg/cmd/del.go‎

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
/*
2+
Copyright 2025 The Cozystack Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package cmd
18+
19+
import (
20+
"bufio"
21+
"context"
22+
"fmt"
23+
"os"
24+
"strings"
25+
26+
"github.com/spf13/cobra"
27+
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
28+
apierrors "k8s.io/apimachinery/pkg/api/errors"
29+
"k8s.io/apimachinery/pkg/runtime"
30+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
31+
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
32+
"k8s.io/client-go/rest"
33+
"k8s.io/client-go/tools/clientcmd"
34+
ctrl "sigs.k8s.io/controller-runtime"
35+
"sigs.k8s.io/controller-runtime/pkg/client"
36+
_ "k8s.io/client-go/plugin/pkg/client/auth"
37+
)
38+
39+
var delCmdFlags struct {
40+
files []string
41+
kubeconfig string
42+
}
43+
44+
var delCmd = &cobra.Command{
45+
Use: "del [package]...",
46+
Short: "Delete Package resources",
47+
Long: `Delete Package resources.
48+
49+
You can specify packages as arguments or use -f flag to read from files.
50+
Multiple -f flags can be specified, and they can point to files or directories.`,
51+
Args: cobra.ArbitraryArgs,
52+
RunE: func(cmd *cobra.Command, args []string) error {
53+
ctx := context.Background()
54+
55+
// Collect package names from arguments and files
56+
packageNames := make(map[string]bool)
57+
packagesFromFiles := make(map[string]string) // packageName -> filePath
58+
59+
for _, arg := range args {
60+
packageNames[arg] = true
61+
}
62+
63+
// Read packages from files (reuse function from add.go)
64+
for _, filePath := range delCmdFlags.files {
65+
packages, err := readPackagesFromFile(filePath)
66+
if err != nil {
67+
return fmt.Errorf("failed to read packages from %s: %w", filePath, err)
68+
}
69+
for _, pkg := range packages {
70+
packageNames[pkg] = true
71+
if oldPath, ok := packagesFromFiles[pkg]; ok {
72+
fmt.Fprintf(os.Stderr, "warning: package %q is defined in both %s and %s, using the latter\n", pkg, oldPath, filePath)
73+
}
74+
packagesFromFiles[pkg] = filePath
75+
}
76+
}
77+
78+
if len(packageNames) == 0 {
79+
return fmt.Errorf("no packages specified")
80+
}
81+
82+
// Create Kubernetes client config
83+
var config *rest.Config
84+
var err error
85+
86+
if delCmdFlags.kubeconfig != "" {
87+
config, err = clientcmd.BuildConfigFromFlags("", delCmdFlags.kubeconfig)
88+
if err != nil {
89+
return fmt.Errorf("failed to load kubeconfig from %s: %w", delCmdFlags.kubeconfig, err)
90+
}
91+
} else {
92+
config, err = ctrl.GetConfig()
93+
if err != nil {
94+
return fmt.Errorf("failed to get kubeconfig: %w", err)
95+
}
96+
}
97+
98+
scheme := runtime.NewScheme()
99+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
100+
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
101+
102+
k8sClient, err := client.New(config, client.Options{Scheme: scheme})
103+
if err != nil {
104+
return fmt.Errorf("failed to create k8s client: %w", err)
105+
}
106+
107+
// Check which requested packages are installed
108+
var installedPackages cozyv1alpha1.PackageList
109+
if err := k8sClient.List(ctx, &installedPackages); err != nil {
110+
return fmt.Errorf("failed to list Packages: %w", err)
111+
}
112+
installedMap := make(map[string]bool)
113+
for _, pkg := range installedPackages.Items {
114+
installedMap[pkg.Name] = true
115+
}
116+
117+
// Warn about requested packages that are not installed
118+
for pkgName := range packageNames {
119+
if !installedMap[pkgName] {
120+
fmt.Fprintf(os.Stderr, "⚠ Package %s is not installed, skipping\n", pkgName)
121+
}
122+
}
123+
124+
// Find all packages to delete (including dependents)
125+
packagesToDelete, err := findPackagesToDelete(ctx, k8sClient, packageNames)
126+
if err != nil {
127+
return fmt.Errorf("failed to analyze dependencies: %w", err)
128+
}
129+
130+
if len(packagesToDelete) == 0 {
131+
fmt.Fprintf(os.Stderr, "No packages found to delete\n")
132+
return nil
133+
}
134+
135+
// Show packages to be deleted and ask for confirmation
136+
if err := confirmDeletion(packagesToDelete, packageNames); err != nil {
137+
return err
138+
}
139+
140+
// Delete packages in reverse topological order (dependents first, then dependencies)
141+
deleteOrder, err := getDeleteOrder(ctx, k8sClient, packagesToDelete)
142+
if err != nil {
143+
return fmt.Errorf("failed to determine delete order: %w", err)
144+
}
145+
146+
// Delete each package
147+
for _, packageName := range deleteOrder {
148+
pkg := &cozyv1alpha1.Package{}
149+
pkg.Name = packageName
150+
if err := k8sClient.Delete(ctx, pkg); err != nil {
151+
if apierrors.IsNotFound(err) {
152+
fmt.Fprintf(os.Stderr, "⚠ Package %s not found, skipping\n", packageName)
153+
continue
154+
}
155+
return fmt.Errorf("failed to delete Package %s: %w", packageName, err)
156+
}
157+
fmt.Fprintf(os.Stderr, "✓ Deleted Package %s\n", packageName)
158+
}
159+
160+
return nil
161+
},
162+
}
163+
164+
// findPackagesToDelete finds all packages that need to be deleted, including dependents
165+
func findPackagesToDelete(ctx context.Context, k8sClient client.Client, requestedPackages map[string]bool) (map[string]bool, error) {
166+
// Get all installed Packages
167+
var installedPackages cozyv1alpha1.PackageList
168+
if err := k8sClient.List(ctx, &installedPackages); err != nil {
169+
return nil, fmt.Errorf("failed to list Packages: %w", err)
170+
}
171+
172+
installedMap := make(map[string]bool)
173+
for _, pkg := range installedPackages.Items {
174+
installedMap[pkg.Name] = true
175+
}
176+
177+
// Get all PackageSources to build dependency graph
178+
var packageSources cozyv1alpha1.PackageSourceList
179+
if err := k8sClient.List(ctx, &packageSources); err != nil {
180+
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
181+
}
182+
183+
// Build reverse dependency graph (dependents -> dependencies)
184+
// This tells us: for each package, which packages depend on it
185+
reverseDeps := make(map[string][]string)
186+
for _, ps := range packageSources.Items {
187+
// Only consider installed packages
188+
if !installedMap[ps.Name] {
189+
continue
190+
}
191+
for _, variant := range ps.Spec.Variants {
192+
for _, dep := range variant.DependsOn {
193+
// Only consider installed dependencies
194+
if installedMap[dep] {
195+
reverseDeps[dep] = append(reverseDeps[dep], ps.Name)
196+
}
197+
}
198+
}
199+
}
200+
201+
// Find all packages to delete (requested + their dependents)
202+
packagesToDelete := make(map[string]bool)
203+
visited := make(map[string]bool)
204+
205+
var findDependents func(string)
206+
findDependents = func(pkgName string) {
207+
if visited[pkgName] {
208+
return
209+
}
210+
visited[pkgName] = true
211+
212+
// Only add if it's installed
213+
if installedMap[pkgName] {
214+
packagesToDelete[pkgName] = true
215+
}
216+
217+
// Recursively find all dependents
218+
for _, dependent := range reverseDeps[pkgName] {
219+
if installedMap[dependent] {
220+
findDependents(dependent)
221+
}
222+
}
223+
}
224+
225+
// Start from requested packages
226+
for pkgName := range requestedPackages {
227+
if !installedMap[pkgName] {
228+
continue
229+
}
230+
findDependents(pkgName)
231+
}
232+
233+
return packagesToDelete, nil
234+
}
235+
236+
// confirmDeletion shows the list of packages to be deleted and asks for user confirmation
237+
func confirmDeletion(packagesToDelete map[string]bool, requestedPackages map[string]bool) error {
238+
// Separate requested packages from dependents
239+
var requested []string
240+
var dependents []string
241+
242+
for pkg := range packagesToDelete {
243+
if requestedPackages[pkg] {
244+
requested = append(requested, pkg)
245+
} else {
246+
dependents = append(dependents, pkg)
247+
}
248+
}
249+
250+
fmt.Fprintf(os.Stderr, "\nThe following packages will be deleted:\n\n")
251+
252+
if len(requested) > 0 {
253+
fmt.Fprintf(os.Stderr, "Requested packages:\n")
254+
for _, pkg := range requested {
255+
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
256+
}
257+
fmt.Fprintf(os.Stderr, "\n")
258+
}
259+
260+
if len(dependents) > 0 {
261+
fmt.Fprintf(os.Stderr, "Dependent packages (will also be deleted):\n")
262+
for _, pkg := range dependents {
263+
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
264+
}
265+
fmt.Fprintf(os.Stderr, "\n")
266+
}
267+
268+
fmt.Fprintf(os.Stderr, "Total: %d package(s)\n\n", len(packagesToDelete))
269+
fmt.Fprintf(os.Stderr, "Do you want to continue? [y/N]: ")
270+
271+
reader := bufio.NewReader(os.Stdin)
272+
input, err := reader.ReadString('\n')
273+
if err != nil {
274+
return fmt.Errorf("failed to read input: %w", err)
275+
}
276+
277+
input = strings.TrimSpace(strings.ToLower(input))
278+
if input != "y" && input != "yes" {
279+
return fmt.Errorf("deletion cancelled")
280+
}
281+
282+
return nil
283+
}
284+
285+
// getDeleteOrder returns packages in reverse topological order (dependents first, then dependencies)
286+
// This ensures we delete dependents before their dependencies
287+
func getDeleteOrder(ctx context.Context, k8sClient client.Client, packagesToDelete map[string]bool) ([]string, error) {
288+
// Get all PackageSources to build dependency graph
289+
var packageSources cozyv1alpha1.PackageSourceList
290+
if err := k8sClient.List(ctx, &packageSources); err != nil {
291+
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
292+
}
293+
294+
// Build forward dependency graph (package -> dependencies)
295+
dependencyGraph := make(map[string][]string)
296+
for _, ps := range packageSources.Items {
297+
if !packagesToDelete[ps.Name] {
298+
continue
299+
}
300+
deps := make(map[string]bool)
301+
for _, variant := range ps.Spec.Variants {
302+
for _, dep := range variant.DependsOn {
303+
if packagesToDelete[dep] {
304+
deps[dep] = true
305+
}
306+
}
307+
}
308+
var depList []string
309+
for dep := range deps {
310+
depList = append(depList, dep)
311+
}
312+
dependencyGraph[ps.Name] = depList
313+
}
314+
315+
// Build reverse graph for topological sort
316+
reverseGraph := make(map[string][]string)
317+
allNodes := make(map[string]bool)
318+
319+
for node, deps := range dependencyGraph {
320+
allNodes[node] = true
321+
for _, dep := range deps {
322+
allNodes[dep] = true
323+
reverseGraph[dep] = append(reverseGraph[dep], node)
324+
}
325+
}
326+
327+
// Add nodes that have no dependencies
328+
for pkg := range packagesToDelete {
329+
if !allNodes[pkg] {
330+
allNodes[pkg] = true
331+
dependencyGraph[pkg] = []string{}
332+
}
333+
}
334+
335+
// Calculate in-degrees
336+
inDegree := make(map[string]int)
337+
for node := range allNodes {
338+
inDegree[node] = 0
339+
}
340+
for node, deps := range dependencyGraph {
341+
inDegree[node] = len(deps)
342+
}
343+
344+
// Kahn's algorithm - start with nodes that have no dependencies
345+
var queue []string
346+
for node, degree := range inDegree {
347+
if degree == 0 {
348+
queue = append(queue, node)
349+
}
350+
}
351+
352+
var result []string
353+
for len(queue) > 0 {
354+
node := queue[0]
355+
queue = queue[1:]
356+
result = append(result, node)
357+
358+
// Process dependents
359+
for _, dependent := range reverseGraph[node] {
360+
inDegree[dependent]--
361+
if inDegree[dependent] == 0 {
362+
queue = append(queue, dependent)
363+
}
364+
}
365+
}
366+
367+
// Check for cycles: if not all nodes were processed, there's a cycle
368+
if len(result) != len(allNodes) {
369+
// Find unprocessed nodes
370+
processed := make(map[string]bool)
371+
for _, node := range result {
372+
processed[node] = true
373+
}
374+
var unprocessed []string
375+
for node := range allNodes {
376+
if !processed[node] {
377+
unprocessed = append(unprocessed, node)
378+
}
379+
}
380+
return nil, fmt.Errorf("dependency cycle detected: the following packages form a cycle and cannot be deleted: %v", unprocessed)
381+
}
382+
383+
// Reverse the result to get dependents first, then dependencies
384+
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
385+
result[i], result[j] = result[j], result[i]
386+
}
387+
388+
return result, nil
389+
}
390+
391+
func init() {
392+
rootCmd.AddCommand(delCmd)
393+
delCmd.Flags().StringArrayVarP(&delCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
394+
delCmd.Flags().StringVar(&delCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
395+
}
396+

0 commit comments

Comments
 (0)