diff --git a/.dockerignore b/.dockerignore index 16d3c4dbbfe..dbb2fcacbf2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,4 @@ .cache +bin/antctl-darwin +bin/antctl-linux +bin/antctl-windows.exe diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 73d33caa5ec..2e8299a41a4 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -58,6 +58,22 @@ jobs: - name: Build Antrea binaries run: make bin + antctl: + name: Build antctl for macOS, Linux and Windows + runs-on: [ubuntu-18.04] + steps: + + - name: Set up Go 1.13 + uses: actions/setup-go@v1 + with: + go-version: 1.13 + + - name: Check-out code + uses: actions/checkout@v1 + + - name: Build antctl binaries + run: make antctl + codegen: name: Check code generation diff --git a/Makefile b/Makefile index e7e473f0710..e331fe7ce26 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ +SHELL := /bin/bash # go options GO ?= go LDFLAGS := @@ -75,6 +76,19 @@ docker-tidy: $(DOCKER_CACHE) .linux-bin: GOBIN=$(BINDIR) $(GO) install $(GOFLAGS) -ldflags '$(LDFLAGS)' github.com/vmware-tanzu/antrea/cmd/... +# TODO: strip binary when building releases +ANTCTL_BINARIES := antctl-darwin antctl-linux antctl-windows +$(ANTCTL_BINARIES): antctl-%: + @GOOS=$* $(GO) build -o $(BINDIR)/$@ $(GOFLAGS) -ldflags '$(LDFLAGS)' github.com/vmware-tanzu/antrea/cmd/antctl + @if [[ $@ != *windows ]]; then \ + chmod 0755 $(BINDIR)/$@; \ + else \ + mv $(BINDIR)/$@ $(BINDIR)/$@.exe; \ + fi + +.PHONY: antctl +antctl: $(ANTCTL_BINARIES) + .PHONY: .linux-test-unit .linux-test-unit: @echo diff --git a/build/yamls/antrea-ipsec.yml b/build/yamls/antrea-ipsec.yml index b1a3d4b9b93..4abb051d23e 100644 --- a/build/yamls/antrea-ipsec.yml +++ b/build/yamls/antrea-ipsec.yml @@ -40,6 +40,14 @@ spec: --- apiVersion: v1 kind: ServiceAccount +metadata: + labels: + app: antrea + name: antctl + namespace: kube-system +--- +apiVersion: v1 +kind: ServiceAccount metadata: labels: app: antrea @@ -56,6 +64,19 @@ metadata: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + labels: + app: antrea + name: antctl +rules: +- nonResourceURLs: + - /apis/system.antrea.tanzu.vmware.com + - /apis/system.antrea.tanzu.vmware.com/* + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: labels: app: antrea @@ -146,6 +167,22 @@ rules: --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding +metadata: + labels: + app: antrea + name: antctl + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: antctl +subjects: +- kind: ServiceAccount + name: antctl + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding metadata: labels: app: antrea @@ -336,6 +373,22 @@ spec: name: antrea-config-k5f958t9km name: antrea-config --- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + labels: + app: antrea + name: v1beta1.system.antrea.tanzu.vmware.com +spec: + group: system.antrea.tanzu.vmware.com + groupPriorityMinimum: 100 + insecureSkipTLSVerify: true + service: + name: antrea + namespace: kube-system + version: v1beta1 + versionPriority: 100 +--- apiVersion: apps/v1 kind: DaemonSet metadata: diff --git a/build/yamls/antrea.yml b/build/yamls/antrea.yml index 26032b6e478..6f0dd0c59e5 100644 --- a/build/yamls/antrea.yml +++ b/build/yamls/antrea.yml @@ -40,6 +40,14 @@ spec: --- apiVersion: v1 kind: ServiceAccount +metadata: + labels: + app: antrea + name: antctl + namespace: kube-system +--- +apiVersion: v1 +kind: ServiceAccount metadata: labels: app: antrea @@ -56,6 +64,19 @@ metadata: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + labels: + app: antrea + name: antctl +rules: +- nonResourceURLs: + - /apis/system.antrea.tanzu.vmware.com + - /apis/system.antrea.tanzu.vmware.com/* + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: labels: app: antrea @@ -146,6 +167,22 @@ rules: --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding +metadata: + labels: + app: antrea + name: antctl + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: antctl +subjects: +- kind: ServiceAccount + name: antctl + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding metadata: labels: app: antrea @@ -327,6 +364,22 @@ spec: name: antrea-config-tm7bht9mg6 name: antrea-config --- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + labels: + app: antrea + name: v1beta1.system.antrea.tanzu.vmware.com +spec: + group: system.antrea.tanzu.vmware.com + groupPriorityMinimum: 100 + insecureSkipTLSVerify: true + service: + name: antrea + namespace: kube-system + version: v1beta1 + versionPriority: 100 +--- apiVersion: apps/v1 kind: DaemonSet metadata: diff --git a/build/yamls/base/antctl.yml b/build/yamls/base/antctl.yml new file mode 100644 index 00000000000..1d7a6288466 --- /dev/null +++ b/build/yamls/base/antctl.yml @@ -0,0 +1,33 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: antctl + namespace: kube-system +--- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: antctl +rules: + - nonResourceURLs: + - /apis/system.antrea.tanzu.vmware.com + - /apis/system.antrea.tanzu.vmware.com/* + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app: antrea + name: antctl + namespace: kube-system +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: antctl +subjects: + - kind: ServiceAccount + name: antctl + namespace: kube-system diff --git a/build/yamls/base/controller.yml b/build/yamls/base/controller.yml index 3d0ee43536d..bdb422e7d44 100644 --- a/build/yamls/base/controller.yml +++ b/build/yamls/base/controller.yml @@ -11,6 +11,20 @@ spec: selector: component: antrea-controller --- +apiVersion: apiregistration.k8s.io/v1 +kind: APIService +metadata: + name: v1beta1.system.antrea.tanzu.vmware.com +spec: + insecureSkipTLSVerify: true + group: system.antrea.tanzu.vmware.com + groupPriorityMinimum: 100 + version: v1beta1 + versionPriority: 100 + service: + name: antrea + namespace: kube-system +--- apiVersion: apps/v1 kind: Deployment metadata: diff --git a/build/yamls/base/kustomization.yml b/build/yamls/base/kustomization.yml index 126e8ded47b..649552401c6 100644 --- a/build/yamls/base/kustomization.yml +++ b/build/yamls/base/kustomization.yml @@ -1,5 +1,6 @@ resources: - crds.yml +- antctl.yml - controller-rbac.yml - controller.yml - agent-rbac.yml diff --git a/cmd/antctl/main.go b/cmd/antctl/main.go new file mode 100644 index 00000000000..00a4bb8a639 --- /dev/null +++ b/cmd/antctl/main.go @@ -0,0 +1,60 @@ +// Copyright 2019 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 main + +import ( + "flag" + "os" + "path" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "k8s.io/component-base/logs" + + "github.com/vmware-tanzu/antrea/pkg/antctl" +) + +var ( + commandName = path.Base(os.Args[0]) + // TODO: May not work for antrea on windows + inPod = len(os.Getenv("POD_NAME")) != 0 + isAgent = strings.HasPrefix(os.Getenv("POD_NAME"), "antrea-agent") +) + +var rootCmd = &cobra.Command{ + Use: commandName, + Short: commandName + " is the command line tool for Antrea", + Long: commandName + " is the command line tool for Antrea that supports showing status of ${component}", +} + +func init() { + // prevent any unexpected output at beginning + flag.Set("logtostderr", "false") + flag.Set("v", "0") + pflag.CommandLine.MarkHidden("log-flush-frequency") +} + +func main() { + logs.InitLogs() + defer logs.FlushLogs() + + antctl.CommandList.ApplyToRootCommand(rootCmd, isAgent, inPod) + err := rootCmd.Execute() + if err != nil { + logs.FlushLogs() + os.Exit(1) + } +} diff --git a/cmd/antrea-agent/agent.go b/cmd/antrea-agent/agent.go index d00af36c892..cf366a5e69e 100644 --- a/cmd/antrea-agent/agent.go +++ b/cmd/antrea-agent/agent.go @@ -28,6 +28,7 @@ import ( "github.com/vmware-tanzu/antrea/pkg/agent/controller/noderoute" "github.com/vmware-tanzu/antrea/pkg/agent/interfacestore" "github.com/vmware-tanzu/antrea/pkg/agent/openflow" + "github.com/vmware-tanzu/antrea/pkg/antctl" "github.com/vmware-tanzu/antrea/pkg/apis/networking/v1beta1" "github.com/vmware-tanzu/antrea/pkg/k8s" "github.com/vmware-tanzu/antrea/pkg/monitor" @@ -89,6 +90,11 @@ func run(o *Options) error { } nodeConfig := agentInitializer.GetNodeConfig() + antctlServer, err := antctl.NewLocalServer() + if err != nil { + return fmt.Errorf("error when creating local antctl server: %w", err) + } + nodeRouteController := noderoute.NewNodeRouteController( k8sClient, informerFactory, @@ -138,6 +144,8 @@ func run(o *Options) error { go agentMonitor.Run(stopCh) + antctlServer.Start(agentMonitor, nil, stopCh) + <-stopCh klog.Info("Stopping Antrea agent") return nil diff --git a/cmd/antrea-controller/controller.go b/cmd/antrea-controller/controller.go index 07b63cf3cea..9fa3cc7cae3 100644 --- a/cmd/antrea-controller/controller.go +++ b/cmd/antrea-controller/controller.go @@ -24,6 +24,7 @@ import ( "k8s.io/client-go/informers" "k8s.io/klog" + "github.com/vmware-tanzu/antrea/pkg/antctl" "github.com/vmware-tanzu/antrea/pkg/apiserver" "github.com/vmware-tanzu/antrea/pkg/apiserver/storage" "github.com/vmware-tanzu/antrea/pkg/controller/networkpolicy" @@ -77,6 +78,11 @@ func run(o *Options) error { return fmt.Errorf("error creating API server: %v", err) } + antctlServer, err := antctl.NewLocalServer() + if err != nil { + return fmt.Errorf("error when creating local antctl server: %w", err) + } + // set up signal capture: the first SIGTERM / SIGINT signal is handled gracefully and will // cause the stopCh channel to be closed; if another signal is received before the program // exits, we will force exit. @@ -89,7 +95,13 @@ func run(o *Options) error { go networkPolicyController.Run(stopCh) - go apiServer.GenericAPIServer.PrepareRun().Run(stopCh) + preparedAPIServer := apiServer.GenericAPIServer.PrepareRun() + // Set up the antctl handlers on the controller API server for remote access. + antctl.CommandList.InstallToAPIServer(preparedAPIServer.GenericAPIServer, controllerMonitor) + go preparedAPIServer.Run(stopCh) + + // Set up the antctl server for in-pod access. + antctlServer.Start(nil, controllerMonitor, stopCh) <-stopCh klog.Info("Stopping Antrea controller") diff --git a/go.mod b/go.mod index 09050419fd0..491adb91264 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/coreos/go-iptables v0.4.1 github.com/elazarl/goproxy v0.0.0-20190911111923-ecfe977594f1 // indirect github.com/evanphx/json-patch v4.5.0+incompatible // indirect + github.com/fatih/structtag v1.2.0 github.com/gogo/protobuf v1.2.1 github.com/golang/mock v1.3.1 github.com/golang/protobuf v1.3.2 diff --git a/go.sum b/go.sum index 76e736aed2a..846b4806353 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/evanphx/json-patch v0.0.0-20190203023257-5858425f7550/go.mod h1:50XU6 github.com/evanphx/json-patch v4.1.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5IcNrDCXZ6OoTAWu7M= github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4 h1:bRzFpEzvausOAt4va+I/22BZ1vXDtERngp0BNYDKej0= diff --git a/hack/update-codegen-dockerized.sh b/hack/update-codegen-dockerized.sh index 28741e9327d..c3447e86e35 100755 --- a/hack/update-codegen-dockerized.sh +++ b/hack/update-codegen-dockerized.sh @@ -25,6 +25,23 @@ ANTREA_PKG="github.com/vmware-tanzu/antrea" # Generate protobuf code for CNI gRPC service with protoc. protoc --go_out=plugins=grpc:. pkg/apis/cni/v1beta1/cni.proto +# Generate clientset and apis code with K8s codegen tools. +$GOPATH/bin/client-gen \ + --clientset-name versioned \ + --input-base "${ANTREA_PKG}/pkg/apis/" \ + --input "clusterinformation/v1beta1,networking/v1beta1" \ + --output-package "${ANTREA_PKG}/pkg/client/clientset" \ + --go-header-file hack/boilerplate/license_header.go.txt + +$GOPATH/bin/deepcopy-gen \ + --input-dirs "${ANTREA_PKG}/pkg/apis/clusterinformation/v1beta1,${ANTREA_PKG}/pkg/apis/networking,${ANTREA_PKG}/pkg/apis/networking/v1beta1" \ + -O zz_generated.deepcopy \ + --go-header-file hack/boilerplate/license_header.go.txt + +$GOPATH/bin/conversion-gen \ + --input-dirs "${ANTREA_PKG}/pkg/apis/networking/v1beta1,${ANTREA_PKG}/pkg/apis/networking/" \ + -O zz_generated.conversion \ + --go-header-file hack/boilerplate/license_header.go.txt # Generate mocks for testing with mockgen. MOCKGEN_TARGETS=( @@ -32,6 +49,7 @@ MOCKGEN_TARGETS=( "pkg/agent/openflow Client,FlowOperations" "pkg/ovs/openflow Bridge,Table,Flow,Action,FlowBuilder" "pkg/ovs/ovsconfig OVSBridgeClient" + "pkg/monitor AgentQuerier,ControllerQuerier" ) for target in "${MOCKGEN_TARGETS[@]}"; do @@ -44,25 +62,6 @@ for target in "${MOCKGEN_TARGETS[@]}"; do "${ANTREA_PKG}/${package}" "${interfaces}" done - -# Generate clientset and apis code with K8s codegen tools. -$GOPATH/bin/client-gen \ - --clientset-name versioned \ - --input-base "${ANTREA_PKG}/pkg/apis/" \ - --input "clusterinformation/v1beta1,networking/v1beta1" \ - --output-package "${ANTREA_PKG}/pkg/client/clientset" \ - --go-header-file hack/boilerplate/license_header.go.txt - -$GOPATH/bin/deepcopy-gen \ - --input-dirs "${ANTREA_PKG}/pkg/apis/clusterinformation/v1beta1,${ANTREA_PKG}/pkg/apis/networking,${ANTREA_PKG}/pkg/apis/networking/v1beta1" \ - -O zz_generated.deepcopy \ - --go-header-file hack/boilerplate/license_header.go.txt - -$GOPATH/bin/conversion-gen \ - --input-dirs "${ANTREA_PKG}/pkg/apis/networking/v1beta1,${ANTREA_PKG}/pkg/apis/networking/" \ - -O zz_generated.conversion \ - --go-header-file hack/boilerplate/license_header.go.txt - # Download vendored modules to the vendor directory so it's easier to # specify the search path of required protobuf files. go mod vendor diff --git a/pkg/antctl/antctl.go b/pkg/antctl/antctl.go new file mode 100644 index 00000000000..6f04c85b564 --- /dev/null +++ b/pkg/antctl/antctl.go @@ -0,0 +1,81 @@ +// Copyright 2019 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 antctl + +import ( + "encoding/json" + "io" + "io/ioutil" + "reflect" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog" + + "github.com/vmware-tanzu/antrea/pkg/antctl/handlers" + "github.com/vmware-tanzu/antrea/pkg/client/clientset/versioned/scheme" + "github.com/vmware-tanzu/antrea/pkg/version" +) + +// unixDomainSockAddr is the address for antctl server in local mode. +const unixDomainSockAddr = "/var/run/antctl.sock" + +var systemGroup = schema.GroupVersion{Group: "system.antrea.tanzu.vmware.com", Version: "v1beta1"} + +type transformedVersionResponse struct { + handlers.ComponentVersionResponse `json:",inline" yaml:",inline"` + AntctlVersion string `json:"antctlVersion" yaml:"antctlVersion"` +} + +// versionTransform is the AddonTransform for the version command. This function +// will try to parse the response as a ComponentVersionResponse and then populate +// it with the version of antctl to a transformedVersionResponse object. +func versionTransform(reader io.Reader, _ bool) (interface{}, error) { + b, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + klog.Infof("version transform received: %s", string(b)) + cv := new(handlers.ComponentVersionResponse) + err = json.Unmarshal(b, cv) + if err != nil { + return nil, err + } + resp := &transformedVersionResponse{ + ComponentVersionResponse: *cv, + AntctlVersion: version.GetFullVersion(), + } + return resp, nil +} + +// CommandList defines all commands that could be used in the antctl for both agent +// and controller. The unit test "TestCommandListValidation" ensures it to be valid. +var CommandList = &commandList{ + definitions: []commandDefinition{ + { + Use: "version", + Short: "Print version information", + Long: "Print version information of the antctl and the ${component}", + HandlerFactory: new(handlers.Version), + GroupVersion: &systemGroup, + TransformedResponse: reflect.TypeOf(transformedVersionResponse{}), + Agent: true, + Controller: true, + SingleObject: true, + CommandGroup: flat, + AddonTransform: versionTransform, + }, + }, + codec: scheme.Codecs, +} diff --git a/pkg/antctl/antctl_test.go b/pkg/antctl/antctl_test.go new file mode 100644 index 00000000000..8e43db41950 --- /dev/null +++ b/pkg/antctl/antctl_test.go @@ -0,0 +1,27 @@ +// Copyright 2019 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 antctl + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestCommandListValidation ensures the command list is valid. +func TestCommandListValidation(t *testing.T) { + errs := CommandList.validate() + assert.Len(t, errs, 0) +} diff --git a/pkg/antctl/client.go b/pkg/antctl/client.go new file mode 100644 index 00000000000..0c3f7f427db --- /dev/null +++ b/pkg/antctl/client.go @@ -0,0 +1,135 @@ +// Copyright 2019 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 antctl + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "time" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/klog" +) + +// RequestOption describes options to issue requests to the antctl server. +type RequestOption struct { + GroupVersion *schema.GroupVersion + // Path is the URI the ongoing request + Path string + // Kubeconfig is the path to the config file for kubectl. + Kubeconfig string + // Name is the command which is going to be requested. + Name string + // Args are the parameters of the ongoing request. + Args map[string]string + // Timeout specifies a time limit for requests made by the client. The timeout + // duration includes connection setup, all redirects, and reading of the + // response body. + TimeOut time.Duration +} + +// client issues requests to an antctl server and gets the response. +type client struct { + // inPod indicate whether the client is running in a pod or not. + inPod bool + // codec is the CodecFactory for this command, it is needed for remote accessing. + codec serializer.CodecFactory +} + +// resolveKubeconfig tries to load the kubeconfig specified in the RequestOption. +// It will return error if the stating of the file failed or the kubeconfig is malformed. +// It will not try to look up InCluster configuration. If the kubeconfig is loaded, +// the groupVersion and the codec in the RequestOption will be populated into the +// kubeconfig object. +func (c *client) resolveKubeconfig(opt *RequestOption) (*rest.Config, error) { + kubeconfig, err := clientcmd.BuildConfigFromFlags("", opt.Kubeconfig) + if err != nil { + return nil, err + } + kubeconfig.GroupVersion = opt.GroupVersion + kubeconfig.NegotiatedSerializer = serializer.DirectCodecFactory{CodecFactory: c.codec} + return kubeconfig, nil +} + +// localRequest issues the local request according to the RequestOption. It only cares about +// the groupVersion of the RequestOption which will be used to construct the request +// URI. localRequest is basically a raw http request, no authentication and authorization +// will be done during the request. For safety concerns, it communicates with the +// antctl server by a predefined unix domain socket. If the request succeeds, it +// will return an io.Reader which contains the response data. +func (c *client) localRequest(opt *RequestOption) (io.Reader, error) { + klog.Infof("Requesting %s", opt.Path) + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (conn net.Conn, err error) { + if opt.TimeOut != 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opt.TimeOut) + defer cancel() + } + return new(net.Dialer).DialContext(ctx, "unix", unixDomainSockAddr) + }, + }, + } + resp, err := client.Get("http://antctl" + opt.Path) + if err != nil { + return nil, err + } + defer resp.Body.Close() + // Since we are going to close the connection, copying the response. + var buf bytes.Buffer + _, err = io.Copy(&buf, resp.Body) + return &buf, err +} + +// Request issues the appropriate request to the antctl server according to the +// request options. If the inPod field of the client is true, the client will do +// a local request by invoking localRequest. Otherwise, it will check the kubeconfig +// and delegate the request destined to the antctl server to the kubernetes API server. +// If the request succeeds, it will return an io.Reader which contains the response +// data. +func (c *client) Request(opt *RequestOption) (io.Reader, error) { + if c.inPod { + klog.Infoln("antctl runs as local mode") + return c.localRequest(opt) + } + kubeconfig, err := c.resolveKubeconfig(opt) + if err != nil { + return nil, err + } + restClient, err := rest.UnversionedRESTClientFor(kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to create rest client: %w", err) + } + // If timeout is not set, no timeout. + restClient.Client.Timeout = opt.TimeOut + klog.Infof("Requesting URI %s", opt.Path) + result := restClient.Get().RequestURI(opt.Path).Do() + if result.Error() != nil { + return nil, fmt.Errorf("error when requesting URI %s: %w", opt.Path, result.Error()) + } + raw, err := result.Raw() + if err != nil { + return nil, err + } + return bytes.NewReader(raw), nil +} diff --git a/pkg/antctl/command_definition.go b/pkg/antctl/command_definition.go new file mode 100644 index 00000000000..de764d34fa0 --- /dev/null +++ b/pkg/antctl/command_definition.go @@ -0,0 +1,392 @@ +// Copyright 2019 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 antctl + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path" + "reflect" + "strings" + + "github.com/fatih/structtag" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog" + + "github.com/vmware-tanzu/antrea/pkg/antctl/handlers" +) + +type formatterType string + +const ( + jsonFormatter formatterType = "json" + yamlFormatter formatterType = "yaml" + tableFormatter formatterType = "table" +) + +// commandGroup is used to group commands, it could be specified in commandDefinition. +// The default commandGroup of a commandDefinition is `flat` which means the command +// is a direct sub-command of the root command. For any other commandGroup, the +// antctl framework will generate a same name sub-command of the root command for +// each of them, any commands specified as one of these group will need to be invoked +// as: +// antctl +type commandGroup uint + +const ( + flat commandGroup = iota + get +) + +var groupCommands = map[commandGroup]*cobra.Command{ + get: { + Use: "get", + Short: "Get the status or resource of a topic", + Long: "Get the status or resource of a topic", + }, +} + +const ( + // tagKey is the tag name of the antctl specific annotation. + // For example: + // type FooResponse struct { + // Bar BarType `antctl:"key"` + // } + // If the field is annotated with antctl:"key", the framework assumes this field + // could be used to retrieve a unique Response, thus the framework will generate + // corresponding arg options to the cobra.Command. + tagKey = "antctl" + // tagOptionKeyArg is the option for antctl annotation. It tells the antctl + // the field is a primary key. + tagOptionKeyArg = "key" +) + +// argOption describes one argument which can be used in a RequestOption. +type argOption struct { + name string + fieldName string + usage string + key bool +} + +// commandDefinition defines options to create a cobra.Command for an antctl client. +type commandDefinition struct { + // Cobra related + Use string // The lower value of it will be used as the endpoint path, like: /. + Short string + Long string + Example string // It will be filled with generated examples if it is not provided. + // commandGroup represents the group of the command. + CommandGroup commandGroup + // Controller should be true if this command works for antrea-controller. + Controller bool + // Agent should be true if this command works for antrea-agent. + Agent bool + // SingleObject should be true if the handler always returns single object. The + // antctl assumes the response data as a slice of the objects by default. + SingleObject bool + // API is the endpoint for the command to retrieve data. + API string + // The handler factory of the command. + HandlerFactory handlers.Factory + // GroupVersion is the group version of the command handler, it should be set + // alongside with the HandlerFactory. + GroupVersion *schema.GroupVersion + // TransformedResponse is the final response struct of the command. If the + // AddonTransform is set, TransformedResponse is not needed to be used as the + // response struct of the handler, but it is still needed to guide the formatter. + // It should always be filled. + TransformedResponse reflect.Type + // AddonTransform is used to transform or update the response data received + // from the handler, it must returns an interface which has same type as + // TransformedResponse. + AddonTransform func(reader io.Reader, single bool) (interface{}, error) +} + +// applySubCommandToRoot applies the commandDefinition to a cobra.Command with +// the client. It populates basic fields of a cobra.Command and creates the +// appropriate RunE function for it according to the commandDefinition. +func (cd *commandDefinition) applySubCommandToRoot(root *cobra.Command, client *client, isAgent bool) { + cmd := &cobra.Command{ + Use: cd.Use, + Short: cd.Short, + Long: cd.Long, + } + renderDescription(cmd, isAgent) + + cd.applyFlagsToCommand(cmd) + var keyArgOption *argOption + argOpts := cd.argOptions() + for i := range argOpts { + a := argOpts[i] + if a.key { + cmd.Use += fmt.Sprintf(" [%s]", a.name) + cmd.Long += "\n\nArgs:\n" + fmt.Sprintf(" %s\t%s", a.name, a.usage) + // save the key arg option + keyArgOption = a + } + } + + if groupCommand, ok := groupCommands[cd.CommandGroup]; ok { + groupCommand.AddCommand(cmd) + } else { + root.AddCommand(cmd) + } + cd.applyExampleToCommand(cmd, keyArgOption) + + // Set key arg length validator to the command. + if keyArgOption != nil { + cmd.Args = cobra.MaximumNArgs(1) + } else { + cmd.Args = cobra.NoArgs + } + + cmd.RunE = cd.newCommandRunE(keyArgOption, client) +} + +// validate checks if the commandDefinition is valid. +func (cd *commandDefinition) validate() []error { + var errs []error + if len(cd.Use) == 0 { + errs = append(errs, fmt.Errorf("the command does not have name")) + } + if cd.TransformedResponse == nil { + errs = append(errs, fmt.Errorf("%s: command does not define output struct", cd.Use)) + } + if !cd.Agent && !cd.Controller { + errs = append(errs, fmt.Errorf("%s: command does not define any supported component", cd.Use)) + } + if cd.HandlerFactory == nil && len(cd.API) == 0 { + errs = append(errs, fmt.Errorf("%s: no handler or API specified", cd.Use)) + } + if len(cd.API) != 0 && cd.Agent { + errs = append(errs, fmt.Errorf("%s: commands for agent do not allow to request API directly", cd.Use)) + } + if cd.HandlerFactory != nil && cd.GroupVersion == nil { + errs = append(errs, fmt.Errorf("%s: must provide the group version of customize handler", cd.Use)) + } + // Only one key arg is allowed. + var hasKey bool + for _, arg := range cd.argOptions() { + if arg.key && hasKey { + errs = append(errs, fmt.Errorf("%s: has more than one key field", cd.Use)) + break + } else if arg.key { + hasKey = true + } + } + return errs +} + +// argOptions returns the list of arguments that could be used in a commandDefinition. +func (cd *commandDefinition) argOptions() []*argOption { + var ret []*argOption + for i := 0; i < cd.TransformedResponse.NumField(); i++ { + f := cd.TransformedResponse.Field(i) + argOpt := &argOption{fieldName: f.Name} + + tags, err := structtag.Parse(string(f.Tag)) + if err != nil { // Broken cli tags, skip this field + continue + } + + jsonTag, err := tags.Get("json") + if err != nil { + argOpt.name = strings.ToLower(f.Name) + } else { + argOpt.name = jsonTag.Name + } + + cliTag, err := tags.Get(tagKey) + if err == nil { + argOpt.key = cliTag.Name == tagOptionKeyArg + argOpt.usage = strings.Join(cliTag.Options, ", ") + } + + ret = append(ret, argOpt) + } + return ret +} + +// decode parses the data in reader and converts it to one or more +// TransformedResponse objects. If single is false, the return type is +// []TransformedResponse. Otherwise, the return type is TransformedResponse. +func (cd *commandDefinition) decode(r io.Reader, single bool) (interface{}, error) { + var result interface{} + + if single || cd.SingleObject { + ref := reflect.New(cd.TransformedResponse) + err := json.NewDecoder(r).Decode(ref.Interface()) + if err != nil { + return nil, err + } + result = ref.Interface() + } else { + ref := reflect.New(reflect.SliceOf(cd.TransformedResponse)) + err := json.NewDecoder(r).Decode(ref.Interface()) + if err != nil { + return nil, err + } + result = reflect.Indirect(ref).Interface() + } + + return result, nil +} + +func (cd *commandDefinition) jsonOutput(obj interface{}, writer io.Writer) error { + var output bytes.Buffer + err := json.NewEncoder(&output).Encode(obj) + if err != nil { + return fmt.Errorf("error when encoding data in json: %w", err) + } + var prettifiedBuf bytes.Buffer + err = json.Indent(&prettifiedBuf, output.Bytes(), "", " ") + if err != nil { + return fmt.Errorf("error when formatting outputing in json: %w", err) + } + _, err = io.Copy(writer, &prettifiedBuf) + if err != nil { + return fmt.Errorf("error when outputing in json format: %w", err) + } + return nil +} + +func (cd *commandDefinition) yamlOutput(obj interface{}, writer io.Writer) error { + err := yaml.NewEncoder(writer).Encode(obj) + if err != nil { + return fmt.Errorf("error when outputing in yaml format: %w", err) + } + return nil +} + +// output reads bytes from the resp and outputs the data to the writer in desired +// format. If the AddonTransform is set, it will use the function to transform +// the data first. It will try to output the resp in the format ft specified after +// doing transform. +func (cd *commandDefinition) output(resp io.Reader, writer io.Writer, ft formatterType, single bool) (err error) { + var obj interface{} + if cd.AddonTransform == nil { // Decode the data if there is no AddonTransform. + obj, err = cd.decode(resp, single) + if err != nil { + return fmt.Errorf("error when decoding response: %w", err) + } + } else { + obj, err = cd.AddonTransform(resp, single) + if err != nil { + return fmt.Errorf("error when doing local transform: %w", err) + } + klog.Infof("After transforming %v", obj) + } + + // Output structure data in format + switch ft { + case jsonFormatter: + return cd.jsonOutput(obj, writer) + case yamlFormatter: + return cd.yamlOutput(obj, writer) + case tableFormatter: // TODO: Add table formatter + panic("Implement it") + default: + return fmt.Errorf("unsupport format type: %v", ft) + } +} + +// newCommandRunE creates the RunE function for the command. The RunE function +// checks the args according to ArgOptions and flags. +func (cd *commandDefinition) newCommandRunE(key *argOption, c *client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + argMap := make(map[string]string) + if len(args) > 0 { + argMap[key.name] = args[0] + } + kubeconfigPath, _ := cmd.Flags().GetString("kubeconfig") + timeout, _ := cmd.Flags().GetDuration("timeout") + var reqPath string + if len(cd.API) > 0 { + reqPath = cd.API + } else { + reqPath = "/apis/" + cd.GroupVersion.String() + "/" + strings.ToLower(cd.Use) + } + resp, err := c.Request(&RequestOption{ + Path: reqPath, + Kubeconfig: kubeconfigPath, + Name: cmd.Name(), + Args: argMap, + TimeOut: timeout, + GroupVersion: cd.GroupVersion, + }) + if err != nil { + return err + } + single := len(argMap) != 0 + outputFormat, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + return cd.output(resp, os.Stdout, formatterType(outputFormat), single) + } +} + +// applyFlagsToCommand sets up flags for the command. +func (cd *commandDefinition) applyFlagsToCommand(cmd *cobra.Command) { + cmd.Flags().StringP("output", "o", "json", "output format: json|yaml|table") +} + +func (cd *commandDefinition) requestPath(prefix string) string { + return path.Join(prefix, strings.ToLower(cd.Use)) +} + +// applyExampleToCommand generates examples according to the commandDefinition. +// It only creates for commands which specified TransformedResponse. If the SingleObject +// is specified, it only creates one example to retrieve the single object. Otherwise, +// it will generates examples about retrieving single object according to the key +// argOption and retrieving the object list. +func (cd *commandDefinition) applyExampleToCommand(cmd *cobra.Command, key *argOption) { + if len(cd.Example) != 0 { + cmd.Example = cd.Example + return + } + + var commands []string + for iter := cmd; iter != nil; iter = iter.Parent() { + commands = append(commands, iter.Name()) + } + for i := 0; i < len(commands)/2; i++ { + commands[i], commands[len(commands)-1-i] = commands[len(commands)-1-i], commands[i] + } + + var buf bytes.Buffer + typeName := cd.TransformedResponse.Name() + dataName := strings.ToLower(strings.TrimSuffix(typeName, "Response")) + + if cd.SingleObject { + fmt.Fprintf(&buf, " Get the %s\n", dataName) + fmt.Fprintf(&buf, " $ %s\n", strings.Join(commands, " ")) + } else { + if key != nil { + fmt.Fprintf(&buf, " Get a %s\n", dataName) + fmt.Fprintf(&buf, " $ %s [%s]\n", strings.Join(commands, " "), key.name) + } + fmt.Fprintf(&buf, " Get the list of %s\n", dataName) + fmt.Fprintf(&buf, " $ %s\n", strings.Join(commands, " ")) + } + + cmd.Example = buf.String() +} diff --git a/pkg/antctl/command_definition_test.go b/pkg/antctl/command_definition_test.go new file mode 100644 index 00000000000..86992af48e9 --- /dev/null +++ b/pkg/antctl/command_definition_test.go @@ -0,0 +1,140 @@ +// Copyright 2019 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 antctl + +import ( + "bytes" + "encoding/json" + "io" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type FooResponse struct { + Bar string +} + +// TestFormat ensures the formatter and AddonTransform works as expected. +func TestFormat(t *testing.T) { + // TODO: Add table formatter tests after implementing table formatter + for _, tc := range []struct { + name string + singleton bool + single bool + transform func(reader io.Reader, single bool) (interface{}, error) + rawResponseData interface{} + responseStruct reflect.Type + expected string + formatter formatterType + }{ + { + name: "StructureData-NoTransform-List", + rawResponseData: []struct{ Foo string }{{Foo: "foo"}}, + responseStruct: reflect.TypeOf(struct{ Foo string }{}), + expected: "- foo: foo\n", + formatter: yamlFormatter, + }, + { + name: "StructureData-NoTransform-Single", + single: true, + rawResponseData: &struct{ Foo string }{Foo: "foo"}, + responseStruct: reflect.TypeOf(struct{ Foo string }{}), + expected: "foo: foo\n", + formatter: yamlFormatter, + }, + { + name: "StructureData-Transform-Single", + single: true, + transform: func(reader io.Reader, single bool) (i interface{}, err error) { + foo := &struct{ Foo string }{} + err = json.NewDecoder(reader).Decode(foo) + return &struct{ Bar string }{Bar: foo.Foo}, err + }, + rawResponseData: &struct{ Foo string }{Foo: "foo"}, + responseStruct: reflect.TypeOf(struct{ Bar string }{}), + expected: "bar: foo\n", + formatter: yamlFormatter, + }, + } { + t.Run(tc.name, func(t *testing.T) { + opt := &commandDefinition{ + SingleObject: tc.singleton, + TransformedResponse: tc.responseStruct, + AddonTransform: tc.transform, + } + var responseData []byte + responseData, err := json.Marshal(tc.rawResponseData) + assert.Nil(t, err) + var outputBuf bytes.Buffer + err = opt.output(bytes.NewBuffer(responseData), &outputBuf, tc.formatter, tc.single) + assert.Nil(t, err) + assert.Equal(t, tc.expected, outputBuf.String()) + }) + } +} + +// TestCommandDefinitionGenerateExample checks example strings are generated as +// expected. +func TestCommandDefinitionGenerateExample(t *testing.T) { + for k, tc := range map[string]struct { + use string + cmdChain string + key *argOption + singleton bool + expect string + }{ + "SingleObject": { + use: "test", + cmdChain: "first second third", + singleton: true, + expect: " Get the foo\n $ first second third test\n", + }, + "NoneKeyList": { + use: "test", + cmdChain: "first second third", + expect: " Get the list of foo\n $ first second third test\n", + }, + "KeyList": { + use: "test", + cmdChain: "first second third", + key: &argOption{ + name: "bar", + fieldName: "Bar", + usage: "", + key: true, + }, + expect: " Get a foo\n $ first second third test [bar]\n Get the list of foo\n $ first second third test\n", + }, + } { + t.Run(k, func(t *testing.T) { + cmd := new(cobra.Command) + for _, seg := range strings.Split(tc.cmdChain, " ") { + cmd.Use = seg + tmp := new(cobra.Command) + cmd.AddCommand(tmp) + cmd = tmp + } + cmd.Use = tc.use + + co := &commandDefinition{SingleObject: tc.singleton, TransformedResponse: reflect.TypeOf(FooResponse{})} + co.applyExampleToCommand(cmd, tc.key) + assert.Equal(t, tc.expect, cmd.Example) + }) + } +} diff --git a/pkg/antctl/command_list.go b/pkg/antctl/command_list.go new file mode 100644 index 00000000000..126e563d585 --- /dev/null +++ b/pkg/antctl/command_list.go @@ -0,0 +1,167 @@ +// Copyright 2019 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 antctl + +import ( + "encoding/json" + "flag" + "fmt" + "math" + "net/http" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apiserver/pkg/server" + "k8s.io/apiserver/pkg/server/mux" + "k8s.io/client-go/util/homedir" + "k8s.io/klog" + + "github.com/vmware-tanzu/antrea/pkg/monitor" +) + +// commandList organizes definitions. +// It is the protocol for a pair of antctl client and server. +type commandList struct { + definitions []commandDefinition + codec serializer.CodecFactory +} + +func (cl *commandList) InstallToAPIServer(apiServer *server.GenericAPIServer, cq monitor.ControllerQuerier) { + cl.applyToMux(apiServer.Handler.NonGoRestfulMux, nil, cq) +} + +// applyToMux adds the handler function of each commandDefinition in the +// commandList to the mux with path /apis//. It also adds +// corresponding discovery handlers at /apis/ for kubernetes service +// discovery. +func (cl *commandList) applyToMux(mux *mux.PathRecorderMux, aq monitor.AgentQuerier, cq monitor.ControllerQuerier) { + resources := map[string][]metav1.APIResource{} + for _, def := range cl.definitions { + if def.HandlerFactory == nil { + continue + } + handler := def.HandlerFactory.Handler(aq, cq) + groupPath := "/apis/" + def.GroupVersion.String() + reqPath := def.requestPath(groupPath) + klog.Infof("Adding cli handler %s", reqPath) + mux.HandleFunc(reqPath, handler) + resources[groupPath] = append(resources[groupPath], metav1.APIResource{ + Name: def.Use, + SingularName: def.Use, + Kind: def.Use, + Namespaced: false, + Group: def.GroupVersion.Group, + Version: def.GroupVersion.Version, + Verbs: metav1.Verbs{"get"}, + }) + } + // Setup up discovery handlers for command handlers. + for groupPath, resource := range resources { + mux.HandleFunc(groupPath, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + l := metav1.APIResourceList{ + TypeMeta: metav1.TypeMeta{Kind: "APIResourceList", APIVersion: metav1.SchemeGroupVersion.Version}, + GroupVersion: systemGroup.Version, + APIResources: resource, + } + jsonResp, err := json.MarshalIndent(l, "", " ") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, err = w.Write(jsonResp) + if err != nil { + klog.Errorf("Error when responding APIResourceList: %v", err) + w.WriteHeader(http.StatusInternalServerError) + } + }) + } +} + +func (cl *commandList) applyFlagsToRootCommand(root *cobra.Command) { + defaultKubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config") + root.PersistentFlags().BoolP("verbose", "v", false, "enable verbose output") + root.PersistentFlags().StringP("kubeconfig", "k", defaultKubeconfig, "absolute path to the kubeconfig file") + root.PersistentFlags().DurationP("timeout", "t", 0, "time limit of the execution of the command") +} + +// ApplyToRootCommand applies the commandList to the root cobra command, it applies +// each commandDefinition of it to the root command as a sub-command. +func (cl *commandList) ApplyToRootCommand(root *cobra.Command, isAgent bool, inPod bool) { + client := &client{ + inPod: inPod, + codec: cl.codec, + } + for _, groupCommand := range groupCommands { + root.AddCommand(groupCommand) + } + for i := range cl.definitions { + def := cl.definitions[i] + if (def.Agent != isAgent) && (def.Controller != !isAgent) { + continue + } + def.applySubCommandToRoot(root, client, isAgent) + klog.Infof("Added command %s", def.Use) + } + cl.applyFlagsToRootCommand(root) + root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + enableVerbose, err := root.PersistentFlags().GetBool("verbose") + if err != nil { + return err + } + err = flag.Set("logtostderr", fmt.Sprint(enableVerbose)) + if err != nil { + return err + } + if enableVerbose { + err := flag.Set("v", fmt.Sprint(math.MaxInt32)) + if err != nil { + return err + } + } + return nil + } + renderDescription(root, isAgent) +} + +// validate checks the validation of the commandList. +func (cl *commandList) validate() []error { + var errs []error + if len(cl.definitions) == 0 { + return []error{fmt.Errorf("no command found in the command list")} + } + for i, c := range cl.definitions { + for _, err := range c.validate() { + errs = append(errs, fmt.Errorf("#%d command<%s>: %w", i, c.Use, err)) + } + } + return errs +} + +// renderDescription replaces placeholders ${component} in Short and Long of a command +// to the determined component during runtime. +func renderDescription(command *cobra.Command, isAgent bool) { + var componentName string + if isAgent { + componentName = "agent" + } else { + componentName = "controller" + } + command.Short = strings.ReplaceAll(command.Short, "${component}", componentName) + command.Long = strings.ReplaceAll(command.Long, "${component}", componentName) +} diff --git a/pkg/antctl/command_list_test.go b/pkg/antctl/command_list_test.go new file mode 100644 index 00000000000..1bf791a7b0f --- /dev/null +++ b/pkg/antctl/command_list_test.go @@ -0,0 +1,114 @@ +// Copyright 2019 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 antctl + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/server/mux" + + "github.com/vmware-tanzu/antrea/pkg/client/clientset/versioned/scheme" + "github.com/vmware-tanzu/antrea/pkg/monitor" +) + +type testHandlerFactory struct{} + +func (t *testHandlerFactory) Handler(_ monitor.AgentQuerier, _ monitor.ControllerQuerier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + fmt.Fprint(w, "test") + } +} + +type testResponse struct { + Label string `json:"label" antctl:"key"` + Value uint64 `json:"value"` +} + +var testCommandList = &commandList{ + definitions: []commandDefinition{ + { + Use: "test", + Short: "test short description ${component}", + Long: "test description ${component}", + HandlerFactory: new(testHandlerFactory), + TransformedResponse: reflect.TypeOf(testResponse{}), + Agent: true, + Controller: true, + GroupVersion: &schema.GroupVersion{ + Group: "test-clusterinformation.antrea.tanzu.vmware.com", + Version: "v1", + }, + }, + }, + codec: scheme.Codecs, +} + +func TestCommandListApplyToCommand(t *testing.T) { + testRoot := new(cobra.Command) + testRoot.Short = "The component is ${component}" + testRoot.Long = "The component is ${component}" + testCommandList.ApplyToRootCommand(testRoot, true, false) + // sub-commands should be attached + assert.True(t, testRoot.HasSubCommands()) + // render should work as expected + assert.Contains(t, testRoot.Short, "The component is agent") + assert.Contains(t, testRoot.Long, "The component is agent") +} + +// TestParseCommandList ensures the commandList could be correctly parsed. +func TestParseCommandList(t *testing.T) { + r := mux.NewPathRecorderMux("") + assert.Len(t, testCommandList.validate(), 0) + testCommandList.applyToMux(r, nil, nil) + + ts := httptest.NewServer(r) + defer ts.Close() + + testcases := map[string]struct { + path string + statusCode int + }{ + "ExistPath": { + path: "/apis/" + testCommandList.definitions[0].GroupVersion.String() + "/test", + statusCode: http.StatusOK, + }, + "NotExistPath": { + path: "test", + statusCode: http.StatusNotFound, + }, + } + + for k, tc := range testcases { + t.Run(k, func(t *testing.T) { + reqPath, err := url.Parse(ts.URL) + assert.Nil(t, err) + reqPath.Path = tc.path + resp, err := ts.Client().Get(reqPath.String()) + assert.Nil(t, err, k) + + defer resp.Body.Close() + assert.Equal(t, tc.statusCode, resp.StatusCode, fmt.Sprintf("case %s %s", k, reqPath)) + }) + } +} diff --git a/pkg/antctl/doc.go b/pkg/antctl/doc.go new file mode 100644 index 00000000000..35f00705741 --- /dev/null +++ b/pkg/antctl/doc.go @@ -0,0 +1,16 @@ +// Copyright 2019 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 antctl provides the antctl framework and antctl command implementations. +package antctl diff --git a/pkg/antctl/handlers/doc.go b/pkg/antctl/handlers/doc.go new file mode 100644 index 00000000000..ed74d9b8b80 --- /dev/null +++ b/pkg/antctl/handlers/doc.go @@ -0,0 +1,16 @@ +// Copyright 2019 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 handlers contains handler implementations for different antctl commands. +package handlers diff --git a/pkg/antctl/handlers/interface.go b/pkg/antctl/handlers/interface.go new file mode 100644 index 00000000000..8cdc9f47a8d --- /dev/null +++ b/pkg/antctl/handlers/interface.go @@ -0,0 +1,33 @@ +// Copyright 2019 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 handlers + +import ( + "net/http" + + "github.com/vmware-tanzu/antrea/pkg/monitor" +) + +// Factory is the interface to generate command handlers. +type Factory interface { + // Handler returns a net/http.HandlerFunc which will be used to handle + // requests issued by commands from the antctl client. An implementation + // needs to determine the component it is running in by checking nullable + // of the AgentQuerier or the ControllerQuerier. If the antctl server is + // running in the antrea-agent, the AgentQuerier will not be nil, otherwise, + // the ControllerQuerier will not be nil. If the command has no AddonTransform, + // the HandlerFunc need to write the data to the response body in JSON format. + Handler(aq monitor.AgentQuerier, cq monitor.ControllerQuerier) http.HandlerFunc +} diff --git a/pkg/antctl/handlers/version.go b/pkg/antctl/handlers/version.go new file mode 100644 index 00000000000..ef5ec3da577 --- /dev/null +++ b/pkg/antctl/handlers/version.go @@ -0,0 +1,58 @@ +// Copyright 2019 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 handlers + +import ( + "encoding/json" + "net/http" + + "k8s.io/klog" + + "github.com/vmware-tanzu/antrea/pkg/monitor" +) + +var _ Factory = new(Version) + +// ComponentVersionResponse describes the internal response struct of the version +// command. It contains the version of the component the antctl server is running +// in, either the agent or the controller. +// This struct is not the final response struct of the version command. The version +// command definition has an AddonTransform which will populate this struct and the +// version of antctl client to the final response. +type ComponentVersionResponse struct { + AgentVersion string `json:"agentVersion,omitempty" yaml:"agentVersion,omitempty"` + ControllerVersion string `json:"controllerVersion,omitempty" yaml:"controllerVersion,omitempty"` +} + +// Version is Factory to generate version command handler. +type Version struct{} + +// Handler returns the function which can handle queries issued by the version command, +func (v *Version) Handler(aq monitor.AgentQuerier, cq monitor.ControllerQuerier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var m ComponentVersionResponse + if aq != nil { + m.AgentVersion = aq.GetVersion() + } else if cq != nil { + m.ControllerVersion = cq.GetVersion() + } + err := json.NewEncoder(w).Encode(m) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + klog.Errorf("Error when encoding ComponentVersionResponse to json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + } +} diff --git a/pkg/antctl/handlers/version_test.go b/pkg/antctl/handlers/version_test.go new file mode 100644 index 00000000000..ab7a8f8172a --- /dev/null +++ b/pkg/antctl/handlers/version_test.go @@ -0,0 +1,69 @@ +// Copyright 2019 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 handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + mockmonitor "github.com/vmware-tanzu/antrea/pkg/monitor/testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestVersion(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + testcases := map[string]struct { + version string + expectedOutput string + expectedStatusCode int + isAgent, isController bool + }{ + "AgentVersion": { + version: "v0.0.1", + expectedOutput: "{\"agentVersion\":\"v0.0.1\"}\n", + expectedStatusCode: http.StatusOK, + isAgent: true, + }, + "ControllerVersion": { + version: "v0.0.1", + expectedOutput: "{\"controllerVersion\":\"v0.0.1\"}\n", + expectedStatusCode: http.StatusOK, + isController: true, + }, + } + for k, tc := range testcases { + t.Run(k, func(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + assert.Nil(t, err) + recorder := httptest.NewRecorder() + if tc.isAgent { + aq := mockmonitor.NewMockAgentQuerier(ctrl) + aq.EXPECT().GetVersion().Return(tc.version) + new(Version).Handler(aq, nil).ServeHTTP(recorder, req) + } else if tc.isController { + cq := mockmonitor.NewMockControllerQuerier(ctrl) + cq.EXPECT().GetVersion().Return(tc.version) + new(Version).Handler(nil, cq).ServeHTTP(recorder, req) + } + assert.Equal(t, tc.expectedStatusCode, recorder.Code, k) + assert.Equal(t, tc.expectedOutput, recorder.Body.String(), k) + }) + } +} diff --git a/pkg/antctl/server.go b/pkg/antctl/server.go new file mode 100644 index 00000000000..df0e572b334 --- /dev/null +++ b/pkg/antctl/server.go @@ -0,0 +1,85 @@ +// Copyright 2019 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 antctl + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "sync" + + "k8s.io/apiserver/pkg/server/mux" + "k8s.io/klog" + + "github.com/vmware-tanzu/antrea/pkg/monitor" +) + +// Server defines operations of an antctl server. +type Server interface { + // Start runs the antctl server. When invoking this method, either AgentQuerier + // or ControllerQuerier must be passed, because implementations need to + // use the value of AgentMonitor and Controller monitor to tell out which + // component the server is running in. A running server can be stopped by + // closing the stopCh. + Start(aq monitor.AgentQuerier, cq monitor.ControllerQuerier, stopCh <-chan struct{}) +} + +type localServer struct { + // startOnce ensures the server could only be started one. + startOnce sync.Once + listener net.Listener +} + +// Start starts the server with the AgentQuerier or the ControllerQuerier passed. +// The server will do graceful stop whenever it receives from the stopCh. One server +// could only be run once. +func (s *localServer) Start(aq monitor.AgentQuerier, cq monitor.ControllerQuerier, stopCh <-chan struct{}) { + s.startOnce.Do(func() { + antctlMux := mux.NewPathRecorderMux("antctl-server") + CommandList.applyToMux(antctlMux, aq, cq) + server := &http.Server{Handler: antctlMux} + // HTTP server graceful stop + go func() { + <-stopCh + err := server.Shutdown(context.Background()) + if err != nil { + klog.Errorf("Antctl server stopped with error: %v", err) + } else { + klog.Info("Antctl server stopped") + } + }() + // Start the http server + go func() { + klog.Info("Starting antctl server") + err := server.Serve(s.listener) + if !errors.Is(err, http.ErrServerClosed) { + klog.Fatalf("Antctl server stopped with error: %v", err) + } + }() + }) +} + +// NewLocalServer creates an antctl server which listens on the local domain socket. +func NewLocalServer() (Server, error) { + os.Remove(unixDomainSockAddr) + ln, err := net.Listen("unix", unixDomainSockAddr) + if err != nil { + return nil, fmt.Errorf("error when creating antctl local server: %w", err) + } + return &localServer{listener: ln}, nil +} diff --git a/pkg/monitor/monitor.go b/pkg/monitor/monitor.go index 8e2d90aae41..0a1af402d14 100644 --- a/pkg/monitor/monitor.go +++ b/pkg/monitor/monitor.go @@ -33,9 +33,10 @@ import ( "github.com/vmware-tanzu/antrea/pkg/version" ) -type monitor interface { - Run(stopCh <-chan struct{}) -} +var ( + _ ControllerQuerier = new(controllerMonitor) + _ AgentQuerier = new(agentMonitor) +) type controllerMonitor struct { client clientset.Interface @@ -56,7 +57,7 @@ type agentMonitor struct { networkPolicyInfoQuerier AgentNetworkPolicyInfoQuerier } -func NewControllerMonitor(client clientset.Interface, nodeInformer coreinformers.NodeInformer, networkPolicyInfoQuerier ControllerNetworkPolicyInfoQuerier) monitor { +func NewControllerMonitor(client clientset.Interface, nodeInformer coreinformers.NodeInformer, networkPolicyInfoQuerier ControllerNetworkPolicyInfoQuerier) *controllerMonitor { m := &controllerMonitor{client: client, nodeInformer: nodeInformer, nodeListerSynced: nodeInformer.Informer().HasSynced, networkPolicyInfoQuerier: networkPolicyInfoQuerier} nodeInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: nil, @@ -76,7 +77,7 @@ func NewAgentMonitor( ofClient openflow.Client, ovsBridgeClient ovsconfig.OVSBridgeClient, networkPolicyInfoQuerier AgentNetworkPolicyInfoQuerier, -) monitor { +) *agentMonitor { return &agentMonitor{client: client, ovsBridge: ovsBridge, nodeName: nodeName, nodeSubnet: nodeSubnet, interfaceStore: interfaceStore, ofClient: ofClient, ovsBridgeClient: ovsBridgeClient, networkPolicyInfoQuerier: networkPolicyInfoQuerier} } diff --git a/pkg/monitor/querier.go b/pkg/monitor/querier.go index 7b7277a1895..6458b3e58b3 100644 --- a/pkg/monitor/querier.go +++ b/pkg/monitor/querier.go @@ -18,18 +18,19 @@ import ( "os" "strconv" + "github.com/vmware-tanzu/antrea/pkg/apis/clusterinformation/v1beta1" + "github.com/vmware-tanzu/antrea/pkg/version" + "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog" - - "github.com/vmware-tanzu/antrea/pkg/apis/clusterinformation/v1beta1" ) const ( - SERVICE_NAME = "antrea" - POD_NAME = "POD_NAME" - POD_NAMESPACE = "POD_NAMESPACE" - NODE_NAME = "NODE_NAME" + serviceName = "antrea" + podName = "POD_NAME" + podNamespace = "POD_NAMESPACE" + nodeName = "NODE_NAME" ) // Querier provides interface for both monitor CRD and CLI to consume controller and agent status. @@ -37,6 +38,7 @@ type Querier interface { GetSelfPod() v1.ObjectReference GetSelfNode() v1.ObjectReference GetNetworkPolicyControllerInfo() v1beta1.NetworkPolicyControllerInfo + GetVersion() string } type AgentQuerier interface { @@ -67,10 +69,10 @@ type ControllerNetworkPolicyInfoQuerier interface { } func (monitor *agentMonitor) GetSelfPod() v1.ObjectReference { - if os.Getenv(POD_NAME) == "" || os.Getenv(POD_NAMESPACE) == "" { + if os.Getenv(podName) == "" || os.Getenv(podNamespace) == "" { return v1.ObjectReference{} } - return v1.ObjectReference{Kind: "Pod", Name: os.Getenv(POD_NAME), Namespace: os.Getenv(POD_NAMESPACE)} + return v1.ObjectReference{Kind: "Pod", Name: os.Getenv(podName), Namespace: os.Getenv(podNamespace)} } func (monitor *agentMonitor) GetSelfNode() v1.ObjectReference { @@ -81,12 +83,12 @@ func (monitor *agentMonitor) GetSelfNode() v1.ObjectReference { } func (monitor *agentMonitor) GetOVSVersion() string { - version, err := monitor.ovsBridgeClient.GetOVSVersion() + v, err := monitor.ovsBridgeClient.GetOVSVersion() if err != nil { klog.Errorf("Failed to get OVS client version: %v", err) return "" } - return version + return v } func (monitor *agentMonitor) GetOVSFlowTable() map[string]int32 { @@ -149,22 +151,30 @@ func (monitor *agentMonitor) GetAgentConditions(ovsConnected bool) []v1beta1.Age } } +func (monitor *agentMonitor) GetVersion() string { + return version.GetFullVersion() +} + func (monitor *controllerMonitor) GetSelfPod() v1.ObjectReference { - if os.Getenv(POD_NAME) == "" || os.Getenv(POD_NAMESPACE) == "" { + if os.Getenv(podName) == "" || os.Getenv(podNamespace) == "" { return v1.ObjectReference{} } - return v1.ObjectReference{Kind: "Pod", Name: os.Getenv(POD_NAME), Namespace: os.Getenv(POD_NAMESPACE)} + return v1.ObjectReference{Kind: "Pod", Name: os.Getenv(podName), Namespace: os.Getenv(podNamespace)} } func (monitor *controllerMonitor) GetSelfNode() v1.ObjectReference { - if os.Getenv(NODE_NAME) == "" { + if os.Getenv(nodeName) == "" { return v1.ObjectReference{} } - return v1.ObjectReference{Kind: "Node", Name: os.Getenv(NODE_NAME)} + return v1.ObjectReference{Kind: "Node", Name: os.Getenv(nodeName)} } func (monitor *controllerMonitor) GetService() v1.ObjectReference { - return v1.ObjectReference{Kind: "Service", Name: SERVICE_NAME} + return v1.ObjectReference{Kind: "Service", Name: serviceName} +} + +func (monitor *controllerMonitor) GetVersion() string { + return version.GetFullVersion() } func (monitor *controllerMonitor) GetNetworkPolicyControllerInfo() v1beta1.NetworkPolicyControllerInfo { diff --git a/pkg/monitor/testing/mock_monitor.go b/pkg/monitor/testing/mock_monitor.go new file mode 100644 index 00000000000..c8a73e90598 --- /dev/null +++ b/pkg/monitor/testing/mock_monitor.go @@ -0,0 +1,227 @@ +// Copyright 2019 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. +// + +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/vmware-tanzu/antrea/pkg/monitor (interfaces: AgentQuerier,ControllerQuerier) + +// Package testing is a generated GoMock package. +package testing + +import ( + gomock "github.com/golang/mock/gomock" + v1beta1 "github.com/vmware-tanzu/antrea/pkg/apis/clusterinformation/v1beta1" + v1 "k8s.io/api/core/v1" + reflect "reflect" +) + +// MockAgentQuerier is a mock of AgentQuerier interface +type MockAgentQuerier struct { + ctrl *gomock.Controller + recorder *MockAgentQuerierMockRecorder +} + +// MockAgentQuerierMockRecorder is the mock recorder for MockAgentQuerier +type MockAgentQuerierMockRecorder struct { + mock *MockAgentQuerier +} + +// NewMockAgentQuerier creates a new mock instance +func NewMockAgentQuerier(ctrl *gomock.Controller) *MockAgentQuerier { + mock := &MockAgentQuerier{ctrl: ctrl} + mock.recorder = &MockAgentQuerierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockAgentQuerier) EXPECT() *MockAgentQuerierMockRecorder { + return m.recorder +} + +// GetLocalPodNum mocks base method +func (m *MockAgentQuerier) GetLocalPodNum() int32 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLocalPodNum") + ret0, _ := ret[0].(int32) + return ret0 +} + +// GetLocalPodNum indicates an expected call of GetLocalPodNum +func (mr *MockAgentQuerierMockRecorder) GetLocalPodNum() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocalPodNum", reflect.TypeOf((*MockAgentQuerier)(nil).GetLocalPodNum)) +} + +// GetNetworkPolicyControllerInfo mocks base method +func (m *MockAgentQuerier) GetNetworkPolicyControllerInfo() v1beta1.NetworkPolicyControllerInfo { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkPolicyControllerInfo") + ret0, _ := ret[0].(v1beta1.NetworkPolicyControllerInfo) + return ret0 +} + +// GetNetworkPolicyControllerInfo indicates an expected call of GetNetworkPolicyControllerInfo +func (mr *MockAgentQuerierMockRecorder) GetNetworkPolicyControllerInfo() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkPolicyControllerInfo", reflect.TypeOf((*MockAgentQuerier)(nil).GetNetworkPolicyControllerInfo)) +} + +// GetOVSFlowTable mocks base method +func (m *MockAgentQuerier) GetOVSFlowTable() map[string]int32 { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOVSFlowTable") + ret0, _ := ret[0].(map[string]int32) + return ret0 +} + +// GetOVSFlowTable indicates an expected call of GetOVSFlowTable +func (mr *MockAgentQuerierMockRecorder) GetOVSFlowTable() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOVSFlowTable", reflect.TypeOf((*MockAgentQuerier)(nil).GetOVSFlowTable)) +} + +// GetSelfNode mocks base method +func (m *MockAgentQuerier) GetSelfNode() v1.ObjectReference { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSelfNode") + ret0, _ := ret[0].(v1.ObjectReference) + return ret0 +} + +// GetSelfNode indicates an expected call of GetSelfNode +func (mr *MockAgentQuerierMockRecorder) GetSelfNode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSelfNode", reflect.TypeOf((*MockAgentQuerier)(nil).GetSelfNode)) +} + +// GetSelfPod mocks base method +func (m *MockAgentQuerier) GetSelfPod() v1.ObjectReference { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSelfPod") + ret0, _ := ret[0].(v1.ObjectReference) + return ret0 +} + +// GetSelfPod indicates an expected call of GetSelfPod +func (mr *MockAgentQuerierMockRecorder) GetSelfPod() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSelfPod", reflect.TypeOf((*MockAgentQuerier)(nil).GetSelfPod)) +} + +// GetVersion mocks base method +func (m *MockAgentQuerier) GetVersion() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVersion") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetVersion indicates an expected call of GetVersion +func (mr *MockAgentQuerierMockRecorder) GetVersion() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockAgentQuerier)(nil).GetVersion)) +} + +// MockControllerQuerier is a mock of ControllerQuerier interface +type MockControllerQuerier struct { + ctrl *gomock.Controller + recorder *MockControllerQuerierMockRecorder +} + +// MockControllerQuerierMockRecorder is the mock recorder for MockControllerQuerier +type MockControllerQuerierMockRecorder struct { + mock *MockControllerQuerier +} + +// NewMockControllerQuerier creates a new mock instance +func NewMockControllerQuerier(ctrl *gomock.Controller) *MockControllerQuerier { + mock := &MockControllerQuerier{ctrl: ctrl} + mock.recorder = &MockControllerQuerierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockControllerQuerier) EXPECT() *MockControllerQuerierMockRecorder { + return m.recorder +} + +// GetNetworkPolicyControllerInfo mocks base method +func (m *MockControllerQuerier) GetNetworkPolicyControllerInfo() v1beta1.NetworkPolicyControllerInfo { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNetworkPolicyControllerInfo") + ret0, _ := ret[0].(v1beta1.NetworkPolicyControllerInfo) + return ret0 +} + +// GetNetworkPolicyControllerInfo indicates an expected call of GetNetworkPolicyControllerInfo +func (mr *MockControllerQuerierMockRecorder) GetNetworkPolicyControllerInfo() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkPolicyControllerInfo", reflect.TypeOf((*MockControllerQuerier)(nil).GetNetworkPolicyControllerInfo)) +} + +// GetSelfNode mocks base method +func (m *MockControllerQuerier) GetSelfNode() v1.ObjectReference { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSelfNode") + ret0, _ := ret[0].(v1.ObjectReference) + return ret0 +} + +// GetSelfNode indicates an expected call of GetSelfNode +func (mr *MockControllerQuerierMockRecorder) GetSelfNode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSelfNode", reflect.TypeOf((*MockControllerQuerier)(nil).GetSelfNode)) +} + +// GetSelfPod mocks base method +func (m *MockControllerQuerier) GetSelfPod() v1.ObjectReference { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSelfPod") + ret0, _ := ret[0].(v1.ObjectReference) + return ret0 +} + +// GetSelfPod indicates an expected call of GetSelfPod +func (mr *MockControllerQuerierMockRecorder) GetSelfPod() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSelfPod", reflect.TypeOf((*MockControllerQuerier)(nil).GetSelfPod)) +} + +// GetService mocks base method +func (m *MockControllerQuerier) GetService() v1.ObjectReference { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetService") + ret0, _ := ret[0].(v1.ObjectReference) + return ret0 +} + +// GetService indicates an expected call of GetService +func (mr *MockControllerQuerierMockRecorder) GetService() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetService", reflect.TypeOf((*MockControllerQuerier)(nil).GetService)) +} + +// GetVersion mocks base method +func (m *MockControllerQuerier) GetVersion() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetVersion") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetVersion indicates an expected call of GetVersion +func (mr *MockControllerQuerierMockRecorder) GetVersion() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVersion", reflect.TypeOf((*MockControllerQuerier)(nil).GetVersion)) +} diff --git a/test/e2e/antctl_test.go b/test/e2e/antctl_test.go new file mode 100644 index 00000000000..de23e2c5f81 --- /dev/null +++ b/test/e2e/antctl_test.go @@ -0,0 +1,145 @@ +package e2e + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// antctlOutput is a helper function for logging antctl outputs. +func antctlOutput(stdout, stderr string, tb testing.TB) { + tb.Logf("antctl stdout:\n%s", stdout) + tb.Logf("antctl stderr:\n%s", stderr) +} + +// runAntctl runs antctl commands on antrea Pods, the controller, or agents. It +// always runs the commands with verbose flag enabled. +func runAntctl(podName string, subCMDs []string, data *TestData, tb testing.TB) (string, string, error) { + var containerName string + if strings.Contains(podName, "agent") { + containerName = "antrea-agent" + } else { + containerName = "antrea-controller" + } + cmds := []string{"antctl", "-v"} + stdout, stderr, err := data.runCommandFromPod(antreaNamespace, podName, containerName, append(cmds, subCMDs...)) + antctlOutput(stdout, stderr, tb) + return stdout, stderr, err +} + +// TestAntctlAgentLocalAccess ensures antctl is accessible in a agent Pod. +func TestAntctlAgentLocalAccess(t *testing.T) { + data, err := setupTest(t) + if err != nil { + t.Fatalf("Error when setting up test: %v", err) + } + defer teardownTest(t, data) + podName, err := data.getAntreaPodOnNode(masterNodeName()) + if err != nil { + t.Fatalf("Error when getting antrea-agent pod name: %v", err) + } + if _, _, err := runAntctl(podName, []string{"version"}, data, t); err != nil { + t.Fatalf("Error when running `antctl version` from %s: %v", podName, err) + } +} + +// TestAntctlControllerLocalAccess ensures antctl is accessible in the controller Pod. +func TestAntctlControllerLocalAccess(t *testing.T) { + data, err := setupTest(t) + if err != nil { + t.Fatalf("Error when setting up test: %v", err) + } + defer teardownTest(t, data) + podName, err := data.getAntreaController() + if err != nil { + t.Fatalf("Error when getting antrea-controller pod name: %v", err) + } + if _, _, err := runAntctl(podName, []string{"version"}, data, t); err != nil { + t.Fatalf("Error when running `antctl version` from %s: %v", podName, err) + } +} + +// TestAntctlControllerRemoteAccess ensures antctl is able to be run outside of +// the kubernetes cluster. It uses the antctl client binary copied from the controller +// Pod. +func TestAntctlControllerRemoteAccess(t *testing.T) { + data, err := setupTest(t) + if err != nil { + t.Fatalf("Error when setting up test: %v", err) + } + defer teardownTest(t, data) + podName, err := data.getAntreaController() + require.Nil(t, err, "Error when retrieving antrea controller pod name") + + // Copy antctl from the controller Pod to the master Node. + cmd := fmt.Sprintf("kubectl cp %s/%s:/usr/local/bin/antctl ~/antctl", antreaNamespace, podName) + rc, stdout, stderr, err := RunCommandOnNode(masterNodeName(), cmd) + require.Zero(t, rc) + require.Nil(t, err, "Error when copying antctl from %s, stdout: %s, stderr: %s", podName, stdout, stderr) + // Make sure the antctl binary executable on the master Node. + rc, stdout, stderr, err = RunCommandOnNode(masterNodeName(), "chmod 0755 ~/antctl") + require.Zero(t, rc) + require.Nil(t, err, "Error when make the antctl on master node executable, stdout: %s, stderr: %s", podName, stdout, stderr) + + for k, tc := range map[string]struct { + commands string + expectedReturnCode int + }{ + "CorrectConfig": { + commands: "-v version", + expectedReturnCode: 0, + }, + "MalformedConfig": { + commands: "-v version --kubeconfig /dev/null", + expectedReturnCode: 1, + }, + } { + t.Run(k, func(t *testing.T) { + commands := "~/antctl " + tc.commands + rc, stdout, stderr, err = RunCommandOnNode(masterNodeName(), commands) + antctlOutput(stdout, stderr, t) + assert.Equal(t, tc.expectedReturnCode, rc) + if err != nil { + t.Fatalf("Error when running `antctl version` from %s: %v", masterNodeName(), err) + } + }) + } +} + +// TestAntctlVerboseMode ensures no unexpected outputs during the execution of +// the antctl client. +func TestAntctlVerboseMode(t *testing.T) { + data, err := setupTest(t) + if err != nil { + t.Fatalf("Error when setting up test: %v", err) + } + defer teardownTest(t, data) + podName, err := data.getAntreaController() + require.Nil(t, err, "Error when retrieving antrea controller pod name") + for _, tc := range []struct { + name string + hasStderr bool + commands []string + }{ + {name: "RootNonVerbose", hasStderr: false, commands: []string{"antctl"}}, + {name: "RootVerbose", hasStderr: false, commands: []string{"antctl", "-v"}}, + {name: "CommandNonVerbose", hasStderr: false, commands: []string{"antctl", "version"}}, + {name: "CommandVerbose", hasStderr: true, commands: []string{"antctl", "-v", "version"}}, + {name: "CommandVerbose", hasStderr: true, commands: []string{"antctl", "version", "-v"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Running commnad `%s` on pod %s", tc.commands, podName) + stdout, stderr, err := data.runCommandFromPod(antreaNamespace, podName, "antrea-controller", tc.commands) + antctlOutput(stdout, stderr, t) + assert.Nil(t, err) + if !tc.hasStderr { + assert.Empty(t, stderr) + } else { + assert.NotEmpty(t, stderr) + } + }) + } +} diff --git a/test/e2e/framework.go b/test/e2e/framework.go index f8df364b37f..73d325e8647 100755 --- a/test/e2e/framework.go +++ b/test/e2e/framework.go @@ -641,6 +641,21 @@ func (data *TestData) getAntreaPodOnNode(nodeName string) (podName string, err e return pods.Items[0].Name, nil } +// getAntreaController retrieves the name of the Antrea Controller (antrea-controller-*) running in the k8s cluster. +func (data *TestData) getAntreaController() (podName string, err error) { + listOptions := metav1.ListOptions{ + LabelSelector: "app=antrea,component=antrea-controller", + } + pods, err := data.clientset.CoreV1().Pods(antreaNamespace).List(listOptions) + if err != nil { + return "", fmt.Errorf("failed to list Antrea Controller: %v", err) + } + if len(pods.Items) != 1 { + return "", fmt.Errorf("expected *exactly* one Pod") + } + return pods.Items[0].Name, nil +} + // validatePodIP checks that the provided IP address is in the Pod Network CIDR for the cluster. func validatePodIP(podNetworkCIDR, podIP string) (bool, error) { ip := net.ParseIP(podIP)