diff --git a/README.md b/README.md index b82155b84e..1a52aaed81 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,13 @@ filters: The pipeline means it will forward traffic to 3 backend endpoints, using the `roundRobin` load balance policy. +You can also create them using `egctl create httpproxy` command. +```bash +egctl create httpproxy demo --port 10080 \ + --rule="/pipeline=http://127.0.0.1:9095,http://127.0.0.1:9096,http://127.0.0.1:9097" +``` +this command will create `HTTPServer` `demo` and `Pipeline` `demo-0` which work exactly same to `server-demo` and `pipeline-demo`. See more about [`egctl create httpproxy`](./doc/egctl-cheat-sheet.md#create-httpproxy). + Additionally, we provide a [dashboard](https://cloud.megaease.com) that streamlines the aforementioned steps, this intuitive tool can help you create, manage HTTPServers, Pipelines and other Easegress configuration. diff --git a/build/test/integration_test.go b/build/test/integration_test.go index 3b25e839c2..861a4d13ba 100644 --- a/build/test/integration_test.go +++ b/build/test/integration_test.go @@ -18,6 +18,7 @@ package test import ( + "bytes" "context" "fmt" "io" @@ -535,3 +536,104 @@ list: err = deleteResource("cdk", "custom-data-kind1") assert.NoError(err) } + +func TestCreateHTTPProxy(t *testing.T) { + assert := assert.New(t) + + for i, port := range []int{9096, 9097, 9098} { + currentPort := port + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "hello from backend %d", currentPort) + }) + + server := startServer(currentPort, mux) + defer server.Shutdown(context.Background()) + started := checkServerStart(t, func() *http.Request { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d", currentPort), nil) + require.Nil(t, err) + return req + }) + require.True(t, started, i) + } + + cmd := egctlCmd( + "create", + "httpproxy", + "http-proxy-test", + "--port", "10080", + "--rule", + "/pipeline=http://127.0.0.1:9096", + "--rule", + "/barz=http://127.0.0.1:9097", + "--rule", + "/bar*=http://127.0.0.1:9098", + ) + _, stderr, err := runCmd(cmd) + assert.NoError(err) + assert.Empty(stderr) + + output, err := getResource("httpserver") + assert.NoError(err) + assert.True(strings.Contains(output, "http-proxy-test")) + + output, err = getResource("pipeline") + assert.NoError(err) + assert.True(strings.Contains(output, "http-proxy-test-0")) + + testFn := func(p string, expected string) { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:10080"+p, nil) + assert.Nil(err, p) + resp, err := http.DefaultClient.Do(req) + assert.Nil(err, p) + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + assert.Nil(err, p) + assert.Equal(expected, string(data), p) + } + + testFn("/pipeline", "hello from backend 9096") + testFn("/barz", "hello from backend 9097") + testFn("/bar-prefix", "hello from backend 9098") +} + +func TestLogs(t *testing.T) { + assert := assert.New(t) + + { + // test egctl logs --tail n + cmd := egctlCmd("logs", "--tail", "5") + output, stderr, err := runCmd(cmd) + assert.NoError(err) + assert.Empty(stderr) + assert.True(strings.Contains(output, "INFO")) + assert.True(strings.Contains(output, ".go"), "should contain go file in log") + lines := strings.Split(strings.TrimSpace(output), "\n") + assert.Len(lines, 5) + } + { + // test egctl logs -f + cmd := egctlCmd("logs", "-f", "--tail", "0") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Start() + assert.NoError(err) + + // check if new logs are printed + yamlStr := ` +kind: HTTPServer +name: test-egctl-logs +port: 12345 +rules: +- paths: + - pathPrefix: /pipeline + backend: pipeline-demo +` + err = applyResource(yamlStr) + assert.NoError(err) + time.Sleep(1 * time.Second) + cmd.Process.Kill() + assert.True(strings.Contains(stdout.String(), "test-egctl-logs")) + } +} diff --git a/cmd/client/commandv2/create.go b/cmd/client/commandv2/create.go index 7d4fa93c6a..539fdf692e 100644 --- a/cmd/client/commandv2/create.go +++ b/cmd/client/commandv2/create.go @@ -19,55 +19,13 @@ package commandv2 import ( - "errors" - - "github.com/megaease/easegress/v2/cmd/client/general" - "github.com/megaease/easegress/v2/cmd/client/resources" + "github.com/megaease/easegress/v2/cmd/client/commandv2/create" "github.com/spf13/cobra" ) // CreateCmd returns create command. func CreateCmd() *cobra.Command { - examples := []general.Example{ - {Desc: "Create a resource from a file", Command: "egctl create -f .yaml"}, - {Desc: "Create a resource from stdin", Command: "cat .yaml | egctl create -f -"}, - } - - var specFile string - cmd := &cobra.Command{ - Use: "create", - Short: "Create a resource from a file or from stdin.", - Example: createMultiExample(examples), - Args: func(cmd *cobra.Command, args []string) error { - if specFile == "" { - return errors.New("yaml file is required") - } - return nil - }, - Run: func(cmd *cobra.Command, args []string) { - visitor := general.BuildSpecVisitor(specFile, cmd) - visitor.Visit(func(s *general.Spec) error { - var err error - defer func() { - if err != nil { - general.ExitWithError(err) - } - }() - - switch s.Kind { - case resources.CustomDataKind().Kind: - err = resources.CreateCustomDataKind(cmd, s) - case resources.CustomData().Kind: - err = resources.CreateCustomData(cmd, s) - default: - err = resources.CreateObject(cmd, s) - } - return err - }) - visitor.Close() - }, - } - - cmd.Flags().StringVarP(&specFile, "file", "f", "", "A yaml file specifying the object.") + cmd := create.Cmd() + cmd.AddCommand(create.HTTPProxyCmd()) return cmd } diff --git a/cmd/client/commandv2/create/create.go b/cmd/client/commandv2/create/create.go new file mode 100644 index 0000000000..83ab24f87b --- /dev/null +++ b/cmd/client/commandv2/create/create.go @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 create provides create commands. +package create + +import ( + "errors" + + "github.com/megaease/easegress/v2/cmd/client/general" + "github.com/megaease/easegress/v2/cmd/client/resources" + "github.com/spf13/cobra" +) + +// Cmd returns create command. +func Cmd() *cobra.Command { + examples := []general.Example{ + {Desc: "Create a resource from a file", Command: "egctl create -f .yaml"}, + {Desc: "Create a resource from stdin", Command: "cat .yaml | egctl create -f -"}, + } + + var specFile string + cmd := &cobra.Command{ + Use: "create", + Short: "Create a resource from a file or from stdin.", + Example: general.CreateMultiExample(examples), + Args: func(cmd *cobra.Command, args []string) error { + if specFile == "" { + return errors.New("yaml file is required") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + visitor := general.BuildSpecVisitor(specFile, cmd) + visitor.Visit(func(s *general.Spec) error { + var err error + defer func() { + if err != nil { + general.ExitWithError(err) + } + }() + + switch s.Kind { + case resources.CustomDataKind().Kind: + err = resources.CreateCustomDataKind(cmd, s) + case resources.CustomData().Kind: + err = resources.CreateCustomData(cmd, s) + default: + err = resources.CreateObject(cmd, s) + } + return err + }) + visitor.Close() + }, + } + + cmd.Flags().StringVarP(&specFile, "file", "f", "", "A yaml file specifying the object.") + return cmd +} diff --git a/cmd/client/commandv2/create/create_test.go b/cmd/client/commandv2/create/create_test.go new file mode 100644 index 0000000000..5720904f77 --- /dev/null +++ b/cmd/client/commandv2/create/create_test.go @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 create provides create commands. +package create + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + assert.NotNil(t, cmd) +} diff --git a/cmd/client/commandv2/create/createhttpproxy.go b/cmd/client/commandv2/create/createhttpproxy.go new file mode 100644 index 0000000000..8f859b0af4 --- /dev/null +++ b/cmd/client/commandv2/create/createhttpproxy.go @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 create + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/megaease/easegress/v2/cmd/client/general" + "github.com/megaease/easegress/v2/cmd/client/resources" + "github.com/megaease/easegress/v2/pkg/filters" + "github.com/megaease/easegress/v2/pkg/filters/proxies" + "github.com/megaease/easegress/v2/pkg/filters/proxies/httpproxy" + "github.com/megaease/easegress/v2/pkg/object/httpserver" + "github.com/megaease/easegress/v2/pkg/object/httpserver/routers" + "github.com/megaease/easegress/v2/pkg/object/pipeline" + "github.com/megaease/easegress/v2/pkg/util/codectool" + "github.com/spf13/cobra" +) + +// HTTPProxyOptions are the options to create a HTTPProxy. +type HTTPProxyOptions struct { + Name string + Port int + Rules []string + + TLS bool + AutoCert bool + CaCertFile string + CertFiles []string + KeyFiles []string + + caCert string + certs []string + keys []string + rules []*HTTPProxyRule +} + +var httpProxyOptions = &HTTPProxyOptions{} + +var httpProxyExamples = `# General case +egctl create httpproxy NAME --port PORT \ + --rule HOST/PATH=ENDPOINT1,ENDPOINT2 \ + [--rule HOST/PATH=ENDPOINT1,ENDPOINT2] \ + [--tls] \ + [--auto-cert] \ + [--ca-cert-file CA_CERT_FILE] \ + [--cert-file CERT_FILE] \ + [--key-file KEY_FILE] + +# Create a HTTPServer (with port 10080) and corresponding Pipelines to direct +# request with path "/bar" to "http://127.0.0.1:8080" and "http://127.0.0.1:8081" and +# request with path "/foo" to "http://127.0.0.1:8082". +egctl create httpproxy demo --port 10080 \ + --rule="/bar=http://127.0.0.1:8080,http://127.0.0.1:8081" \ + --rule="/foo=http://127.0.0.1:8082" + +# Create a HTTPServer (with port 10081) and corresponding Pipelines to direct request +# with path prefix "foo.com/prefix" to "http://127.0.0.1:8083". +egctl create httpproxy demo2 --port 10081 \ + --rule="foo.com/prefix*=http://127.0.0.1:8083" +` + +// HTTPProxyCmd returns create command of HTTPProxy. +func HTTPProxyCmd() *cobra.Command { + o := httpProxyOptions + + cmd := &cobra.Command{ + Use: "httpproxy NAME", + Short: "Create a HTTPServer and corresponding Pipelines with a specific name", + Args: httpProxyArgs, + Example: general.CreateMultiLineExample(httpProxyExamples), + Run: func(cmd *cobra.Command, args []string) { + err := httpProxyRun(cmd, args) + if err != nil { + general.ExitWithError(err) + } + }, + } + + cmd.Flags().IntVar(&o.Port, "port", -1, "Port of HTTPServer") + cmd.Flags().StringArrayVar(&o.Rules, "rule", []string{}, "Rule in format host/path=endpoint1,endpoint2. Paths containing the leading character '*' are considered as PathPrefix.") + + cmd.Flags().BoolVar(&o.TLS, "tls", false, "Enable TLS") + cmd.Flags().BoolVar(&o.AutoCert, "auto-cert", false, "Enable auto cert") + cmd.Flags().StringVar(&o.CaCertFile, "ca-cert-file", "", "CA cert file") + cmd.Flags().StringArrayVar(&o.CertFiles, "cert-file", []string{}, "Cert file") + cmd.Flags().StringArrayVar(&o.KeyFiles, "key-file", []string{}, "Key file") + return cmd +} + +func httpProxyArgs(_ *cobra.Command, args []string) error { + o := httpProxyOptions + if len(args) != 1 { + return fmt.Errorf("create httpproxy requires a name") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("port %d is invalid", o.Port) + } + if len(o.Rules) == 0 { + return fmt.Errorf("rule is required") + } + if len(o.CertFiles) != len(o.KeyFiles) { + return fmt.Errorf("cert files and key files are not matched") + } + return nil +} + +func httpProxyRun(cmd *cobra.Command, args []string) error { + o := httpProxyOptions + o.Complete(args) + if err := o.Parse(); err != nil { + return err + } + hs, pls := o.Translate() + allSpec := []interface{}{hs} + for _, p := range pls { + allSpec = append(allSpec, p) + } + for _, s := range allSpec { + spec, err := toGeneralSpec(s) + if err != nil { + return err + } + if err := resources.CreateObject(cmd, spec); err != nil { + return err + } + } + return nil +} + +// HTTPServerSpec is the spec of HTTPServer. +type HTTPServerSpec struct { + Name string `json:"name"` + Kind string `json:"kind"` + + httpserver.Spec `json:",inline"` +} + +// PipelineSpec is the spec of Pipeline. +type PipelineSpec struct { + Name string `json:"name"` + Kind string `json:"kind"` + + pipeline.Spec `json:",inline"` +} + +// Complete completes all the required options. +func (o *HTTPProxyOptions) Complete(args []string) { + o.Name = args[0] +} + +// Parse parses all the optional options. +func (o *HTTPProxyOptions) Parse() error { + // parse rules + rules := []*HTTPProxyRule{} + for _, rule := range o.Rules { + r, err := parseRule(rule) + if err != nil { + return err + } + rules = append(rules, r) + } + o.rules = rules + + // parse ca cert + if o.CaCertFile != "" { + caCert, err := loadCertFile(o.CaCertFile) + if err != nil { + return err + } + o.caCert = caCert + } + + // parse certs + certs := []string{} + for _, certFile := range o.CertFiles { + cert, err := loadCertFile(certFile) + if err != nil { + return err + } + certs = append(certs, cert) + } + o.certs = certs + + // parse keys + keys := []string{} + for _, keyFile := range o.KeyFiles { + key, err := loadCertFile(keyFile) + if err != nil { + return err + } + keys = append(keys, key) + } + o.keys = keys + return nil +} + +func (o *HTTPProxyOptions) getServerName() string { + return o.Name +} + +func (o *HTTPProxyOptions) getPipelineName(id int) string { + return fmt.Sprintf("%s-%d", o.Name, id) +} + +// Translate translates HTTPProxyOptions to HTTPServerSpec and PipelineSpec. +func (o *HTTPProxyOptions) Translate() (*HTTPServerSpec, []*PipelineSpec) { + hs := &HTTPServerSpec{ + Name: o.getServerName(), + Kind: httpserver.Kind, + Spec: *getDefaultHTTPServerSpec(), + } + hs.Port = uint16(o.Port) + if o.TLS { + hs.HTTPS = true + hs.AutoCert = o.AutoCert + hs.CaCertBase64 = o.caCert + hs.Certs = map[string]string{} + hs.Keys = map[string]string{} + for i := 0; i < len(o.certs); i++ { + // same key for cert and key + hs.Certs[o.CertFiles[i]] = o.certs[i] + hs.Keys[o.CertFiles[i]] = o.keys[i] + } + } + routerRules, pipelines := o.translateRules() + hs.Rules = routerRules + return hs, pipelines +} + +func (o *HTTPProxyOptions) translateRules() (routers.Rules, []*PipelineSpec) { + var rules routers.Rules + var pipelines []*PipelineSpec + pipelineID := 0 + + for _, rule := range o.rules { + pipelineName := o.getPipelineName(pipelineID) + pipelineID++ + + routerPath := &routers.Path{ + Path: rule.Path, + PathPrefix: rule.PathPrefix, + Backend: pipelineName, + } + pipelines = append(pipelines, &PipelineSpec{ + Name: pipelineName, + Kind: pipeline.Kind, + Spec: *translateToPipeline(rule.Endpoints), + }) + + l := len(rules) + if l != 0 && rules[l-1].Host == rule.Host { + rules[l-1].Paths = append(rules[l-1].Paths, routerPath) + } else { + rules = append(rules, &routers.Rule{ + Host: rule.Host, + Paths: []*routers.Path{routerPath}, + }) + } + } + return rules, pipelines +} + +func toGeneralSpec(data interface{}) (*general.Spec, error) { + var yamlStr []byte + var err error + if yamlStr, err = codectool.MarshalYAML(data); err != nil { + return nil, err + } + + var spec *general.Spec + if spec, err = general.GetSpecFromYaml(string(yamlStr)); err != nil { + return nil, err + } + return spec, nil +} + +func loadCertFile(filePath string) (string, error) { + absPath, err := filepath.Abs(filePath) + if err != nil { + return "", err + } + data, err := os.ReadFile(absPath) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(data), nil +} + +func translateToPipeline(endpoints []string) *pipeline.Spec { + proxy := translateToProxyFilter(endpoints) + data := codectool.MustMarshalYAML(proxy) + maps, _ := general.UnmarshalMapInterface(data, false) + + spec := getDefaultPipelineSpec() + spec.Filters = maps + return spec +} + +func translateToProxyFilter(endpoints []string) *httpproxy.Spec { + spec := getDefaultProxyFilterSpec() + spec.BaseSpec.MetaSpec.Name = "proxy" + spec.BaseSpec.MetaSpec.Kind = httpproxy.Kind + + servers := make([]*proxies.Server, len(endpoints)) + for i, endpoint := range endpoints { + servers[i] = &proxies.Server{ + URL: endpoint, + } + } + spec.Pools = []*httpproxy.ServerPoolSpec{{ + BaseServerPoolSpec: proxies.ServerPoolBaseSpec{ + Servers: servers, + LoadBalance: &proxies.LoadBalanceSpec{ + Policy: proxies.LoadBalancePolicyRoundRobin, + }, + }, + }} + return spec +} + +func parseRule(rule string) (*HTTPProxyRule, error) { + parts := strings.Split(rule, "=") + if len(parts) != 2 { + return nil, fmt.Errorf("rule %s should in format 'host/path=endpoint1,endpoint2', invalid format", rule) + } + + // host and path + uri := strings.SplitN(parts[0], "/", 2) + if len(uri) != 2 { + return nil, fmt.Errorf("rule %s not contain a path", rule) + } + host := uri[0] + var path, pathPrefix string + if strings.HasSuffix(uri[1], "*") { + pathPrefix = "/" + strings.TrimSuffix(uri[1], "*") + } else { + path = "/" + uri[1] + } + + // endpoints + endpoints := strings.Split(parts[1], ",") + endpoints = general.Filter(endpoints, func(s string) bool { + return s != "" + }) + if len(endpoints) == 0 { + return nil, fmt.Errorf("endpoints in rule %s is empty", rule) + } + + return &HTTPProxyRule{ + Host: host, + Path: path, + PathPrefix: pathPrefix, + Endpoints: endpoints, + }, nil +} + +// HTTPProxyRule is the rule of HTTPProxy. +type HTTPProxyRule struct { + Host string + Path string + PathPrefix string + Endpoints []string +} + +func getDefaultHTTPServerSpec() *httpserver.Spec { + return (&httpserver.HTTPServer{}).DefaultSpec().(*httpserver.Spec) +} + +func getDefaultPipelineSpec() *pipeline.Spec { + return (&pipeline.Pipeline{}).DefaultSpec().(*pipeline.Spec) +} + +func getDefaultProxyFilterSpec() *httpproxy.Spec { + return filters.GetKind(httpproxy.Kind).DefaultSpec().(*httpproxy.Spec) +} diff --git a/cmd/client/commandv2/create/createhttpproxy_test.go b/cmd/client/commandv2/create/createhttpproxy_test.go new file mode 100644 index 0000000000..c05f194282 --- /dev/null +++ b/cmd/client/commandv2/create/createhttpproxy_test.go @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 create provides create commands. +package create + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/megaease/easegress/v2/pkg/filters" + "github.com/megaease/easegress/v2/pkg/filters/proxies/httpproxy" + "github.com/megaease/easegress/v2/pkg/object/httpserver/routers" + "github.com/megaease/easegress/v2/pkg/util/codectool" + "github.com/stretchr/testify/assert" +) + +func TestTranslateToProxyFilter(t *testing.T) { + assert := assert.New(t) + + yamlStr := ` +name: proxy +kind: Proxy +pools: +- servers: + - url: http://127.0.0.1:9095 + - url: http://127.0.0.1:9096 + loadBalance: + policy: roundRobin +` + expected := getDefaultProxyFilterSpec() + err := codectool.UnmarshalYAML([]byte(yamlStr), expected) + assert.Nil(err) + + endpoints := []string{"http://127.0.0.1:9095", "http://127.0.0.1:9096"} + got := translateToProxyFilter(endpoints) + assert.Equal(expected, got) + + got2 := translateToProxyFilter(append(endpoints, "http://127.0.0.1:9097")) + assert.NotEqual(expected, got2) +} + +func TestTranslateToPipeline(t *testing.T) { + assert := assert.New(t) + + yamlStr := ` +filters: +- name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:9095 + - url: http://127.0.0.1:9096 + loadBalance: + policy: roundRobin +` + // compare expected and got pipeline + expected := getDefaultPipelineSpec() + err := codectool.UnmarshalYAML([]byte(yamlStr), expected) + assert.Nil(err) + + endpoints := []string{"http://127.0.0.1:9095", "http://127.0.0.1:9096"} + got := translateToPipeline(endpoints) + + // filters part is not compare here, because the filter part is map[string]interface{}, + // the expected map[string]interface{} is unmarshal from yaml, + // the got map[string]interface{} is marshal from Proxy filter spec. + // they have same valid information, but different content. + assert.Equal(expected.Flow, got.Flow) + assert.Equal(expected.Resilience, got.Resilience) + assert.Equal(expected.Data, got.Data) + assert.Len(got.Filters, 1) + + // compare expected and got filter + // the expected filter is unmarshal twice from yaml, + // if marshal it once, some part of expectedFilter will be nil. + // but gotFilter will be empty. for example []string{} vs nil. + // []string{} and nil are actually same in this case. + expectedFilter := getDefaultProxyFilterSpec() + filterYaml := codectool.MustMarshalYAML(expected.Filters[0]) + err = codectool.UnmarshalYAML(filterYaml, expectedFilter) + assert.Nil(err) + filterYaml = codectool.MustMarshalYAML(expectedFilter) + err = codectool.UnmarshalYAML(filterYaml, expectedFilter) + assert.Nil(err) + + gotFilter := filters.GetKind(httpproxy.Kind).DefaultSpec().(*httpproxy.Spec) + filterYaml = codectool.MustMarshalYAML(got.Filters[0]) + err = codectool.UnmarshalYAML(filterYaml, gotFilter) + assert.Nil(err) + + assert.Equal(expectedFilter, gotFilter) +} + +func TestParseRule(t *testing.T) { + assert := assert.New(t) + + testCases := []struct { + rule string + host string + path string + pathPrefix string + endpoints []string + hasErr bool + }{ + { + rule: "foo.com/bar/bala=http://127.0.0.1:9096", + host: "foo.com", + path: "/bar/bala", + pathPrefix: "", + endpoints: []string{"http://127.0.0.1:9096"}, + hasErr: false, + }, + { + rule: "/bar=http://127.0.0.1:9096,http://127.0.0.1:9097", + host: "", + path: "/bar", + pathPrefix: "", + endpoints: []string{"http://127.0.0.1:9096", "http://127.0.0.1:9097"}, + hasErr: false, + }, + { + rule: "foo.com/bar*=http://127.0.0.1:9096", + host: "foo.com", + path: "", + pathPrefix: "/bar", + endpoints: []string{"http://127.0.0.1:9096"}, + hasErr: false, + }, + { + rule: "/=http://127.0.0.1:9096", + host: "", + path: "/", + pathPrefix: "", + endpoints: []string{"http://127.0.0.1:9096"}, + hasErr: false, + }, + { + rule: "foo.com/bar*=http://127.0.0.1:9096=", + hasErr: true, + }, + { + rule: "foo.com=http://127.0.0.1:9096", + hasErr: true, + }, + { + rule: "=http://127.0.0.1:9096", + hasErr: true, + }, + { + rule: "foo.com/path=", + hasErr: true, + }, + { + rule: "foo.com/path", + hasErr: true, + }, + } + + for _, tc := range testCases { + rule, err := parseRule(tc.rule) + if tc.hasErr { + assert.NotNil(err, "case %v", tc) + continue + } + assert.Nil(err, "case %v", tc) + assert.Equal(tc.host, rule.Host, "case %v", tc) + assert.Equal(tc.path, rule.Path, "case %v", tc) + assert.Equal(tc.pathPrefix, rule.PathPrefix, "case %v", tc) + assert.Equal(tc.endpoints, rule.Endpoints, "case %v", tc) + } +} + +func TestLoadCertFile(t *testing.T) { + assert := assert.New(t) + + _, err := loadCertFile("not-exist-file.cert") + assert.NotNil(err) + + text := "hello" + textBase64 := "aGVsbG8=" + fileDir, err := os.MkdirTemp("", "test-load-cert-file") + filePath := filepath.Join(fileDir, "test.cert") + assert.Nil(err) + defer os.RemoveAll(fileDir) + os.WriteFile(filePath, []byte(text), 0644) + + got, err := loadCertFile(filePath) + assert.Nil(err) + assert.Equal(textBase64, got) +} + +func TestToGeneralSpec(t *testing.T) { + assert := assert.New(t) + + _, err := toGeneralSpec([]byte("not-yaml")) + assert.NotNil(err) + + testStruct := struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Other string `yaml:"other"` + }{ + Name: "test", + Kind: "test", + Other: "other", + } + spec, err := toGeneralSpec(testStruct) + assert.Nil(err) + assert.Equal(testStruct.Name, spec.Name) + assert.Equal(testStruct.Kind, spec.Kind) + + doc := `name: test +kind: test +other: other +` + assert.Equal(doc, spec.Doc()) +} + +func TestCreateHTTPProxyOptions(t *testing.T) { + assert := assert.New(t) + + tempDir, err := os.MkdirTemp("", "test-create-http-proxy-options") + assert.Nil(err) + defer os.RemoveAll(tempDir) + + createCert := func(name string) string { + p := filepath.Join(tempDir, name) + os.WriteFile(p, []byte("hello"), 0644) + return p + } + certBase64 := "aGVsbG8=" + + o := &HTTPProxyOptions{ + Port: 10080, + Rules: []string{ + "foo.com/barz=http://127.0.0.1:9095", + "foo.com/bar*=http://127.0.0.1:9095", + "/bar=http://127.0.0.1:9095", + }, + TLS: true, + AutoCert: true, + CaCertFile: createCert("ca.cert"), + CertFiles: []string{createCert("cert1"), createCert("cert2")}, + KeyFiles: []string{createCert("key1"), createCert("key2")}, + } + o.Complete([]string{"test"}) + err = o.Parse() + assert.Nil(err) + + hs, pls := o.Translate() + + // meta + assert.Equal("test", hs.Name) + assert.Equal("HTTPServer", hs.Kind) + assert.Equal(uint16(10080), hs.Port) + + // tls + assert.True(hs.HTTPS) + assert.True(hs.AutoCert) + assert.Equal(certBase64, hs.CaCertBase64) + assert.Equal(len(hs.Certs), len(hs.Keys)) + for k, v := range hs.Certs { + assert.Equal(certBase64, v) + assert.Equal(certBase64, hs.Keys[k]) + } + + // rules, host foo.com has two path, host "" has one path + assert.Len(hs.Rules, 2) + assert.Equal("foo.com", hs.Rules[0].Host) + assert.Equal(routers.Paths{ + {Path: "/barz", Backend: "test-0"}, + {PathPrefix: "/bar", Backend: "test-1"}, + }, hs.Rules[0].Paths) + assert.Equal(routers.Paths{ + {Path: "/bar", Backend: "test-2"}, + }, hs.Rules[1].Paths) + + // pipelines + assert.Len(pls, 3) + for i, pl := range pls { + assert.Equal(fmt.Sprintf("test-%d", i), pl.Name) + assert.Equal("Pipeline", pl.Kind) + assert.Len(pl.Filters, 1) + } + + yamlStr := ` +filters: +- name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:9095 + loadBalance: + policy: roundRobin +` + expectedFilter := func() *httpproxy.Spec { + expected := getDefaultPipelineSpec() + err = codectool.UnmarshalYAML([]byte(yamlStr), expected) + assert.Nil(err) + + expectedFilter := getDefaultProxyFilterSpec() + filterYaml := codectool.MustMarshalYAML(expected.Filters[0]) + err = codectool.UnmarshalYAML(filterYaml, expectedFilter) + assert.Nil(err) + filterYaml = codectool.MustMarshalYAML(expectedFilter) + err = codectool.UnmarshalYAML(filterYaml, expectedFilter) + assert.Nil(err) + return expectedFilter + }() + + for i, p := range pls { + gotFilter := getDefaultProxyFilterSpec() + filterYaml := codectool.MustMarshalYAML(p.Filters[0]) + err = codectool.UnmarshalYAML(filterYaml, gotFilter) + assert.Nil(err) + assert.Equal(expectedFilter, gotFilter, i) + } +} + +func TestCreateHTTPProxyCmd(t *testing.T) { + cmd := HTTPProxyCmd() + assert.NotNil(t, cmd) + + resetOption := func() { + httpProxyOptions = &HTTPProxyOptions{ + Port: 10080, + Rules: []string{ + "foo.com/bar=http://127.0.0.1:9096", + }, + } + } + resetOption() + err := httpProxyArgs(cmd, []string{"demo"}) + assert.Nil(t, err) + + // test arg len + err = httpProxyArgs(cmd, []string{}) + assert.NotNil(t, err) + err = httpProxyArgs(cmd, []string{"demo", "123"}) + assert.NotNil(t, err) + + // test port + httpProxyOptions.Port = -1 + err = httpProxyArgs(cmd, []string{"demo"}) + assert.NotNil(t, err) + + httpProxyOptions.Port = 65536 + err = httpProxyArgs(cmd, []string{"demo"}) + assert.NotNil(t, err) + resetOption() + + // test rule + httpProxyOptions.Rules = []string{} + err = httpProxyArgs(cmd, []string{"demo"}) + assert.NotNil(t, err) + resetOption() + + // test cert files + httpProxyOptions.CertFiles = []string{"not-exist-file.cert"} + err = httpProxyArgs(cmd, []string{"demo"}) + assert.NotNil(t, err) + resetOption() + + // test run + err = httpProxyRun(cmd, []string{"demo"}) + assert.NotNil(t, err) +} diff --git a/cmd/client/commandv2/describe.go b/cmd/client/commandv2/describe.go index 85e3bbf90d..5833f94952 100644 --- a/cmd/client/commandv2/describe.go +++ b/cmd/client/commandv2/describe.go @@ -33,6 +33,7 @@ func DescribeCmd() *cobra.Command { {Desc: "Describe all instances in that resource", Command: "egctl describe "}, {Desc: "Describe a httpserver", Command: "egctl describe httpserver "}, {Desc: "Describe all pipelines", Command: "egctl describe pipeline"}, + {Desc: "Describe pipelines with verbose information", Command: "egctl describe pipeline -v"}, {Desc: "Describe all members", Command: "egctl describe member"}, {Desc: "Describe a customdata kind", Command: "egctl describe customdatakind "}, {Desc: "Describe a customdata of given kind", Command: "egctl describe customdata "}, @@ -45,6 +46,7 @@ func DescribeCmd() *cobra.Command { Example: createMultiExample(examples), Run: describeCmdRun, } + cmd.Flags().BoolVarP(&general.CmdGlobalFlags.Verbose, "verbose", "v", false, "Print verbose information") return cmd } @@ -80,7 +82,6 @@ func describeCmdRun(cmd *cobra.Command, args []string) { // egctl describe customdata func describeCmdArgs(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 { - cmd.Help() return fmt.Errorf("no resource specified") } if len(args) == 1 { diff --git a/cmd/client/commandv2/edit.go b/cmd/client/commandv2/edit.go new file mode 100644 index 0000000000..92bd557e20 --- /dev/null +++ b/cmd/client/commandv2/edit.go @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 commandv2 + +import ( + "errors" + "fmt" + + "github.com/megaease/easegress/v2/cmd/client/general" + "github.com/megaease/easegress/v2/cmd/client/resources" + "github.com/spf13/cobra" +) + +// EditCmd returns edit command. +func EditCmd() *cobra.Command { + examples := []general.Example{ + {Desc: "Edit a resource with name", Command: "egctl edit "}, + {Desc: "Edit a resource with nano", Command: "env EGCTL_EDITOR=nano egctl edit "}, + {Desc: "Edit a httpserver with name", Command: "egctl edit httpserver httpserver-demo"}, + {Desc: "Edit all custom data with kind name", Command: "egctl edit customdata kind1"}, + {Desc: "Edit custom data with kind name and id name", Command: "egctl edit customdata kind1 data1"}, + } + cmd := &cobra.Command{ + Use: "edit", + Short: "Edit a resource", + Args: editCmdArgs, + Example: createMultiExample(examples), + Run: editCmdRun, + } + return cmd +} + +func editCmdRun(cmd *cobra.Command, args []string) { + var err error + defer func() { + if err != nil { + general.ExitWithError(err) + } + }() + + a := general.ParseArgs(args) + + kind, err := resources.GetResourceKind(a.Resource) + if err != nil { + return + } + switch kind { + case resources.CustomData().Kind: + err = resources.EditCustomData(cmd, a) + case resources.CustomDataKind().Kind: + err = resources.EditCustomDataKind(cmd, a) + case resources.Member().Kind: + err = errors.New("cannot edit member") + default: + err = resources.EditObject(cmd, a, kind) + } +} + +// editCmdArgs checks if args are valid. +// egctl edit +// special: +// egctl edit customdata +func editCmdArgs(cmd *cobra.Command, args []string) (err error) { + if len(args) <= 1 { + return fmt.Errorf("no resource and name specified") + } + if len(args) == 2 { + return nil + } + if len(args) == 3 && general.InAPIResource(args[0], resources.CustomData()) { + return nil + } + return fmt.Errorf("invalid args") +} diff --git a/cmd/client/commandv2/get.go b/cmd/client/commandv2/get.go index 1377afc034..76dfcba21e 100644 --- a/cmd/client/commandv2/get.go +++ b/cmd/client/commandv2/get.go @@ -121,7 +121,6 @@ func getCmdRun(cmd *cobra.Command, args []string) { // egctl get customdata func getCmdArgs(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 { - cmd.Help() return fmt.Errorf("no resource specified") } if len(args) == 1 { diff --git a/cmd/client/commandv2/logs.go b/cmd/client/commandv2/logs.go new file mode 100644 index 0000000000..7ef1afe353 --- /dev/null +++ b/cmd/client/commandv2/logs.go @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 commandv2 + +import ( + "bufio" + "fmt" + "io" + "net/http" + + "github.com/megaease/easegress/v2/cmd/client/general" + "github.com/spf13/cobra" +) + +// LogsCmd returns logs command. +func LogsCmd() *cobra.Command { + var n int + var follow bool + examples := []general.Example{ + {Desc: "Print the most recent 500 logs by default.", Command: "egctl logs"}, + {Desc: "Print the most recent 100 logs.", Command: "egctl logs --tail 100"}, + {Desc: "Print all logs.", Command: "egctl logs --tail -1"}, + {Desc: "Print the most recent 500 logs and streaming the log.", Command: "egctl logs -f"}, + } + + cmd := &cobra.Command{ + Use: "logs", + Short: "Print the logs of Easegress server", + Args: cobra.NoArgs, + Example: createMultiExample(examples), + Run: func(cmd *cobra.Command, args []string) { + query := fmt.Sprintf("?tail=%d&follow=%v", n, follow) + p := general.LogsURL + query + reader, err := general.HandleReqWithStreamResp(http.MethodGet, p, nil) + if err != nil { + general.ExitWithError(err) + } + defer reader.Close() + r := bufio.NewReader(reader) + for { + bytes, err := r.ReadBytes('\n') + if err != nil { + if err != io.EOF { + general.ExitWithError(err) + } + return + } + fmt.Print(string(bytes)) + } + }, + } + cmd.Flags().IntVar(&n, "tail", 500, "Lines of recent log file to display. Defaults to 500, use -1 to show all lines") + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Specify if the logs should be streamed.") + return cmd +} diff --git a/cmd/client/general/config.go b/cmd/client/general/config.go index 9489a11f18..d5257047c1 100644 --- a/cmd/client/general/config.go +++ b/cmd/client/general/config.go @@ -35,6 +35,9 @@ type ( ForceTLS bool InsecureSkipVerify bool OutputFormat string + + // following are some general flags. Can be used by all commands. But not all commands use them. + Verbose bool } // APIErr is the standard return of error. diff --git a/cmd/client/general/print.go b/cmd/client/general/print.go index 0aa0170fcc..7d84d82f5a 100644 --- a/cmd/client/general/print.go +++ b/cmd/client/general/print.go @@ -133,6 +133,13 @@ func DurationMostSignificantUnit(d time.Duration) string { // For example, if specials is ["name", "kind", "", "filters"] // then, "name", "kind" will in group one, and "filters" will in group two, others will in group three. func PrintMapInterface(maps []map[string]interface{}, fronts []string, backs []string) { + cutLine := func(line string) string { + if CmdGlobalFlags.Verbose || len(line) < 50 { + return line + } + return line[0:50] + "..." + } + printKV := func(k string, v interface{}) { value, err := codectool.MarshalYAML(v) if err != nil { @@ -142,7 +149,7 @@ func PrintMapInterface(maps []map[string]interface{}, fronts []string, backs []s lines := strings.Split(string(value), "\n") lines = lines[0 : len(lines)-1] if len(lines) == 1 { - fmt.Printf("%s: %s\n", Capitalize(k), lines[0]) + fmt.Printf("%s: %s\n", Capitalize(k), cutLine(lines[0])) return } fmt.Printf("%s:\n", Capitalize(k)) @@ -259,11 +266,23 @@ func CreateMultiExample(examples []Example) string { return output } +// CreateMultiLineExample creates cobra example by using multiple lines. +func CreateMultiLineExample(example string) string { + lines := strings.Split(example, "\n") + for i, line := range lines { + lines[i] = " " + line + } + return strings.Join(lines, "\n") +} + // GenerateExampleFromChild generates cobra example from child commands. func GenerateExampleFromChild(cmd *cobra.Command) { if len(cmd.Commands()) == 0 { return } + if cmd.Example != "" { + return + } example := "" for i, c := range cmd.Commands() { diff --git a/cmd/client/general/request.go b/cmd/client/general/request.go index e43ffafd8f..faa1ce7066 100644 --- a/cmd/client/general/request.go +++ b/cmd/client/general/request.go @@ -123,6 +123,56 @@ func SuccessfulStatusCode(code int) bool { return code >= 200 && code < 300 } +func HandleReqWithStreamResp(httpMethod string, path string, yamlBody []byte) (io.ReadCloser, error) { + var jsonBody []byte + if yamlBody != nil { + var err error + jsonBody, err = codectool.YAMLToJSON(yamlBody) + if err != nil { + return nil, fmt.Errorf("yaml %s to json failed: %v", yamlBody, err) + } + } + + url, err := MakeURL(path) + if err != nil { + return nil, err + } + client, err := GetHTTPClient() + if err != nil { + return nil, err + } + resp, err := doRequest(httpMethod, url, jsonBody, client) + if err != nil { + return nil, err + } + + if strings.HasPrefix(url, HTTPProtocol) && resp.StatusCode == http.StatusBadRequest { + resp, err = doRequest(httpMethod, HTTPSProtocol+strings.TrimPrefix(url, HTTPProtocol), jsonBody, client) + if err != nil { + return nil, err + } + } + + if !SuccessfulStatusCode(resp.StatusCode) { + defer func() { + resp.Body.Close() + }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %v", err) + } + + msg := string(body) + apiErr := &APIErr{} + err = codectool.Unmarshal(body, apiErr) + if err == nil { + msg = apiErr.Message + } + return nil, fmt.Errorf("%d: %s", apiErr.Code, msg) + } + return resp.Body, nil +} + // HandleRequest used in cmd/client/resources. It will return the response body in yaml or json format. func HandleRequest(httpMethod string, path string, yamlBody []byte) (body []byte, err error) { var jsonBody []byte @@ -142,14 +192,14 @@ func HandleRequest(httpMethod string, path string, yamlBody []byte) (body []byte if err != nil { return nil, err } - resp, body, err := doRequest(httpMethod, url, jsonBody, client) + resp, body, err := doRequestWithBody(httpMethod, url, jsonBody, client) if err != nil { return nil, err } msg := string(body) if strings.HasPrefix(url, HTTPProtocol) && resp.StatusCode == http.StatusBadRequest && strings.Contains(strings.ToUpper(msg), "HTTPS") { - resp, body, err = doRequest(httpMethod, HTTPSProtocol+strings.TrimPrefix(url, HTTPProtocol), jsonBody, client) + resp, body, err = doRequestWithBody(httpMethod, HTTPSProtocol+strings.TrimPrefix(url, HTTPProtocol), jsonBody, client) if err != nil { return nil, err } @@ -166,19 +216,27 @@ func HandleRequest(httpMethod string, path string, yamlBody []byte) (body []byte return body, nil } -func doRequest(httpMethod string, url string, jsonBody []byte, client *http.Client) (*http.Response, []byte, error) { +func doRequest(httpMethod string, url string, jsonBody []byte, client *http.Client) (*http.Response, error) { config, err := GetCurrentConfig() if err != nil { - return nil, nil, err + return nil, err } req, err := http.NewRequest(httpMethod, url, bytes.NewReader(jsonBody)) if config != nil && config.GetUsername() != "" { req.SetBasicAuth(config.GetUsername(), config.GetPassword()) } if err != nil { - return nil, nil, err + return nil, err } resp, err := client.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func doRequestWithBody(httpMethod string, url string, jsonBody []byte, client *http.Client) (*http.Response, []byte, error) { + resp, err := doRequest(httpMethod, url, jsonBody, client) if err != nil { return nil, nil, err } diff --git a/cmd/client/general/type.go b/cmd/client/general/type.go index 8471e31510..27ed6fb31f 100644 --- a/cmd/client/general/type.go +++ b/cmd/client/general/type.go @@ -24,6 +24,8 @@ type CmdType string const ( // GetCmd is the get command. GetCmd CmdType = "get" + // EditCmd is the edit command. + EditCmd CmdType = "edit" // CreateCmd is the create command. CreateCmd CmdType = "create" // ApplyCmd is the apply command. diff --git a/cmd/client/general/urls.go b/cmd/client/general/urls.go index bb40395885..d079913146 100644 --- a/cmd/client/general/urls.go +++ b/cmd/client/general/urls.go @@ -67,6 +67,9 @@ const ( // ProfileStopURL is the URL of stop profile. ProfileStopURL = APIURL + "/profile/stop" + // LogsURL is the URL of logs. + LogsURL = APIURL + "/logs" + // HTTPProtocol is prefix for HTTP protocol HTTPProtocol = "http://" // HTTPSProtocol is prefix for HTTPS protocol diff --git a/cmd/client/general/visitor.go b/cmd/client/general/visitor.go index 6dfa6f86a0..8560f5e7d6 100644 --- a/cmd/client/general/visitor.go +++ b/cmd/client/general/visitor.go @@ -143,3 +143,34 @@ func BuildSpecVisitor(yamlFile string, cmd *cobra.Command) SpecVisitor { v := BuildYAMLVisitor(yamlFile, cmd) return &specVisitor{v: v} } + +func GetSpecFromYaml(yamlStr string) (*Spec, error) { + s := Spec{} + err := yaml.Unmarshal([]byte(yamlStr), &s) + if err != nil { + return nil, fmt.Errorf("error parsing %s: %v", yamlStr, err) + } + s.doc = yamlStr + return &s, nil +} + +// CompareYamlNameKind compares the name and kind of two YAML strings +func CompareYamlNameKind(oldYaml, newYaml string) (*Spec, *Spec, error) { + s1, err := GetSpecFromYaml(oldYaml) + if err != nil { + return nil, nil, err + } + + s2, err := GetSpecFromYaml(newYaml) + if err != nil { + return nil, nil, err + } + + if s1.Kind != s2.Kind { + return nil, nil, fmt.Errorf("kind is not equal: %s, %s", s1.Kind, s2.Kind) + } + if s1.Name != s2.Name { + return nil, nil, fmt.Errorf("name is not equal: %s, %s", s1.Name, s2.Name) + } + return s1, s2, nil +} diff --git a/cmd/client/main.go b/cmd/client/main.go index dfe78fe75e..886ac51cf4 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -74,6 +74,7 @@ func main() { commandv2.GetCmd(), commandv2.DescribeCmd(), commandv2.ApplyCmd(), + commandv2.EditCmd(), ) addCommandWithGroup( @@ -85,6 +86,7 @@ func main() { commandv2.APIResourcesCmd(), commandv2.WasmCmd(), commandv2.ConfigCmd(), + commandv2.LogsCmd(), ) addCommandWithGroup( diff --git a/cmd/client/resources/customdata.go b/cmd/client/resources/customdata.go index d991ae038b..c37fa1740d 100644 --- a/cmd/client/resources/customdata.go +++ b/cmd/client/resources/customdata.go @@ -21,8 +21,7 @@ package resources import ( "fmt" "net/http" - "sort" - "strconv" + "os" "strings" "github.com/megaease/easegress/v2/cmd/client/general" @@ -32,15 +31,6 @@ import ( "github.com/spf13/cobra" ) -// CustomDataKind is CustomDataKind resource. -func CustomDataKind() *api.APIResource { - return &api.APIResource{ - Kind: "CustomDataKind", - Name: "customdatakind", - Aliases: []string{"customdatakinds", "cdk"}, - } -} - // CustomData is CustomData resource. func CustomData() *api.APIResource { return &api.APIResource{ @@ -50,179 +40,7 @@ func CustomData() *api.APIResource { } } -// DescribeCustomDataKind describes the custom data kind. -func DescribeCustomDataKind(cmd *cobra.Command, args *general.ArgInfo) error { - msg := "all " + CustomDataKind().Kind - if args.ContainName() { - msg = fmt.Sprintf("%s %s", CustomDataKind().Kind, args.Name) - } - getErr := func(err error) error { - return general.ErrorMsg(general.DescribeCmd, err, msg) - } - - body, err := httpGetCustomDataKind(cmd, args.Name) - if err != nil { - return getErr(err) - } - - if !general.CmdGlobalFlags.DefaultFormat() { - general.PrintBody(body) - return nil - } - - kinds, err := general.UnmarshalMapInterface(body, !args.ContainName()) - if err != nil { - return getErr(err) - } - // Output: - // Name: customdatakindxxx - // ... - general.PrintMapInterface(kinds, []string{"name"}, []string{}) - return nil -} - -func httpGetCustomDataKind(cmd *cobra.Command, name string) ([]byte, error) { - url := func(name string) string { - if len(name) == 0 { - return makePath(general.CustomDataKindURL) - } - return makePath(general.CustomDataKindItemURL, name) - }(name) - - return handleReq(http.MethodGet, url, nil) -} - -// GetCustomDataKind returns the custom data kind. -func GetCustomDataKind(cmd *cobra.Command, args *general.ArgInfo) error { - msg := "all " + CustomDataKind().Kind - if args.ContainName() { - msg = fmt.Sprintf("%s %s", CustomDataKind().Kind, args.Name) - } - getErr := func(err error) error { - return general.ErrorMsg(general.GetCmd, err, msg) - } - - body, err := httpGetCustomDataKind(cmd, args.Name) - if err != nil { - return getErr(err) - } - - if !general.CmdGlobalFlags.DefaultFormat() { - general.PrintBody(body) - return nil - } - - kinds, err := unmarshalCustomDataKind(body, !args.ContainName()) - if err != nil { - return getErr(err) - } - - sort.Slice(kinds, func(i, j int) bool { - return kinds[i].Name < kinds[j].Name - }) - printCustomDataKinds(kinds) - return nil -} - -func unmarshalCustomDataKind(body []byte, listBody bool) ([]*customdata.KindWithLen, error) { - if listBody { - metas := []*customdata.KindWithLen{} - err := codectool.Unmarshal(body, &metas) - return metas, err - } - meta := &customdata.KindWithLen{} - err := codectool.Unmarshal(body, meta) - return []*customdata.KindWithLen{meta}, err -} - -func printCustomDataKinds(kinds []*customdata.KindWithLen) { - // Output: - // NAME ID-FIELD JSON-SCHEMA DATA-NUM - // xxx - or name yes/no 10 - table := [][]string{} - table = append(table, []string{"NAME", "ID-FIELD", "JSON-SCHEMA", "DATA-NUM"}) - - getRow := func(kind *customdata.KindWithLen) []string { - jsonSchema := "no" - if kind.JSONSchema != nil { - jsonSchema = "yes" - } - idField := "-" - if kind.IDField != "" { - idField = kind.IDField - } - return []string{kind.Name, idField, jsonSchema, strconv.Itoa(kind.Len)} - } - - for _, kind := range kinds { - table = append(table, getRow(kind)) - } - general.PrintTable(table) -} - -// CreateCustomDataKind creates the custom data kind. -func CreateCustomDataKind(cmd *cobra.Command, s *general.Spec) error { - _, err := handleReq(http.MethodPost, makePath(general.CustomDataKindURL), []byte(s.Doc())) - if err != nil { - return general.ErrorMsg(general.CreateCmd, err, s.Kind, s.Name) - } - fmt.Println(general.SuccessMsg(general.CreateCmd, s.Kind, s.Name)) - return nil -} - -// DeleteCustomDataKind deletes the custom data kind. -func DeleteCustomDataKind(cmd *cobra.Command, names []string, all bool) error { - if all { - _, err := handleReq(http.MethodDelete, makePath(general.CustomDataKindURL), nil) - if err != nil { - return general.ErrorMsg(general.DeleteCmd, err, "all", CustomDataKind().Kind) - } - fmt.Println(general.SuccessMsg(general.DeleteCmd, "all", CustomDataKind().Kind)) - return nil - } - - for _, name := range names { - _, err := handleReq(http.MethodDelete, makePath(general.CustomDataKindItemURL, name), nil) - if err != nil { - return general.ErrorMsg(general.DeleteCmd, err, CustomDataKind().Kind, name) - } - fmt.Println(general.SuccessMsg(general.DeleteCmd, CustomDataKind().Kind, name)) - } - return nil -} - -// ApplyCustomDataKind applies the custom data kind. -func ApplyCustomDataKind(cmd *cobra.Command, s *general.Spec) error { - checkKindExist := func(cmd *cobra.Command, name string) bool { - _, err := httpGetCustomDataKind(cmd, name) - return err == nil - } - - createOrUpdate := func(cmd *cobra.Command, yamlDoc []byte, exist bool) error { - if exist { - _, err := handleReq(http.MethodPut, makePath(general.CustomDataKindURL), yamlDoc) - return err - } - _, err := handleReq(http.MethodPost, makePath(general.CustomDataKindURL), yamlDoc) - return err - } - - exist := checkKindExist(cmd, s.Name) - action := general.CreateCmd - if exist { - action = "update" - } - - err := createOrUpdate(cmd, []byte(s.Doc()), exist) - if err != nil { - return general.ErrorMsg(action, err, s.Kind, s.Name) - } - - fmt.Println(general.SuccessMsg(action, s.Kind, s.Name)) - return nil -} - -func httpGetCustomData(cmd *cobra.Command, args *general.ArgInfo) ([]byte, error) { +func httpGetCustomData(args *general.ArgInfo) ([]byte, error) { url := func(args *general.ArgInfo) string { if !args.ContainOther() { return makePath(general.CustomDataURL, args.Name) @@ -233,18 +51,6 @@ func httpGetCustomData(cmd *cobra.Command, args *general.ArgInfo) ([]byte, error return handleReq(http.MethodGet, url, nil) } -func getCertainCustomDataKind(cmd *cobra.Command, kindName string) (*customdata.KindWithLen, error) { - body, err := httpGetCustomDataKind(cmd, kindName) - if err != nil { - return nil, err - } - kinds, err := unmarshalCustomDataKind(body, false) - if err != nil { - return nil, err - } - return kinds[0], err -} - // GetCustomData gets the custom data. func GetCustomData(cmd *cobra.Command, args *general.ArgInfo) error { msg := fmt.Sprintf("%s for kind %s", CustomData().Kind, args.Name) @@ -255,7 +61,7 @@ func GetCustomData(cmd *cobra.Command, args *general.ArgInfo) error { return general.ErrorMsg(general.GetCmd, err, msg) } - body, err := httpGetCustomData(cmd, args) + body, err := httpGetCustomData(args) if err != nil { return getErr(err) } @@ -265,7 +71,7 @@ func GetCustomData(cmd *cobra.Command, args *general.ArgInfo) error { return nil } - kind, err := getCertainCustomDataKind(cmd, args.Name) + kind, err := getCertainCustomDataKind(args.Name) if err != nil { return getErr(err) } @@ -288,7 +94,7 @@ func DescribeCustomData(cmd *cobra.Command, args *general.ArgInfo) error { return general.ErrorMsg(general.DescribeCmd, err, msg) } - body, err := httpGetCustomData(cmd, args) + body, err := httpGetCustomData(args) if err != nil { return getErr(err) } @@ -298,7 +104,7 @@ func DescribeCustomData(cmd *cobra.Command, args *general.ArgInfo) error { return nil } - kind, err := getCertainCustomDataKind(cmd, args.Name) + kind, err := getCertainCustomDataKind(args.Name) if err != nil { return getErr(err) } @@ -372,3 +178,122 @@ func ApplyCustomData(cmd *cobra.Command, s *general.Spec) error { fmt.Println(general.SuccessMsg(general.ApplyCmd, s.Kind, "for kind "+s.Name)) return nil } + +func EditCustomData(cmd *cobra.Command, args *general.ArgInfo) error { + if args.ContainOther() { + return editCustomDataItem(cmd, args) + } + return editCustomDataBatch(cmd, args) +} + +func editCustomDataBatch(cmd *cobra.Command, args *general.ArgInfo) error { + getErr := func(err error) error { + return general.ErrorMsg(general.EditCmd, err, fmt.Sprintf("%s %s", CustomData().Kind, args.Name)) + } + var oldYaml string + var err error + if oldYaml, err = getCustomDataBatchYaml(args); err != nil { + return getErr(err) + } + filePath := getResourceTempFilePath(CustomData().Kind, args.Name) + newYaml, err := editResource(oldYaml, filePath) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + if newYaml == "" { + return nil + } + _, newSpec, err := general.CompareYamlNameKind(oldYaml, newYaml) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + err = ApplyCustomData(cmd, newSpec) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + os.Remove(filePath) + return nil +} + +func getCustomDataBatchYaml(args *general.ArgInfo) (string, error) { + body, err := httpGetCustomData(args) + if err != nil { + return "", err + } + + batch := api.ChangeRequest{} + err = codectool.Unmarshal(body, &batch.List) + if err != nil { + return "", err + } + batch.Rebuild = true + yamlStr, err := codectool.MarshalYAML(batch) + if err != nil { + return "", err + } + return fmt.Sprintf("name: %s\nkind: CustomData\n\n%s", args.Name, yamlStr), nil +} + +func editCustomDataItem(cmd *cobra.Command, args *general.ArgInfo) error { + getErr := func(err error) error { + return general.ErrorMsg(general.EditCmd, err, fmt.Sprintf("%s %s %s", CustomData().Kind, args.Name, args.Other)) + } + var oldYaml string + var err error + if oldYaml, err = getCustomDataItemYaml(args); err != nil { + return getErr(err) + } + filePath := getResourceTempFilePath(CustomData().Kind, args.Name) + newYaml, err := editResource(oldYaml, filePath) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + if newYaml == "" { + return nil + } + + if err = compareCustomDataID(args.Name, oldYaml, newYaml); err != nil { + return getErr(editErrWithPath(err, filePath)) + } + _, err = handleReq(http.MethodPut, makePath(general.CustomDataURL, args.Name), []byte(newYaml)) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + os.Remove(filePath) + fmt.Println(general.SuccessMsg(general.EditCmd, CustomData().Kind, args.Name, args.Other)) + return nil +} + +func compareCustomDataID(kindName, oldYaml, newYaml string) error { + kind, err := getCertainCustomDataKind(kindName) + if err != nil { + return err + } + oldData := &customdata.Data{} + newData := &customdata.Data{} + err = codectool.Unmarshal([]byte(oldYaml), oldData) + if err != nil { + return err + } + err = codectool.Unmarshal([]byte(newYaml), newData) + if err != nil { + return err + } + if kind.DataID(oldData) != kind.DataID(newData) { + fmt.Println(oldData, kind.DataID(oldData), newData, kind.DataID(newData)) + return fmt.Errorf("edit cannot change the %s of custom data", kind.GetIDField()) + } + return nil +} + +func getCustomDataItemYaml(args *general.ArgInfo) (string, error) { + body, err := httpGetCustomData(args) + if err != nil { + return "", err + } + yamlBody, err := codectool.JSONToYAML(body) + if err != nil { + return "", err + } + return fmt.Sprintf("# edit CustomData for kind %s\n\n%s", args.Name, string(yamlBody)), nil +} diff --git a/cmd/client/resources/customdatakind.go b/cmd/client/resources/customdatakind.go new file mode 100644 index 0000000000..29751f9729 --- /dev/null +++ b/cmd/client/resources/customdatakind.go @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 resources provides the resources utilities for the client. +package resources + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/megaease/easegress/v2/cmd/client/general" + "github.com/megaease/easegress/v2/pkg/api" + "github.com/megaease/easegress/v2/pkg/cluster/customdata" + "github.com/megaease/easegress/v2/pkg/util/codectool" + "github.com/spf13/cobra" +) + +// CustomDataKind is CustomDataKind resource. +func CustomDataKind() *api.APIResource { + return &api.APIResource{ + Kind: "CustomDataKind", + Name: "customdatakind", + Aliases: []string{"customdatakinds", "cdk"}, + } +} + +// DescribeCustomDataKind describes the custom data kind. +func DescribeCustomDataKind(cmd *cobra.Command, args *general.ArgInfo) error { + msg := "all " + CustomDataKind().Kind + if args.ContainName() { + msg = fmt.Sprintf("%s %s", CustomDataKind().Kind, args.Name) + } + getErr := func(err error) error { + return general.ErrorMsg(general.DescribeCmd, err, msg) + } + + body, err := httpGetCustomDataKind(args.Name) + if err != nil { + return getErr(err) + } + + if !general.CmdGlobalFlags.DefaultFormat() { + general.PrintBody(body) + return nil + } + + kinds, err := general.UnmarshalMapInterface(body, !args.ContainName()) + if err != nil { + return getErr(err) + } + // Output: + // Name: customdatakindxxx + // ... + general.PrintMapInterface(kinds, []string{"name"}, []string{}) + return nil +} + +func httpGetCustomDataKind(name string) ([]byte, error) { + url := func(name string) string { + if len(name) == 0 { + return makePath(general.CustomDataKindURL) + } + return makePath(general.CustomDataKindItemURL, name) + }(name) + + return handleReq(http.MethodGet, url, nil) +} + +// GetCustomDataKind returns the custom data kind. +func GetCustomDataKind(cmd *cobra.Command, args *general.ArgInfo) error { + msg := "all " + CustomDataKind().Kind + if args.ContainName() { + msg = fmt.Sprintf("%s %s", CustomDataKind().Kind, args.Name) + } + getErr := func(err error) error { + return general.ErrorMsg(general.GetCmd, err, msg) + } + + body, err := httpGetCustomDataKind(args.Name) + if err != nil { + return getErr(err) + } + + if !general.CmdGlobalFlags.DefaultFormat() { + general.PrintBody(body) + return nil + } + + kinds, err := unmarshalCustomDataKind(body, !args.ContainName()) + if err != nil { + return getErr(err) + } + + sort.Slice(kinds, func(i, j int) bool { + return kinds[i].Name < kinds[j].Name + }) + printCustomDataKinds(kinds) + return nil +} + +// EditCustomDataKind edit the custom data kind. +func EditCustomDataKind(cmd *cobra.Command, args *general.ArgInfo) error { + getErr := func(err error) error { + return general.ErrorMsg(general.EditCmd, err, CustomDataKind().Kind, args.Name) + } + + var oldYaml string + var err error + if oldYaml, err = getCustomDataKindYaml(args.Name); err != nil { + return getErr(err) + } + filePath := getResourceTempFilePath(CustomDataKind().Kind, args.Name) + newYaml, err := editResource(oldYaml, filePath) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + if newYaml == "" { + return nil + } + + _, newSpec, err := general.CompareYamlNameKind(oldYaml, newYaml) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + err = ApplyCustomDataKind(cmd, newSpec) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + return nil +} + +func getCustomDataKindYaml(kindName string) (string, error) { + body, err := httpGetCustomDataKind(kindName) + if err != nil { + return "", err + } + + yamlBody, err := codectool.JSONToYAML(body) + if err != nil { + return "", err + } + + // reorder yaml, put name, kind in front of other fields. + lines := strings.Split(string(yamlBody), "\n") + var name string + var sb strings.Builder + sb.Grow(len(yamlBody)) + for _, l := range lines { + if strings.HasPrefix(l, "name:") { + name = l + } else { + sb.WriteString(l) + sb.WriteString("\n") + } + } + return fmt.Sprintf("%s\n%s\n\n%s", name, "kind: CustomDataKind", sb.String()), nil +} + +func unmarshalCustomDataKind(body []byte, listBody bool) ([]*customdata.KindWithLen, error) { + if listBody { + metas := []*customdata.KindWithLen{} + err := codectool.Unmarshal(body, &metas) + return metas, err + } + meta := &customdata.KindWithLen{} + err := codectool.Unmarshal(body, meta) + return []*customdata.KindWithLen{meta}, err +} + +func printCustomDataKinds(kinds []*customdata.KindWithLen) { + // Output: + // NAME ID-FIELD JSON-SCHEMA DATA-NUM + // xxx - or name yes/no 10 + table := [][]string{} + table = append(table, []string{"NAME", "ID-FIELD", "JSON-SCHEMA", "DATA-NUM"}) + + getRow := func(kind *customdata.KindWithLen) []string { + jsonSchema := "no" + if kind.JSONSchema != nil { + jsonSchema = "yes" + } + idField := "-" + if kind.IDField != "" { + idField = kind.IDField + } + return []string{kind.Name, idField, jsonSchema, strconv.Itoa(kind.Len)} + } + + for _, kind := range kinds { + table = append(table, getRow(kind)) + } + general.PrintTable(table) +} + +// CreateCustomDataKind creates the custom data kind. +func CreateCustomDataKind(cmd *cobra.Command, s *general.Spec) error { + _, err := handleReq(http.MethodPost, makePath(general.CustomDataKindURL), []byte(s.Doc())) + if err != nil { + return general.ErrorMsg(general.CreateCmd, err, s.Kind, s.Name) + } + fmt.Println(general.SuccessMsg(general.CreateCmd, s.Kind, s.Name)) + return nil +} + +// DeleteCustomDataKind deletes the custom data kind. +func DeleteCustomDataKind(cmd *cobra.Command, names []string, all bool) error { + if all { + _, err := handleReq(http.MethodDelete, makePath(general.CustomDataKindURL), nil) + if err != nil { + return general.ErrorMsg(general.DeleteCmd, err, "all", CustomDataKind().Kind) + } + fmt.Println(general.SuccessMsg(general.DeleteCmd, "all", CustomDataKind().Kind)) + return nil + } + + for _, name := range names { + _, err := handleReq(http.MethodDelete, makePath(general.CustomDataKindItemURL, name), nil) + if err != nil { + return general.ErrorMsg(general.DeleteCmd, err, CustomDataKind().Kind, name) + } + fmt.Println(general.SuccessMsg(general.DeleteCmd, CustomDataKind().Kind, name)) + } + return nil +} + +// ApplyCustomDataKind applies the custom data kind. +func ApplyCustomDataKind(cmd *cobra.Command, s *general.Spec) error { + checkKindExist := func(cmd *cobra.Command, name string) bool { + _, err := httpGetCustomDataKind(name) + return err == nil + } + + createOrUpdate := func(cmd *cobra.Command, yamlDoc []byte, exist bool) error { + if exist { + _, err := handleReq(http.MethodPut, makePath(general.CustomDataKindURL), yamlDoc) + return err + } + _, err := handleReq(http.MethodPost, makePath(general.CustomDataKindURL), yamlDoc) + return err + } + + exist := checkKindExist(cmd, s.Name) + action := general.CreateCmd + if exist { + action = "update" + } + + err := createOrUpdate(cmd, []byte(s.Doc()), exist) + if err != nil { + return general.ErrorMsg(action, err, s.Kind, s.Name) + } + + fmt.Println(general.SuccessMsg(action, s.Kind, s.Name)) + return nil +} + +func getCertainCustomDataKind(kindName string) (*customdata.KindWithLen, error) { + body, err := httpGetCustomDataKind(kindName) + if err != nil { + return nil, err + } + kinds, err := unmarshalCustomDataKind(body, false) + if err != nil { + return nil, err + } + return kinds[0], err +} diff --git a/cmd/client/resources/member.go b/cmd/client/resources/member.go index 3fb8927321..3f5af5ff09 100644 --- a/cmd/client/resources/member.go +++ b/cmd/client/resources/member.go @@ -117,7 +117,7 @@ func DeleteMember(_ *cobra.Command, names []string) error { for _, name := range names { _, err := handleReq(http.MethodDelete, makePath(general.MemberItemURL, name), nil) if err != nil { - return general.ErrorMsg("purge", err, fmt.Sprintf("%s %s", MemberKind, name)) + return general.ErrorMsg("purge", err, MemberKind, name) } fmt.Println(general.SuccessMsg("purge", MemberKind, name)) } diff --git a/cmd/client/resources/object.go b/cmd/client/resources/object.go index e4cd39238f..104d24c8a9 100644 --- a/cmd/client/resources/object.go +++ b/cmd/client/resources/object.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "net/http" + "os" "sort" "strings" "time" @@ -90,6 +91,68 @@ func GetAllObject(cmd *cobra.Command) error { return nil } +// EditObject edit an object. +func EditObject(cmd *cobra.Command, args *general.ArgInfo, kind string) error { + getErr := func(err error) error { + return general.ErrorMsg(general.EditCmd, err, kind, args.Name) + } + + // get old yaml and save it to a temp file + var oldYaml string + var err error + if oldYaml, err = getObjectYaml(args.Name); err != nil { + return getErr(err) + } + filePath := getResourceTempFilePath(kind, args.Name) + newYaml, err := editResource(oldYaml, filePath) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + if newYaml == "" { + return nil + } + + _, newSpec, err := general.CompareYamlNameKind(oldYaml, newYaml) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + err = ApplyObject(cmd, newSpec) + if err != nil { + return getErr(editErrWithPath(err, filePath)) + } + os.Remove(filePath) + return nil +} + +func getObjectYaml(objectName string) (string, error) { + body, err := httpGetObject(objectName) + if err != nil { + return "", err + } + + yamlBody, err := codectool.JSONToYAML(body) + if err != nil { + return "", err + } + + // reorder yaml, put name, kind in front of other fields. + lines := strings.Split(string(yamlBody), "\n") + var name, kind string + var sb strings.Builder + sb.Grow(len(yamlBody)) + for _, l := range lines { + if strings.HasPrefix(l, "name: ") { + name = l + } else if strings.HasPrefix(l, "kind: ") { + kind = l + } else { + sb.WriteString(l) + sb.WriteString("\n") + } + } + return fmt.Sprintf("%s\n%s\n\n%s", name, kind, sb.String()), nil +} + // GetObject gets an object. func GetObject(cmd *cobra.Command, args *general.ArgInfo, kind string) error { msg := fmt.Sprintf("all %s", kind) diff --git a/cmd/client/resources/resources.go b/cmd/client/resources/resources.go index 2be6633ee9..91ff005004 100644 --- a/cmd/client/resources/resources.go +++ b/cmd/client/resources/resources.go @@ -20,6 +20,10 @@ package resources import ( "fmt" + "os" + "os/exec" + "path/filepath" + "time" "github.com/megaease/easegress/v2/cmd/client/general" ) @@ -46,3 +50,45 @@ func GetResourceKind(arg string) (string, error) { } return "", fmt.Errorf("unknown resource: %s", arg) } + +func getResourceTempFilePath(kind, name string) string { + ts := time.Now().Format("2006-01-02-1504") + return filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s-%s.yaml", kind, name, ts)) +} + +func execEditor(filePath string) error { + editor := os.Getenv("EGCTL_EDITOR") + if editor == "" { + editor = "vi" + } + cmd := exec.Command(editor, filePath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func editErrWithPath(err error, filePath string) error { + return fmt.Errorf("%s\n yaml saved in %s", err.Error(), filePath) +} + +func editResource(oldYaml, filePath string) (string, error) { + var err error + if err = os.WriteFile(filePath, []byte(oldYaml), 0644); err != nil { + return "", err + } + // exec editor and get new yaml + if err = execEditor(filePath); err != nil { + return "", err + } + + var newYaml []byte + if newYaml, err = os.ReadFile(filePath); err != nil { + return "", err + } + if string(newYaml) == oldYaml { + fmt.Printf("nothing changed; temp file in %s\n", filePath) + return "", nil + } + return string(newYaml), nil +} diff --git a/doc/egctl-cheat-sheet.md b/doc/egctl-cheat-sheet.md index cd0e96b4bf..f5ba9c7837 100644 --- a/doc/egctl-cheat-sheet.md +++ b/doc/egctl-cheat-sheet.md @@ -4,7 +4,7 @@ Easegress manifests are defined using YAML. They can be identified by the file extensions `.yaml` or `.yml`. You can create resources using either the `egctl create` or `egctl apply` commands. To view all available resources along with their supported actions, use the `egctl api-resources` command. -``` +```bash cat globalfilter.yaml | egctl create -f - # create GlobalFilter resource from stdin cat httpserver-new.yaml | egctl apply -f - # create HTTPServer resource from stdin @@ -16,9 +16,73 @@ egctl apply -f ./cdk-demo.yaml # create CustomDataKind resource egctl create -f ./custom-data-demo.yaml # create CustomData resource ``` -## Viewing and finding resources +## Create HTTPProxy +`egctl create httpproxy` is used to create `HTTPServer` and corresponding `Pipelines` quickly. + +```bash +egctl create httpproxy NAME --port PORT \ + --rule HOST/PATH=ENDPOINT1,ENDPOINT2 \ + [--rule HOST/PATH=ENDPOINT1,ENDPOINT2] \ + [--tls] \ + [--auto-cert] \ + [--ca-cert-file CA_CERT_FILE] \ + [--cert-file CERT_FILE] \ + [--key-file KEY_FILE] +``` +For example: + +```bash +# Create a HTTPServer (with port 10080) and corresponding Pipelines to direct +# request with path "/bar" to "http://127.0.0.1:8080" and "http://127.0.0.1:8081" and +# request with path "/foo" to "http://127.0.0.1:8082". +egctl create httpproxy demo --port 10080 \ + --rule="/bar=http://127.0.0.1:8080,http://127.0.0.1:8081" \ + --rule="/foo=http://127.0.0.1:8082" ``` + +this equals to +```yaml +kind: HTTPServer +name: demo +port: 10080 +https: false +rules: + - paths: + - path: /bar + backend: demo-0 + - path: /foo + backend: demo-1 + +--- +name: demo-0 +kind: Pipeline +filters: + - name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:8080 + - url: http://127.0.0.1:8081 + loadBalance: + policy: roundRobin + +--- +name: demo-1 +kind: Pipeline +filters: + - name: proxy + kind: Proxy + pools: + - servers: + - url: http://127.0.0.1:8082 + loadBalance: + policy: roundRobin +``` + +## Viewing and finding resources + +```bash egctl get all # view all resources egctl get httpserver httpserver-demo # find HTTPServer resources with name "httpserver-demo" @@ -33,20 +97,32 @@ egctl describe pipeline pipeline-demo # describe Pipeline resource with name "p ``` ## Updating resources -``` +```bash egctl apply -f httpserver-demo-version2.yaml # update HTTPServer resource egctl apply -f cdk-demo2.yaml # udpate CustomDataKind resource ``` -## Deleting resources +## Editing resources +```bash +egctl edit httpserver httpserver-demo # edit httpserver with name httpserver-demo +egctl edit customdata cdk-demo # batch edit custom data with kind cdk-demo +egctl edit customdata cdk-demo data1 # edit custom data data1 of kind cdk-demo ``` +The default editor for `egctl edit` is `vi`. To change it, update the `EGCTL_EDITOR` environment variable. + +## Deleting resources +```bash egctl delete httpserver httpserver-demo # delete HTTPServer resource with name "httpserver-demo" egctl delete httpserver --all # delete all HTTPServer resources egctl delete customdatakind cdk-demo cdk-kind # delete CustomDataKind resources named "cdk-demo" and "cdk-kind" ``` ## Other commands -``` +```bash +egctl logs # print easegress-server logs +egctl logs --tail 100 # print most recent 100 logs +egctl logs -f # print logs as stream + egctl api-resources # view all available resources egctl completion zsh # generate completion script for zsh egctl health # check easegress health @@ -123,7 +199,7 @@ cluster: client-ca-file: "/tmp/certs/ca.crt" ``` -``` +```bash egctl config current-context # display the current context in use by egctl egctl config get-contexts # view all available contexts egctl config use-context # update the current-context field in the .egctlrc file to diff --git a/pkg/api/api.go b/pkg/api/api.go index 1da3c5b4b2..f7fb379669 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -107,6 +107,7 @@ func (s *Server) registerAPIs() { group.Entries = append(group.Entries, s.customDataAPIEntries()...) group.Entries = append(group.Entries, s.profileAPIEntries()...) group.Entries = append(group.Entries, s.prometheusMetricsAPIEntries()...) + group.Entries = append(group.Entries, s.logsAPIEntries()...) for _, fn := range appendAddonAPIs { fn(s, group) diff --git a/pkg/api/logs.go b/pkg/api/logs.go new file mode 100644 index 0000000000..fb9e5a1a52 --- /dev/null +++ b/pkg/api/logs.go @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2017, MegaEase + * All rights reserved. + * + * 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 api + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + + "github.com/fsnotify/fsnotify" + "github.com/megaease/easegress/v2/pkg/logger" +) + +func (s *Server) logsAPIEntries() []*Entry { + return []*Entry{ + { + Path: "/logs", + Method: "GET", + Handler: s.getLogs, + }, + } +} + +type logFile struct { + Path string + File *os.File + Tail int + Follow bool + EndIndex int64 +} + +// Close close the log file. +func (lf *logFile) Close() { + lf.File.Close() +} + +func newLogFile(r *http.Request, filePath string) (*logFile, error) { + tail, follow, err := parseLogQueries(r) + if err != nil { + return nil, err + } + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + end, err := file.Seek(0, io.SeekEnd) + if err != nil { + file.Close() + return nil, err + } + + return &logFile{ + Path: filePath, + File: file, + Tail: tail, + Follow: follow, + EndIndex: end, + }, nil +} + +func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) { + flusher := w.(http.Flusher) + var err error + defer func() { + if err != nil { + HandleAPIError(w, r, http.StatusInternalServerError, err) + } + }() + + logPath := logger.GetLogPath() + if logPath == "" { + err = errors.New("log path not found") + return + } + + logFile, err := newLogFile(r, logPath) + if err != nil { + return + } + defer logFile.Close() + + err = logFile.ReadWithTail(w) + if err != nil { + return + } + flusher.Flush() + + // watch log file from the end + if !logFile.Follow { + return + } + writeCh, closeFn, err := logFile.Watch() + if err != nil { + return + } + defer closeFn() + + for { + select { + case str, ok := <-writeCh: + if !ok { + return // log file closed + } + _, err = w.Write([]byte(str)) + if err != nil { + return + } + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +// ReadWithTail read log file wilt tail. If tail is -1, read all. +func (lf *logFile) ReadWithTail(w http.ResponseWriter) error { + var index int64 + var err error + end := lf.EndIndex + // tail -1 means read all, find index to start reading + if lf.Tail == -1 { + index = 0 + } else { + index, err = lf.findLastNLineIndex() + if err != nil { + return err + } + } + + // reading from index to end + for index < end { + data := make([]byte, 1024) + length, err := lf.File.ReadAt(data, index) + index += int64(length) + if index > end { + length = int(end - index + int64(length)) + } + _, err2 := w.Write(data[:length]) + if err2 != nil { + return err2 + } + if err != nil { + if err != io.EOF { + return err + } + return nil + } + } + return nil +} + +func parseLogQueries(r *http.Request) (int, bool, error) { + tailValue := r.URL.Query().Get("tail") + if tailValue == "" { + tailValue = "500" + } + tail, err := strconv.Atoi(tailValue) + if err != nil { + return 0, false, fmt.Errorf("invalid tail %s, %v", tailValue, err) + } + if tail < -1 { + return 0, false, fmt.Errorf("invalid tail %d, tail should not less than -1", tail) + } + + followValue := r.URL.Query().Get("follow") + follow, err := strconv.ParseBool(followValue) + if err != nil { + return 0, false, fmt.Errorf("invalid follow %s, %v", followValue, err) + } + return tail, follow, nil +} + +// Watch watch the log file from the end index. +// It returns watchCh to receive the new log and closeFn to close the watcher. +func (lf *logFile) Watch() (<-chan string, func(), error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, nil, err + } + + watchCh := make(chan string) + closeCh := make(chan struct{}) + go func(lf *logFile) { + index := lf.EndIndex + defer func() { + logger.Infof("close watcher for %s", lf.Path) + close(watchCh) + }() + + for { + select { + case <-closeCh: + // caller close the watcher, return + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + for { + data := make([]byte, 1024) + length, err := lf.File.ReadAt(data, index) + if err != nil { + if err != io.EOF { + return + } + } + index += int64(length) + watchCh <- string(data[:length]) + if err == io.EOF || length < 1024 { + break + } + } + } + case _, ok := <-watcher.Errors: + if !ok { + return + } + } + } + }(lf) + + err = watcher.Add(lf.Path) + if err != nil { + close(closeCh) + return nil, nil, err + } + closeFn := func() { + watcher.Close() + close(closeCh) + } + return watchCh, closeFn, nil +} + +// findLastNLineIndex find the index of the last n line +// from EndIndex of file. It return 0 if the file has +// less than n lines. +func (lf *logFile) findLastNLineIndex() (int64, error) { + buf := make([]byte, 1) + lineCount := 0 + end := lf.EndIndex + n := lf.Tail + + for end > 0 && lineCount < n { + end-- + _, err := lf.File.ReadAt(buf, end) + if err != nil { + return 0, err + } + + if buf[0] == '\n' && end != lf.EndIndex-1 { + lineCount++ + if lineCount == n { + end++ + break + } + } + } + if lineCount < n { + return 0, nil + } + return end, nil +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index f0eb4727c0..53e54e7478 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -92,8 +92,15 @@ var ( httpFilterAccessLogger *zap.SugaredLogger httpFilterDumpLogger *zap.SugaredLogger restAPILogger *zap.SugaredLogger + + stdoutLogPath string ) +// GetLogPath returns the path of stdout log. +func GetLogPath() string { + return stdoutLogPath +} + // EtcdClientLoggerConfig generates the config of etcd client logger. func EtcdClientLoggerConfig(opt *option.Options, filename string) *zap.Config { encoderConfig := defaultEncoderConfig() @@ -154,6 +161,7 @@ func initDefault(opt *option.Options) { if err != nil { common.Exit(1, err.Error()) } + stdoutLogPath = gressLF.(*logFile).filename } opts := []zap.Option{zap.AddCaller(), zap.AddCallerSkip(1)}