Skip to content

Commit 1ef4659

Browse files
committed
[cmd] add generic delete command with file-based and type/name modes
1 parent 8f437c4 commit 1ef4659

File tree

1 file changed

+355
-0
lines changed

1 file changed

+355
-0
lines changed

pkg/cmd/delete.go

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"time"
9+
10+
"github.com/spf13/cobra"
11+
apierrors "k8s.io/apimachinery/pkg/api/errors"
12+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
14+
"k8s.io/apimachinery/pkg/runtime/schema"
15+
"k8s.io/apimachinery/pkg/watch"
16+
"k8s.io/client-go/discovery"
17+
memory "k8s.io/client-go/discovery/cached"
18+
"k8s.io/client-go/dynamic"
19+
"k8s.io/client-go/restmapper"
20+
21+
"github.com/apoxy-dev/apoxy/client/versioned/scheme"
22+
"github.com/apoxy-dev/apoxy/config"
23+
"github.com/apoxy-dev/apoxy/pkg/cmd/resource"
24+
)
25+
26+
var (
27+
deleteFiles []string
28+
deleteRecursive bool
29+
deleteIgnoreNotFound bool
30+
deleteWait bool
31+
deleteWaitTimeout time.Duration
32+
)
33+
34+
// deleteCmd is the global delete command for multi-resource operations.
35+
var deleteCmd = &cobra.Command{
36+
Use: "delete (-f <filename> | <type> <name> [<name>...])",
37+
Short: "Delete resources by file or type/name",
38+
Long: `Delete resources identified in file(s), directories, stdin, or by type and name.
39+
40+
Supports two modes:
41+
1. File-based: delete resources specified in YAML/JSON files, directories, or stdin.
42+
2. Type+name: delete one or more resources by specifying the resource type and name(s).
43+
44+
When a directory is specified with -f, all .yaml, .yml, and .json files are processed.
45+
46+
Examples:
47+
# Delete resources in a single file
48+
apoxy delete -f gateway.yaml
49+
50+
# Delete resources in multiple files
51+
apoxy delete -f gateway.yaml -f routes.yaml
52+
53+
# Delete all resources in a directory
54+
apoxy delete -f ./manifests/
55+
56+
# Delete from stdin
57+
cat manifest.yaml | apoxy delete -f -
58+
59+
# Delete a resource by type and name
60+
apoxy delete proxy my-proxy
61+
62+
# Delete multiple resources by type and name
63+
apoxy delete backend backend-a backend-b
64+
65+
# Delete a resource, ignoring if it doesn't exist
66+
apoxy delete proxy my-proxy --ignore-not-found
67+
68+
# Delete and wait for the resource to be fully removed
69+
apoxy delete proxy my-proxy --wait`,
70+
RunE: func(cmd *cobra.Command, args []string) error {
71+
hasFiles := len(deleteFiles) > 0
72+
hasArgs := len(args) > 0
73+
74+
if !hasFiles && !hasArgs {
75+
return fmt.Errorf("please specify files with -f/--filename or provide <type> <name>")
76+
}
77+
if hasFiles && hasArgs {
78+
return fmt.Errorf("cannot specify both -f/--filename and type/name arguments")
79+
}
80+
81+
cmd.SilenceUsage = true
82+
83+
c, err := config.DefaultAPIClient()
84+
if err != nil {
85+
return err
86+
}
87+
88+
// Set up dynamic client and REST mapper.
89+
dc, err := discovery.NewDiscoveryClientForConfig(c.RESTConfig)
90+
if err != nil {
91+
return fmt.Errorf("failed to create discovery client: %w", err)
92+
}
93+
dynClient, err := dynamic.NewForConfig(c.RESTConfig)
94+
if err != nil {
95+
return fmt.Errorf("failed to create dynamic client: %w", err)
96+
}
97+
mapper := restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(dc))
98+
99+
if hasArgs {
100+
return deleteByTypeAndName(cmd.Context(), dynClient, mapper, args)
101+
}
102+
return deleteFromFiles(cmd.Context(), dynClient, mapper)
103+
},
104+
}
105+
106+
// deleteFromFiles processes -f flag inputs and deletes each resource found.
107+
func deleteFromFiles(
108+
ctx context.Context,
109+
dynClient dynamic.Interface,
110+
mapper *restmapper.DeferredDiscoveryRESTMapper,
111+
) error {
112+
// Collect all file contents.
113+
var allData [][]byte
114+
for _, f := range deleteFiles {
115+
data, err := readInput(f, deleteRecursive)
116+
if err != nil {
117+
return err
118+
}
119+
allData = append(allData, data...)
120+
}
121+
122+
var errs []error
123+
var deleted int
124+
125+
for _, data := range allData {
126+
docs := splitYAMLDocuments(data)
127+
for _, doc := range docs {
128+
if len(strings.TrimSpace(string(doc))) == 0 {
129+
continue
130+
}
131+
132+
name, kind, notFound, err := deleteResource(ctx, dynClient, mapper, doc)
133+
if err != nil {
134+
errs = append(errs, err)
135+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
136+
} else if notFound {
137+
fmt.Printf("%s %q not found (ignored)\n", strings.ToLower(kind), name)
138+
} else {
139+
fmt.Printf("%s %q deleted\n", strings.ToLower(kind), name)
140+
deleted++
141+
}
142+
}
143+
}
144+
145+
if len(errs) > 0 {
146+
fmt.Fprintf(os.Stderr, "\nDeleted %d resource(s) with %d error(s)\n", deleted, len(errs))
147+
return fmt.Errorf("failed to delete %d resource(s)", len(errs))
148+
}
149+
150+
if deleted > 1 {
151+
fmt.Printf("\nDeleted %d resources\n", deleted)
152+
}
153+
return nil
154+
}
155+
156+
// deleteResource decodes and deletes a single resource using the dynamic client.
157+
// notFound is true when the resource was not found and --ignore-not-found is set.
158+
func deleteResource(
159+
ctx context.Context,
160+
dynClient dynamic.Interface,
161+
mapper *restmapper.DeferredDiscoveryRESTMapper,
162+
data []byte,
163+
) (name, kind string, notFound bool, err error) {
164+
// Decode into unstructured object.
165+
unObj := &unstructured.Unstructured{}
166+
_, gvk, err := scheme.Codecs.UniversalDeserializer().Decode(data, nil, unObj)
167+
if err != nil {
168+
return "", "", false, fmt.Errorf("failed to decode resource: %w", err)
169+
}
170+
171+
name = unObj.GetName()
172+
if name == "" {
173+
if fn, ok := resource.LookupDefaultName(*gvk); ok {
174+
derivedName, err := fn(data)
175+
if err != nil {
176+
return "", "", false, err
177+
}
178+
unObj.SetName(derivedName)
179+
name = derivedName
180+
}
181+
}
182+
if name == "" {
183+
return "", "", false, fmt.Errorf("resource name is required")
184+
}
185+
kind = gvk.Kind
186+
187+
// Get the REST mapping for this GVK.
188+
mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
189+
if err != nil {
190+
return name, kind, false, fmt.Errorf("failed to get REST mapping for %s: %w", gvk.String(), err)
191+
}
192+
193+
res := dynClient.Resource(mapping.Resource).Namespace(unObj.GetNamespace())
194+
195+
// Delete the resource.
196+
err = res.Delete(ctx, name, metav1.DeleteOptions{})
197+
if err != nil {
198+
if apierrors.IsNotFound(err) && deleteIgnoreNotFound {
199+
return name, kind, true, nil
200+
}
201+
return name, kind, false, fmt.Errorf("failed to delete %s %q: %w", kind, name, err)
202+
}
203+
204+
if deleteWait {
205+
if err := waitForDeletion(ctx, res, name, deleteWaitTimeout); err != nil {
206+
return name, kind, false, fmt.Errorf("failed waiting for %s %q to be deleted: %w", kind, name, err)
207+
}
208+
}
209+
210+
return name, kind, false, nil
211+
}
212+
213+
// deleteByTypeAndName deletes resources specified as positional arguments: <type> <name> [<name>...].
214+
func deleteByTypeAndName(
215+
ctx context.Context,
216+
dynClient dynamic.Interface,
217+
mapper *restmapper.DeferredDiscoveryRESTMapper,
218+
args []string,
219+
) error {
220+
if len(args) < 2 {
221+
return fmt.Errorf("please specify resource type and at least one name: <type> <name> [<name>...]")
222+
}
223+
224+
typeName := args[0]
225+
names := args[1:]
226+
227+
// Resolve the user-provided type name to a GroupVersionResource.
228+
gvr, err := resolveResourceType(mapper, typeName)
229+
if err != nil {
230+
return err
231+
}
232+
233+
// Resolve the kind name for display purposes.
234+
displayKind := typeName
235+
if gvk, err := mapper.KindFor(gvr); err == nil {
236+
displayKind = strings.ToLower(gvk.Kind)
237+
}
238+
239+
var errs []error
240+
var deleted int
241+
242+
for _, name := range names {
243+
// Use .Namespace("") explicitly to match the file-based path's pattern.
244+
// Cluster-scoped resources are unaffected; if namespace support is added
245+
// later, this is the call site to update.
246+
res := dynClient.Resource(gvr).Namespace("")
247+
err := res.Delete(ctx, name, metav1.DeleteOptions{})
248+
if err != nil {
249+
if apierrors.IsNotFound(err) && deleteIgnoreNotFound {
250+
fmt.Printf("%s %q not found (ignored)\n", displayKind, name)
251+
continue
252+
}
253+
errs = append(errs, fmt.Errorf("failed to delete %s %q: %w", displayKind, name, err))
254+
fmt.Fprintf(os.Stderr, "error: failed to delete %s %q: %v\n", displayKind, name, err)
255+
continue
256+
}
257+
258+
if deleteWait {
259+
if err := waitForDeletion(ctx, res, name, deleteWaitTimeout); err != nil {
260+
errs = append(errs, fmt.Errorf("failed waiting for %s %q to be deleted: %w", displayKind, name, err))
261+
fmt.Fprintf(os.Stderr, "error: failed waiting for %s %q to be deleted: %v\n", displayKind, name, err)
262+
continue
263+
}
264+
}
265+
266+
fmt.Printf("%s %q deleted\n", displayKind, name)
267+
deleted++
268+
}
269+
270+
if len(errs) > 0 {
271+
fmt.Fprintf(os.Stderr, "\nDeleted %d resource(s) with %d error(s)\n", deleted, len(errs))
272+
return fmt.Errorf("failed to delete %d resource(s)", len(errs))
273+
}
274+
275+
if deleted > 1 {
276+
fmt.Printf("\nDeleted %d resources\n", deleted)
277+
}
278+
return nil
279+
}
280+
281+
// resolveResourceType resolves a user-provided resource type string (e.g. "proxy", "proxies",
282+
// "backends") to a fully qualified GroupVersionResource using the API server's discovery info.
283+
func resolveResourceType(
284+
mapper *restmapper.DeferredDiscoveryRESTMapper,
285+
typeName string,
286+
) (schema.GroupVersionResource, error) {
287+
// Try the user-provided name as a resource (handles both singular and plural).
288+
fullySpecified := schema.GroupVersionResource{Resource: typeName}
289+
gvr, err := mapper.ResourceFor(fullySpecified)
290+
if err == nil {
291+
return gvr, nil
292+
}
293+
294+
return schema.GroupVersionResource{}, fmt.Errorf(
295+
"unable to find resource type %q: %w",
296+
typeName, err,
297+
)
298+
}
299+
300+
// waitForDeletion watches the resource and blocks until it is fully removed or the timeout expires.
301+
func waitForDeletion(
302+
ctx context.Context,
303+
res dynamic.ResourceInterface,
304+
name string,
305+
timeout time.Duration,
306+
) error {
307+
// Get the current resourceVersion so the subsequent Watch doesn't miss
308+
// a deletion that happens between the Get and Watch calls.
309+
obj, err := res.Get(ctx, name, metav1.GetOptions{})
310+
if apierrors.IsNotFound(err) {
311+
return nil
312+
}
313+
if err != nil {
314+
return fmt.Errorf("failed to check resource: %w", err)
315+
}
316+
317+
ctx, cancel := context.WithTimeout(ctx, timeout)
318+
defer cancel()
319+
320+
watcher, err := res.Watch(ctx, metav1.ListOptions{
321+
FieldSelector: "metadata.name=" + name,
322+
ResourceVersion: obj.GetResourceVersion(),
323+
})
324+
if err != nil {
325+
return fmt.Errorf("failed to watch resource: %w", err)
326+
}
327+
defer watcher.Stop()
328+
329+
for event := range watcher.ResultChan() {
330+
if event.Type == watch.Deleted {
331+
return nil
332+
}
333+
}
334+
335+
// Channel closed — check if context timed out.
336+
if ctx.Err() != nil {
337+
return fmt.Errorf("timed out after %v waiting for deletion", timeout)
338+
}
339+
return nil
340+
}
341+
342+
func init() {
343+
deleteCmd.Flags().StringArrayVarP(&deleteFiles, "filename", "f", nil,
344+
"Files or directories containing resources to delete (can be specified multiple times)")
345+
deleteCmd.Flags().BoolVarP(&deleteRecursive, "recursive", "R", false,
346+
"Process directories recursively")
347+
deleteCmd.Flags().BoolVar(&deleteIgnoreNotFound, "ignore-not-found", false,
348+
"Treat \"resource not found\" as a successful delete")
349+
deleteCmd.Flags().BoolVar(&deleteWait, "wait", false,
350+
"Wait for the resource to be fully deleted before returning")
351+
deleteCmd.Flags().DurationVar(&deleteWaitTimeout, "timeout", 60*time.Second,
352+
"Timeout for --wait (e.g. 30s, 2m)")
353+
354+
RootCmd.AddCommand(deleteCmd)
355+
}

0 commit comments

Comments
 (0)