Skip to content

Commit dbe129a

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

8 files changed

Lines changed: 1728 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: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
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+
for _, arg := range args {
58+
packageNames[arg] = true
59+
}
60+
61+
// Read packages from files (reuse function from add.go)
62+
for _, filePath := range delCmdFlags.files {
63+
packages, err := readPackagesFromFile(filePath)
64+
if err != nil {
65+
return fmt.Errorf("failed to read packages from %s: %w", filePath, err)
66+
}
67+
for _, pkg := range packages {
68+
packageNames[pkg] = true
69+
}
70+
}
71+
72+
if len(packageNames) == 0 {
73+
return fmt.Errorf("no packages specified")
74+
}
75+
76+
// Create Kubernetes client config
77+
var config *rest.Config
78+
var err error
79+
80+
if delCmdFlags.kubeconfig != "" {
81+
config, err = clientcmd.BuildConfigFromFlags("", delCmdFlags.kubeconfig)
82+
if err != nil {
83+
return fmt.Errorf("failed to load kubeconfig from %s: %w", delCmdFlags.kubeconfig, err)
84+
}
85+
} else {
86+
config, err = ctrl.GetConfig()
87+
if err != nil {
88+
return fmt.Errorf("failed to get kubeconfig: %w", err)
89+
}
90+
}
91+
92+
scheme := runtime.NewScheme()
93+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
94+
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
95+
96+
k8sClient, err := client.New(config, client.Options{Scheme: scheme})
97+
if err != nil {
98+
return fmt.Errorf("failed to create k8s client: %w", err)
99+
}
100+
101+
// Check which requested packages are installed
102+
var installedPackages cozyv1alpha1.PackageList
103+
if err := k8sClient.List(ctx, &installedPackages); err != nil {
104+
return fmt.Errorf("failed to list Packages: %w", err)
105+
}
106+
installedMap := make(map[string]bool)
107+
for _, pkg := range installedPackages.Items {
108+
installedMap[pkg.Name] = true
109+
}
110+
111+
// Warn about requested packages that are not installed
112+
for pkgName := range packageNames {
113+
if !installedMap[pkgName] {
114+
fmt.Fprintf(os.Stderr, "⚠ Package %s is not installed, skipping\n", pkgName)
115+
}
116+
}
117+
118+
// Find all packages to delete (including dependents)
119+
packagesToDelete, err := findPackagesToDelete(ctx, k8sClient, packageNames)
120+
if err != nil {
121+
return fmt.Errorf("failed to analyze dependencies: %w", err)
122+
}
123+
124+
if len(packagesToDelete) == 0 {
125+
fmt.Fprintf(os.Stderr, "No packages found to delete\n")
126+
return nil
127+
}
128+
129+
// Show packages to be deleted and ask for confirmation
130+
if err := confirmDeletion(packagesToDelete, packageNames); err != nil {
131+
return err
132+
}
133+
134+
// Delete packages in reverse topological order (dependents first, then dependencies)
135+
deleteOrder, err := getDeleteOrder(ctx, k8sClient, packagesToDelete)
136+
if err != nil {
137+
return fmt.Errorf("failed to determine delete order: %w", err)
138+
}
139+
140+
// Delete each package
141+
for _, packageName := range deleteOrder {
142+
pkg := &cozyv1alpha1.Package{}
143+
pkg.Name = packageName
144+
if err := k8sClient.Delete(ctx, pkg); err != nil {
145+
if apierrors.IsNotFound(err) {
146+
fmt.Fprintf(os.Stderr, "⚠ Package %s not found, skipping\n", packageName)
147+
continue
148+
}
149+
return fmt.Errorf("failed to delete Package %s: %w", packageName, err)
150+
}
151+
fmt.Fprintf(os.Stderr, "✓ Deleted Package %s\n", packageName)
152+
}
153+
154+
return nil
155+
},
156+
}
157+
158+
// findPackagesToDelete finds all packages that need to be deleted, including dependents
159+
func findPackagesToDelete(ctx context.Context, k8sClient client.Client, requestedPackages map[string]bool) (map[string]bool, error) {
160+
// Get all installed Packages
161+
var installedPackages cozyv1alpha1.PackageList
162+
if err := k8sClient.List(ctx, &installedPackages); err != nil {
163+
return nil, fmt.Errorf("failed to list Packages: %w", err)
164+
}
165+
166+
installedMap := make(map[string]bool)
167+
for _, pkg := range installedPackages.Items {
168+
installedMap[pkg.Name] = true
169+
}
170+
171+
// Get all PackageSources to build dependency graph
172+
var packageSources cozyv1alpha1.PackageSourceList
173+
if err := k8sClient.List(ctx, &packageSources); err != nil {
174+
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
175+
}
176+
177+
// Build reverse dependency graph (dependents -> dependencies)
178+
// This tells us: for each package, which packages depend on it
179+
reverseDeps := make(map[string][]string)
180+
for _, ps := range packageSources.Items {
181+
// Only consider installed packages
182+
if !installedMap[ps.Name] {
183+
continue
184+
}
185+
for _, variant := range ps.Spec.Variants {
186+
for _, dep := range variant.DependsOn {
187+
// Only consider installed dependencies
188+
if installedMap[dep] {
189+
reverseDeps[dep] = append(reverseDeps[dep], ps.Name)
190+
}
191+
}
192+
}
193+
}
194+
195+
// Find all packages to delete (requested + their dependents)
196+
packagesToDelete := make(map[string]bool)
197+
visited := make(map[string]bool)
198+
199+
var findDependents func(string)
200+
findDependents = func(pkgName string) {
201+
if visited[pkgName] {
202+
return
203+
}
204+
visited[pkgName] = true
205+
206+
// Only add if it's installed
207+
if installedMap[pkgName] {
208+
packagesToDelete[pkgName] = true
209+
}
210+
211+
// Recursively find all dependents
212+
for _, dependent := range reverseDeps[pkgName] {
213+
if installedMap[dependent] {
214+
findDependents(dependent)
215+
}
216+
}
217+
}
218+
219+
// Start from requested packages
220+
for pkgName := range requestedPackages {
221+
if !installedMap[pkgName] {
222+
continue
223+
}
224+
findDependents(pkgName)
225+
}
226+
227+
return packagesToDelete, nil
228+
}
229+
230+
// confirmDeletion shows the list of packages to be deleted and asks for user confirmation
231+
func confirmDeletion(packagesToDelete map[string]bool, requestedPackages map[string]bool) error {
232+
// Separate requested packages from dependents
233+
var requested []string
234+
var dependents []string
235+
236+
for pkg := range packagesToDelete {
237+
if requestedPackages[pkg] {
238+
requested = append(requested, pkg)
239+
} else {
240+
dependents = append(dependents, pkg)
241+
}
242+
}
243+
244+
fmt.Fprintf(os.Stderr, "\nThe following packages will be deleted:\n\n")
245+
246+
if len(requested) > 0 {
247+
fmt.Fprintf(os.Stderr, "Requested packages:\n")
248+
for _, pkg := range requested {
249+
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
250+
}
251+
fmt.Fprintf(os.Stderr, "\n")
252+
}
253+
254+
if len(dependents) > 0 {
255+
fmt.Fprintf(os.Stderr, "Dependent packages (will also be deleted):\n")
256+
for _, pkg := range dependents {
257+
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
258+
}
259+
fmt.Fprintf(os.Stderr, "\n")
260+
}
261+
262+
fmt.Fprintf(os.Stderr, "Total: %d package(s)\n\n", len(packagesToDelete))
263+
fmt.Fprintf(os.Stderr, "Do you want to continue? [y/N]: ")
264+
265+
reader := bufio.NewReader(os.Stdin)
266+
input, err := reader.ReadString('\n')
267+
if err != nil {
268+
return fmt.Errorf("failed to read input: %w", err)
269+
}
270+
271+
input = strings.TrimSpace(strings.ToLower(input))
272+
if input != "y" && input != "yes" {
273+
return fmt.Errorf("deletion cancelled")
274+
}
275+
276+
return nil
277+
}
278+
279+
// getDeleteOrder returns packages in reverse topological order (dependents first, then dependencies)
280+
// This ensures we delete dependents before their dependencies
281+
func getDeleteOrder(ctx context.Context, k8sClient client.Client, packagesToDelete map[string]bool) ([]string, error) {
282+
// Get all PackageSources to build dependency graph
283+
var packageSources cozyv1alpha1.PackageSourceList
284+
if err := k8sClient.List(ctx, &packageSources); err != nil {
285+
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
286+
}
287+
288+
// Build forward dependency graph (package -> dependencies)
289+
dependencyGraph := make(map[string][]string)
290+
for _, ps := range packageSources.Items {
291+
if !packagesToDelete[ps.Name] {
292+
continue
293+
}
294+
deps := make(map[string]bool)
295+
for _, variant := range ps.Spec.Variants {
296+
for _, dep := range variant.DependsOn {
297+
if packagesToDelete[dep] {
298+
deps[dep] = true
299+
}
300+
}
301+
}
302+
var depList []string
303+
for dep := range deps {
304+
depList = append(depList, dep)
305+
}
306+
dependencyGraph[ps.Name] = depList
307+
}
308+
309+
// Build reverse graph for topological sort
310+
reverseGraph := make(map[string][]string)
311+
allNodes := make(map[string]bool)
312+
313+
for node, deps := range dependencyGraph {
314+
allNodes[node] = true
315+
for _, dep := range deps {
316+
allNodes[dep] = true
317+
reverseGraph[dep] = append(reverseGraph[dep], node)
318+
}
319+
}
320+
321+
// Add nodes that have no dependencies
322+
for pkg := range packagesToDelete {
323+
if !allNodes[pkg] {
324+
allNodes[pkg] = true
325+
dependencyGraph[pkg] = []string{}
326+
}
327+
}
328+
329+
// Calculate in-degrees
330+
inDegree := make(map[string]int)
331+
for node := range allNodes {
332+
inDegree[node] = 0
333+
}
334+
for node, deps := range dependencyGraph {
335+
inDegree[node] = len(deps)
336+
}
337+
338+
// Kahn's algorithm - start with nodes that have no dependencies
339+
var queue []string
340+
for node, degree := range inDegree {
341+
if degree == 0 {
342+
queue = append(queue, node)
343+
}
344+
}
345+
346+
var result []string
347+
for len(queue) > 0 {
348+
node := queue[0]
349+
queue = queue[1:]
350+
result = append(result, node)
351+
352+
// Process dependents
353+
for _, dependent := range reverseGraph[node] {
354+
inDegree[dependent]--
355+
if inDegree[dependent] == 0 {
356+
queue = append(queue, dependent)
357+
}
358+
}
359+
}
360+
361+
// Reverse the result to get dependents first, then dependencies
362+
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
363+
result[i], result[j] = result[j], result[i]
364+
}
365+
366+
return result, nil
367+
}
368+
369+
func init() {
370+
rootCmd.AddCommand(delCmd)
371+
delCmd.Flags().StringArrayVarP(&delCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
372+
delCmd.Flags().StringVar(&delCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
373+
}
374+

0 commit comments

Comments
 (0)