diff --git a/pkg/antctl/antctl.go b/pkg/antctl/antctl.go index 78981fcfe86..8e9dce13c0b 100644 --- a/pkg/antctl/antctl.go +++ b/pkg/antctl/antctl.go @@ -23,6 +23,7 @@ import ( checkinstallation "antrea.io/antrea/pkg/antctl/raw/check/installation" "antrea.io/antrea/pkg/antctl/raw/featuregates" "antrea.io/antrea/pkg/antctl/raw/multicluster" + "antrea.io/antrea/pkg/antctl/raw/packetcapture" "antrea.io/antrea/pkg/antctl/raw/proxy" "antrea.io/antrea/pkg/antctl/raw/set" "antrea.io/antrea/pkg/antctl/raw/supportbundle" @@ -750,6 +751,11 @@ $ antctl get podmulticaststats pod -n namespace`, supportAgent: true, supportController: true, }, + { + cobraCommand: packetcapture.Command, + supportAgent: true, + supportController: true, + }, { cobraCommand: proxy.Command, supportAgent: false, diff --git a/pkg/antctl/command_list_test.go b/pkg/antctl/command_list_test.go index 509d9c8701d..54a08f2267c 100644 --- a/pkg/antctl/command_list_test.go +++ b/pkg/antctl/command_list_test.go @@ -65,12 +65,12 @@ func TestGetDebugCommands(t *testing.T) { { name: "Antctl running against controller mode", mode: "controller", - expected: [][]string{{"version"}, {"get", "networkpolicy"}, {"get", "appliedtogroup"}, {"get", "addressgroup"}, {"get", "controllerinfo"}, {"supportbundle"}, {"traceflow"}, {"get", "featuregates"}}, + expected: [][]string{{"version"}, {"get", "networkpolicy"}, {"get", "appliedtogroup"}, {"get", "addressgroup"}, {"get", "controllerinfo"}, {"supportbundle"}, {"traceflow"}, {"packetcapture"}, {"get", "featuregates"}}, }, { name: "Antctl running against agent mode", mode: "agent", - expected: [][]string{{"version"}, {"get", "podmulticaststats"}, {"log-level"}, {"get", "networkpolicy"}, {"get", "appliedtogroup"}, {"get", "addressgroup"}, {"get", "agentinfo"}, {"get", "podinterface"}, {"get", "ovsflows"}, {"trace-packet"}, {"get", "serviceexternalip"}, {"get", "memberlist"}, {"get", "bgppolicy"}, {"get", "bgppeers"}, {"get", "bgproutes"}, {"supportbundle"}, {"traceflow"}, {"get", "featuregates"}}, + expected: [][]string{{"version"}, {"get", "podmulticaststats"}, {"log-level"}, {"get", "networkpolicy"}, {"get", "appliedtogroup"}, {"get", "addressgroup"}, {"get", "agentinfo"}, {"get", "podinterface"}, {"get", "ovsflows"}, {"trace-packet"}, {"get", "serviceexternalip"}, {"get", "memberlist"}, {"get", "bgppolicy"}, {"get", "bgppeers"}, {"get", "bgproutes"}, {"supportbundle"}, {"traceflow"}, {"packetcapture"}, {"get", "featuregates"}}, }, { name: "Antctl running against flow-aggregator mode", diff --git a/pkg/antctl/raw/packetcapture/command.go b/pkg/antctl/raw/packetcapture/command.go new file mode 100644 index 00000000000..a20838a48b0 --- /dev/null +++ b/pkg/antctl/raw/packetcapture/command.go @@ -0,0 +1,325 @@ +// Copyright 2025 Antrea 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 packetcapture + +import ( + "context" + "errors" + "fmt" + "net" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + + "antrea.io/antrea/pkg/antctl/raw" + "antrea.io/antrea/pkg/apis/crd/v1alpha1" + antrea "antrea.io/antrea/pkg/client/clientset/versioned" + "antrea.io/antrea/pkg/util/env" +) + +var ( + defaultTimeout = time.Second * 60 + Command *cobra.Command + getClients = getConfigAndClients + getCopier = getPodFile + defaultFS = afero.NewOsFs() +) +var option = &struct { + source string + dest string + nowait bool + timeout time.Duration + number int32 + flow string + outputDir string +}{} + +var packetCaptureExample = strings.TrimSpace(` + Start capture packets from pod1 to pod2, both Pods are in Namespace default + $ antctl packetcaputre -S pod1 -D pod2 + Start capture packets from pod1 in Namespace ns1 to a destination IP + $ antctl packetcapture -S ns1/pod1 -D 192.168.123.123 + Start capture UDP packets from pod1 to pod2, with destination port 1234 + $ antctl packetcapture -S pod1 -D pod2 -f udp,udp_dst=1234 + Save the packets file to a specified directory + $ antctl packetcapture -S 192.168.123.123 -D pod2 -f tcp,tcp_dst=80 -o /tmp +`) + +func init() { + Command = &cobra.Command{ + Use: "packetcapture", + Short: "Start capture packets", + Long: "Start capture packets on the target flow.", + Aliases: []string{"pc", "packetcaptures"}, + Example: packetCaptureExample, + RunE: packetCaptureRunE, + } + + Command.Flags().StringVarP(&option.source, "source", "S", "", "source of the the PacketCapture: Namespace/Pod, Pod, or IP") + Command.Flags().StringVarP(&option.dest, "destination", "D", "", "destination of the PacketCapture: Namespace/Pod, Pod, or IP") + Command.Flags().Int32VarP(&option.number, "number", "n", 0, "target packets number") + Command.Flags().StringVarP(&option.flow, "flow", "f", "", "specify the flow (packet headers) of the PacketCapture , including tcp_src, tcp_dst, udp_src, udp_dst") + Command.Flags().BoolVarP(&option.nowait, "nowait", "", false, "if set, command returns without retrieving results") + Command.Flags().StringVarP(&option.outputDir, "output-dir", "o", ".", "save the packets file to the target directory") +} + +var protocols = map[string]int32{ + "icmp": 1, + "tcp": 6, + "udp": 17, +} + +func getFlowFields(flow string) (map[string]int, error) { + fields := map[string]int{} + for _, v := range strings.Split(flow, ",") { + kv := strings.Split(v, "=") + if len(kv) == 2 && len(kv[0]) != 0 && len(kv[1]) != 0 { + r, err := strconv.Atoi(kv[1]) + if err != nil { + return nil, err + } + fields[kv[0]] = r + } else if len(kv) == 1 { + if len(kv[0]) != 0 { + fields[v] = 0 + } + } else { + return nil, fmt.Errorf("%s is not valid in flow", v) + } + } + return fields, nil +} + +func getConfigAndClients(cmd *cobra.Command) (*rest.Config, kubernetes.Interface, antrea.Interface, error) { + kubeConfig, err := raw.ResolveKubeconfig(cmd) + if err != nil { + return nil, nil, nil, err + } + k8sClientset, client, err := raw.SetupClients(kubeConfig) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to create clientset: %w", err) + } + return kubeConfig, k8sClientset, client, nil +} + +func getPodFile(cmd *cobra.Command) (PodFileCopy, error) { + config, client, _, err := getClients(cmd) + if err != nil { + return nil, err + } + return &podFile{ + restConfig: config, + restInterface: client.CoreV1().RESTClient(), + }, nil +} + +func packetCaptureRunE(cmd *cobra.Command, args []string) error { + option.timeout, _ = cmd.Flags().GetDuration("timeout") + if option.timeout > time.Hour { + return errors.New("timeout cannot be longer than 1 hour") + } + if option.timeout == 0 { + option.timeout = defaultTimeout + } + if option.number == 0 { + return errors.New("packet number should be larger than 0") + } + + _, _, antreaClient, err := getClients(cmd) + if err != nil { + return err + } + pc, err := newPacketCapture() + if err != nil { + return fmt.Errorf("error when filling up PacketCapture config: %w", err) + } + createCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if _, err := antreaClient.CrdV1alpha1().PacketCaptures().Create(createCtx, pc, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("error when creating PacketCapture, is PacketCapture feature gate enabled? %w", err) + } + defer func() { + if !option.nowait { + if err = antreaClient.CrdV1alpha1().PacketCaptures().Delete(context.TODO(), pc.Name, metav1.DeleteOptions{}); err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "error when deleting PacketCapture: %s", err.Error()) + } + } + }() + + if option.nowait { + fmt.Fprintf(cmd.OutOrStdout(), "PacketCapture Name: %s\n", pc.Name) + return nil + } + + var latestPC *v1alpha1.PacketCapture + err = wait.PollUntilContextTimeout(context.TODO(), 1*time.Second, option.timeout, false, func(ctx context.Context) (bool, error) { + res, err := antreaClient.CrdV1alpha1().PacketCaptures().Get(ctx, pc.Name, metav1.GetOptions{}) + if err != nil { + return false, err + } + for _, cond := range res.Status.Conditions { + if cond.Type == v1alpha1.PacketCaptureComplete && cond.Status == metav1.ConditionTrue { + latestPC = res + return true, nil + } + } + return false, nil + + }) + + if wait.Interrupted(err) { + err = errors.New("timeout waiting for PacketCapture done") + if latestPC == nil { + return err + } + } else if err != nil { + return fmt.Errorf("error when retrieving PacketCapture: %w", err) + } + + splits := strings.Split(latestPC.Status.FilePath, ":") + fileName := filepath.Base(splits[1]) + copier, _ := getCopier(cmd) + err = copier.CopyFromPod(context.TODO(), env.GetAntreaNamespace(), splits[0], "antrea-agent", splits[1], option.outputDir) + if err == nil { + fmt.Fprintf(cmd.OutOrStdout(), "Packet File: %s\n", filepath.Join(option.outputDir, fileName)) + } + return err +} + +func parseEndpoint(endpoint string) (pod *v1alpha1.PodReference, ip *string) { + parsedIP := net.ParseIP(endpoint) + if parsedIP != nil && parsedIP.To4() != nil { + ip = ptr.To(parsedIP.String()) + } else { + split := strings.Split(endpoint, "/") + if len(split) == 1 { + pod = &v1alpha1.PodReference{ + Namespace: "default", + Name: split[0], + } + } else if len(split) == 2 && len(split[0]) != 0 && len(split[1]) != 0 { + pod = &v1alpha1.PodReference{ + Namespace: split[0], + Name: split[1], + } + } + } + return +} + +func getPCName(src, dest string) string { + replace := func(s string) string { + return strings.ReplaceAll(s, "/", "-") + } + prefix := fmt.Sprintf("%s-%s", replace(src), replace(dest)) + if option.nowait { + return prefix + } + return fmt.Sprintf("%s-%s", prefix, rand.String(8)) +} + +func parseFlow() (*v1alpha1.Packet, error) { + cleanFlow := strings.ReplaceAll(option.flow, " ", "") + fields, err := getFlowFields(cleanFlow) + if err != nil { + return nil, fmt.Errorf("error when parsing the flow: %w", err) + } + var pkt v1alpha1.Packet + pkt.IPFamily = v1.IPv4Protocol + for k, v := range protocols { + if _, ok := fields[k]; ok { + pkt.Protocol = ptr.To(intstr.FromInt32(v)) + break + } + } + if r, ok := fields["tcp_src"]; ok { + pkt.TransportHeader.TCP = new(v1alpha1.TCPHeader) + pkt.TransportHeader.TCP.SrcPort = ptr.To(int32(r)) + } + if r, ok := fields["tcp_dst"]; ok { + if pkt.TransportHeader.TCP == nil { + pkt.TransportHeader.TCP = new(v1alpha1.TCPHeader) + } + pkt.TransportHeader.TCP.DstPort = ptr.To(int32(r)) + } + if r, ok := fields["udp_src"]; ok { + pkt.TransportHeader.UDP = new(v1alpha1.UDPHeader) + pkt.TransportHeader.UDP.SrcPort = ptr.To(int32(r)) + } + if r, ok := fields["udp_dst"]; ok { + if pkt.TransportHeader.UDP != nil { + pkt.TransportHeader.UDP = new(v1alpha1.UDPHeader) + } + pkt.TransportHeader.UDP.DstPort = ptr.To(int32(r)) + } + return &pkt, nil +} + +func newPacketCapture() (*v1alpha1.PacketCapture, error) { + var src v1alpha1.Source + if option.source != "" { + src.Pod, src.IP = parseEndpoint(option.source) + if src.Pod == nil && src.IP == nil { + return nil, fmt.Errorf("source should be in the format of Namespace/Pod, Pod, or IPv4") + } + } + + var dst v1alpha1.Destination + if option.dest != "" { + dst.Pod, dst.IP = parseEndpoint(option.dest) + if dst.Pod == nil && dst.IP == nil { + return nil, fmt.Errorf("destination should be in the format of Namespace/Pod, Pod, or IPv4") + } + } + + if src.Pod == nil && dst.Pod == nil { + return nil, errors.New("one of source and destination must be a Pod") + } + pkt, err := parseFlow() + if err != nil { + return nil, fmt.Errorf("failed to parse flow: %w", err) + } + + name := getPCName(option.source, option.dest) + pc := &v1alpha1.PacketCapture{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.PacketCaptureSpec{ + Source: src, + Destination: dst, + Packet: pkt, + CaptureConfig: v1alpha1.CaptureConfig{ + FirstN: &v1alpha1.PacketCaptureFirstNConfig{ + Number: option.number, + }, + }, + }, + } + return pc, nil +} diff --git a/pkg/antctl/raw/packetcapture/command_test.go b/pkg/antctl/raw/packetcapture/command_test.go new file mode 100644 index 00000000000..ebde5d99143 --- /dev/null +++ b/pkg/antctl/raw/packetcapture/command_test.go @@ -0,0 +1,267 @@ +// Copyright 2025 Antrea 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 packetcapture + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" + k8sfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + "k8s.io/utils/ptr" + + "antrea.io/antrea/pkg/apis/crd/v1alpha1" + antrea "antrea.io/antrea/pkg/client/clientset/versioned" + antreafakeclient "antrea.io/antrea/pkg/client/clientset/versioned/fake" +) + +const ( + srcPod = "default/pod-1" + dstPod = "pod-2" + ipv4 = "192.168.10.10" + testNum int32 = 10 +) + +var ( + antreaAgentPod = v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "antrea-agent-1", + Namespace: "kube-system", + }, + } + pod1 = v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", + Namespace: "default", + }, + } + pod2 = v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-2", + Namespace: "default", + }, + } + k8sClient = k8sfake.NewSimpleClientset(&pod1, &pod2, &antreaAgentPod) +) + +type testPodFile struct { +} + +func (p *testPodFile) CopyFromPod(ctx context.Context, namespace, name, containerName, srcPath, dstDir string) error { + return nil +} + +func setCommandOptions(src, dst, notwait, flow string) { + Command.Flags().Set("source", src) + Command.Flags().Set("destination", dst) + Command.Flags().Set("nowait", notwait) + Command.Flags().Set("number", "10") + Command.Flags().Set("flow", flow) +} + +func TestRun(t *testing.T) { + tcs := []struct { + name string + src string + dst string + nowait string + flow string + expectErr string + }{ + { + name: "pod-2-pod", + src: srcPod, + dst: dstPod, + flow: "tcp,tcp_src=500060,tcp_dst=80", + }, + { + name: "pod-2-ip", + src: srcPod, + dst: ipv4, + nowait: "true", + flow: "udp,udp_src=1234,udp_dst=1234", + }, + { + name: "timeout", + src: srcPod, + dst: dstPod, + flow: "icmp", + expectErr: "timeout waiting for PacketCapture done", + }, + { + name: "invalid-packetcapture", + src: ipv4, + dst: ipv4, + flow: "icmp", + expectErr: "error when filling up PacketCapture config: one of source and destination must be a Pod", + }, + } + for _, tt := range tcs { + t.Run(tt.name, func(t *testing.T) { + setCommandOptions(tt.src, tt.dst, tt.nowait, tt.flow) + defaultTimeout = 3 * time.Second + defer func() { + setCommandOptions("", "", "", "") + defaultTimeout = 60 * time.Second + }() + + client := antreafakeclient.NewSimpleClientset() + client.PrependReactor("create", "packetcaptures", func(action k8stesting.Action) (bool, runtime.Object, error) { + createAction := action.(k8stesting.CreateAction) + obj := createAction.GetObject().(*v1alpha1.PacketCapture) + if tt.expectErr == "" { + obj.Status.FilePath = fmt.Sprintf("%s:/tmp/antrea/packages/%s.pcapng", antreaAgentPod.Name, antreaAgentPod.Name) + obj.Status.Conditions = []v1alpha1.PacketCaptureCondition{ + { + Type: v1alpha1.PacketCaptureComplete, + Status: metav1.ConditionTrue, + }, + } + } + return false, obj, nil + }) + getClients = func(cmd *cobra.Command) (*rest.Config, kubernetes.Interface, antrea.Interface, error) { + return nil, k8sClient, client, nil + } + getCopier = func(cmd *cobra.Command) (PodFileCopy, error) { + return &testPodFile{}, nil + } + + defer func() { + getClients = getConfigAndClients + getCopier = getPodFile + }() + buf := new(bytes.Buffer) + Command.SetOutput(buf) + Command.SetOut(buf) + Command.SetErr(buf) + + err := packetCaptureRunE(Command, nil) + if tt.expectErr == "" { + require.NoError(t, err) + } else { + require.NotNil(t, err) + require.Equal(t, tt.expectErr, err.Error()) + } + }) + } +} + +func TestNewPacketCapture(t *testing.T) { + tcs := []struct { + name string + src string + dst string + flow string + expectErr string + expectPC *v1alpha1.PacketCapture + }{ + { + name: "pod-2-pod-tcp", + src: srcPod, + dst: dstPod, + flow: "tcp,tcp_dst=80", + expectPC: &v1alpha1.PacketCapture{ + Spec: v1alpha1.PacketCaptureSpec{ + Source: v1alpha1.Source{ + Pod: &v1alpha1.PodReference{ + Namespace: "default", + Name: "pod-1", + }, + }, + Destination: v1alpha1.Destination{ + Pod: &v1alpha1.PodReference{ + Namespace: "default", + Name: "pod-2", + }, + }, + CaptureConfig: v1alpha1.CaptureConfig{ + FirstN: &v1alpha1.PacketCaptureFirstNConfig{ + Number: testNum, + }, + }, + Packet: &v1alpha1.Packet{ + IPFamily: v1.IPv4Protocol, + Protocol: ptr.To(intstr.FromInt(6)), + TransportHeader: v1alpha1.TransportHeader{ + TCP: &v1alpha1.TCPHeader{ + DstPort: ptr.To(int32(80)), + }, + }, + }, + }, + }, + }, + { + name: "no-pod", + src: "127.0.0.1", + dst: "127.0.0.1", + expectErr: "one of source and destination must be a Pod", + }, + { + name: "bad-flow", + src: srcPod, + dst: dstPod, + flow: "tcp,tcp_dst=invalid", + expectErr: "failed to parse flow: error when parsing the flow: strconv.Atoi: parsing \"invalid\": invalid syntax", + }, + { + name: "bad-flow-2", + src: srcPod, + dst: dstPod, + flow: "tcp,tcp_dst=80=80", + expectErr: "failed to parse flow: error when parsing the flow: tcp_dst=80=80 is not valid in flow", + }, + } + + defer func() { + option.source = "" + option.dest = "" + option.flow = "" + option.number = 0 + }() + + for _, tt := range tcs { + t.Run(tt.name, func(t *testing.T) { + option.source = tt.src + option.dest = tt.dst + option.flow = tt.flow + option.number = testNum + + pc, err := newPacketCapture() + if tt.expectErr != "" { + require.NotNil(t, err) + require.Equal(t, tt.expectErr, err.Error()) + } else { + require.Nil(t, err) + assert.Equal(t, tt.expectPC.Spec, pc.Spec) + } + + }) + } + +} diff --git a/pkg/antctl/raw/packetcapture/cp.go b/pkg/antctl/raw/packetcapture/cp.go new file mode 100644 index 00000000000..07251aaf686 --- /dev/null +++ b/pkg/antctl/raw/packetcapture/cp.go @@ -0,0 +1,101 @@ +// Copyright 2025 Antrea 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 packetcapture + +import ( + "archive/tar" + "context" + "io" + "os" + "path/filepath" + _ "unsafe" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" +) + +type PodFileCopy interface { + CopyFromPod(ctx context.Context, namespace, name, containerName, srcPath, dstDir string) error +} + +type podFile struct { + restConfig *rest.Config + restInterface rest.Interface +} + +func (p *podFile) CopyFromPod(ctx context.Context, namespace, name, containerName, srcPath, dstDir string) error { + reader, outStream := io.Pipe() + cmdArr := []string{"tar", "cf", "-", srcPath} + req := p.restInterface. + Get(). + Namespace(namespace). + Resource("pods"). + Name(name). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: containerName, + Command: cmdArr, + Stdin: true, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(p.restConfig, "POST", req.URL()) + if err != nil { + return err + } + go func() { + defer outStream.Close() + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: os.Stdin, + Stdout: outStream, + Stderr: os.Stderr, + Tty: false, + }) + if err != nil { + panic(err) + } + }() + err = untarAll(reader, dstDir) + return err +} + +// TODO: wait for https://github.com/antrea-io/antrea/pull/3659 got merged and reuse its function. +// nolint: gosec +func untarAll(reader io.Reader, dstDir string) error { + tarReader := tar.NewReader(reader) + for { + header, err := tarReader.Next() + if err != nil { + if err != io.EOF { + return err + } + break + } + baseName := filepath.Base(header.Name) + outFile, err := defaultFS.Create(filepath.Join(dstDir, baseName)) + if err != nil { + return err + } + defer outFile.Close() + if _, err := io.Copy(outFile, tarReader); err != nil { + return err + } + } + return nil +}