diff --git a/client/client.go b/client/client.go index 12bbb86f..62e6d429 100644 --- a/client/client.go +++ b/client/client.go @@ -1,11 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 PANTHEON.tech +// +// 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 client import ( + "context" + "crypto/tls" + "encoding/json" "fmt" + "net/http" + "strings" + "time" + + docker "github.com/fsouza/go-dockerclient" + "github.com/sirupsen/logrus" + vppagent "go.ligato.io/vpp-agent/v3/cmd/agentctl/client" + "go.ligato.io/vpp-agent/v3/cmd/agentctl/client/tlsconfig" + + "go.pantheon.tech/stonework/plugins/cnfreg" +) + +const ( + FallbackHost = "127.0.0.1" + DefaultHTTPClientTimeout = 60 * time.Second + DefaultPortHTTP = 9191 + DockerComposeServiceLabel = "com.docker.compose.service" ) // Option is a function that customizes a Client. -type Option func(*Client) +type Option func(*Client) error + +func WithHTTPPort(p uint16) Option { + return func(c *Client) error { + c.httpPort = p + return nil + } +} + +func WithHTTPTLS(cert, key, ca string, skipVerify bool) Option { + return func(c *Client) (err error) { + c.httpTLS, err = withTLS(cert, key, ca, skipVerify) + return err + } +} // API defines client API. It is supposed to be used by various client // applications, such as swctl or other user applications interacting with @@ -16,25 +66,196 @@ type API interface { // Client implements API interface. type Client struct { - components []Component + dockerClient *docker.Client + httpClient *http.Client + host string + scheme string + protocol string + httpPort uint16 + httpTLS *tls.Config + customHTTPHeaders map[string]string } // NewClient creates a new client that implements API. The client can be // customized by options. func NewClient(opts ...Option) (*Client, error) { - c := &Client{} + c := &Client{ + scheme: "http", + protocol: "tcp", + httpPort: DefaultPortHTTP, + } + var err error + + c.dockerClient, err = docker.NewClientFromEnv() + if err != nil { + return nil, err + } + + containers, err := c.dockerClient.ListContainers(docker.ListContainersOptions{}) + if err != nil { + return nil, err + } + + // find IP address of the StoneWork service + for _, container := range containers { + if container.Labels[DockerComposeServiceLabel] != "stonework" { + continue + } + cont, err := c.dockerClient.InspectContainerWithOptions(docker.InspectContainerOptions{ID: container.ID}) + if err != nil { + return nil, err + } + for _, nw := range cont.NetworkSettings.Networks { + if nw.IPAddress != "" { + c.host = nw.IPAddress + break + } + } + break + } + for _, o := range opts { - o(c) + if err = o(c); err != nil { + return nil, err + } + } + if c.host == "" { + logrus.Warnf("could not find StoneWork service management IP address falling back to: %s", FallbackHost) + c.host = FallbackHost + } else { + logrus.Debugf("found StoneWork service management IP address: %s", c.host) } + return c, nil } -// GetComponents returns list of components. +func (c *Client) DockerClient() *docker.Client { + return c.dockerClient +} + +// HTTPClient returns configured HTTP client. +func (c *Client) HTTPClient() *http.Client { + if c.httpClient == nil { + tr := http.DefaultTransport.(*http.Transport).Clone() + tr.TLSClientConfig = c.httpTLS + c.httpClient = &http.Client{ + Transport: tr, + Timeout: DefaultHTTPClientTimeout, + } + } + return c.httpClient +} + +func withTLS(cert, key, ca string, skipVerify bool) (*tls.Config, error) { + var options []tlsconfig.Option + if cert != "" && key != "" { + options = append(options, tlsconfig.CertKey(cert, key)) + } + if ca != "" { + options = append(options, tlsconfig.CA(ca)) + } + if skipVerify { + options = append(options, tlsconfig.SkipServerVerification()) + } + return tlsconfig.New(options...) +} + +func (c *Client) StatusInfo(ctx context.Context) ([]cnfreg.Info, error) { + resp, err := c.get(ctx, "/status/info", nil, nil) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + var infos []cnfreg.Info + if err := json.NewDecoder(resp.body).Decode(&infos); err != nil { + return nil, fmt.Errorf("decoding reply failed: %w", err) + } + return infos, nil +} + func (c *Client) GetComponents() ([]Component, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + infos, err := c.StatusInfo(ctx) + if err != nil { + return nil, err + } + + dc := c.DockerClient() + containerInfo, err := dc.ListContainers(docker.ListContainersOptions{}) + if err != nil { + return nil, err + } + + var containers []*docker.Container + for _, container := range containerInfo { + c, err := dc.InspectContainerWithOptions(docker.InspectContainerOptions{ID: container.ID}) + if err != nil { + return nil, err + } + containers = append(containers, c) + } + + cnfInfos := make(map[string]cnfreg.Info) + for _, info := range infos { + cnfInfos[info.MsLabel] = info + } + var components []Component + for _, container := range containers { + + metadata := make(map[string]string) + metadata["containerID"] = container.ID + metadata["containerName"] = container.Name + metadata["containerServiceName"] = container.Config.Labels[DockerComposeServiceLabel] + metadata["dockerImage"] = container.Config.Image + if container.NetworkSettings.IPAddress != "" { + metadata["containerIPAddress"] = container.NetworkSettings.IPAddress + } else { + for _, nw := range container.NetworkSettings.Networks { + if nw.IPAddress != "" { + metadata["containerIPAddress"] = nw.IPAddress + break + } + } + } + + logrus.Tracef("found metadata for container: %s, data: %+v", container.Name, metadata) - // TODO: implement retrieval of components + compo := &component{Metadata: metadata} + after, found := containsPrefix(container.Config.Env, "MICROSERVICE_LABEL=") + if !found { + compo.Name = container.Config.Labels[DockerComposeServiceLabel] + compo.Mode = ComponentAuxiliary + components = append(components, compo) + continue + } + info, ok := cnfInfos[after] + if ok { + compo.Name = info.MsLabel + compo.Info = &info + compo.Mode = cnfModeToCompoMode(info.CnfMode) + } else { + compo.Name = container.Config.Labels[DockerComposeServiceLabel] + compo.Mode = ComponentStandalone + } - return components, fmt.Errorf("NOT IMPLEMENTED YET") + client, err := vppagent.NewClientWithOpts(vppagent.WithHost(info.IPAddr), vppagent.WithHTTPPort(info.HTTPPort)) + if err != nil { + return components, err + } + compo.agentclient = client + components = append(components, compo) + } + return components, nil +} +func containsPrefix(strs []string, prefix string) (string, bool) { + for _, str := range strs { + found := strings.HasPrefix(str, prefix) + if found { + return strings.TrimPrefix(str, prefix), found + } + } + return "", false } diff --git a/client/component.go b/client/component.go index e68e5def..16858d8a 100644 --- a/client/component.go +++ b/client/component.go @@ -1,23 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 PANTHEON.tech +// +// 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 client -import "go.pantheon.tech/stonework/proto/cnfreg" +import ( + "context" + "fmt" + "strings" + + "go.ligato.io/vpp-agent/v3/cmd/agentctl/api/types" + "go.ligato.io/vpp-agent/v3/cmd/agentctl/client" + + "go.ligato.io/vpp-agent/v3/proto/ligato/kvscheduler" + "go.pantheon.tech/stonework/plugins/cnfreg" + cnfregpb "go.pantheon.tech/stonework/proto/cnfreg" +) + +type ComponentMode int32 + +const ( + ComponentUnknown ComponentMode = iota + + // Auxiliary means the component is not a CNF and is not managed by StoneWork + ComponentAuxiliary + + // Standalone means the component is a standalone CNF + ComponentStandalone + + // ComponentStonework means the component is a StoneWork module managed by StoneWork + ComponentStoneworkModule + + // Stonework means the component is a StoneWork instance + ComponentStonework +) // Component is a component of StoneWork. It can be StoneWork instance itself, // a CNF connected to it or other Ligato service in connected to StoneWork. type Component interface { - Name() string - Mode() cnfreg.CnfMode + GetName() string + GetMode() ComponentMode + GetInfo() *cnfreg.Info + GetMetadata() map[string]string + ConfigStatus() (*ConfigCounts, error) } type component struct { - name string - mode cnfreg.CnfMode + agentclient *client.Client + Name string + Mode ComponentMode + Info *cnfreg.Info + Metadata map[string]string +} + +func (c *component) GetName() string { + return c.Name +} + +func (c *component) GetMode() ComponentMode { + return c.Mode +} + +func (c *component) GetInfo() *cnfreg.Info { + return c.Info +} + +func (c *component) Client() *client.Client { + return c.agentclient +} + +func (c *component) GetMetadata() map[string]string { + return c.Metadata +} + +func (c *component) ConfigStatus() (*ConfigCounts, error) { + if c.Mode == ComponentAuxiliary || c.Mode == ComponentUnknown { + return nil, fmt.Errorf("cannot get scheduler values of component %s, this component in not managed by StoneWork", c.Name) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + values, err := c.agentclient.SchedulerValues(ctx, types.SchedulerValuesOptions{}) + if err != nil { + return nil, err + } + + var allVals []*kvscheduler.ValueStatus + for _, baseVal := range values { + allVals = append(allVals, baseVal.Value) + allVals = append(allVals, baseVal.DerivedValues...) + } + + var res ConfigCounts + for _, val := range allVals { + switch val.State { + case kvscheduler.ValueState_INVALID, kvscheduler.ValueState_FAILED: + res.Err++ + case kvscheduler.ValueState_MISSING: + res.Missing++ + case kvscheduler.ValueState_PENDING: + res.Pending++ + case kvscheduler.ValueState_RETRYING: + res.Retrying++ + case kvscheduler.ValueState_UNIMPLEMENTED: + res.Unimplemented++ + case kvscheduler.ValueState_CONFIGURED, kvscheduler.ValueState_DISCOVERED, kvscheduler.ValueState_OBTAINED, kvscheduler.ValueState_REMOVED, kvscheduler.ValueState_NONEXISTENT: + res.Ok++ + } + } + + return &res, nil +} + +type ConfigCounts struct { + Ok int + Err int + Missing int + Pending int + Retrying int + Unimplemented int +} + +func (cc ConfigCounts) String() string { + var fields []string + if cc.Ok != 0 { + fields = append(fields, fmt.Sprintf("%d OK", cc.Ok)) + } + if cc.Err != 0 { + errStr := fmt.Sprintf("%d errors", cc.Ok) + if cc.Err == 1 { + errStr = errStr[:len(errStr)-1] + } + fields = append(fields, errStr) + } + if cc.Missing != 0 { + fields = append(fields, fmt.Sprintf("%d missing", cc.Missing)) + } + if cc.Pending != 0 { + fields = append(fields, fmt.Sprintf("%d pending", cc.Pending)) + } + if cc.Retrying != 0 { + fields = append(fields, fmt.Sprintf("%d retrying", cc.Retrying)) + } + if cc.Unimplemented != 0 { + fields = append(fields, fmt.Sprintf("%d unimplemented", cc.Unimplemented)) + } + return strings.Join(fields, ", ") } -func (c *component) Name() string { - return c.name +func (c ComponentMode) String() string { + switch c { + case ComponentAuxiliary: + return "auxiliary" + case ComponentStandalone: + return "standalone CNF" + case ComponentStonework: + return "StoneWork" + case ComponentStoneworkModule: + return "StoneWork module" + default: + return "unknown" + } } -func (c *component) Mode() cnfreg.CnfMode { - return c.mode +func cnfModeToCompoMode(cm cnfregpb.CnfMode) ComponentMode { + switch cm { + case cnfregpb.CnfMode_STANDALONE: + return ComponentStandalone + case cnfregpb.CnfMode_STONEWORK_MODULE: + return ComponentStoneworkModule + case cnfregpb.CnfMode_STONEWORK: + return ComponentStonework + default: + return ComponentUnknown + } } diff --git a/client/http.go b/client/http.go new file mode 100644 index 00000000..c2d949e5 --- /dev/null +++ b/client/http.go @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 PANTHEON.tech +// +// 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 client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + + "go.ligato.io/vpp-agent/v3/cmd/agentctl/api/types" +) + +// serverResponse is a wrapper for http API responses. +type serverResponse struct { + body io.ReadCloser + contentLen int64 + header http.Header + statusCode int + reqURL *url.URL +} + +func (c *Client) get(ctx context.Context, path string, query url.Values, headers map[string][]string) (serverResponse, error) { + return c.sendRequest(ctx, "GET", path, query, nil, headers) +} + +func (c *Client) post(ctx context.Context, path string, query url.Values, obj interface{}, headers map[string][]string) (serverResponse, error) { + body, headers, err := encodeBody(obj, headers) + if err != nil { + return serverResponse{}, err + } + return c.sendRequest(ctx, "POST", path, query, body, headers) +} + +func (c *Client) put(ctx context.Context, path string, query url.Values, obj interface{}, headers map[string][]string) (serverResponse, error) { + body, headers, err := encodeBody(obj, headers) + if err != nil { + return serverResponse{}, err + } + return c.sendRequest(ctx, "PUT", path, query, body, headers) +} + +type headers map[string][]string + +func encodeBody(obj interface{}, headers headers) (io.Reader, headers, error) { + if obj == nil { + return nil, headers, nil + } + + body, err := encodeData(obj) + if err != nil { + return nil, headers, err + } + if headers == nil { + headers = make(map[string][]string) + } + headers["Content-Type"] = []string{"application/json"} + return body, headers, nil +} + +func (c *Client) buildRequest(method, path string, body io.Reader, headers headers) (*http.Request, error) { + expectedPayload := method == "POST" || method == "PUT" + if expectedPayload && body == nil { + body = bytes.NewReader([]byte{}) + } + + req, err := http.NewRequest(method, path, body) + if err != nil { + return nil, err + } + req = c.addHeaders(req, headers) + + if c.protocol == "unix" || c.protocol == "npipe" { + // For local communications, it doesn't matter what the host is. + // We just need a valid and meaningful host name. + req.Host = "stonework-agent" + } + + req.URL.Host = net.JoinHostPort(c.host, strconv.Itoa(int(c.httpPort))) + req.URL.Scheme = c.scheme + + if expectedPayload && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "text/plain") + } + return req, nil +} + +func (c *Client) sendRequest(ctx context.Context, method, path string, query url.Values, body io.Reader, headers headers) (serverResponse, error) { + fullPath := (&url.URL{Path: path, RawQuery: query.Encode()}).String() + req, err := c.buildRequest(method, fullPath, body, headers) + if err != nil { + return serverResponse{}, err + } + resp, err := c.doRequest(ctx, req) + if err != nil { + return resp, err + } + err = c.checkResponseErr(resp) + return resp, err +} + +func (c *Client) doRequest(ctx context.Context, req *http.Request) (serverResponse, error) { + serverResp := serverResponse{ + statusCode: -1, + reqURL: req.URL, + } + var ( + err error + resp *http.Response + ) + req = req.WithContext(ctx) + + fields := map[string]interface{}{} + if req.ContentLength > 0 { + fields["contentLength"] = req.ContentLength + } + logrus.WithFields(fields).Debugf("=> sending http request: %s %s", req.Method, req.URL) + defer func() { + if err != nil { + logrus.Debugf("<- http response ERROR: %v", err) + } else { + logrus.Debugf("<- http response %v (%d bytes)", serverResp.statusCode, serverResp.contentLen) + } + }() + + resp, err = c.HTTPClient().Do(req) + if err != nil { + if c.scheme != "https" && strings.Contains(err.Error(), "malformed HTTP response") { + return serverResp, fmt.Errorf("%v.\n* Are you trying to connect to a TLS-enabled daemon without TLS?", err) + } + if c.scheme == "https" && strings.Contains(err.Error(), "bad certificate") { + return serverResp, errors.Wrap(err, "The server probably has client authentication (--tlsverify) enabled. Please check your TLS client certification settings") + } + + // Don't decorate context sentinel errors; users may be comparing to + // them directly. + switch err { + case context.Canceled, context.DeadlineExceeded: + return serverResp, err + } + if nErr, ok := err.(*url.Error); ok { + if nErr, ok := nErr.Err.(*net.OpError); ok { + if os.IsPermission(nErr.Err) { + return serverResp, errors.Wrapf(err, "Got permission denied while trying to connect to the agent socket at %v", c.host) + } + } + } + if err, ok := err.(net.Error); ok { + if err.Timeout() { + return serverResp, fmt.Errorf("cannot connect to StoneWork at %s", c.host) + } + if !err.Temporary() { + if strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "dial unix") { + return serverResp, fmt.Errorf("cannot connect to StoneWork at %s", c.host) + } + } + } + return serverResp, errors.Wrap(err, "error during connect") + } + if logrus.IsLevelEnabled(logrus.DebugLevel) { + body, err := io.ReadAll(resp.Body) + if err != nil { + logrus.Debugf("reading body failed: %v", err) + } else { + logrus.Debugf("body: %s", body) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + } + if resp != nil { + serverResp.statusCode = resp.StatusCode + serverResp.body = resp.Body + serverResp.header = resp.Header + serverResp.contentLen = resp.ContentLength + } + return serverResp, nil +} + +func (c *Client) checkResponseErr(serverResp serverResponse) error { + if serverResp.statusCode >= 200 && serverResp.statusCode < 400 { + return nil + } + var body []byte + var err error + if serverResp.body != nil { + bodyMax := 1 * 1024 * 1024 // 1 MiB + bodyR := &io.LimitedReader{ + R: serverResp.body, + N: int64(bodyMax), + } + body, err = io.ReadAll(bodyR) + if err != nil { + return err + } + if bodyR.N == 0 { + return fmt.Errorf("request returned %s with a message (> %d bytes) for API route and version %s, check if the server supports the requested API version", + http.StatusText(serverResp.statusCode), bodyMax, serverResp.reqURL) + } + } + if len(body) == 0 { + return fmt.Errorf("request returned %s for API route and version %s, check if the server supports the requested API version", + http.StatusText(serverResp.statusCode), serverResp.reqURL) + } + var ct string + if serverResp.header != nil { + ct = serverResp.header.Get("Content-Type") + } + var errorMsg string + if ct == "application/json" { + var errorResponse types.ErrorResponse + if err := json.Unmarshal(body, &errorResponse); err != nil { + return errors.Wrap(err, "Error unmarshaling JSON body") + } + errorMsg = errorResponse.Message + } else { + errorMsg = string(body) + } + errorMsg = fmt.Sprintf("[%d] %s", serverResp.statusCode, strings.TrimSpace(errorMsg)) + + return errors.Wrap(errors.New(errorMsg), "Error response from daemon") +} + +func (c *Client) addHeaders(req *http.Request, headers headers) *http.Request { + // Add CLI Config's HTTP Headers BEFORE we set the client headers + // then the user can't change OUR headers + for k, v := range c.customHTTPHeaders { + req.Header.Set(k, v) + } + for k, v := range headers { + req.Header[k] = v + } + return req +} + +func encodeData(data interface{}) (*bytes.Buffer, error) { + params := bytes.NewBuffer(nil) + if data != nil { + if err := json.NewEncoder(params).Encode(data); err != nil { + return nil, err + } + } + return params, nil +} + +func ensureReaderClosed(response serverResponse) { + if response.body != nil { + // Drain up to 512 bytes and close the body to let the Transport reuse the connection + _, _ = io.CopyN(io.Discard, response.body, 512) + _ = response.body.Close() + } +} diff --git a/cmd/swctl/cmd_status.go b/cmd/swctl/cmd_status.go index 882e8a83..bcedc70e 100644 --- a/cmd/swctl/cmd_status.go +++ b/cmd/swctl/cmd_status.go @@ -1,30 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 PANTHEON.tech +// +// 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 ( "fmt" + "io" "os/exec" + "strconv" + "strings" + "sync" "github.com/gookit/color" + "github.com/olekukonko/tablewriter" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" -) + "github.com/spf13/pflag" + "golang.org/x/exp/slices" -// TODO: improve status overview, show status of components (CNFs) -// - instead of using raw output from vpp-probe, retrieve the important info -// about the running/deployed components of StoneWork and show those by default -// - optionally allow user to set more details which shows the more detailed output -// similar to vpp-probe discover + "go.pantheon.tech/stonework/client" +) const statusExample = ` # Show status for all components $ swctl status + + # Show interface status of StoneWork VPP instance + $ swctl status --show-interfaces ` -type StatusCmdOptions struct { - Args []string +type StatusOptions struct { + Format string + ShowInterfaces bool +} + +func (opts *StatusOptions) InstallFlags(flagset *pflag.FlagSet) { + flagset.StringVar(&opts.Format, "format", "", "Format for the output (yaml, json, go template)") + flagset.BoolVar(&opts.ShowInterfaces, "show-interfaces", false, "Show interface status of StoneWork VPP instance") } func NewStatusCmd(cli Cli) *cobra.Command { - var opts StatusCmdOptions + var opts StatusOptions cmd := &cobra.Command{ Use: "status [flags]", Short: "Show status of StoneWork components", @@ -34,23 +63,169 @@ func NewStatusCmd(cli Cli) *cobra.Command { UnknownFlags: true, }, RunE: func(cmd *cobra.Command, args []string) error { - opts.Args = args return runStatusCmd(cli, opts) }, } + opts.InstallFlags(cmd.PersistentFlags()) return cmd } -func runStatusCmd(cli Cli, opts StatusCmdOptions) error { - cmd := fmt.Sprintf("vpp-probe --env=%s discover", defaultVppProbeEnv) - out, err := cli.Exec(cmd, opts.Args) +type statusInfo struct { + client.Component + ConfigCounts *client.ConfigCounts +} + +func runStatusCmd(cli Cli, opts StatusOptions) error { + resp, err := cli.Client().GetComponents() if err != nil { - if ee, ok := err.(*exec.ExitError); ok { - return fmt.Errorf("%v: %s", ee.String(), ee.Stderr) - } return err } - fmt.Fprintln(cli.Out(), out) + if opts.ShowInterfaces { + for _, compo := range resp { + if sn, ok := compo.GetMetadata()["containerServiceName"]; ok { + cmd := fmt.Sprintf("vpp-probe --env=%s --query label=%s=%s discover", defaultVppProbeEnv, client.DockerComposeServiceLabel, sn) + formatArg := fmt.Sprintf("--format=%s", opts.Format) + out, err := cli.Exec(cmd, []string{formatArg}) + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + logrus.Tracef("vpp-probe discover failed for service %s with error: %v: %s", sn, ee.String(), ee.Stderr) + continue + } + } + fmt.Fprintln(cli.Out(), out) + } + } + return nil + } + + type infoWithErr struct { + statusInfo + error + } + var infos []statusInfo + var wg sync.WaitGroup + infoCh := make(chan infoWithErr) + + for _, compo := range resp { + wg.Add(1) + go func(compo client.Component) { + defer wg.Done() + var counts *client.ConfigCounts + if compo.GetMode() != client.ComponentAuxiliary { + counts, err = compo.ConfigStatus() + if err != nil { + infoCh <- infoWithErr{error: err} + } + } + infoCh <- infoWithErr{ + statusInfo: statusInfo{ + Component: compo, + ConfigCounts: counts, + }, + } + }(compo) + } + + go func() { + wg.Wait() + close(infoCh) + }() + + for i := range infoCh { + if i.error != nil { + return i.error + } + infos = append(infos, i.statusInfo) + } + slices.SortFunc(infos, cmpStatus) + if opts.Format == "" { + printStatusTable(cli.Out(), infos) + } else { + if err := formatAsTemplate(cli.Out(), opts.Format, infos); err != nil { + return err + } + } return nil } + +func cmpStatus(a, b statusInfo) bool { + greater := a.GetMode() > b.GetMode() + if !greater && a.GetMode() == b.GetMode() { + greater = a.GetName() > b.GetName() + } + return greater +} + +func printStatusTable(out io.Writer, infos []statusInfo) { + table := tablewriter.NewWriter(out) + header := []string{ + "Name", "Mode", "IP Address", "GPRC Port", "HTTP Port", "Status", "Configuration", + } + aleft := tablewriter.ALIGN_LEFT + acenter := tablewriter.ALIGN_CENTER + table.SetHeader(header) + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetColumnAlignment([]int{aleft, aleft, aleft, acenter, acenter, acenter, aleft}) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetHeaderLine(false) + table.SetBorder(false) + table.SetTablePadding("\t") + for _, info := range infos { + row := []string{info.GetName(), info.GetMode().String()} + var clrs []tablewriter.Colors + if info.GetMode() == client.ComponentAuxiliary { + clrs = []tablewriter.Colors{{}, {}} + for i := range header[2:] { + clrs = append(clrs, []int{tablewriter.FgHiBlackColor}) + row = append(row, strings.Repeat("-", len(header[i+2]))) + } + table.Rich(row, clrs) + continue + } + config := info.ConfigCounts.String() + configColor := configColor(info.ConfigCounts) + compoInfo := info.GetInfo() + grpcState := compoInfo.GRPCConnState.String() + var statusClr int + // gRPC state does not make sense for StoneWork itself + if info.GetMode() == client.ComponentStonework { + grpcState = strings.Repeat("-", len("Status")) + statusClr = tablewriter.FgHiBlackColor + } + row = append(row, + compoInfo.IPAddr, + strconv.Itoa(compoInfo.GRPCPort), + strconv.Itoa(compoInfo.HTTPPort), + grpcState, + config) + clrs = []tablewriter.Colors{ + {}, {}, {}, {}, {}, {statusClr}, {configColor}, + } + table.Rich(row, clrs) + } + table.Render() +} + +func configColor(cc *client.ConfigCounts) int { + if cc.Err > 0 { + return tablewriter.FgHiRedColor + } + if cc.Retrying > 0 || cc.Pending > 0 { + return tablewriter.FgYellowColor + } + if cc.Unimplemented > 0 { + return tablewriter.FgMagentaColor + } + if cc.Missing > 0 { + return tablewriter.FgHiYellowColor + } + if cc.Ok > 0 { + return tablewriter.FgGreenColor + } + return 0 +} diff --git a/docs/SWCTL.md b/docs/SWCTL.md index 28507e02..90346711 100644 --- a/docs/SWCTL.md +++ b/docs/SWCTL.md @@ -228,14 +228,14 @@ swctl config history #### Status -To display the status of StoneWork components and their interfaces, run: +To display the status of StoneWork components, run: ```bash swctl status ``` > **Note** -> The `status` command is a simple wrapper for `vpp-probe discover`. +> When used with `--show-interfaces` flag the `status` command calls `vpp-probe discover`. #### Trace diff --git a/go.mod b/go.mod index b777e91b..498f7c92 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( github.com/fatih/color v1.10.0 // indirect github.com/fluent/fluent-logger-golang v1.3.0 // indirect github.com/fogleman/gg v1.3.0 // indirect - github.com/fsouza/go-dockerclient v1.7.1 // indirect + github.com/fsouza/go-dockerclient v1.7.1 github.com/ftrvxmtrx/fd v0.0.0-20150925145434-c6d800382fff // indirect github.com/goccy/go-graphviz v0.0.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -82,7 +82,7 @@ require ( github.com/moby/sys/mount v0.2.0 // indirect github.com/moby/sys/mountinfo v0.6.2 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/olekukonko/tablewriter v0.0.4 // indirect + github.com/olekukonko/tablewriter v0.0.4 github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.2 // indirect github.com/opencontainers/runc v1.1.2 // indirect @@ -99,7 +99,7 @@ require ( github.com/spf13/viper v1.7.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/tinylib/msgp v1.0.2 // indirect - github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d // indirect + github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.1.0 // indirect diff --git a/plugins/cnfreg/discovery.go b/plugins/cnfreg/discovery.go index c4e5cd6b..8677e69d 100644 --- a/plugins/cnfreg/discovery.go +++ b/plugins/cnfreg/discovery.go @@ -135,22 +135,23 @@ func (p *Plugin) loadSwModFromFile(fpath string) (swModule, error) { if err != nil { return swMod, fmt.Errorf("failed to parse PID file %s: %v", fname, err) } - swMod, err = p.getCnfModels(pf.IpAddress, pf.GrpcPort, pf.HttpPort) + swMod, err = p.getCnfModels(pf) if err != nil { return swMod, fmt.Errorf("failed to obtain CNF models (pid file: %v): %v", fname, err) } return swMod, nil } -func (p *Plugin) getCnfModels(ipAddress string, grpcPort, httpPort int) (swMod swModule, err error) { - swMod.ipAddress = ipAddress - swMod.grpcPort = grpcPort - swMod.httpPort = httpPort +func (p *Plugin) getCnfModels(pf PidFile) (swMod swModule, err error) { + swMod.pid = pf.Pid + swMod.ipAddress = pf.IpAddress + swMod.grpcPort = pf.GrpcPort + swMod.httpPort = pf.HttpPort // connect to the SW-Module CNF over gRPC ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - swMod.grpcConn, err = grpc.DialContext(ctx, fmt.Sprintf("%s:%d", ipAddress, grpcPort), + swMod.grpcConn, err = grpc.DialContext(ctx, fmt.Sprintf("%s:%d", pf.IpAddress, pf.GrpcPort), grpc.WithBlock(), grpc.WithInsecure()) if err != nil { return swMod, err diff --git a/plugins/cnfreg/options.go b/plugins/cnfreg/options.go index 8ef9e3f4..2c43fdb3 100644 --- a/plugins/cnfreg/options.go +++ b/plugins/cnfreg/options.go @@ -22,6 +22,7 @@ import ( "go.ligato.io/cn-infra/v2/config" "go.ligato.io/cn-infra/v2/logging" "go.ligato.io/cn-infra/v2/rpc/grpc" + "go.ligato.io/cn-infra/v2/rpc/rest" "go.ligato.io/cn-infra/v2/servicelabel" "go.ligato.io/vpp-agent/v3/plugins/kvscheduler" @@ -58,6 +59,7 @@ func NewPlugin(opts ...Option) *Plugin { p.MgmtSubnet = os.Getenv(CnfMgmtSubnetEnvVar) p.ServiceLabel = &servicelabel.DefaultPlugin p.KVScheduler = &kvscheduler.DefaultPlugin + p.HTTPPlugin = &rest.DefaultPlugin p.GRPCPlugin = &grpc.DefaultPlugin // Note: Punt Manager not injected by default due to a cyclical dependency between these two plugins diff --git a/plugins/cnfreg/plugin.go b/plugins/cnfreg/plugin.go index 8245a058..9d09c448 100644 --- a/plugins/cnfreg/plugin.go +++ b/plugins/cnfreg/plugin.go @@ -168,11 +168,12 @@ type swAttrs struct { // CNF used as a StoneWork Module. type swModule struct { + pid int cnfMsLabel string ipAddress string grpcPort int httpPort int - grpcConn grpc.ClientConnInterface + grpcConn *grpc.ClientConn cnfClient pb.CnfDiscoveryClient cfgClient client.GenericClient cnfModels []cnfModel @@ -259,6 +260,7 @@ func (p *Plugin) Init() (err error) { case pb.CnfMode_STONEWORK: p.sw.modules = conc.NewMap[string, swModule]() + p.registerHandlers(p.HTTPPlugin) // CNF discovery go p.cnfDiscovery(make(chan struct{})) } @@ -296,7 +298,7 @@ func (p *Plugin) GetGrpcPort() (port int) { } // Returns gRPC port that should be used by this CNF. -// Not to be used by StoneWork or a standalone CFN (they should respect what is in http.conf). +// Not to be used by StoneWork or a standalone CNF (they should respect what is in http.conf). func (p *Plugin) GetHttpPort() (port int) { if p.cnfMode != pb.CnfMode_STONEWORK_MODULE { panic(fmt.Errorf("method GetHttpPort is not available in the CNF mode %v", p.cnfMode)) diff --git a/plugins/cnfreg/rest.go b/plugins/cnfreg/rest.go new file mode 100644 index 00000000..81d5aa58 --- /dev/null +++ b/plugins/cnfreg/rest.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Copyright 2023 PANTHEON.tech +// +// 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 cnfreg + +import ( + "net/http" + "os" + + "github.com/unrolled/render" + "go.ligato.io/cn-infra/v2/rpc/rest" + "google.golang.org/grpc/connectivity" + + pb "go.pantheon.tech/stonework/proto/cnfreg" +) + +type Info struct { + PID int + MsLabel string + CnfMode pb.CnfMode + IPAddr string + GRPCPort int + HTTPPort int + GRPCConnState connectivity.State +} + +func (p *Plugin) registerHandlers(handlers rest.HTTPHandlers) { + if handlers == nil { + p.Log.Debug("No http handler provided, skipping registration of REST handlers") + return + } + if p.cnfMode == pb.CnfMode_STONEWORK { + handlers.RegisterHTTPHandler("/status/info", p.statusHandler, http.MethodGet) + } +} + +func (p *Plugin) statusHandler(formatter *render.Render) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + var infos []*Info + + swInfo := &Info{ + PID: os.Getpid(), + MsLabel: p.ServiceLabel.GetAgentLabel(), + CnfMode: pb.CnfMode_STONEWORK, + IPAddr: p.ipAddress.String(), + GRPCPort: p.GRPCPlugin.GetPort(), + HTTPPort: p.HTTPPlugin.GetPort(), + } + infos = append(infos, swInfo) + + for kv := range p.sw.modules.Iter() { + swMod := kv.Val + swModInfo := &Info{ + PID: swMod.pid, + MsLabel: swMod.cnfMsLabel, + CnfMode: pb.CnfMode_STONEWORK_MODULE, + IPAddr: swMod.ipAddress, + GRPCPort: swMod.grpcPort, + HTTPPort: swMod.httpPort, + GRPCConnState: swMod.grpcConn.GetState(), + } + infos = append(infos, swModInfo) + } + if err := formatter.JSON(w, http.StatusOK, infos); err != nil { + p.Log.Error(err) + } + } +}