From 1cf9c29ef059072f76a43fa0e6b1f3bd3b1afa00 Mon Sep 17 00:00:00 2001 From: Nimrod Gilboa Markevich <59927337+nimrod-up9@users.noreply.github.com> Date: Sun, 8 Aug 2021 17:31:45 +0300 Subject: [PATCH 01/43] Remove hardump flag (#183) Removed hardump flag and made it the default (and only) behavior. --- Dockerfile | 2 -- agent/start.sh | 2 -- cli/kubernetes/provider.go | 1 - tap/passive_tapper.go | 18 ++++-------------- 4 files changed, 4 insertions(+), 19 deletions(-) delete mode 100755 agent/start.sh diff --git a/Dockerfile b/Dockerfile index c19b1805f..a3864f126 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,8 +48,6 @@ WORKDIR /app COPY --from=builder ["/app/agent-build/mizuagent", "."] COPY --from=site-build ["/app/ui-build/build", "site"] -COPY agent/start.sh . - # gin-gonic runs in debug mode without this ENV GIN_MODE=release diff --git a/agent/start.sh b/agent/start.sh deleted file mode 100755 index 4b04b6d47..000000000 --- a/agent/start.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -./mizuagent -i any -hardump -targets ${TAPPED_ADDRESSES} diff --git a/cli/kubernetes/provider.go b/cli/kubernetes/provider.go index fcbb78502..221895ad4 100644 --- a/cli/kubernetes/provider.go +++ b/cli/kubernetes/provider.go @@ -577,7 +577,6 @@ func (provider *Provider) ApplyMizuTapperDaemonSet(ctx context.Context, namespac "./mizuagent", "-i", "any", "--tap", - "--hardump", "--api-server-address", fmt.Sprintf("ws://%s/wsTapper", apiServerPodIp), } if tapOutgoing { diff --git a/tap/passive_tapper.go b/tap/passive_tapper.go index 79274029f..ae745caa3 100644 --- a/tap/passive_tapper.go +++ b/tap/passive_tapper.go @@ -84,7 +84,6 @@ var staleTimeoutSeconds = flag.Int("staletimout", 120, "Max time in seconds to k var memprofile = flag.String("memprofile", "", "Write memory profile") // output -var dumpToHar = flag.Bool("hardump", false, "Dump traffic to har files") var HarOutputDir = flag.String("hardir", "", "Directory in which to store output har files") var harEntriesPerFile = flag.Int("harentriesperfile", 200, "Number of max number of har entries to store in each file") @@ -186,19 +185,12 @@ func (c *Context) GetCaptureInfo() gopacket.CaptureInfo { func StartPassiveTapper(opts *TapOpts) (<-chan *OutputChannelItem, <-chan *OutboundLink) { hostMode = opts.HostMode - var harWriter *HarWriter - if *dumpToHar { - harWriter = NewHarWriter(*HarOutputDir, *harEntriesPerFile) - } + harWriter := NewHarWriter(*HarOutputDir, *harEntriesPerFile) outboundLinkWriter := NewOutboundLinkWriter() go startPassiveTapper(harWriter, outboundLinkWriter) - if harWriter != nil { - return harWriter.OutChan, outboundLinkWriter.OutChan - } - - return nil, outboundLinkWriter.OutChan + return harWriter.OutChan, outboundLinkWriter.OutChan } func startMemoryProfiler() { @@ -321,10 +313,8 @@ func startPassiveTapper(harWriter *HarWriter, outboundLinkWriter *OutboundLinkWr } } - if *dumpToHar { - harWriter.Start() - defer harWriter.Stop() - } + harWriter.Start() + defer harWriter.Stop() defer outboundLinkWriter.Stop() var dec gopacket.Decoder From e36c146979b99d1d335ef617454f05c33c990dfd Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Mon, 9 Aug 2021 12:17:01 +0300 Subject: [PATCH 02/43] temp fix - ignore agent image in config command (#186) --- cli/mizu/config.go | 4 ++++ cli/mizu/configStruct.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/mizu/config.go b/cli/mizu/config.go index 2c2712af5..501173b01 100644 --- a/cli/mizu/config.go +++ b/cli/mizu/config.go @@ -69,6 +69,10 @@ func GetConfigWithDefaults() (string, error) { if err := defaults.Set(&defaultConf); err != nil { return "", err } + + // TODO: change to generic solution + defaultConf.AgentImage = "" + return uiUtils.PrettyYaml(defaultConf) } diff --git a/cli/mizu/configStruct.go b/cli/mizu/configStruct.go index 24ee0d49e..cd7d9bad3 100644 --- a/cli/mizu/configStruct.go +++ b/cli/mizu/configStruct.go @@ -19,7 +19,7 @@ type ConfigStruct struct { Fetch configStructs.FetchConfig `yaml:"fetch"` Version configStructs.VersionConfig `yaml:"version"` View configStructs.ViewConfig `yaml:"view"` - AgentImage string `yaml:"agent-image"` + AgentImage string `yaml:"agent-image,omitempty"` MizuResourcesNamespace string `yaml:"mizu-resources-namespace" default:"mizu"` Telemetry bool `yaml:"telemetry" default:"true"` DumpLogs bool `yaml:"dump-logs" default:"false"` From 413fb5b3f550c783bfefd08c6ebbff4a2eefd845 Mon Sep 17 00:00:00 2001 From: gadotroee <55343099+gadotroee@users.noreply.github.com> Date: Mon, 9 Aug 2021 12:27:13 +0300 Subject: [PATCH 03/43] Add option to supply user agents to ignore via config (#173) --- README.md | 18 +++++++++++++++-- agent/go.sum | 3 ++- agent/main.go | 10 ++++------ cli/cmd/tap.go | 1 - cli/cmd/tapRunner.go | 6 +++++- cli/go.sum | 4 +--- cli/mizu/config.go | 1 + cli/mizu/configStructs/tapConfig.go | 30 ++++++++++++++--------------- shared/go.mod | 5 ++--- shared/go.sum | 8 ++++---- shared/models.go | 6 +++--- 11 files changed, 53 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 91734b4c0..cc3f053ae 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ To tap all pods in current namespace - To tap specific pod - -``` +```bash $ kubectl get pods NAME READY STATUS RESTARTS AGE front-end-649fc5fd6-kqbtn 2/2 Running 0 7m @@ -88,7 +88,7 @@ To tap specific pod - ``` To tap multiple pods using regex - -``` +```bash $ kubectl get pods NAME READY STATUS RESTARTS AGE carts-66c77f5fbb-fq65r 2/2 Running 0 20m @@ -133,3 +133,17 @@ to the namespace set by `mizu-resources-namespace`. The user must set the tapped using the `--namespace` flag or by setting `tap.namespaces` in the config file. Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior. + +### User agent filtering + +User-agent filtering (like health checks) - can be configured: + +Any request that contains one of those values in the user-agent header will not be captured + +```bash +$ mizu tap "^ca.*" --set ignored-user-agents=kube-probe --set ignored-user-agents=prometheus ++carts-66c77f5fbb-fq65r ++catalogue-5f4cb7cf5-7zrmn +Web interface is now available at http://localhost:8899 +^C +``` \ No newline at end of file diff --git a/agent/go.sum b/agent/go.sum index 0917a0fbb..a0b6f00fa 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -540,8 +540,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= diff --git a/agent/main.go b/agent/main.go index 381433832..f87cba902 100644 --- a/agent/main.go +++ b/agent/main.go @@ -149,15 +149,13 @@ func getTrafficFilteringOptions() *shared.TrafficFilteringOptions { return &filteringOptions } -var userAgentsToFilter = []string{"kube-probe", "prometheus"} - func filterHarItems(inChannel <-chan *tap.OutputChannelItem, outChannel chan *tap.OutputChannelItem, filterOptions *shared.TrafficFilteringOptions) { for message := range inChannel { if message.ConnectionInfo.IsOutgoing && api.CheckIsServiceIP(message.ConnectionInfo.ServerIP) { continue } // TODO: move this to tappers https://up9.atlassian.net/browse/TRA-3441 - if filterOptions.HideHealthChecks && isHealthCheckByUserAgent(message) { + if isHealthCheckByUserAgent(message, filterOptions.HealthChecksUserAgentHeaders) { continue } @@ -169,11 +167,11 @@ func filterHarItems(inChannel <-chan *tap.OutputChannelItem, outChannel chan *ta } } -func isHealthCheckByUserAgent(message *tap.OutputChannelItem) bool { +func isHealthCheckByUserAgent(message *tap.OutputChannelItem, userAgentsToIgnore []string) bool { for _, header := range message.HarEntry.Request.Headers { if strings.ToLower(header.Name) == "user-agent" { - for _, userAgent := range userAgentsToFilter { - if strings.Contains(strings.ToLower(header.Value), userAgent) { + for _, userAgent := range userAgentsToIgnore { + if strings.Contains(strings.ToLower(header.Value), strings.ToLower(userAgent)) { return true } } diff --git a/cli/cmd/tap.go b/cli/cmd/tap.go index 72e71f3fb..6e0da4a7a 100644 --- a/cli/cmd/tap.go +++ b/cli/cmd/tap.go @@ -64,7 +64,6 @@ func init() { tapCmd.Flags().Bool(configStructs.AnalysisTapName, defaultTapConfig.Analysis, "Uploads traffic to UP9 for further analysis (Beta)") tapCmd.Flags().BoolP(configStructs.AllNamespacesTapName, "A", defaultTapConfig.AllNamespaces, "Tap all namespaces") tapCmd.Flags().StringArrayP(configStructs.PlainTextFilterRegexesTapName, "r", defaultTapConfig.PlainTextFilterRegexes, "List of regex expressions that are used to filter matching values from text/plain http bodies") - tapCmd.Flags().Bool(configStructs.HideHealthChecksTapName, defaultTapConfig.HideHealthChecks, "Hides requests with kube-probe or prometheus user-agent headers") tapCmd.Flags().Bool(configStructs.DisableRedactionTapName, defaultTapConfig.DisableRedaction, "Disables redaction of potentially sensitive request/response headers and body values") tapCmd.Flags().String(configStructs.HumanMaxEntriesDBSizeTapName, defaultTapConfig.HumanMaxEntriesDBSize, "Override the default max entries db size") tapCmd.Flags().String(configStructs.DirectionTapName, defaultTapConfig.Direction, "Record traffic that goes in this direction (relative to the tapped pod): in/any") diff --git a/cli/cmd/tapRunner.go b/cli/cmd/tapRunner.go index 401b7f77d..7325fcba5 100644 --- a/cli/cmd/tapRunner.go +++ b/cli/cmd/tapRunner.go @@ -207,7 +207,11 @@ func getMizuApiFilteringOptions() (*shared.TrafficFilteringOptions, error) { } } - return &shared.TrafficFilteringOptions{PlainTextMaskingRegexes: compiledRegexSlice, HideHealthChecks: mizu.Config.Tap.HideHealthChecks, DisableRedaction: mizu.Config.Tap.DisableRedaction}, nil + return &shared.TrafficFilteringOptions{ + PlainTextMaskingRegexes: compiledRegexSlice, + HealthChecksUserAgentHeaders: mizu.Config.Tap.HealthChecksUserAgentHeaders, + DisableRedaction: mizu.Config.Tap.DisableRedaction, + }, nil } func updateMizuTappers(ctx context.Context, kubernetesProvider *kubernetes.Provider, nodeToTappedPodIPMap map[string][]string) error { diff --git a/cli/go.sum b/cli/go.sum index ae8e268fd..5f63d82b9 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -217,7 +217,6 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -411,10 +410,9 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= diff --git a/cli/mizu/config.go b/cli/mizu/config.go index 501173b01..e8f4dc537 100644 --- a/cli/mizu/config.go +++ b/cli/mizu/config.go @@ -31,6 +31,7 @@ var allowedSetFlags = []string{ KubeConfigPathName, configStructs.AnalysisDestinationTapName, configStructs.SleepIntervalSecTapName, + configStructs.IgnoredUserAgentsTapName, } var Config = ConfigStruct{} diff --git a/cli/mizu/configStructs/tapConfig.go b/cli/mizu/configStructs/tapConfig.go index 67582e467..553b1bf8c 100644 --- a/cli/mizu/configStructs/tapConfig.go +++ b/cli/mizu/configStructs/tapConfig.go @@ -17,8 +17,8 @@ const ( AnalysisTapName = "analysis" AllNamespacesTapName = "all-namespaces" PlainTextFilterRegexesTapName = "regex-masking" - HideHealthChecksTapName = "hide-healthchecks" DisableRedactionTapName = "no-redact" + IgnoredUserAgentsTapName = "ignored-user-agents" HumanMaxEntriesDBSizeTapName = "max-entries-db-size" DirectionTapName = "direction" DryRunTapName = "dry-run" @@ -26,20 +26,20 @@ const ( ) type TapConfig struct { - AnalysisDestination string `yaml:"dest" default:"up9.app"` - SleepIntervalSec int `yaml:"upload-interval" default:"10"` - PodRegexStr string `yaml:"regex" default:".*"` - GuiPort uint16 `yaml:"gui-port" default:"8899"` - Namespaces []string `yaml:"namespaces"` - Analysis bool `yaml:"analysis" default:"false"` - AllNamespaces bool `yaml:"all-namespaces" default:"false"` - PlainTextFilterRegexes []string `yaml:"regex-masking"` - HideHealthChecks bool `yaml:"hide-healthchecks" default:"false"` - DisableRedaction bool `yaml:"no-redact" default:"false"` - HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"` - Direction string `yaml:"direction" default:"in"` - DryRun bool `yaml:"dry-run" default:"false"` - EnforcePolicyFile string `yaml:"test-rules"` + AnalysisDestination string `yaml:"dest" default:"up9.app"` + SleepIntervalSec int `yaml:"upload-interval" default:"10"` + PodRegexStr string `yaml:"regex" default:".*"` + GuiPort uint16 `yaml:"gui-port" default:"8899"` + Namespaces []string `yaml:"namespaces"` + Analysis bool `yaml:"analysis" default:"false"` + AllNamespaces bool `yaml:"all-namespaces" default:"false"` + PlainTextFilterRegexes []string `yaml:"regex-masking"` + HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents" default:"[]"` + DisableRedaction bool `yaml:"no-redact" default:"false"` + HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"` + Direction string `yaml:"direction" default:"in"` + DryRun bool `yaml:"dry-run" default:"false"` + EnforcePolicyFile string `yaml:"test-rules"` } func (config *TapConfig) PodRegex() *regexp.Regexp { diff --git a/shared/go.mod b/shared/go.mod index 157d3e5fa..5e50ad375 100644 --- a/shared/go.mod +++ b/shared/go.mod @@ -3,8 +3,7 @@ module github.com/up9inc/mizu/shared go 1.16 require ( - github.com/google/martian v2.1.0+incompatible // indirect - github.com/gorilla/websocket v1.4.2 - github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect github.com/docker/go-units v0.4.0 + github.com/gorilla/websocket v1.4.2 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/shared/go.sum b/shared/go.sum index 498bce1d3..65c692cb5 100644 --- a/shared/go.sum +++ b/shared/go.sum @@ -1,8 +1,8 @@ -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= -github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shared/models.go b/shared/models.go index 2a70fa212..ade4a9269 100644 --- a/shared/models.go +++ b/shared/models.go @@ -75,9 +75,9 @@ func CreateWebSocketMessageTypeAnalyzeStatus(analyzeStatus AnalyzeStatus) WebSoc } type TrafficFilteringOptions struct { - PlainTextMaskingRegexes []*SerializableRegexp - HideHealthChecks bool - DisableRedaction bool + HealthChecksUserAgentHeaders []string + PlainTextMaskingRegexes []*SerializableRegexp + DisableRedaction bool } type VersionResponse struct { From 440691956525964006fd4f6db14e464c93c5723d Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Mon, 9 Aug 2021 16:04:00 +0300 Subject: [PATCH 04/43] added test workflow, added test for contains func (#184) --- .github/workflows/test.yaml | 22 ++++++++++ Makefile | 2 + README.md | 1 + cli/Makefile | 3 ++ cli/mizu/sliceUtils_test.go | 82 +++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 .github/workflows/test.yaml create mode 100644 cli/mizu/sliceUtils_test.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 000000000..dc4af4dca --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +name: test +on: + pull_request: + branches: + - 'develop' + - 'main' +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.x + uses: actions/setup-go@v2 + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Test + run: make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 diff --git a/Makefile b/Makefile index bb85ff8ea..a3515ff83 100644 --- a/Makefile +++ b/Makefile @@ -65,3 +65,5 @@ clean-cli: ## Clean CLI. clean-docker: @(echo "DOCKER cleanup - NOT IMPLEMENTED YET " ) +test: ## Run tests. + @echo "running cli tests"; cd cli && $(MAKE) test diff --git a/README.md b/README.md index cc3f053ae..0f5b0d30b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ ![Mizu: The API Traffic Viewer for Kubernetes](assets/mizu-logo.svg) + # The API Traffic Viewer for Kubernetes A simple-yet-powerful API traffic viewer for Kubernetes to help you troubleshoot and debug your microservices. Think TCPDump and Chrome Dev Tools combined. diff --git a/cli/Makefile b/cli/Makefile index b4841ba0c..065abcd3d 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -39,3 +39,6 @@ build-all: ## Build for all supported platforms. clean: ## Clean all build artifacts. go clean rm -rf ./bin/* + +test: ## Run cli tests. + @go test ./... -race -coverprofile=coverage.out -covermode=atomic diff --git a/cli/mizu/sliceUtils_test.go b/cli/mizu/sliceUtils_test.go new file mode 100644 index 000000000..f7f6529a1 --- /dev/null +++ b/cli/mizu/sliceUtils_test.go @@ -0,0 +1,82 @@ +package mizu_test + +import ( + "github.com/up9inc/mizu/cli/mizu" + "testing" +) + +func TestContainsExists(t *testing.T) { + tests := []struct { + slice []string + containsValue string + expected bool + }{ + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "apple", expected: true}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "orange", expected: true}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "banana", expected: true}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "grapes", expected: true}, + } + + for _, test := range tests { + actual := mizu.Contains(test.slice, test.containsValue) + if actual != test.expected { + t.Errorf("unexpected result - expected: %v, actual: %v", test.expected, actual) + } + } +} + +func TestContainsNotExists(t *testing.T) { + tests := []struct { + slice []string + containsValue string + expected bool + }{ + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "cat", expected: false}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "dog", expected: false}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "apples", expected: false}, + {slice: []string{"apple", "orange", "banana", "grapes"}, containsValue: "rapes", expected: false}, + } + + for _, test := range tests { + actual := mizu.Contains(test.slice, test.containsValue) + if actual != test.expected { + t.Errorf("unexpected result - expected: %v, actual: %v", test.expected, actual) + } + } +} + +func TestContainsEmptySlice(t *testing.T) { + tests := []struct { + slice []string + containsValue string + expected bool + }{ + {slice: []string{}, containsValue: "cat", expected: false}, + {slice: []string{}, containsValue: "dog", expected: false}, + } + + for _, test := range tests { + actual := mizu.Contains(test.slice, test.containsValue) + if actual != test.expected { + t.Errorf("unexpected result - expected: %v, actual: %v", test.expected, actual) + } + } +} + +func TestContainsNilSlice(t *testing.T) { + tests := []struct { + slice []string + containsValue string + expected bool + }{ + {slice: nil, containsValue: "cat", expected: false}, + {slice: nil, containsValue: "dog", expected: false}, + } + + for _, test := range tests { + actual := mizu.Contains(test.slice, test.containsValue) + if actual != test.expected { + t.Errorf("unexpected result - expected: %v, actual: %v", test.expected, actual) + } + } +} From ca897dd3c7be3e791ee703caf4a04f74ae1b8e85 Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Mon, 9 Aug 2021 18:36:56 +0300 Subject: [PATCH 05/43] Update issue templates (#189) --- .github/ISSUE_TEMPLATE/bug_report.md | 38 ++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..ff5a7ecfd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run mizu '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +Upload logs: +1. Run the mizu command with `--set dump-logs=true` (e.g `mizu tap --set dump-logs=true`) +2. Try to reproduce the issue +3. CNTRL+C on terminal tab which runs mizu +4. Upload the logs zip file from ~/.mizu/mizu_logs_**.zip + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome] + +**Additional context** +Add any other context about the problem here. From c53b2148d1912d4090473718e5751e6fe0d28bc0 Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Tue, 10 Aug 2021 09:51:35 +0300 Subject: [PATCH 06/43] add readonly tag (#190) --- cli/mizu/config.go | 36 ++++++++++++++++++++------ cli/mizu/configStruct.go | 2 +- cli/mizu/configStructs/tapConfig.go | 2 +- cli/mizu/config_test.go | 39 +++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 cli/mizu/config_test.go diff --git a/cli/mizu/config.go b/cli/mizu/config.go index e8f4dc537..bb6b9d65a 100644 --- a/cli/mizu/config.go +++ b/cli/mizu/config.go @@ -21,6 +21,8 @@ import ( const ( Separator = "=" SetCommandName = "set" + FieldNameTag = "yaml" + ReadonlyTag = "readonly" ) var allowedSetFlags = []string{ @@ -71,8 +73,8 @@ func GetConfigWithDefaults() (string, error) { return "", err } - // TODO: change to generic solution - defaultConf.AgentImage = "" + configElem := reflect.ValueOf(&defaultConf).Elem() + setZeroForReadonlyFields(configElem) return uiUtils.PrettyYaml(defaultConf) } @@ -110,16 +112,14 @@ func initFlag(f *pflag.Flag) { } if f.Name == SetCommandName { - mergeSetFlag(sliceValue.GetSlice()) + mergeSetFlag(configElem, sliceValue.GetSlice()) return } mergeFlagValues(configElem, f.Name, sliceValue.GetSlice()) } -func mergeSetFlag(setValues []string) { - configElem := reflect.ValueOf(&Config).Elem() - +func mergeSetFlag(configElem reflect.Value, setValues []string) { for _, setValue := range setValues { if !strings.Contains(setValue, Separator) { Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) @@ -150,7 +150,7 @@ func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string) continue } - if currentField.Tag.Get("yaml") != flagKey { + if getFieldNameByTag(currentField) != flagKey { continue } @@ -176,7 +176,7 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str continue } - if currentField.Tag.Get("yaml") != flagKey { + if getFieldNameByTag(currentField) != flagKey { continue } @@ -197,6 +197,10 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str } } +func getFieldNameByTag(field reflect.StructField) string { + return strings.Split(field.Tag.Get(FieldNameTag), ",")[0] +} + func getParsedValue(kind reflect.Kind, value string) (reflect.Value, error) { switch kind { case reflect.String: @@ -282,3 +286,19 @@ func getParsedValue(kind reflect.Kind, value string) (reflect.Value, error) { return reflect.ValueOf(nil), errors.New("value to parse does not match type") } + +func setZeroForReadonlyFields(currentElem reflect.Value) { + for i := 0; i < currentElem.NumField(); i++ { + currentField := currentElem.Type().Field(i) + currentFieldByName := currentElem.FieldByName(currentField.Name) + + if currentField.Type.Kind() == reflect.Struct { + setZeroForReadonlyFields(currentFieldByName) + continue + } + + if _, ok := currentField.Tag.Lookup(ReadonlyTag); ok { + currentFieldByName.Set(reflect.Zero(currentField.Type)) + } + } +} diff --git a/cli/mizu/configStruct.go b/cli/mizu/configStruct.go index cd7d9bad3..a4e81bb90 100644 --- a/cli/mizu/configStruct.go +++ b/cli/mizu/configStruct.go @@ -19,7 +19,7 @@ type ConfigStruct struct { Fetch configStructs.FetchConfig `yaml:"fetch"` Version configStructs.VersionConfig `yaml:"version"` View configStructs.ViewConfig `yaml:"view"` - AgentImage string `yaml:"agent-image,omitempty"` + AgentImage string `yaml:"agent-image,omitempty" readonly:""` MizuResourcesNamespace string `yaml:"mizu-resources-namespace" default:"mizu"` Telemetry bool `yaml:"telemetry" default:"true"` DumpLogs bool `yaml:"dump-logs" default:"false"` diff --git a/cli/mizu/configStructs/tapConfig.go b/cli/mizu/configStructs/tapConfig.go index 553b1bf8c..16403ca57 100644 --- a/cli/mizu/configStructs/tapConfig.go +++ b/cli/mizu/configStructs/tapConfig.go @@ -34,7 +34,7 @@ type TapConfig struct { Analysis bool `yaml:"analysis" default:"false"` AllNamespaces bool `yaml:"all-namespaces" default:"false"` PlainTextFilterRegexes []string `yaml:"regex-masking"` - HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents" default:"[]"` + HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents"` DisableRedaction bool `yaml:"no-redact" default:"false"` HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"` Direction string `yaml:"direction" default:"in"` diff --git a/cli/mizu/config_test.go b/cli/mizu/config_test.go new file mode 100644 index 000000000..f1f9edb8a --- /dev/null +++ b/cli/mizu/config_test.go @@ -0,0 +1,39 @@ +package mizu_test + +import ( + "github.com/up9inc/mizu/cli/mizu" + "reflect" + "strings" + "testing" +) + +func TestConfigWriteIgnoresReadonlyFields(t *testing.T) { + var readonlyFields []string + + configElem := reflect.ValueOf(&mizu.ConfigStruct{}).Elem() + getFieldsWithReadonlyTag(configElem, &readonlyFields) + + config, _ := mizu.GetConfigWithDefaults() + for _, readonlyField := range readonlyFields { + if strings.Contains(config, readonlyField) { + t.Errorf("unexpected result - readonly field: %v, config: %v", readonlyField, config) + } + } +} + +func getFieldsWithReadonlyTag(currentElem reflect.Value, readonlyFields *[]string) { + for i := 0; i < currentElem.NumField(); i++ { + currentField := currentElem.Type().Field(i) + currentFieldByName := currentElem.FieldByName(currentField.Name) + + if currentField.Type.Kind() == reflect.Struct { + getFieldsWithReadonlyTag(currentFieldByName, readonlyFields) + continue + } + + if _, ok := currentField.Tag.Lookup(mizu.ReadonlyTag); ok { + fieldNameByTag := strings.Split(currentField.Tag.Get(mizu.FieldNameTag), ",")[0] + *readonlyFields = append(*readonlyFields, fieldNameByTag) + } + } +} From d705ae3eb664ccdfc4f91450d61ae5b2f628fe58 Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Tue, 10 Aug 2021 16:16:58 +0300 Subject: [PATCH 07/43] added support of slice in set, removed support of allowed set flags (#191) --- README.md | 8 +++----- cli/cmd/tap.go | 4 ++-- cli/cmd/tapRunner.go | 4 ++-- cli/mizu/config.go | 39 +++++++++++++++++++++++++++++++-------- 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 0f5b0d30b..eda3822f3 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,9 @@ You can always override the defaults or config file with CLI flags. To get the default config params run `mizu config`
To generate a new config file with default values use `mizu config -r` -Mizu has several undocumented flags which can be set by using --set flag (e.g., `mizu tap --set dump-logs=true`) -* **mizu-resources-namespace**: Type - String, See [Namespace-Restricted Mode](#namespace-restricted-mode) -* **telemetry**: Type - Boolean, Reports telemetry -* **dump-logs**: Type - Boolean, At the end of the execution it creates a zip file with logs (in .mizu folder) -* **kube-config-path**: Type - String, Setting the path to kube config (which isn't in standard path) +### Telemetry + +By default, mizu reports usage telemetry. It can be disabled by adding a line of telemetry: false in the ${HOME}/.mizu/config.yaml file ## Advanced Usage diff --git a/cli/cmd/tap.go b/cli/cmd/tap.go index 6e0da4a7a..a213d7f2e 100644 --- a/cli/cmd/tap.go +++ b/cli/cmd/tap.go @@ -60,10 +60,10 @@ func init() { defaults.Set(&defaultTapConfig) tapCmd.Flags().Uint16P(configStructs.GuiPortTapName, "p", defaultTapConfig.GuiPort, "Provide a custom port for the web interface webserver") - tapCmd.Flags().StringArrayP(configStructs.NamespacesTapName, "n", defaultTapConfig.Namespaces, "Namespaces selector") + tapCmd.Flags().StringSliceP(configStructs.NamespacesTapName, "n", defaultTapConfig.Namespaces, "Namespaces selector") tapCmd.Flags().Bool(configStructs.AnalysisTapName, defaultTapConfig.Analysis, "Uploads traffic to UP9 for further analysis (Beta)") tapCmd.Flags().BoolP(configStructs.AllNamespacesTapName, "A", defaultTapConfig.AllNamespaces, "Tap all namespaces") - tapCmd.Flags().StringArrayP(configStructs.PlainTextFilterRegexesTapName, "r", defaultTapConfig.PlainTextFilterRegexes, "List of regex expressions that are used to filter matching values from text/plain http bodies") + tapCmd.Flags().StringSliceP(configStructs.PlainTextFilterRegexesTapName, "r", defaultTapConfig.PlainTextFilterRegexes, "List of regex expressions that are used to filter matching values from text/plain http bodies") tapCmd.Flags().Bool(configStructs.DisableRedactionTapName, defaultTapConfig.DisableRedaction, "Disables redaction of potentially sensitive request/response headers and body values") tapCmd.Flags().String(configStructs.HumanMaxEntriesDBSizeTapName, defaultTapConfig.HumanMaxEntriesDBSize, "Override the default max entries db size") tapCmd.Flags().String(configStructs.DirectionTapName, defaultTapConfig.Direction, "Record traffic that goes in this direction (relative to the tapped pod): in/any") diff --git a/cli/cmd/tapRunner.go b/cli/cmd/tapRunner.go index 7325fcba5..43756cf59 100644 --- a/cli/cmd/tapRunner.go +++ b/cli/cmd/tapRunner.go @@ -70,7 +70,7 @@ func RunMizuTap() { targetNamespaces := getNamespaces(kubernetesProvider) var namespacesStr string - if targetNamespaces[0] != mizu.K8sAllNamespaces { + if !mizu.Contains(targetNamespaces, mizu.K8sAllNamespaces) { namespacesStr = fmt.Sprintf("namespaces \"%s\"", strings.Join(targetNamespaces, "\", \"")) } else { namespacesStr = "all namespaces" @@ -85,7 +85,7 @@ func RunMizuTap() { if len(state.currentlyTappedPods) == 0 { var suggestionStr string - if targetNamespaces[0] != mizu.K8sAllNamespaces { + if !mizu.Contains(targetNamespaces, mizu.K8sAllNamespaces) { suggestionStr = ". Select a different namespace with -n or tap all namespaces with -A" } mizu.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Did not find any pods matching the regex argument%s", suggestionStr)) diff --git a/cli/mizu/config.go b/cli/mizu/config.go index bb6b9d65a..e8cbeea92 100644 --- a/cli/mizu/config.go +++ b/cli/mizu/config.go @@ -120,23 +120,36 @@ func initFlag(f *pflag.Flag) { } func mergeSetFlag(configElem reflect.Value, setValues []string) { + setMap := map[string][]string{} + for _, setValue := range setValues { if !strings.Contains(setValue, Separator) { Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + continue } split := strings.SplitN(setValue, Separator, 2) if len(split) != 2 { Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + continue } argumentKey, argumentValue := split[0], split[1] + setMap[argumentKey] = append(setMap[argumentKey], argumentValue) + } + + for argumentKey, argumentValues := range setMap { if !Contains(allowedSetFlags, argumentKey) { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s, flag name must be one of the following: \"%s\"", setValue, strings.Join(allowedSetFlags, "\", \""))) + Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument name \"%s\", flag name must be one of the following: \"%s\"", argumentKey, strings.Join(allowedSetFlags, "\", \""))) + continue } - mergeFlagValue(configElem, argumentKey, argumentValue) + if len(argumentValues) > 1 { + mergeFlagValues(configElem, argumentKey, argumentValues) + } else { + mergeFlagValue(configElem, argumentKey, argumentValues[0]) + } } } @@ -144,8 +157,9 @@ func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string) for i := 0; i < currentElem.NumField(); i++ { currentField := currentElem.Type().Field(i) currentFieldByName := currentElem.FieldByName(currentField.Name) + currentFieldKind := currentField.Type.Kind() - if currentField.Type.Kind() == reflect.Struct { + if currentFieldKind == reflect.Struct { mergeFlagValue(currentFieldByName, flagKey, flagValue) continue } @@ -154,11 +168,14 @@ func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string) continue } - flagValueKind := currentField.Type.Kind() + if currentFieldKind == reflect.Slice { + mergeFlagValues(currentElem, flagKey, []string{flagValue}) + return + } - parsedValue, err := getParsedValue(flagValueKind, flagValue) + parsedValue, err := getParsedValue(currentFieldKind, flagValue) if err != nil { - Log.Warningf(uiUtils.Red, fmt.Sprintf("Invalid value %v for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) + Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, currentFieldKind)) return } @@ -170,8 +187,9 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str for i := 0; i < currentElem.NumField(); i++ { currentField := currentElem.Type().Field(i) currentFieldByName := currentElem.FieldByName(currentField.Name) + currentFieldKind := currentField.Type.Kind() - if currentField.Type.Kind() == reflect.Struct { + if currentFieldKind == reflect.Struct { mergeFlagValues(currentFieldByName, flagKey, flagValues) continue } @@ -180,13 +198,18 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str continue } + if currentFieldKind != reflect.Slice { + Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagKey, currentFieldKind)) + return + } + flagValueKind := currentField.Type.Elem().Kind() parsedValues := reflect.MakeSlice(reflect.SliceOf(currentField.Type.Elem()), 0, 0) for _, flagValue := range flagValues { parsedValue, err := getParsedValue(flagValueKind, flagValue) if err != nil { - Log.Warningf(uiUtils.Red, fmt.Sprintf("Invalid value %v for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) + Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) return } From 8c9b8d3217b953ddf1974511dc569162912972de Mon Sep 17 00:00:00 2001 From: Selton Fiuza <40501884+seltonfiuza@users.noreply.github.com> Date: Tue, 10 Aug 2021 10:20:16 -0300 Subject: [PATCH 08/43] Redesign test rules entry component (#174) --- agent/pkg/models/models.go | 8 ++-- agent/pkg/rules/models.go | 10 ++--- ui/src/components/HarEntry.tsx | 41 ++++++++++++++++--- ui/src/components/HarEntryDetailed.tsx | 1 - .../HAREntrySections.module.sass | 3 ++ .../HarEntryViewer/HAREntrySections.tsx | 2 +- ui/src/components/style/HarEntry.module.sass | 32 ++++++++++++++- 7 files changed, 79 insertions(+), 18 deletions(-) diff --git a/agent/pkg/models/models.go b/agent/pkg/models/models.go index 0648d4f5c..bf8c0cb7b 100644 --- a/agent/pkg/models/models.go +++ b/agent/pkg/models/models.go @@ -56,12 +56,14 @@ type BaseEntryDetails struct { type ApplicableRules struct { Latency int64 `json:"latency,omitempty"` Status bool `json:"status,omitempty"` + NumberOfRules int `json:"numberOfRules,omitempty"` } -func NewApplicableRules(status bool, latency int64) ApplicableRules { +func NewApplicableRules(status bool, latency int64, number int) ApplicableRules { ar := ApplicableRules{} ar.Status = status ar.Latency = latency + ar.NumberOfRules = number return ar } @@ -218,7 +220,7 @@ func (fewp *FullEntryWithPolicy) UnmarshalData(entry *MizuEntry) error { func RunValidationRulesState(harEntry har.Entry, service string) ApplicableRules { numberOfRules, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service) - statusPolicyToSend, latency := rules.PassedValidationRules(resultPolicyToSend, numberOfRules) - ar := NewApplicableRules(statusPolicyToSend, latency) + statusPolicyToSend, latency, numberOfRules := rules.PassedValidationRules(resultPolicyToSend, numberOfRules) + ar := NewApplicableRules(statusPolicyToSend, latency, numberOfRules) return ar } diff --git a/agent/pkg/rules/models.go b/agent/pkg/rules/models.go index 8b1f1f617..2107e6447 100644 --- a/agent/pkg/rules/models.go +++ b/agent/pkg/rules/models.go @@ -92,19 +92,19 @@ func MatchRequestPolicy(harEntry har.Entry, service string) (int, []RulesMatched return len(enforcePolicy.Rules), resultPolicyToSend } -func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64) { +func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64, int) { if len(rulesMatched) == 0 { - return false, 0 + return false, 0, 0 } for _, rule := range rulesMatched { if rule.Matched == false { - return false, -1 + return false, -1, len(rulesMatched) } } for _, rule := range rulesMatched { if strings.ToLower(rule.Rule.Type) == "latency" { - return true, rule.Rule.Latency + return true, rule.Rule.Latency, len(rulesMatched) } } - return true, -1 + return true, -1, len(rulesMatched) } diff --git a/ui/src/components/HarEntry.tsx b/ui/src/components/HarEntry.tsx index f51a0bcf0..ec426347c 100644 --- a/ui/src/components/HarEntry.tsx +++ b/ui/src/components/HarEntry.tsx @@ -25,7 +25,8 @@ interface HAREntry { interface Rules { status: boolean; - latency: number + latency: number; + numberOfRules: number; } interface HAREntryProps { @@ -36,6 +37,7 @@ interface HAREntryProps { export const HarEntry: React.FC = ({entry, setFocusedEntryId, isSelected}) => { const classification = getClassification(entry.statusCode) + const numberOfRules = entry.rules.numberOfRules let ingoingIcon; let outgoingIcon; switch(classification) { @@ -55,16 +57,36 @@ export const HarEntry: React.FC = ({entry, setFocusedEntryId, isS break; } } - let backgroundColor = ""; - if ('latency' in entry.rules) { + let additionalRulesProperties = ""; + let ruleSuccess: boolean; + let rule = 'latency' in entry.rules + if (rule) { if (entry.rules.latency !== -1) { - backgroundColor = entry.rules.latency >= entry.latency ? styles.ruleSuccessRow : styles.ruleFailureRow + if (entry.rules.latency >= entry.latency) { + additionalRulesProperties = styles.ruleSuccessRow + ruleSuccess = true + } else { + additionalRulesProperties = styles.ruleFailureRow + ruleSuccess = false + } + if (isSelected) { + additionalRulesProperties += ` ${entry.rules.latency >= entry.latency ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` + } } else { - backgroundColor = entry.rules.status ? styles.ruleSuccessRow : styles.ruleFailureRow + if (entry.rules.status) { + additionalRulesProperties = styles.ruleSuccessRow + ruleSuccess = true + } else { + additionalRulesProperties = styles.ruleFailureRow + ruleSuccess = false + } + if (isSelected) { + additionalRulesProperties += ` ${entry.rules.status ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` + } } } return <> -
setFocusedEntryId(entry.id)}> +
setFocusedEntryId(entry.id)}> {entry.statusCode &&
} @@ -74,6 +96,13 @@ export const HarEntry: React.FC = ({entry, setFocusedEntryId, isS {entry.service}
+ { + rule ? +
+ {`Rules (${numberOfRules})`} +
+ : "" + }
{entry.isOutgoing ? outgoing traffic diff --git a/ui/src/components/HarEntryDetailed.tsx b/ui/src/components/HarEntryDetailed.tsx index 8e86d140f..82b6f029b 100644 --- a/ui/src/components/HarEntryDetailed.tsx +++ b/ui/src/components/HarEntryDetailed.tsx @@ -43,7 +43,6 @@ const HarEntryTitle: React.FC = ({har}) => {
{formatSize(bodySize)}
{status} {statusText}
{Math.round(receive)}ms
-
{'rulesMatched' in entries[0] ? entries[0].rulesMatched?.length : '0'} Rules Applied
; }; diff --git a/ui/src/components/HarEntryViewer/HAREntrySections.module.sass b/ui/src/components/HarEntryViewer/HAREntrySections.module.sass index 50bb0cc19..06c19f302 100644 --- a/ui/src/components/HarEntryViewer/HAREntrySections.module.sass +++ b/ui/src/components/HarEntryViewer/HAREntrySections.module.sass @@ -92,3 +92,6 @@ tr td:first-child white-space: nowrap padding-right: .5rem + +.noRules + padding: 0 1rem 1rem diff --git a/ui/src/components/HarEntryViewer/HAREntrySections.tsx b/ui/src/components/HarEntryViewer/HAREntrySections.tsx index 088dd3f51..3b5bd1d87 100644 --- a/ui/src/components/HarEntryViewer/HAREntrySections.tsx +++ b/ui/src/components/HarEntryViewer/HAREntrySections.tsx @@ -260,7 +260,7 @@ export const HAREntryTablePolicySection: React.FC = - : + : No rules could be applied to this request. } } \ No newline at end of file diff --git a/ui/src/components/style/HarEntry.module.sass b/ui/src/components/style/HarEntry.module.sass index 5425b1786..6a20447ff 100644 --- a/ui/src/components/style/HarEntry.module.sass +++ b/ui/src/components/style/HarEntry.module.sass @@ -24,12 +24,40 @@ margin-right: 3px .ruleSuccessRow - border: 1px $success-color solid - border-left: 5px $success-color solid + background: #E8FFF1 + +.ruleSuccessRowSelected + border: 1px #6FCF97 solid + border-left: 5px #6FCF97 solid + margin-left: 10px + margin-right: 3px .ruleFailureRow + background: #FFE9EF + +.ruleFailureRowSelected border: 1px $failure-color solid border-left: 5px $failure-color solid + margin-left: 10px + margin-right: 3px + +.ruleNumberTextFailure + color: #DB2156 + font-family: Source Sans Pro; + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 15px; + padding-right: 12px + +.ruleNumberTextSuccess + color: #219653 + font-family: Source Sans Pro; + font-style: normal; + font-weight: 600; + font-size: 12px; + line-height: 15px; + padding-right: 12px .service text-overflow: ellipsis From c4afeee5b3470042c86adf280b75b120883c6197 Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Tue, 10 Aug 2021 16:45:47 +0300 Subject: [PATCH 09/43] Policy rules remove redundant function (#193) --- agent/pkg/models/models.go | 20 +++++--------------- agent/pkg/rules/models.go | 27 ++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/agent/pkg/models/models.go b/agent/pkg/models/models.go index bf8c0cb7b..ef3e4d4d0 100644 --- a/agent/pkg/models/models.go +++ b/agent/pkg/models/models.go @@ -54,17 +54,9 @@ type BaseEntryDetails struct { } type ApplicableRules struct { - Latency int64 `json:"latency,omitempty"` - Status bool `json:"status,omitempty"` - NumberOfRules int `json:"numberOfRules,omitempty"` -} - -func NewApplicableRules(status bool, latency int64, number int) ApplicableRules { - ar := ApplicableRules{} - ar.Status = status - ar.Latency = latency - ar.NumberOfRules = number - return ar + Latency int64 `json:"latency,omitempty"` + Status bool `json:"status,omitempty"` + NumberOfRules int `json:"numberOfRules,omitempty"` } type FullEntryDetails struct { @@ -219,8 +211,6 @@ func (fewp *FullEntryWithPolicy) UnmarshalData(entry *MizuEntry) error { } func RunValidationRulesState(harEntry har.Entry, service string) ApplicableRules { - numberOfRules, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service) - statusPolicyToSend, latency, numberOfRules := rules.PassedValidationRules(resultPolicyToSend, numberOfRules) - ar := NewApplicableRules(statusPolicyToSend, latency, numberOfRules) - return ar + _, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service) + return rules.PassedValidationRules(resultPolicyToSend) } diff --git a/agent/pkg/rules/models.go b/agent/pkg/rules/models.go index 2107e6447..1af44331f 100644 --- a/agent/pkg/rules/models.go +++ b/agent/pkg/rules/models.go @@ -3,6 +3,7 @@ package rules import ( "encoding/json" "fmt" + "mizuserver/pkg/models" "reflect" "regexp" "strings" @@ -92,19 +93,35 @@ func MatchRequestPolicy(harEntry har.Entry, service string) (int, []RulesMatched return len(enforcePolicy.Rules), resultPolicyToSend } -func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64, int) { +func PassedValidationRules(rulesMatched []RulesMatched) models.ApplicableRules { if len(rulesMatched) == 0 { - return false, 0, 0 + return models.ApplicableRules{ + Status: false, + Latency: 0, + NumberOfRules: 0, + } } for _, rule := range rulesMatched { if rule.Matched == false { - return false, -1, len(rulesMatched) + return models.ApplicableRules{ + Status: false, + Latency: -1, + NumberOfRules: len(rulesMatched), + } } } for _, rule := range rulesMatched { if strings.ToLower(rule.Rule.Type) == "latency" { - return true, rule.Rule.Latency, len(rulesMatched) + return models.ApplicableRules{ + Status: true, + Latency: rule.Rule.Latency, + NumberOfRules: len(rulesMatched), + } } } - return true, -1, len(rulesMatched) + return models.ApplicableRules{ + Status: true, + Latency: -1, + NumberOfRules: len(rulesMatched), + } } From 59dec1a54733a879acc008679aad568c13f281bb Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Tue, 10 Aug 2021 16:45:57 +0300 Subject: [PATCH 10/43] Readme fixes (#194) --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index eda3822f3..088df71af 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,16 @@ # The API Traffic Viewer for Kubernetes -A simple-yet-powerful API traffic viewer for Kubernetes to help you troubleshoot and debug your microservices. Think TCPDump and Chrome Dev Tools combined. +A simple-yet-powerful API traffic viewer for Kubernetes to help you troubleshoot and debug your microservices. Think TCPDump and Chrome Dev Tools combined ![Simple UI](assets/mizu-ui.png) ## Features - Simple and powerful CLI -- Real time view of all HTTP requests, REST and gRPC API calls +- Real-time view of all HTTP requests, REST and gRPC API calls - No installation or code instrumentation -- Works completely on premises (on-prem) +- Works completely on premises ## Download @@ -33,10 +33,10 @@ https://github.com/up9inc/mizu/releases/latest/download/mizu_linux_amd64 \ && chmod 755 mizu ``` -SHA256 checksums are available on the [Releases](https://github.com/up9inc/mizu/releases) page. +SHA256 checksums are available on the [Releases](https://github.com/up9inc/mizu/releases) page ### Development (unstable) Build -Pick one from the [Releases](https://github.com/up9inc/mizu/releases) page. +Pick one from the [Releases](https://github.com/up9inc/mizu/releases) page ## Prerequisites 1. Set `KUBECONFIG` environment variable to your Kubernetes configuration. If this is not set, Mizu assumes that configuration is at `${HOME}/.kube/config` @@ -49,8 +49,8 @@ For detailed list of k8s permissions see [PERMISSIONS](PERMISSIONS.md) document 1. Find pods you'd like to tap to in your Kubernetes cluster 2. Run `mizu tap` or `mizu tap PODNAME` -3. Open browser on `http://localhost:8899/mizu` **or** as instructed in the CLI .. -4. Watch the API traffic flowing .. +3. Open browser on `http://localhost:8899/mizu` **or** as instructed in the CLI +4. Watch the API traffic flowing 5. Type ^C to stop ## Examples @@ -107,9 +107,9 @@ To tap multiple pods using regex - ## Configuration Mizu can work with config file which should be stored in ${HOME}/.mizu/config.yaml (macOS: ~/.mizu/config.yaml)
-In case no config file found, defaults will be used.
-In case of partial configuration defined, all other fields will be used with defaults.
-You can always override the defaults or config file with CLI flags. +In case no config file found, defaults will be used
+In case of partial configuration defined, all other fields will be used with defaults
+You can always override the defaults or config file with CLI flags To get the default config params run `mizu config`
To generate a new config file with default values use `mizu config -r` @@ -122,16 +122,16 @@ By default, mizu reports usage telemetry. It can be disabled by adding a line of ### Namespace-Restricted Mode -Some users have permission to only manage resources in one particular namespace assigned to them. +Some users have permission to only manage resources in one particular namespace assigned to them By default `mizu tap` creates a new namespace `mizu` for all of its Kubernetes resources. In order to instead install -Mizu in an existing namespace, set the `mizu-resources-namespace` config option. +Mizu in an existing namespace, set the `mizu-resources-namespace` config option If `mizu-resources-namespace` is set to a value other than the default `mizu`, Mizu will operate in a Namespace-Restricted mode. It will only tap pods in `mizu-resources-namespace`. This way Mizu only requires permissions to the namespace set by `mizu-resources-namespace`. The user must set the tapped namespace to the same namespace by -using the `--namespace` flag or by setting `tap.namespaces` in the config file. +using the `--namespace` flag or by setting `tap.namespaces` in the config file -Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior. +Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior ### User agent filtering From cbe04af8011d3f6b9e4fa7bf9f256e41733dea5e Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Tue, 10 Aug 2021 18:04:30 +0300 Subject: [PATCH 11/43] Revert "Policy rules remove redundant function (#193)" (#199) This reverts commit c4afeee5b3470042c86adf280b75b120883c6197. --- agent/pkg/models/models.go | 20 +++++++++++++++----- agent/pkg/rules/models.go | 27 +++++---------------------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/agent/pkg/models/models.go b/agent/pkg/models/models.go index ef3e4d4d0..bf8c0cb7b 100644 --- a/agent/pkg/models/models.go +++ b/agent/pkg/models/models.go @@ -54,9 +54,17 @@ type BaseEntryDetails struct { } type ApplicableRules struct { - Latency int64 `json:"latency,omitempty"` - Status bool `json:"status,omitempty"` - NumberOfRules int `json:"numberOfRules,omitempty"` + Latency int64 `json:"latency,omitempty"` + Status bool `json:"status,omitempty"` + NumberOfRules int `json:"numberOfRules,omitempty"` +} + +func NewApplicableRules(status bool, latency int64, number int) ApplicableRules { + ar := ApplicableRules{} + ar.Status = status + ar.Latency = latency + ar.NumberOfRules = number + return ar } type FullEntryDetails struct { @@ -211,6 +219,8 @@ func (fewp *FullEntryWithPolicy) UnmarshalData(entry *MizuEntry) error { } func RunValidationRulesState(harEntry har.Entry, service string) ApplicableRules { - _, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service) - return rules.PassedValidationRules(resultPolicyToSend) + numberOfRules, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service) + statusPolicyToSend, latency, numberOfRules := rules.PassedValidationRules(resultPolicyToSend, numberOfRules) + ar := NewApplicableRules(statusPolicyToSend, latency, numberOfRules) + return ar } diff --git a/agent/pkg/rules/models.go b/agent/pkg/rules/models.go index 1af44331f..2107e6447 100644 --- a/agent/pkg/rules/models.go +++ b/agent/pkg/rules/models.go @@ -3,7 +3,6 @@ package rules import ( "encoding/json" "fmt" - "mizuserver/pkg/models" "reflect" "regexp" "strings" @@ -93,35 +92,19 @@ func MatchRequestPolicy(harEntry har.Entry, service string) (int, []RulesMatched return len(enforcePolicy.Rules), resultPolicyToSend } -func PassedValidationRules(rulesMatched []RulesMatched) models.ApplicableRules { +func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64, int) { if len(rulesMatched) == 0 { - return models.ApplicableRules{ - Status: false, - Latency: 0, - NumberOfRules: 0, - } + return false, 0, 0 } for _, rule := range rulesMatched { if rule.Matched == false { - return models.ApplicableRules{ - Status: false, - Latency: -1, - NumberOfRules: len(rulesMatched), - } + return false, -1, len(rulesMatched) } } for _, rule := range rulesMatched { if strings.ToLower(rule.Rule.Type) == "latency" { - return models.ApplicableRules{ - Status: true, - Latency: rule.Rule.Latency, - NumberOfRules: len(rulesMatched), - } + return true, rule.Rule.Latency, len(rulesMatched) } } - return models.ApplicableRules{ - Status: true, - Latency: -1, - NumberOfRules: len(rulesMatched), - } + return true, -1, len(rulesMatched) } From 0409eb239d5064dad426f3bd4fa54744725281db Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Tue, 10 Aug 2021 18:10:02 +0300 Subject: [PATCH 12/43] Report telemetry on develop and main branches (#195) --- cli/mizu/telemetry.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/mizu/telemetry.go b/cli/mizu/telemetry.go index e36956cd9..cb108cd43 100644 --- a/cli/mizu/telemetry.go +++ b/cli/mizu/telemetry.go @@ -15,6 +15,10 @@ func ReportRun(cmd string, args interface{}) { return } + if Branch != "main" && Branch != "develop" { + Log.Debugf("not reporting telemetry on private branches") + } + argsBytes, _ := json.Marshal(args) argsMap := map[string]string{ "telemetry_type": "execution", From 56dc6843e089e6ec8e9ca5a7ede42f060944d691 Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Tue, 10 Aug 2021 18:33:46 +0300 Subject: [PATCH 13/43] Add build cli & agent to CI (#198) * Add build cli & agent to CI --- .github/workflows/test.yaml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dc4af4dca..400bf3bdc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,12 +9,25 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - name: Set up Go 1.x + - name: Set up Go 1.16 uses: actions/setup-go@v2 + with: + go-version: '^1.16' + - run: go version - name: Check out code into the Go module directory uses: actions/checkout@v2 + - name: Build CLI + run: make cli + + - shell: bash + run: | + sudo apt-get install libpcap-dev + + - name: Build Agent + run: make agent + - name: Test run: make test From 7b73004e853b5e5d946e0672332e4d96c3acf944 Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Wed, 11 Aug 2021 09:56:03 +0300 Subject: [PATCH 14/43] Cli pkg refactor (2) (#200) --- cli/cmd/config.go | 17 +- cli/cmd/fetch.go | 10 +- cli/cmd/fetchRunner.go | 13 +- cli/cmd/logs.go | 13 +- cli/cmd/root.go | 14 +- cli/cmd/tap.go | 22 +- cli/cmd/tapRunner.go | 213 +++++++++--------- cli/cmd/version.go | 15 +- cli/cmd/view.go | 7 +- cli/cmd/viewRunner.go | 25 +- cli/{mizu => config}/config.go | 26 ++- cli/{mizu => config}/configStruct.go | 8 +- .../configStructs/fetchConfig.go | 0 .../configStructs/tapConfig.go | 0 .../configStructs/versionConfig.go | 0 .../configStructs/viewConfig.go | 0 cli/{mizu => config}/config_test.go | 12 +- cli/errormessage/errormessage.go | 10 +- cli/fsUtils/mizuLogsUtils.go | 58 ----- cli/kubernetes/provider.go | 5 +- cli/kubernetes/proxy.go | 4 +- cli/{mizu => logger}/logger.go | 7 +- cli/mizu.go | 2 +- cli/{ => mizu}/fsUtils/dirUtils.go | 0 cli/mizu/fsUtils/mizuLogsUtils.go | 60 +++++ cli/{ => mizu}/fsUtils/zipUtils.go | 0 cli/{ => mizu}/goUtils/funcWrappers.go | 4 +- cli/mizu/{ => version}/versionCheck.go | 26 ++- cli/{mizu => telemetry}/telemetry.go | 23 +- 29 files changed, 311 insertions(+), 283 deletions(-) rename cli/{mizu => config}/config.go (85%) rename cli/{mizu => config}/configStruct.go (91%) rename cli/{mizu => config}/configStructs/fetchConfig.go (100%) rename cli/{mizu => config}/configStructs/tapConfig.go (100%) rename cli/{mizu => config}/configStructs/versionConfig.go (100%) rename cli/{mizu => config}/configStructs/viewConfig.go (100%) rename cli/{mizu => config}/config_test.go (72%) delete mode 100644 cli/fsUtils/mizuLogsUtils.go rename cli/{mizu => logger}/logger.go (83%) rename cli/{ => mizu}/fsUtils/dirUtils.go (100%) create mode 100644 cli/mizu/fsUtils/mizuLogsUtils.go rename cli/{ => mizu}/fsUtils/zipUtils.go (100%) rename cli/{ => mizu}/goUtils/funcWrappers.go (81%) rename cli/mizu/{ => version}/versionCheck.go (62%) rename cli/{mizu => telemetry}/telemetry.go (52%) diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 66d4485d8..6fc666fb5 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -3,7 +3,8 @@ package cmd import ( "fmt" "github.com/spf13/cobra" - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/logger" "github.com/up9inc/mizu/cli/uiUtils" "io/ioutil" ) @@ -14,20 +15,20 @@ var configCmd = &cobra.Command{ Use: "config", Short: "Generate config with default values", RunE: func(cmd *cobra.Command, args []string) error { - template, err := mizu.GetConfigWithDefaults() + template, err := config.GetConfigWithDefaults() if err != nil { - mizu.Log.Errorf("Failed generating config with defaults %v", err) + logger.Log.Errorf("Failed generating config with defaults %v", err) return nil } if regenerateFile { data := []byte(template) - if err := ioutil.WriteFile(mizu.GetConfigFilePath(), data, 0644); err != nil { - mizu.Log.Errorf("Failed writing config %v", err) + if err := ioutil.WriteFile(config.GetConfigFilePath(), data, 0644); err != nil { + logger.Log.Errorf("Failed writing config %v", err) return nil } - mizu.Log.Infof(fmt.Sprintf("Template File written to %s", fmt.Sprintf(uiUtils.Purple, mizu.GetConfigFilePath()))) + logger.Log.Infof(fmt.Sprintf("Template File written to %s", fmt.Sprintf(uiUtils.Purple, config.GetConfigFilePath()))) } else { - mizu.Log.Debugf("Writing template config.\n%v", template) + logger.Log.Debugf("Writing template config.\n%v", template) fmt.Printf("%v", template) } return nil @@ -36,5 +37,5 @@ var configCmd = &cobra.Command{ func init() { rootCmd.AddCommand(configCmd) - configCmd.Flags().BoolVarP(®enerateFile, "regenerate", "r", false, fmt.Sprintf("Regenerate the config file with default values %s", mizu.GetConfigFilePath())) + configCmd.Flags().BoolVarP(®enerateFile, "regenerate", "r", false, fmt.Sprintf("Regenerate the config file with default values %s", config.GetConfigFilePath())) } diff --git a/cli/cmd/fetch.go b/cli/cmd/fetch.go index 3b9b4cc09..f26f43c44 100644 --- a/cli/cmd/fetch.go +++ b/cli/cmd/fetch.go @@ -3,16 +3,18 @@ package cmd import ( "github.com/creasty/defaults" "github.com/spf13/cobra" - "github.com/up9inc/mizu/cli/mizu" - "github.com/up9inc/mizu/cli/mizu/configStructs" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/mizu/version" + "github.com/up9inc/mizu/cli/telemetry" ) var fetchCmd = &cobra.Command{ Use: "fetch", Short: "Download recorded traffic to files", RunE: func(cmd *cobra.Command, args []string) error { - go mizu.ReportRun("fetch", mizu.Config.Fetch) - if isCompatible, err := mizu.CheckVersionCompatibility(mizu.Config.Fetch.GuiPort); err != nil { + go telemetry.ReportRun("fetch", config.Config.Fetch) + if isCompatible, err := version.CheckVersionCompatibility(config.Config.Fetch.GuiPort); err != nil { return err } else if !isCompatible { return nil diff --git a/cli/cmd/fetchRunner.go b/cli/cmd/fetchRunner.go index 9c372a632..b0c76020e 100644 --- a/cli/cmd/fetchRunner.go +++ b/cli/cmd/fetchRunner.go @@ -4,8 +4,9 @@ import ( "archive/zip" "bytes" "fmt" + "github.com/up9inc/mizu/cli/config" "github.com/up9inc/mizu/cli/kubernetes" - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/logger" "io" "io/ioutil" "log" @@ -16,8 +17,8 @@ import ( ) func RunMizuFetch() { - mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.Fetch.GuiPort) - resp, err := http.Get(fmt.Sprintf("http://%s/api/har?from=%v&to=%v", mizuProxiedUrl, mizu.Config.Fetch.FromTimestamp, mizu.Config.Fetch.ToTimestamp)) + mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.Fetch.GuiPort) + resp, err := http.Get(fmt.Sprintf("http://%s/api/har?from=%v&to=%v", mizuProxiedUrl, config.Config.Fetch.FromTimestamp, config.Config.Fetch.ToTimestamp)) if err != nil { log.Fatal(err) } @@ -34,7 +35,7 @@ func RunMizuFetch() { log.Fatal(err) } - _ = Unzip(zipReader, mizu.Config.Fetch.Directory) + _ = Unzip(zipReader, config.Config.Fetch.Directory) } func Unzip(reader *zip.Reader, dest string) error { @@ -64,7 +65,7 @@ func Unzip(reader *zip.Reader, dest string) error { _ = os.MkdirAll(path, f.Mode()) } else { _ = os.MkdirAll(filepath.Dir(path), f.Mode()) - mizu.Log.Infof("writing HAR file [ %v ]", path) + logger.Log.Infof("writing HAR file [ %v ]", path) f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err @@ -73,7 +74,7 @@ func Unzip(reader *zip.Reader, dest string) error { if err := f.Close(); err != nil { panic(err) } - mizu.Log.Info(" done") + logger.Log.Info(" done") }() _, err = io.Copy(f, rc) diff --git a/cli/cmd/logs.go b/cli/cmd/logs.go index ec639fc7e..8f7a2f42e 100644 --- a/cli/cmd/logs.go +++ b/cli/cmd/logs.go @@ -3,9 +3,10 @@ package cmd import ( "context" "github.com/spf13/cobra" - "github.com/up9inc/mizu/cli/fsUtils" + "github.com/up9inc/mizu/cli/config" "github.com/up9inc/mizu/cli/kubernetes" - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu/fsUtils" "os" "path" ) @@ -16,7 +17,7 @@ var logsCmd = &cobra.Command{ Use: "logs", Short: "Create a zip file with logs for Github issue or troubleshoot", RunE: func(cmd *cobra.Command, args []string) error { - kubernetesProvider, err := kubernetes.NewProvider(mizu.Config.View.KubeConfigPath) + kubernetesProvider, err := kubernetes.NewProvider(config.Config.View.KubeConfigPath) if err != nil { return nil } @@ -25,15 +26,15 @@ var logsCmd = &cobra.Command{ if filePath == "" { pwd, err := os.Getwd() if err != nil { - mizu.Log.Errorf("Failed to get PWD, %v (try using `mizu logs -f )`", err) + logger.Log.Errorf("Failed to get PWD, %v (try using `mizu logs -f )`", err) return nil } filePath = path.Join(pwd, "mizu_logs.zip") } - mizu.Log.Debugf("Using file path %s", filePath) + logger.Log.Debugf("Using file path %s", filePath) if err := fsUtils.DumpLogs(kubernetesProvider, ctx, filePath); err != nil { - mizu.Log.Errorf("Failed dump logs %v", err) + logger.Log.Errorf("Failed dump logs %v", err) } return nil diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 84f6dde3b..70abf7bd5 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -3,8 +3,10 @@ package cmd import ( "fmt" "github.com/spf13/cobra" - "github.com/up9inc/mizu/cli/fsUtils" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/logger" "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/mizu/fsUtils" ) var rootCmd = &cobra.Command{ @@ -14,11 +16,11 @@ var rootCmd = &cobra.Command{ Further info is available at https://github.com/up9inc/mizu`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if err := fsUtils.EnsureDir(mizu.GetMizuFolderPath()); err != nil { - mizu.Log.Errorf("Failed to use mizu folder, %v", err) + logger.Log.Errorf("Failed to use mizu folder, %v", err) } - mizu.InitLogger() - if err := mizu.InitConfig(cmd); err != nil { - mizu.Log.Fatal(err) + logger.InitLogger() + if err := config.InitConfig(cmd); err != nil { + logger.Log.Fatal(err) } return nil @@ -26,7 +28,7 @@ Further info is available at https://github.com/up9inc/mizu`, } func init() { - rootCmd.PersistentFlags().StringSlice(mizu.SetCommandName, []string{}, fmt.Sprintf("Override values using --%s", mizu.SetCommandName)) + rootCmd.PersistentFlags().StringSlice(config.SetCommandName, []string{}, fmt.Sprintf("Override values using --%s", config.SetCommandName)) } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/cli/cmd/tap.go b/cli/cmd/tap.go index a213d7f2e..abb10e801 100644 --- a/cli/cmd/tap.go +++ b/cli/cmd/tap.go @@ -2,13 +2,15 @@ package cmd import ( "errors" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/telemetry" "os" "github.com/creasty/defaults" "github.com/spf13/cobra" "github.com/up9inc/mizu/cli/errormessage" - "github.com/up9inc/mizu/cli/mizu" - "github.com/up9inc/mizu/cli/mizu/configStructs" "github.com/up9inc/mizu/cli/uiUtils" ) @@ -20,31 +22,31 @@ var tapCmd = &cobra.Command{ Long: `Record the ingoing traffic of a kubernetes pod. Supported protocols are HTTP and gRPC.`, RunE: func(cmd *cobra.Command, args []string) error { - go mizu.ReportRun("tap", mizu.Config.Tap) + go telemetry.ReportRun("tap", config.Config.Tap) RunMizuTap() return nil }, PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) == 1 { - mizu.Config.Tap.PodRegexStr = args[0] + config.Config.Tap.PodRegexStr = args[0] } else if len(args) > 1 { return errors.New("unexpected number of arguments") } - if err := mizu.Config.Validate(); err != nil { + if err := config.Config.Validate(); err != nil { return errormessage.FormatError(err) } - if err := mizu.Config.Tap.Validate(); err != nil { + if err := config.Config.Tap.Validate(); err != nil { return errormessage.FormatError(err) } - mizu.Log.Infof("Mizu will store up to %s of traffic, old traffic will be cleared once the limit is reached.", mizu.Config.Tap.HumanMaxEntriesDBSize) + logger.Log.Infof("Mizu will store up to %s of traffic, old traffic will be cleared once the limit is reached.", config.Config.Tap.HumanMaxEntriesDBSize) - if mizu.Config.Tap.Analysis { - mizu.Log.Infof(analysisMessageToConfirm) + if config.Config.Tap.Analysis { + logger.Log.Infof(analysisMessageToConfirm) if !uiUtils.AskForConfirmation("Would you like to proceed [Y/n]: ") { - mizu.Log.Infof("You can always run mizu without analysis, aborting") + logger.Log.Infof("You can always run mizu without analysis, aborting") os.Exit(0) } } diff --git a/cli/cmd/tapRunner.go b/cli/cmd/tapRunner.go index 43756cf59..be0aac841 100644 --- a/cli/cmd/tapRunner.go +++ b/cli/cmd/tapRunner.go @@ -5,9 +5,12 @@ import ( "context" "encoding/json" "fmt" - "github.com/up9inc/mizu/cli/fsUtils" - "github.com/up9inc/mizu/cli/goUtils" - "github.com/up9inc/mizu/cli/mizu/configStructs" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu/fsUtils" + "github.com/up9inc/mizu/cli/mizu/goUtils" + "github.com/up9inc/mizu/cli/mizu/version" "net/http" "net/url" "os" @@ -46,21 +49,21 @@ var state tapState func RunMizuTap() { mizuApiFilteringOptions, err := getMizuApiFilteringOptions() if err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error parsing regex-masking: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error parsing regex-masking: %v", errormessage.FormatError(err))) return } var mizuValidationRules string - if mizu.Config.Tap.EnforcePolicyFile != "" { - mizuValidationRules, err = readValidationRules(mizu.Config.Tap.EnforcePolicyFile) + if config.Config.Tap.EnforcePolicyFile != "" { + mizuValidationRules, err = readValidationRules(config.Config.Tap.EnforcePolicyFile) if err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error reading policy file: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error reading policy file: %v", errormessage.FormatError(err))) return } } - kubernetesProvider, err := kubernetes.NewProvider(mizu.Config.KubeConfigPath) + kubernetesProvider, err := kubernetes.NewProvider(config.Config.KubeConfigPath) if err != nil { - mizu.Log.Error(err) + logger.Log.Error(err) return } @@ -75,11 +78,11 @@ func RunMizuTap() { } else { namespacesStr = "all namespaces" } - mizu.CheckNewerVersion() - mizu.Log.Infof("Tapping pods in %s", namespacesStr) + version.CheckNewerVersion() + logger.Log.Infof("Tapping pods in %s", namespacesStr) if err, _ := updateCurrentlyTappedPods(kubernetesProvider, ctx, targetNamespaces); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error getting pods by regex: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error getting pods by regex: %v", errormessage.FormatError(err))) return } @@ -88,10 +91,10 @@ func RunMizuTap() { if !mizu.Contains(targetNamespaces, mizu.K8sAllNamespaces) { suggestionStr = ". Select a different namespace with -n or tap all namespaces with -A" } - mizu.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Did not find any pods matching the regex argument%s", suggestionStr)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Did not find any pods matching the regex argument%s", suggestionStr)) } - if mizu.Config.Tap.DryRun { + if config.Config.Tap.DryRun { return } @@ -99,7 +102,7 @@ func RunMizuTap() { defer cleanUpMizuResources(kubernetesProvider) if err := createMizuResources(ctx, kubernetesProvider, nodeToTappedPodIPMap, mizuApiFilteringOptions, mizuValidationRules); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error creating resources: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error creating resources: %v", errormessage.FormatError(err))) return } @@ -120,7 +123,7 @@ func readValidationRules(file string) (string, error) { } func createMizuResources(ctx context.Context, kubernetesProvider *kubernetes.Provider, nodeToTappedPodIPMap map[string][]string, mizuApiFilteringOptions *shared.TrafficFilteringOptions, mizuValidationRules string) error { - if !mizu.Config.IsNsRestrictedMode() { + if !config.Config.IsNsRestrictedMode() { if err := createMizuNamespace(ctx, kubernetesProvider); err != nil { return err } @@ -135,7 +138,7 @@ func createMizuResources(ctx context.Context, kubernetesProvider *kubernetes.Pro } if err := createMizuConfigmap(ctx, kubernetesProvider, mizuValidationRules); err != nil { - mizu.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Failed to create resources required for policy validation. Mizu will not validate policy rules. error: %v\n", errormessage.FormatError(err))) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Failed to create resources required for policy validation. Mizu will not validate policy rules. error: %v\n", errormessage.FormatError(err))) state.doNotRemoveConfigMap = true } else if mizuValidationRules == "" { state.doNotRemoveConfigMap = true @@ -145,12 +148,12 @@ func createMizuResources(ctx context.Context, kubernetesProvider *kubernetes.Pro } func createMizuConfigmap(ctx context.Context, kubernetesProvider *kubernetes.Provider, data string) error { - err := kubernetesProvider.CreateConfigMap(ctx, mizu.Config.MizuResourcesNamespace, mizu.ConfigMapName, data) + err := kubernetesProvider.CreateConfigMap(ctx, config.Config.MizuResourcesNamespace, mizu.ConfigMapName, data) return err } func createMizuNamespace(ctx context.Context, kubernetesProvider *kubernetes.Provider) error { - _, err := kubernetesProvider.CreateNamespace(ctx, mizu.Config.MizuResourcesNamespace) + _, err := kubernetesProvider.CreateNamespace(ctx, config.Config.MizuResourcesNamespace) return err } @@ -159,7 +162,7 @@ func createMizuApiServer(ctx context.Context, kubernetesProvider *kubernetes.Pro state.mizuServiceAccountExists, err = createRBACIfNecessary(ctx, kubernetesProvider) if err != nil { - mizu.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Failed to ensure the resources required for IP resolving. Mizu will not resolve target IPs to names. error: %v", errormessage.FormatError(err))) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Failed to ensure the resources required for IP resolving. Mizu will not resolve target IPs to names. error: %v", errormessage.FormatError(err))) } var serviceAccountName string @@ -170,25 +173,25 @@ func createMizuApiServer(ctx context.Context, kubernetesProvider *kubernetes.Pro } opts := &kubernetes.ApiServerOptions{ - Namespace: mizu.Config.MizuResourcesNamespace, + Namespace: config.Config.MizuResourcesNamespace, PodName: mizu.ApiServerPodName, - PodImage: mizu.Config.AgentImage, + PodImage: config.Config.AgentImage, ServiceAccountName: serviceAccountName, - IsNamespaceRestricted: mizu.Config.IsNsRestrictedMode(), + IsNamespaceRestricted: config.Config.IsNsRestrictedMode(), MizuApiFilteringOptions: mizuApiFilteringOptions, - MaxEntriesDBSizeBytes: mizu.Config.Tap.MaxEntriesDBSizeBytes(), + MaxEntriesDBSizeBytes: config.Config.Tap.MaxEntriesDBSizeBytes(), } _, err = kubernetesProvider.CreateMizuApiServerPod(ctx, opts) if err != nil { return err } - mizu.Log.Debugf("Successfully created API server pod: %s", mizu.ApiServerPodName) + logger.Log.Debugf("Successfully created API server pod: %s", mizu.ApiServerPodName) - state.apiServerService, err = kubernetesProvider.CreateService(ctx, mizu.Config.MizuResourcesNamespace, mizu.ApiServerPodName, mizu.ApiServerPodName) + state.apiServerService, err = kubernetesProvider.CreateService(ctx, config.Config.MizuResourcesNamespace, mizu.ApiServerPodName, mizu.ApiServerPodName) if err != nil { return err } - mizu.Log.Debugf("Successfully created service: %s", mizu.ApiServerPodName) + logger.Log.Debugf("Successfully created service: %s", mizu.ApiServerPodName) return nil } @@ -196,9 +199,9 @@ func createMizuApiServer(ctx context.Context, kubernetesProvider *kubernetes.Pro func getMizuApiFilteringOptions() (*shared.TrafficFilteringOptions, error) { var compiledRegexSlice []*shared.SerializableRegexp - if mizu.Config.Tap.PlainTextFilterRegexes != nil && len(mizu.Config.Tap.PlainTextFilterRegexes) > 0 { + if config.Config.Tap.PlainTextFilterRegexes != nil && len(config.Config.Tap.PlainTextFilterRegexes) > 0 { compiledRegexSlice = make([]*shared.SerializableRegexp, 0) - for _, regexStr := range mizu.Config.Tap.PlainTextFilterRegexes { + for _, regexStr := range config.Config.Tap.PlainTextFilterRegexes { compiledRegex, err := shared.CompileRegexToSerializableRegexp(regexStr) if err != nil { return nil, err @@ -209,8 +212,8 @@ func getMizuApiFilteringOptions() (*shared.TrafficFilteringOptions, error) { return &shared.TrafficFilteringOptions{ PlainTextMaskingRegexes: compiledRegexSlice, - HealthChecksUserAgentHeaders: mizu.Config.Tap.HealthChecksUserAgentHeaders, - DisableRedaction: mizu.Config.Tap.DisableRedaction, + HealthChecksUserAgentHeaders: config.Config.Tap.HealthChecksUserAgentHeaders, + DisableRedaction: config.Config.Tap.DisableRedaction, }, nil } @@ -225,20 +228,20 @@ func updateMizuTappers(ctx context.Context, kubernetesProvider *kubernetes.Provi if err := kubernetesProvider.ApplyMizuTapperDaemonSet( ctx, - mizu.Config.MizuResourcesNamespace, + config.Config.MizuResourcesNamespace, mizu.TapperDaemonSetName, - mizu.Config.AgentImage, + config.Config.AgentImage, mizu.TapperPodName, fmt.Sprintf("%s.%s.svc.cluster.local", state.apiServerService.Name, state.apiServerService.Namespace), nodeToTappedPodIPMap, serviceAccountName, - mizu.Config.Tap.TapOutgoing(), + config.Config.Tap.TapOutgoing(), ); err != nil { return err } - mizu.Log.Debugf("Successfully created %v tappers", len(nodeToTappedPodIPMap)) + logger.Log.Debugf("Successfully created %v tappers", len(nodeToTappedPodIPMap)) } else { - if err := kubernetesProvider.RemoveDaemonSet(ctx, mizu.Config.MizuResourcesNamespace, mizu.TapperDaemonSetName); err != nil { + if err := kubernetesProvider.RemoveDaemonSet(ctx, config.Config.MizuResourcesNamespace, mizu.TapperDaemonSetName); err != nil { return err } } @@ -251,65 +254,65 @@ func cleanUpMizuResources(kubernetesProvider *kubernetes.Provider) { removalCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) defer cancel() - if mizu.Config.DumpLogs { + if config.Config.DumpLogs { mizuDir := mizu.GetMizuFolderPath() filePath = path.Join(mizuDir, fmt.Sprintf("mizu_logs_%s.zip", time.Now().Format("2006_01_02__15_04_05"))) if err := fsUtils.DumpLogs(kubernetesProvider, removalCtx, filePath); err != nil { - mizu.Log.Errorf("Failed dump logs %v", err) + logger.Log.Errorf("Failed dump logs %v", err) } } - mizu.Log.Infof("\nRemoving mizu resources\n") + logger.Log.Infof("\nRemoving mizu resources\n") - if !mizu.Config.IsNsRestrictedMode() { - if err := kubernetesProvider.RemoveNamespace(removalCtx, mizu.Config.MizuResourcesNamespace); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Namespace %s: %v", mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if !config.Config.IsNsRestrictedMode() { + if err := kubernetesProvider.RemoveNamespace(removalCtx, config.Config.MizuResourcesNamespace); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Namespace %s: %v", config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) return } } else { - if err := kubernetesProvider.RemovePod(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.ApiServerPodName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Pod %s in namespace %s: %v", mizu.ApiServerPodName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemovePod(removalCtx, config.Config.MizuResourcesNamespace, mizu.ApiServerPodName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Pod %s in namespace %s: %v", mizu.ApiServerPodName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } - if err := kubernetesProvider.RemoveService(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.ApiServerPodName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Service %s in namespace %s: %v", mizu.ApiServerPodName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveService(removalCtx, config.Config.MizuResourcesNamespace, mizu.ApiServerPodName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Service %s in namespace %s: %v", mizu.ApiServerPodName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } - if err := kubernetesProvider.RemoveDaemonSet(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.TapperDaemonSetName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing DaemonSet %s in namespace %s: %v", mizu.TapperDaemonSetName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveDaemonSet(removalCtx, config.Config.MizuResourcesNamespace, mizu.TapperDaemonSetName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing DaemonSet %s in namespace %s: %v", mizu.TapperDaemonSetName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } if !state.doNotRemoveConfigMap { - if err := kubernetesProvider.RemoveConfigMap(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.ConfigMapName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing ConfigMap %s in namespace %s: %v", mizu.ConfigMapName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveConfigMap(removalCtx, config.Config.MizuResourcesNamespace, mizu.ConfigMapName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing ConfigMap %s in namespace %s: %v", mizu.ConfigMapName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } } } if state.mizuServiceAccountExists { - if !mizu.Config.IsNsRestrictedMode() { + if !config.Config.IsNsRestrictedMode() { if err := kubernetesProvider.RemoveNonNamespacedResources(removalCtx, mizu.ClusterRoleName, mizu.ClusterRoleBindingName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing non-namespaced resources: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing non-namespaced resources: %v", errormessage.FormatError(err))) return } } else { - if err := kubernetesProvider.RemoveServicAccount(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.ServiceAccountName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Service Account %s in namespace %s: %v", mizu.ServiceAccountName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveServicAccount(removalCtx, config.Config.MizuResourcesNamespace, mizu.ServiceAccountName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Service Account %s in namespace %s: %v", mizu.ServiceAccountName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) return } - if err := kubernetesProvider.RemoveRole(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.RoleName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Role %s in namespace %s: %v", mizu.RoleName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveRole(removalCtx, config.Config.MizuResourcesNamespace, mizu.RoleName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing Role %s in namespace %s: %v", mizu.RoleName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } - if err := kubernetesProvider.RemoveRoleBinding(removalCtx, mizu.Config.MizuResourcesNamespace, mizu.RoleBindingName); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing RoleBinding %s in namespace %s: %v", mizu.RoleBindingName, mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + if err := kubernetesProvider.RemoveRoleBinding(removalCtx, config.Config.MizuResourcesNamespace, mizu.RoleBindingName); err != nil { + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error removing RoleBinding %s in namespace %s: %v", mizu.RoleBindingName, config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } } } - if !mizu.Config.IsNsRestrictedMode() { + if !config.Config.IsNsRestrictedMode() { waitUntilNamespaceDeleted(removalCtx, cancel, kubernetesProvider) } } @@ -320,20 +323,20 @@ func waitUntilNamespaceDeleted(ctx context.Context, cancel context.CancelFunc, k waitForFinish(ctx, cancel) }() - if err := kubernetesProvider.WaitUtilNamespaceDeleted(ctx, mizu.Config.MizuResourcesNamespace); err != nil { + if err := kubernetesProvider.WaitUtilNamespaceDeleted(ctx, config.Config.MizuResourcesNamespace); err != nil { switch { case ctx.Err() == context.Canceled: // Do nothing. User interrupted the wait. case err == wait.ErrWaitTimeout: - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Timeout while removing Namespace %s", mizu.Config.MizuResourcesNamespace)) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Timeout while removing Namespace %s", config.Config.MizuResourcesNamespace)) default: - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error while waiting for Namespace %s to be deleted: %v", mizu.Config.MizuResourcesNamespace, errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error while waiting for Namespace %s to be deleted: %v", config.Config.MizuResourcesNamespace, errormessage.FormatError(err))) } } } func reportTappedPods() { - mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.Tap.GuiPort) + mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.Tap.GuiPort) tappedPodsUrl := fmt.Sprintf("http://%s/status/tappedPods", mizuProxiedUrl) podInfos := make([]shared.PodInfo, 0) @@ -343,30 +346,30 @@ func reportTappedPods() { tapStatus := shared.TapStatus{Pods: podInfos} if jsonValue, err := json.Marshal(tapStatus); err != nil { - mizu.Log.Debugf("[ERROR] failed Marshal the tapped pods %v", err) + logger.Log.Debugf("[ERROR] failed Marshal the tapped pods %v", err) } else { if response, err := http.Post(tappedPodsUrl, "application/json", bytes.NewBuffer(jsonValue)); err != nil { - mizu.Log.Debugf("[ERROR] failed sending to API server the tapped pods %v", err) + logger.Log.Debugf("[ERROR] failed sending to API server the tapped pods %v", err) } else if response.StatusCode != 200 { - mizu.Log.Debugf("[ERROR] failed sending to API server the tapped pods, response status code %v", response.StatusCode) + logger.Log.Debugf("[ERROR] failed sending to API server the tapped pods, response status code %v", response.StatusCode) } else { - mizu.Log.Debugf("Reported to server API about %d taped pods successfully", len(podInfos)) + logger.Log.Debugf("Reported to server API about %d taped pods successfully", len(podInfos)) } } } func watchPodsForTapping(ctx context.Context, kubernetesProvider *kubernetes.Provider, targetNamespaces []string, cancel context.CancelFunc) { - added, modified, removed, errorChan := kubernetes.FilteredWatch(ctx, kubernetesProvider, targetNamespaces, mizu.Config.Tap.PodRegex()) + added, modified, removed, errorChan := kubernetes.FilteredWatch(ctx, kubernetesProvider, targetNamespaces, config.Config.Tap.PodRegex()) restartTappers := func() { err, changeFound := updateCurrentlyTappedPods(kubernetesProvider, ctx, targetNamespaces) if err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Failed to update currently tapped pods: %v", err)) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Failed to update currently tapped pods: %v", err)) cancel() } if !changeFound { - mizu.Log.Debugf("Nothing changed update tappers not needed") + logger.Log.Debugf("Nothing changed update tappers not needed") return } @@ -374,11 +377,11 @@ func watchPodsForTapping(ctx context.Context, kubernetesProvider *kubernetes.Pro nodeToTappedPodIPMap := getNodeHostToTappedPodIpsMap(state.currentlyTappedPods) if err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error building node to ips map: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error building node to ips map: %v", errormessage.FormatError(err))) cancel() } if err := updateMizuTappers(ctx, kubernetesProvider, nodeToTappedPodIPMap); err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error updating daemonset: %v", errormessage.FormatError(err))) + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error updating daemonset: %v", errormessage.FormatError(err))) cancel() } } @@ -387,13 +390,13 @@ func watchPodsForTapping(ctx context.Context, kubernetesProvider *kubernetes.Pro for { select { case pod := <-added: - mizu.Log.Debugf("Added matching pod %s, ns: %s", pod.Name, pod.Namespace) + logger.Log.Debugf("Added matching pod %s, ns: %s", pod.Name, pod.Namespace) restartTappersDebouncer.SetOn() case pod := <-removed: - mizu.Log.Debugf("Removed matching pod %s, ns: %s", pod.Name, pod.Namespace) + logger.Log.Debugf("Removed matching pod %s, ns: %s", pod.Name, pod.Namespace) restartTappersDebouncer.SetOn() case pod := <-modified: - mizu.Log.Debugf("Modified matching pod %s, ns: %s, phase: %s, ip: %s", pod.Name, pod.Namespace, pod.Status.Phase, pod.Status.PodIP) + logger.Log.Debugf("Modified matching pod %s, ns: %s, phase: %s, ip: %s", pod.Name, pod.Namespace, pod.Status.Phase, pod.Status.PodIP) // Act only if the modified pod has already obtained an IP address. // After filtering for IPs, on a normal pod restart this includes the following events: // - Pod deletion @@ -405,13 +408,13 @@ func watchPodsForTapping(ctx context.Context, kubernetesProvider *kubernetes.Pro } case err := <-errorChan: - mizu.Log.Debugf("Watching pods loop, got error %v, stopping `restart tappers debouncer`", err) + logger.Log.Debugf("Watching pods loop, got error %v, stopping `restart tappers debouncer`", err) restartTappersDebouncer.Cancel() // TODO: Does this also perform cleanup? cancel() case <-ctx.Done(): - mizu.Log.Debugf("Watching pods loop, context done, stopping `restart tappers debouncer`") + logger.Log.Debugf("Watching pods loop, context done, stopping `restart tappers debouncer`") restartTappersDebouncer.Cancel() return } @@ -420,18 +423,18 @@ func watchPodsForTapping(ctx context.Context, kubernetesProvider *kubernetes.Pro func updateCurrentlyTappedPods(kubernetesProvider *kubernetes.Provider, ctx context.Context, targetNamespaces []string) (error, bool) { changeFound := false - if matchingPods, err := kubernetesProvider.ListAllRunningPodsMatchingRegex(ctx, mizu.Config.Tap.PodRegex(), targetNamespaces); err != nil { + if matchingPods, err := kubernetesProvider.ListAllRunningPodsMatchingRegex(ctx, config.Config.Tap.PodRegex(), targetNamespaces); err != nil { return err, false } else { podsToTap := excludeMizuPods(matchingPods) addedPods, removedPods := getPodArrayDiff(state.currentlyTappedPods, podsToTap) for _, addedPod := range addedPods { changeFound = true - mizu.Log.Infof(uiUtils.Green, fmt.Sprintf("+%s", addedPod.Name)) + logger.Log.Infof(uiUtils.Green, fmt.Sprintf("+%s", addedPod.Name)) } for _, removedPod := range removedPods { changeFound = true - mizu.Log.Infof(uiUtils.Red, fmt.Sprintf("-%s", removedPod.Name)) + logger.Log.Infof(uiUtils.Red, fmt.Sprintf("-%s", removedPod.Name)) } state.currentlyTappedPods = podsToTap } @@ -479,86 +482,86 @@ func getMissingPods(pods1 []core.Pod, pods2 []core.Pod) []core.Pod { func createProxyToApiServerPod(ctx context.Context, kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { podExactRegex := regexp.MustCompile(fmt.Sprintf("^%s$", mizu.ApiServerPodName)) - added, modified, removed, errorChan := kubernetes.FilteredWatch(ctx, kubernetesProvider, []string{mizu.Config.MizuResourcesNamespace}, podExactRegex) + added, modified, removed, errorChan := kubernetes.FilteredWatch(ctx, kubernetesProvider, []string{config.Config.MizuResourcesNamespace}, podExactRegex) isPodReady := false timeAfter := time.After(25 * time.Second) for { select { case <-ctx.Done(): - mizu.Log.Debugf("Watching API Server pod loop, ctx done") + logger.Log.Debugf("Watching API Server pod loop, ctx done") return case <-added: - mizu.Log.Debugf("Watching API Server pod loop, added") + logger.Log.Debugf("Watching API Server pod loop, added") continue case <-removed: - mizu.Log.Infof("%s removed", mizu.ApiServerPodName) + logger.Log.Infof("%s removed", mizu.ApiServerPodName) cancel() return case modifiedPod := <-modified: if modifiedPod == nil { - mizu.Log.Debugf("Watching API Server pod loop, modifiedPod with nil") + logger.Log.Debugf("Watching API Server pod loop, modifiedPod with nil") continue } - mizu.Log.Debugf("Watching API Server pod loop, modified: %v", modifiedPod.Status.Phase) + logger.Log.Debugf("Watching API Server pod loop, modified: %v", modifiedPod.Status.Phase) if modifiedPod.Status.Phase == core.PodRunning && !isPodReady { isPodReady = true go startProxyReportErrorIfAny(kubernetesProvider, cancel) - mizu.Log.Infof("Mizu is available at http://%s\n", kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.Tap.GuiPort)) + logger.Log.Infof("Mizu is available at http://%s\n", kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.Tap.GuiPort)) time.Sleep(time.Second * 5) // Waiting to be sure the proxy is ready requestForAnalysis() reportTappedPods() } case <-timeAfter: if !isPodReady { - mizu.Log.Errorf(uiUtils.Error, "Mizu API server was not ready in time") + logger.Log.Errorf(uiUtils.Error, "Mizu API server was not ready in time") cancel() } case <-errorChan: - mizu.Log.Debugf("[ERROR] Agent creation, watching %v namespace", mizu.Config.MizuResourcesNamespace) + logger.Log.Debugf("[ERROR] Agent creation, watching %v namespace", config.Config.MizuResourcesNamespace) cancel() } } } func startProxyReportErrorIfAny(kubernetesProvider *kubernetes.Provider, cancel context.CancelFunc) { - err := kubernetes.StartProxy(kubernetesProvider, mizu.Config.Tap.GuiPort, mizu.Config.MizuResourcesNamespace, mizu.ApiServerPodName) + err := kubernetes.StartProxy(kubernetesProvider, config.Config.Tap.GuiPort, config.Config.MizuResourcesNamespace, mizu.ApiServerPodName) if err != nil { - mizu.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error occured while running k8s proxy %v\n"+ + logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error occured while running k8s proxy %v\n"+ "Try setting different port by using --%s", errormessage.FormatError(err), configStructs.GuiPortTapName)) cancel() } } func requestForAnalysis() { - if !mizu.Config.Tap.Analysis { + if !config.Config.Tap.Analysis { return } - mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.Tap.GuiPort) - urlPath := fmt.Sprintf("http://%s/api/uploadEntries?dest=%s&interval=%v", mizuProxiedUrl, url.QueryEscape(mizu.Config.Tap.AnalysisDestination), mizu.Config.Tap.SleepIntervalSec) + mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.Tap.GuiPort) + urlPath := fmt.Sprintf("http://%s/api/uploadEntries?dest=%s&interval=%v", mizuProxiedUrl, url.QueryEscape(config.Config.Tap.AnalysisDestination), config.Config.Tap.SleepIntervalSec) u, parseErr := url.ParseRequestURI(urlPath) if parseErr != nil { - mizu.Log.Fatal("Failed parsing the URL (consider changing the analysis dest URL), err: %v", parseErr) + logger.Log.Fatal("Failed parsing the URL (consider changing the analysis dest URL), err: %v", parseErr) } - mizu.Log.Debugf("Sending get request to %v", u.String()) + logger.Log.Debugf("Sending get request to %v", u.String()) if response, requestErr := http.Get(u.String()); requestErr != nil { - mizu.Log.Errorf("Failed to notify agent for analysis, err: %v", requestErr) + logger.Log.Errorf("Failed to notify agent for analysis, err: %v", requestErr) } else if response.StatusCode != 200 { - mizu.Log.Errorf("Failed to notify agent for analysis, status code: %v", response.StatusCode) + logger.Log.Errorf("Failed to notify agent for analysis, status code: %v", response.StatusCode) } else { - mizu.Log.Infof(uiUtils.Purple, "Traffic is uploading to UP9 for further analysis") + logger.Log.Infof(uiUtils.Purple, "Traffic is uploading to UP9 for further analysis") } } func createRBACIfNecessary(ctx context.Context, kubernetesProvider *kubernetes.Provider) (bool, error) { - if !mizu.Config.IsNsRestrictedMode() { - err := kubernetesProvider.CreateMizuRBAC(ctx, mizu.Config.MizuResourcesNamespace, mizu.ServiceAccountName, mizu.ClusterRoleName, mizu.ClusterRoleBindingName, mizu.RBACVersion) + if !config.Config.IsNsRestrictedMode() { + err := kubernetesProvider.CreateMizuRBAC(ctx, config.Config.MizuResourcesNamespace, mizu.ServiceAccountName, mizu.ClusterRoleName, mizu.ClusterRoleBindingName, mizu.RBACVersion) if err != nil { return false, err } } else { - err := kubernetesProvider.CreateMizuRBACNamespaceRestricted(ctx, mizu.Config.MizuResourcesNamespace, mizu.ServiceAccountName, mizu.RoleName, mizu.RoleBindingName, mizu.RBACVersion) + err := kubernetesProvider.CreateMizuRBACNamespaceRestricted(ctx, config.Config.MizuResourcesNamespace, mizu.ServiceAccountName, mizu.RoleName, mizu.RoleBindingName, mizu.RBACVersion) if err != nil { return false, err } @@ -593,10 +596,10 @@ func waitForFinish(ctx context.Context, cancel context.CancelFunc) { } func getNamespaces(kubernetesProvider *kubernetes.Provider) []string { - if mizu.Config.Tap.AllNamespaces { + if config.Config.Tap.AllNamespaces { return []string{mizu.K8sAllNamespaces} - } else if len(mizu.Config.Tap.Namespaces) > 0 { - return mizu.Config.Tap.Namespaces + } else if len(config.Config.Tap.Namespaces) > 0 { + return config.Config.Tap.Namespaces } else { return []string{kubernetesProvider.CurrentNamespace()} } diff --git a/cli/cmd/version.go b/cli/cmd/version.go index 45fc6dc9a..7d3f9029d 100644 --- a/cli/cmd/version.go +++ b/cli/cmd/version.go @@ -1,27 +1,30 @@ package cmd import ( + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/telemetry" "strconv" "time" "github.com/creasty/defaults" "github.com/spf13/cobra" "github.com/up9inc/mizu/cli/mizu" - "github.com/up9inc/mizu/cli/mizu/configStructs" ) var versionCmd = &cobra.Command{ Use: "version", Short: "Print version info", RunE: func(cmd *cobra.Command, args []string) error { - go mizu.ReportRun("version", mizu.Config.Version) - if mizu.Config.Version.DebugInfo { + go telemetry.ReportRun("version", config.Config.Version) + if config.Config.Version.DebugInfo { timeStampInt, _ := strconv.ParseInt(mizu.BuildTimestamp, 10, 0) - mizu.Log.Infof("Version: %s \nBranch: %s (%s)", mizu.SemVer, mizu.Branch, mizu.GitCommitHash) - mizu.Log.Infof("Build Time: %s (%s)", mizu.BuildTimestamp, time.Unix(timeStampInt, 0)) + logger.Log.Infof("Version: %s \nBranch: %s (%s)", mizu.SemVer, mizu.Branch, mizu.GitCommitHash) + logger.Log.Infof("Build Time: %s (%s)", mizu.BuildTimestamp, time.Unix(timeStampInt, 0)) } else { - mizu.Log.Infof("Version: %s (%s)", mizu.SemVer, mizu.Branch) + logger.Log.Infof("Version: %s (%s)", mizu.SemVer, mizu.Branch) } return nil }, diff --git a/cli/cmd/view.go b/cli/cmd/view.go index 8f9742e2a..39aaf75b4 100644 --- a/cli/cmd/view.go +++ b/cli/cmd/view.go @@ -3,15 +3,16 @@ package cmd import ( "github.com/creasty/defaults" "github.com/spf13/cobra" - "github.com/up9inc/mizu/cli/mizu" - "github.com/up9inc/mizu/cli/mizu/configStructs" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/telemetry" ) var viewCmd = &cobra.Command{ Use: "view", Short: "Open GUI in browser", RunE: func(cmd *cobra.Command, args []string) error { - go mizu.ReportRun("view", mizu.Config.View) + go telemetry.ReportRun("view", config.Config.View) runMizuView() return nil }, diff --git a/cli/cmd/viewRunner.go b/cli/cmd/viewRunner.go index 4b2b11a0d..b85384efd 100644 --- a/cli/cmd/viewRunner.go +++ b/cli/cmd/viewRunner.go @@ -3,46 +3,49 @@ package cmd import ( "context" "fmt" + "github.com/up9inc/mizu/cli/config" "github.com/up9inc/mizu/cli/kubernetes" + "github.com/up9inc/mizu/cli/logger" "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/mizu/version" "net/http" ) func runMizuView() { - kubernetesProvider, err := kubernetes.NewProvider(mizu.Config.View.KubeConfigPath) + kubernetesProvider, err := kubernetes.NewProvider(config.Config.View.KubeConfigPath) if err != nil { - mizu.Log.Error(err) + logger.Log.Error(err) return } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - exists, err := kubernetesProvider.DoesServicesExist(ctx, mizu.Config.MizuResourcesNamespace, mizu.ApiServerPodName) + exists, err := kubernetesProvider.DoesServicesExist(ctx, config.Config.MizuResourcesNamespace, mizu.ApiServerPodName) if err != nil { - mizu.Log.Errorf("Failed to found mizu service %v", err) + logger.Log.Errorf("Failed to found mizu service %v", err) cancel() return } if !exists { - mizu.Log.Infof("%s service not found, you should run `mizu tap` command first", mizu.ApiServerPodName) + logger.Log.Infof("%s service not found, you should run `mizu tap` command first", mizu.ApiServerPodName) cancel() return } - mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.View.GuiPort) + mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.View.GuiPort) _, err = http.Get(fmt.Sprintf("http://%s/", mizuProxiedUrl)) if err == nil { - mizu.Log.Infof("Found a running service %s and open port %d", mizu.ApiServerPodName, mizu.Config.View.GuiPort) + logger.Log.Infof("Found a running service %s and open port %d", mizu.ApiServerPodName, config.Config.View.GuiPort) return } - mizu.Log.Debugf("Found service %s, creating k8s proxy", mizu.ApiServerPodName) + logger.Log.Debugf("Found service %s, creating k8s proxy", mizu.ApiServerPodName) go startProxyReportErrorIfAny(kubernetesProvider, cancel) - mizu.Log.Infof("Mizu is available at http://%s\n", kubernetes.GetMizuApiServerProxiedHostAndPath(mizu.Config.View.GuiPort)) - if isCompatible, err := mizu.CheckVersionCompatibility(mizu.Config.View.GuiPort); err != nil { - mizu.Log.Errorf("Failed to check versions compatibility %v", err) + logger.Log.Infof("Mizu is available at http://%s\n", kubernetes.GetMizuApiServerProxiedHostAndPath(config.Config.View.GuiPort)) + if isCompatible, err := version.CheckVersionCompatibility(config.Config.View.GuiPort); err != nil { + logger.Log.Errorf("Failed to check versions compatibility %v", err) cancel() return } else if !isCompatible { diff --git a/cli/mizu/config.go b/cli/config/config.go similarity index 85% rename from cli/mizu/config.go rename to cli/config/config.go index e8cbeea92..43c4749e6 100644 --- a/cli/mizu/config.go +++ b/cli/config/config.go @@ -1,8 +1,11 @@ -package mizu +package config import ( "errors" "fmt" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu" "io/ioutil" "os" "path" @@ -13,7 +16,6 @@ import ( "github.com/creasty/defaults" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/up9inc/mizu/cli/mizu/configStructs" "github.com/up9inc/mizu/cli/uiUtils" "gopkg.in/yaml.v3" ) @@ -62,7 +64,7 @@ func InitConfig(cmd *cobra.Command) error { cmd.Flags().Visit(initFlag) finalConfigPrettified, _ := uiUtils.PrettyJson(Config) - Log.Debugf("Init config finished\n Final config: %v", finalConfigPrettified) + logger.Log.Debugf("Init config finished\n Final config: %v", finalConfigPrettified) return nil } @@ -80,7 +82,7 @@ func GetConfigWithDefaults() (string, error) { } func GetConfigFilePath() string { - return path.Join(GetMizuFolderPath(), "config.yaml") + return path.Join(mizu.GetMizuFolderPath(), "config.yaml") } func mergeConfigFile() error { @@ -97,7 +99,7 @@ func mergeConfigFile() error { if err := yaml.Unmarshal(buf, &Config); err != nil { return err } - Log.Debugf("Found config file, merged to default options") + logger.Log.Debugf("Found config file, merged to default options") return nil } @@ -124,13 +126,13 @@ func mergeSetFlag(configElem reflect.Value, setValues []string) { for _, setValue := range setValues { if !strings.Contains(setValue, Separator) { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) continue } split := strings.SplitN(setValue, Separator, 2) if len(split) != 2 { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) continue } @@ -140,8 +142,8 @@ func mergeSetFlag(configElem reflect.Value, setValues []string) { } for argumentKey, argumentValues := range setMap { - if !Contains(allowedSetFlags, argumentKey) { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument name \"%s\", flag name must be one of the following: \"%s\"", argumentKey, strings.Join(allowedSetFlags, "\", \""))) + if !mizu.Contains(allowedSetFlags, argumentKey) { + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument name \"%s\", flag name must be one of the following: \"%s\"", argumentKey, strings.Join(allowedSetFlags, "\", \""))) continue } @@ -175,7 +177,7 @@ func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string) parsedValue, err := getParsedValue(currentFieldKind, flagValue) if err != nil { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, currentFieldKind)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, currentFieldKind)) return } @@ -199,7 +201,7 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str } if currentFieldKind != reflect.Slice { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagKey, currentFieldKind)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagKey, currentFieldKind)) return } @@ -209,7 +211,7 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str for _, flagValue := range flagValues { parsedValue, err := getParsedValue(flagValueKind, flagValue) if err != nil { - Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) + logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) return } diff --git a/cli/mizu/configStruct.go b/cli/config/configStruct.go similarity index 91% rename from cli/mizu/configStruct.go rename to cli/config/configStruct.go index a4e81bb90..c8947cf52 100644 --- a/cli/mizu/configStruct.go +++ b/cli/config/configStruct.go @@ -1,9 +1,9 @@ -package mizu +package config import ( "fmt" - - "github.com/up9inc/mizu/cli/mizu/configStructs" + "github.com/up9inc/mizu/cli/config/configStructs" + "github.com/up9inc/mizu/cli/mizu" ) const ( @@ -27,7 +27,7 @@ type ConfigStruct struct { } func (config *ConfigStruct) SetDefaults() { - config.AgentImage = fmt.Sprintf("gcr.io/up9-docker-hub/mizu/%s:%s", Branch, SemVer) + config.AgentImage = fmt.Sprintf("gcr.io/up9-docker-hub/mizu/%s:%s", mizu.Branch, mizu.SemVer) } func (config *ConfigStruct) IsNsRestrictedMode() bool { diff --git a/cli/mizu/configStructs/fetchConfig.go b/cli/config/configStructs/fetchConfig.go similarity index 100% rename from cli/mizu/configStructs/fetchConfig.go rename to cli/config/configStructs/fetchConfig.go diff --git a/cli/mizu/configStructs/tapConfig.go b/cli/config/configStructs/tapConfig.go similarity index 100% rename from cli/mizu/configStructs/tapConfig.go rename to cli/config/configStructs/tapConfig.go diff --git a/cli/mizu/configStructs/versionConfig.go b/cli/config/configStructs/versionConfig.go similarity index 100% rename from cli/mizu/configStructs/versionConfig.go rename to cli/config/configStructs/versionConfig.go diff --git a/cli/mizu/configStructs/viewConfig.go b/cli/config/configStructs/viewConfig.go similarity index 100% rename from cli/mizu/configStructs/viewConfig.go rename to cli/config/configStructs/viewConfig.go diff --git a/cli/mizu/config_test.go b/cli/config/config_test.go similarity index 72% rename from cli/mizu/config_test.go rename to cli/config/config_test.go index f1f9edb8a..286d497b4 100644 --- a/cli/mizu/config_test.go +++ b/cli/config/config_test.go @@ -1,7 +1,7 @@ -package mizu_test +package config_test import ( - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/config" "reflect" "strings" "testing" @@ -10,10 +10,10 @@ import ( func TestConfigWriteIgnoresReadonlyFields(t *testing.T) { var readonlyFields []string - configElem := reflect.ValueOf(&mizu.ConfigStruct{}).Elem() + configElem := reflect.ValueOf(&config.ConfigStruct{}).Elem() getFieldsWithReadonlyTag(configElem, &readonlyFields) - config, _ := mizu.GetConfigWithDefaults() + config, _ := config.GetConfigWithDefaults() for _, readonlyField := range readonlyFields { if strings.Contains(config, readonlyField) { t.Errorf("unexpected result - readonly field: %v, config: %v", readonlyField, config) @@ -31,8 +31,8 @@ func getFieldsWithReadonlyTag(currentElem reflect.Value, readonlyFields *[]strin continue } - if _, ok := currentField.Tag.Lookup(mizu.ReadonlyTag); ok { - fieldNameByTag := strings.Split(currentField.Tag.Get(mizu.FieldNameTag), ",")[0] + if _, ok := currentField.Tag.Lookup(config.ReadonlyTag); ok { + fieldNameByTag := strings.Split(currentField.Tag.Get(config.FieldNameTag), ",")[0] *readonlyFields = append(*readonlyFields, fieldNameByTag) } } diff --git a/cli/errormessage/errormessage.go b/cli/errormessage/errormessage.go index 1268f835d..7f9caac0d 100644 --- a/cli/errormessage/errormessage.go +++ b/cli/errormessage/errormessage.go @@ -3,9 +3,7 @@ package errormessage import ( "errors" "fmt" - - "github.com/up9inc/mizu/cli/mizu" - + "github.com/up9inc/mizu/cli/config" regexpsyntax "regexp/syntax" k8serrors "k8s.io/apimachinery/pkg/api/errors" @@ -20,9 +18,9 @@ func FormatError(err error) error { "supply the required permission or control Mizu's access to namespaces by setting %s "+ "in the config file or setting the tapped namespace with --%s %s=", err, - mizu.MizuResourcesNamespaceConfigName, - mizu.SetCommandName, - mizu.MizuResourcesNamespaceConfigName) + config.MizuResourcesNamespaceConfigName, + config.SetCommandName, + config.MizuResourcesNamespaceConfigName) } else if syntaxError, isSyntaxError := asRegexSyntaxError(err); isSyntaxError { errorNew = fmt.Errorf("regex %s is invalid: %w", syntaxError.Expr, err) } else { diff --git a/cli/fsUtils/mizuLogsUtils.go b/cli/fsUtils/mizuLogsUtils.go deleted file mode 100644 index b48cc2aa6..000000000 --- a/cli/fsUtils/mizuLogsUtils.go +++ /dev/null @@ -1,58 +0,0 @@ -package fsUtils - -import ( - "archive/zip" - "context" - "fmt" - "github.com/up9inc/mizu/cli/kubernetes" - "github.com/up9inc/mizu/cli/mizu" - "os" - "regexp" -) - -func DumpLogs(provider *kubernetes.Provider, ctx context.Context, filePath string) error { - podExactRegex := regexp.MustCompile("^" + mizu.MizuResourcesPrefix) - pods, err := provider.ListAllPodsMatchingRegex(ctx, podExactRegex, []string{mizu.Config.MizuResourcesNamespace}) - if err != nil { - return err - } - - if len(pods) == 0 { - return fmt.Errorf("no mizu pods found in namespace %s", mizu.Config.MizuResourcesNamespace) - } - - newZipFile, err := os.Create(filePath) - if err != nil { - return err - } - defer newZipFile.Close() - zipWriter := zip.NewWriter(newZipFile) - defer zipWriter.Close() - - for _, pod := range pods { - logs, err := provider.GetPodLogs(pod.Namespace, pod.Name, ctx) - if err != nil { - mizu.Log.Errorf("Failed to get logs, %v", err) - continue - } else { - mizu.Log.Debugf("Successfully read log length %d for pod: %s.%s", len(logs), pod.Namespace, pod.Name) - } - if err := AddStrToZip(zipWriter, logs, fmt.Sprintf("%s.%s.log", pod.Namespace, pod.Name)); err != nil { - mizu.Log.Errorf("Failed write logs, %v", err) - } else { - mizu.Log.Infof("Successfully added log length %d from pod: %s.%s", len(logs), pod.Namespace, pod.Name) - } - } - if err := AddFileToZip(zipWriter, mizu.GetConfigFilePath()); err != nil { - mizu.Log.Debugf("Failed write file, %v", err) - } else { - mizu.Log.Infof("Successfully added file %s", mizu.GetConfigFilePath()) - } - if err := AddFileToZip(zipWriter, mizu.GetLogFilePath()); err != nil { - mizu.Log.Debugf("Failed write file, %v", err) - } else { - mizu.Log.Infof("Successfully added file %s", mizu.GetLogFilePath()) - } - mizu.Log.Infof("You can find the zip with all logs in %s\n", filePath) - return nil -} diff --git a/cli/kubernetes/provider.go b/cli/kubernetes/provider.go index 221895ad4..91f745b98 100644 --- a/cli/kubernetes/provider.go +++ b/cli/kubernetes/provider.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/up9inc/mizu/cli/logger" "os" "path/filepath" "regexp" @@ -562,7 +563,7 @@ func (provider *Provider) CreateConfigMap(ctx context.Context, namespace string, } func (provider *Provider) ApplyMizuTapperDaemonSet(ctx context.Context, namespace string, daemonSetName string, podImage string, tapperPodName string, apiServerPodIp string, nodeToTappedPodIPMap map[string][]string, serviceAccountName string, tapOutgoing bool) error { - mizu.Log.Debugf("Applying %d tapper deamonsets, ns: %s, daemonSetName: %s, podImage: %s, tapperPodName: %s", len(nodeToTappedPodIPMap), namespace, daemonSetName, podImage, tapperPodName) + logger.Log.Debugf("Applying %d tapper deamonsets, ns: %s, daemonSetName: %s, podImage: %s, tapperPodName: %s", len(nodeToTappedPodIPMap), namespace, daemonSetName, podImage, tapperPodName) if len(nodeToTappedPodIPMap) == 0 { return fmt.Errorf("Daemon set %s must tap at least 1 pod", daemonSetName) @@ -745,7 +746,7 @@ func loadKubernetesConfiguration(kubeConfigPath string) clientcmd.ClientConfig { kubeConfigPath = filepath.Join(home, ".kube", "config") } - mizu.Log.Debugf("Using kube config %s", kubeConfigPath) + logger.Log.Debugf("Using kube config %s", kubeConfigPath) configPathList := filepath.SplitList(kubeConfigPath) configLoadingRules := &clientcmd.ClientConfigLoadingRules{} if len(configPathList) <= 1 { diff --git a/cli/kubernetes/proxy.go b/cli/kubernetes/proxy.go index 5ca2eab16..44397bdc4 100644 --- a/cli/kubernetes/proxy.go +++ b/cli/kubernetes/proxy.go @@ -2,7 +2,7 @@ package kubernetes import ( "fmt" - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/logger" "k8s.io/kubectl/pkg/proxy" "net" "net/http" @@ -14,7 +14,7 @@ const k8sProxyApiPrefix = "/" const mizuServicePort = 80 func StartProxy(kubernetesProvider *Provider, mizuPort uint16, mizuNamespace string, mizuServiceName string) error { - mizu.Log.Debugf("Starting proxy. namespace: [%v], service name: [%s], port: [%v]", mizuNamespace, mizuServiceName, mizuPort) + logger.Log.Debugf("Starting proxy. namespace: [%v], service name: [%s], port: [%v]", mizuNamespace, mizuServiceName, mizuPort) filter := &proxy.FilterServer{ AcceptPaths: proxy.MakeRegexpArrayOrDie(proxy.DefaultPathAcceptRE), RejectPaths: proxy.MakeRegexpArrayOrDie(proxy.DefaultPathRejectRE), diff --git a/cli/mizu/logger.go b/cli/logger/logger.go similarity index 83% rename from cli/mizu/logger.go rename to cli/logger/logger.go index 251922263..050ccb36d 100644 --- a/cli/mizu/logger.go +++ b/cli/logger/logger.go @@ -1,7 +1,8 @@ -package mizu +package logger import ( "github.com/op/go-logging" + "github.com/up9inc/mizu/cli/mizu" "os" "path" ) @@ -13,7 +14,7 @@ var format = logging.MustStringFormatter( ) func GetLogFilePath() string { - return path.Join(GetMizuFolderPath(), "mizu_cli.log") + return path.Join(mizu.GetMizuFolderPath(), "mizu_cli.log") } func InitLogger() { @@ -34,5 +35,5 @@ func InitLogger() { logging.SetBackend(backend1Leveled, backend2Formatter) Log.Debugf("\n\n\n") - Log.Debugf("Running mizu version %v", SemVer) + Log.Debugf("Running mizu version %v", mizu.SemVer) } diff --git a/cli/mizu.go b/cli/mizu.go index 6dc698567..05d692c66 100644 --- a/cli/mizu.go +++ b/cli/mizu.go @@ -2,7 +2,7 @@ package main import ( "github.com/up9inc/mizu/cli/cmd" - "github.com/up9inc/mizu/cli/goUtils" + "github.com/up9inc/mizu/cli/mizu/goUtils" ) func main() { diff --git a/cli/fsUtils/dirUtils.go b/cli/mizu/fsUtils/dirUtils.go similarity index 100% rename from cli/fsUtils/dirUtils.go rename to cli/mizu/fsUtils/dirUtils.go diff --git a/cli/mizu/fsUtils/mizuLogsUtils.go b/cli/mizu/fsUtils/mizuLogsUtils.go new file mode 100644 index 000000000..e063fb84c --- /dev/null +++ b/cli/mizu/fsUtils/mizuLogsUtils.go @@ -0,0 +1,60 @@ +package fsUtils + +import ( + "archive/zip" + "context" + "fmt" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/kubernetes" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu" + "os" + "regexp" +) + +func DumpLogs(provider *kubernetes.Provider, ctx context.Context, filePath string) error { + podExactRegex := regexp.MustCompile("^" + mizu.MizuResourcesPrefix) + pods, err := provider.ListAllPodsMatchingRegex(ctx, podExactRegex, []string{config.Config.MizuResourcesNamespace}) + if err != nil { + return err + } + + if len(pods) == 0 { + return fmt.Errorf("no mizu pods found in namespace %s", config.Config.MizuResourcesNamespace) + } + + newZipFile, err := os.Create(filePath) + if err != nil { + return err + } + defer newZipFile.Close() + zipWriter := zip.NewWriter(newZipFile) + defer zipWriter.Close() + + for _, pod := range pods { + logs, err := provider.GetPodLogs(pod.Namespace, pod.Name, ctx) + if err != nil { + logger.Log.Errorf("Failed to get logs, %v", err) + continue + } else { + logger.Log.Debugf("Successfully read log length %d for pod: %s.%s", len(logs), pod.Namespace, pod.Name) + } + if err := AddStrToZip(zipWriter, logs, fmt.Sprintf("%s.%s.log", pod.Namespace, pod.Name)); err != nil { + logger.Log.Errorf("Failed write logs, %v", err) + } else { + logger.Log.Infof("Successfully added log length %d from pod: %s.%s", len(logs), pod.Namespace, pod.Name) + } + } + if err := AddFileToZip(zipWriter, config.GetConfigFilePath()); err != nil { + logger.Log.Debugf("Failed write file, %v", err) + } else { + logger.Log.Infof("Successfully added file %s", config.GetConfigFilePath()) + } + if err := AddFileToZip(zipWriter, logger.GetLogFilePath()); err != nil { + logger.Log.Debugf("Failed write file, %v", err) + } else { + logger.Log.Infof("Successfully added file %s", logger.GetLogFilePath()) + } + logger.Log.Infof("You can find the zip with all logs in %s\n", filePath) + return nil +} diff --git a/cli/fsUtils/zipUtils.go b/cli/mizu/fsUtils/zipUtils.go similarity index 100% rename from cli/fsUtils/zipUtils.go rename to cli/mizu/fsUtils/zipUtils.go diff --git a/cli/goUtils/funcWrappers.go b/cli/mizu/goUtils/funcWrappers.go similarity index 81% rename from cli/goUtils/funcWrappers.go rename to cli/mizu/goUtils/funcWrappers.go index ad71b684b..fd1f5b1ce 100644 --- a/cli/goUtils/funcWrappers.go +++ b/cli/mizu/goUtils/funcWrappers.go @@ -1,7 +1,7 @@ package goUtils import ( - "github.com/up9inc/mizu/cli/mizu" + "github.com/up9inc/mizu/cli/logger" "reflect" "runtime/debug" ) @@ -10,7 +10,7 @@ func HandleExcWrapper(fn interface{}, params ...interface{}) (result []reflect.V defer func() { if panicMessage := recover(); panicMessage != nil { stack := debug.Stack() - mizu.Log.Fatalf("Unhandled panic: %v\n stack: %s", panicMessage, stack) + logger.Log.Fatalf("Unhandled panic: %v\n stack: %s", panicMessage, stack) } }() f := reflect.ValueOf(fn) diff --git a/cli/mizu/versionCheck.go b/cli/mizu/version/versionCheck.go similarity index 62% rename from cli/mizu/versionCheck.go rename to cli/mizu/version/versionCheck.go index 74a5c84e3..8ac0a356d 100644 --- a/cli/mizu/versionCheck.go +++ b/cli/mizu/version/versionCheck.go @@ -1,9 +1,11 @@ -package mizu +package version import ( "context" "encoding/json" "fmt" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu" "io/ioutil" "net/http" "net/url" @@ -41,22 +43,22 @@ func CheckVersionCompatibility(port uint16) (bool, error) { return false, err } - if semver.SemVersion(apiSemVer).Major() == semver.SemVersion(SemVer).Major() && - semver.SemVersion(apiSemVer).Minor() == semver.SemVersion(SemVer).Minor() { + if semver.SemVersion(apiSemVer).Major() == semver.SemVersion(mizu.SemVer).Major() && + semver.SemVersion(apiSemVer).Minor() == semver.SemVersion(mizu.SemVer).Minor() { return true, nil } - Log.Errorf(uiUtils.Red, fmt.Sprintf("cli version (%s) is not compatible with api version (%s)", SemVer, apiSemVer)) + logger.Log.Errorf(uiUtils.Red, fmt.Sprintf("cli version (%s) is not compatible with api version (%s)", mizu.SemVer, apiSemVer)) return false, nil } func CheckNewerVersion() { - Log.Debugf("Checking for newer version...") + logger.Log.Debugf("Checking for newer version...") start := time.Now() client := github.NewClient(nil) latestRelease, _, err := client.Repositories.GetLatestRelease(context.Background(), "up9inc", "mizu") if err != nil { - Log.Debugf("[ERROR] Failed to get latest release") + logger.Log.Debugf("[ERROR] Failed to get latest release") return } @@ -68,26 +70,26 @@ func CheckNewerVersion() { } } if versionFileUrl == "" { - Log.Debugf("[ERROR] Version file not found in the latest release") + logger.Log.Debugf("[ERROR] Version file not found in the latest release") return } res, err := http.Get(versionFileUrl) if err != nil { - Log.Debugf("[ERROR] Failed to get the version file %v", err) + logger.Log.Debugf("[ERROR] Failed to get the version file %v", err) return } data, err := ioutil.ReadAll(res.Body) res.Body.Close() if err != nil { - Log.Debugf("[ERROR] Failed to read the version file -> %v", err) + logger.Log.Debugf("[ERROR] Failed to read the version file -> %v", err) return } gitHubVersion := string(data) gitHubVersion = gitHubVersion[:len(gitHubVersion)-1] - Log.Debugf("Finished version validation, took %v", time.Since(start)) - if SemVer < gitHubVersion { - Log.Infof(uiUtils.Yellow, fmt.Sprintf("Update available! %v -> %v (%v)", SemVer, gitHubVersion, *latestRelease.HTMLURL)) + logger.Log.Debugf("Finished version validation, took %v", time.Since(start)) + if mizu.SemVer < gitHubVersion { + logger.Log.Infof(uiUtils.Yellow, fmt.Sprintf("Update available! %v -> %v (%v)", mizu.SemVer, gitHubVersion, *latestRelease.HTMLURL)) } } diff --git a/cli/mizu/telemetry.go b/cli/telemetry/telemetry.go similarity index 52% rename from cli/mizu/telemetry.go rename to cli/telemetry/telemetry.go index cb108cd43..6dd438877 100644 --- a/cli/mizu/telemetry.go +++ b/cli/telemetry/telemetry.go @@ -1,22 +1,25 @@ -package mizu +package telemetry import ( "bytes" "encoding/json" "fmt" + "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/mizu" "net/http" ) const telemetryUrl = "https://us-east4-up9-prod.cloudfunctions.net/mizu-telemetry" func ReportRun(cmd string, args interface{}) { - if !Config.Telemetry { - Log.Debugf("not reporting due to config value") + if !config.Config.Telemetry { + logger.Log.Debugf("not reporting due to config value") return } - if Branch != "main" && Branch != "develop" { - Log.Debugf("not reporting telemetry on private branches") + if mizu.Branch != "main" && mizu.Branch != "develop" { + logger.Log.Debugf("not reporting telemetry on private branches") } argsBytes, _ := json.Marshal(args) @@ -25,16 +28,16 @@ func ReportRun(cmd string, args interface{}) { "cmd": cmd, "args": string(argsBytes), "component": "mizu_cli", - "BuildTimestamp": BuildTimestamp, - "Branch": Branch, - "version": SemVer} + "BuildTimestamp": mizu.BuildTimestamp, + "Branch": mizu.Branch, + "version": mizu.SemVer} argsMap["message"] = fmt.Sprintf("mizu %v - %v", argsMap["cmd"], string(argsBytes)) jsonValue, _ := json.Marshal(argsMap) if resp, err := http.Post(telemetryUrl, "application/json", bytes.NewBuffer(jsonValue)); err != nil { - Log.Debugf("error sending telemetry err: %v, response %v", err, resp) + logger.Log.Debugf("error sending telemetry err: %v, response %v", err, resp) } else { - Log.Debugf("Successfully reported telemetry") + logger.Log.Debugf("Successfully reported telemetry") } } From 8a8cf4aa774465f68a67e0ebf6244a9ebb641fab Mon Sep 17 00:00:00 2001 From: Alex Haiut Date: Wed, 11 Aug 2021 09:59:14 +0300 Subject: [PATCH 15/43] Feature/testing contributing doc (#197) --- CONTRIBUTE.md | 18 ++++++++ README.md | 21 ++++++--- TESTING.md | 15 +++++++ assets/validation-example1.png | Bin 0 -> 56018 bytes assets/validation-example2.png | Bin 0 -> 44017 bytes docs/POLICY_RULES.md | 78 +++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 CONTRIBUTE.md create mode 100644 TESTING.md create mode 100644 assets/validation-example1.png create mode 100644 assets/validation-example2.png create mode 100644 docs/POLICY_RULES.md diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md new file mode 100644 index 000000000..21652cca5 --- /dev/null +++ b/CONTRIBUTE.md @@ -0,0 +1,18 @@ +![Mizu: The API Traffic Viewer for Kubernetes](assets/mizu-logo.svg) +# CONTRIBUTE +We welcome code contributions from the community. +Please read and follow the guidelines below. + +## Communication +* Before starting work on a major feature, please reach out to us via [GitHub](https://github.com/up9inc/mizu), [Slack](https://join.slack.com/share/zt-u6bbs3pg-X1zhQOXOH0yEoqILgH~csw), [email](mailto:mizu@up9.com), etc. We will make sure no one else is already working on it. A _major feature_ is defined as any change that is > 100 LOC altered (not including tests), or changes any user-facing behavior +* Small patches and bug fixes don't need prior communication. + +## Contribution requirements +* Code style - most of the code is written in Go, please follow [these guidelines](https://golang.org/doc/effective_go) +* Go-tools compatible (`go get`, `go test`, etc) +* Unit-test coverage can’t go down .. +* Code must be usefully commented. Not only for developers on the project, but also for external users of these packages +* When reviewing PRs, you are encouraged to use Golang's [code review comments page](https://github.com/golang/go/wiki/CodeReviewComments) + + + diff --git a/README.md b/README.md index 088df71af..e540401a4 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ To generate a new config file with default values use `mizu config -r` ### Telemetry -By default, mizu reports usage telemetry. It can be disabled by adding a line of telemetry: false in the ${HOME}/.mizu/config.yaml file +By default, mizu reports usage telemetry. It can be disabled by adding a line of `telemetry: false` in the `${HOME}/.mizu/config.yaml` file + ## Advanced Usage @@ -135,14 +136,22 @@ Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior ### User agent filtering -User-agent filtering (like health checks) - can be configured: +User-agent filtering (like health checks) - can be configured using command-line options: -Any request that contains one of those values in the user-agent header will not be captured - -```bash +```shell $ mizu tap "^ca.*" --set ignored-user-agents=kube-probe --set ignored-user-agents=prometheus +carts-66c77f5fbb-fq65r +catalogue-5f4cb7cf5-7zrmn Web interface is now available at http://localhost:8899 ^C -``` \ No newline at end of file + +``` +Any request that contains `User-Agent` header with one of the specified values (`kube-probe` or `prometheus`) will not be captured + +### API Rules validation + +This feature allows you to define set of simple rules, and test the API against them. +Such validation may test response for specific JSON fields, headers, etc. + +Please see [API RULES](docs/POLICY_RULES.md) page for more details and syntax. + diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 000000000..a9e7b793b --- /dev/null +++ b/TESTING.md @@ -0,0 +1,15 @@ +![Mizu: The API Traffic Viewer for Kubernetes](assets/mizu-logo.svg) +# TESTING +Testing guidelines for Mizu project + +## Unit-tests +* TBD +* TBD +* TBD + + + +## System tests +* TBD +* TBD +* TBD diff --git a/assets/validation-example1.png b/assets/validation-example1.png new file mode 100644 index 0000000000000000000000000000000000000000..cac14b6d20b1c005e21415498b9a4f3cff074564 GIT binary patch literal 56018 zcmaI71y~&0wl&&VaCdii65QS0-66OKcbA}z1y68y*WeyJxVr^{>+9_7b9VN<_kU1) zT~yVwIajSY*BGNCl@ufq;qc%9005%2l$Z(t0D%esfN8+I0bRi=K*<0A;MS}}MU|vQ zMM;#L9W1PD%>e+Z$mCRLwWMLJfc@7QQIP-uxQfVm2UI+o7ob2>8CIGkFC+~Gqc4o1 za-)=lENDbULmWd-A52@kF9;J1{su}L1GsO}6E^Y~DEWQWea(A+HFNlR;`w}~#eHIo z+Z{miSQqm3ofcAnt8gLB*klqGI=*BtA_U(hIp8hHq)BvWOnf{^z)i||18<-HG@JVYKTj1yGF*U}gZ_Nm>lbYygV4eF~!{)Z|W5GwfQhTs~l5TVUF1JQDp@w`!@h1hCnYjb_FBDmmUeN@V@pAfQQZ8-v-v-_8MTc6IpS-%*yfoz(N$<_G z2)&7!Q6Il=d+! ze5XM5)+DyYBs}h=CqvCdJlckz&2m;x1O{SP1eOu~Ec+Ein*K<>5@i-ej)GV$TuO>`sfLjZN4a*z|fFdlZv- z4o%OVZV0g=Rq?m~lrzgT0hc;4E{3%Kw1RR-R9FozvWEi+CMX2)(v2TN%H#Tpt)=X3 z5&XpnibeF@aJw8HM2npso@#d^%a=v1MJ)&xUw6y6-sS3aURc_u$z`^qc+)4M7Uuq+ zjeRNr9XNoJCyT?(GBSXYIABkqQ4Gj#BdYjn3_w+iM^F!>`UWM@P4FE6y#dY_fD8lZ zAp!3T+6_STAR&zCri+K?2m)7v{1Bj@3$7NZ;|N0lH7E?vg*Xu?yMcEA=H4yJ0toAo zWkFgCmWBy(M8W+mQW}qykDM;TfkNmU?2}7EgAf&pmTM*jkj~Y6kFExpEQG1Vu7>=K zkQFo`teIPgM0ATY&CY*EBHbuc$xRxNr?%_q*?Za5NKC2F7)d z|KPsE^MNJ`F#L*66ObrO`=wrakA`Oi9y2CNlrm4O*sWMZ4Y%~oT!gi#Tb_O18zs|X z#rF)Kq6+iIrHg?f8w zW9I3?>53FkiLk@b7>bGUjR{DJJW{vP4#Q@(V2i;7k`ojTR68Y?^OrrB zhnKp$$fJe{#LC^YI5c-Oju@Ai)|eCY2+GQ8`lZ;(@~dJMUkvh*=uI#YG1H@Hqr{^m zG21W)lQDoNz|TMpdUd)iWjrMbT6~(f$uqzvdScDavM0~L0q_Cvvmi!zJnSpRyWCo( ze$mh}mE!4qXUW|{bRdW(GYEpG^oi ztyM98Ip2?76%TILu!!=Af{1woL?3_RnSL7iMD&UFQ^BXxPhWJ@7&#d`HA?2SE3n^D z;svKj&==|FezYmGd9;zOFKcLPpmsxWBXhfS{p3n?jOKROD&1Ppn$^1PrQ;QS?R<@Q zMRb6Eb@;&dy!xR3K#YKZ_!*G{zaA$Brxb?`(FFk?ffzU8k3>$Tcz zXX|3>7#dG%pDfHBk}cFNufOT^@O1M`wvIo1{1}P2TsXbg$hbJa@S(%5?9_%=mA7)$ zug&{L?XmwR{#_Gf6Y=Vsm4;RAGxjq@eph}jB51zs zw=uZ1G2}6t*&5kolS$dc?U^0(UTz*=+MnCuJKB6Pe8_z1e6sJ3?x-F)?qVMoA1f}7 ze+=G1KVU!JzGY9dwcvN*>t;2w>kbuCG4e1H-1U>M_J;4G)l#Oow`a=d$lnT$9^MJE49MwyVM3@pAY zekewsTAQMrf?!QywK1*iFYUiH)!lYKnXtn*D;=)EsyW_R9YTs9+6QrKlw*IsJaG4!hXPK}MOlymJGW3iLzD+j$mGtHx z%+=c>+bG+hM}kLmZx2ovYxZTKs)f)?G5ek=mHlrSs(VqyFY_hik`d;XZTinKb; zN$Ajc!*yMM&2(gU?sCrSKKeLcuspWBaK_Lw}t`z(ori zzzLnO#)So-n@&Yfow%8Q3qIZnZ2|K{oqgJ6*#{P|GLpX@akb$m(~?&r5p{4jC*fdXVPYW@fFmIx z;d3^#;878i_`5jhKYp_JuC9(e%*^iY?o94)nH-!gnOV8HxtUqmnAzAEK~FHcc-p%f zdobF&kpHgaKlO;2yO=s#Il5Xo*pvLK*Vx3t&6S^w>{mno{rTNba}TS3TC#Wfdsv_W zGXJ{6%*w>V{C^d5wX*ns6#I4Ocd@_5^}9K~UzPDFS$UY-YKvLffvgJZn!ww4Y3;xxl);~>hf>Q54n*8I=A0>Ycfk)BV z3e=kMFB=N5@-hFfYkxn_$NbB{e;E9?H~+c{a;E?sAM<~wh5($S)LAD0AOw&W6ISy8 zJIR9fQ4?PnKo0o^o3{XfMG=uCd1o~p{Z5z%Co15;#uJl)W<09%Arey!g*He=5=@v! z1x?5ck^L&5UPuajC&TjV>Wb}$Ui-w=kRJs8ZQi5xkA`Ct>8eyS_dk|vL`cEVNd7p6 zSRgOG%$*MF%syBh+h#&a1^jW4^rGgXC2V%82%x~m1O7RhQvk^@3zz1{6hh$t9Pyhl zN|8g3hP1>Hf7B5GsV5sC6Jm2;>-O)49U+Uw%$lo!$YOugAp~AYDMb0T!Do@@pXNYK zn{k0FP{;EVILQ=-GA5sD-i%hXP2nQJ{EwzoWJro6IXw9c1Fo*Dm|t3C3+h{3HP>k= z!QuLTtma6~uHuXa6P#Us*-eA~r{jMOf-J%P^Uh@T_+`#&&PeK;pHq7nTz>VejTpu&uWKIAdM25-8kt)`J(ShGgZjOFg@k|Wg3bf@6jCTsvoAr zt~QDU+|M%0_1iRJ33;hlSuce5kt@?{(Xz9%xAym`vP5Pq>%30^B{%|%47&NR> zq$3Kj!$gUsP$7%1LZ>0IQol`RzFhkhQ~vsRYHF(1W`Sw-wL1`^L@J-hm_zE19vDRf zR?}^jowo28P?Si(As|*ug^VJUw7xMv406%@%bpuZ;A&(lQn*ZVb7h)}#`Lshu`jGh zaR2gYb1)z|0z*Xtp)Xi3jf{+pg5g9C&b!u-@R^z#YkRH3yY~GQvGeVjev1dIUZFxd zX$B|s^7&98ujOQRDrIykw^E|`-_w=k8dk3vcu~8NFF@HS1x`yXL`k-baM~CU>Dg5I zT;NdyC{e44ovoPBqw;&HD0uqWYna5>B1_?Qy40X~G-sD^z%$bMbeWOzK+FHV)h(m_ zb**S?bCXoj-CIK7Zlex^o_D$NG+sE^KAOJEni_(Cnb9@#~ zfDFodr3=N0xwbM2DGUZogglLN@+T`Tk`|NMWT#6_GSW$O0$*BA*6L`*?W)=SFr?4} z)dm*j){nU)w%rYr`{ONx7AH;~eU#zt1a%#ZO0r;Zo~SPOLp9==RyTa};}pB;CI{sE z57U;d&kxrS<}2S}op+?(gHvubpuS4azP=UixNfNGd@!I#_4RU3>gKtKlKc!e++1xo z%$EMJ&3U;Q{z~msDKASIr)h6BTO6p@?5OPb>f2(whCycTBP+W-a6NbI)Z#|3LM3;n z$Kz&A&2BR=7&MmtRLggw= z*K?y1li`cz5#)U0ei(8h0e9{3G^&!Y#xrCN z*$T$=FVg<;C6N_4le=XQ_>|2vbnV7Utm_w{4g7GnU{+&W98>Gjv(-npBt~8Kxts`0 zE{~1stAihn9q>{Z_P(2YHDiGa6NTDaURpW zwRkEW#B(AS;o)0URlIT88_UB8`p9;;T#{nE*5V>*K+G*M7Yz)CMjjm`_P1~0^!Vn# zGxO?8IajKl;{Vz~M#y`WXy}(-b*}ho)#&Q~fJ>{AU#+3EH)+VgsOwtW=f3e$omVxK zYUoGZFN=}X;({%`%(1$2W4HWL`Fgv!u*7I{SevX;V*;h!VV(8PepbVNwcY%Xx#Y$# zlSkF-28TphS-I9>9n7i_r=9qqpO*iz5*5op z+%Z+-8?98gJwBC+@1jpnKG*x@>0Kjt7wA_`bEU~?;MNo?ts91cnh6!@a9IRZ7k1+!-?s#+Zi^$ z70Ad~38j{za)S;7Z5sQMS|26Y#A%^l?nvW# zdc&SI=`|j}*Z&Nj@4!;&QOlT4^0^(%h3OZAz4 z9o$CKce#c#Q+il0I$t^&SntYY&=t_^cR7jAYP%Vl`i9P9qgt~i?si|K?lYL-5R=?s zGcQ{+5%T$uHGg z8JEOa2D^oc{1XGtC>+nC!u2{Z_gQwbepX_Px=&`U^3JA?CP!SMK_p-xYR1&qEo-P$ zX6D2Yz7?LxK~D9$wjMpn*&jKav7|HShXW=@q)BPxHmY|D;Bn&K#>-vm2tiw^l^1$=nI#ZzLhr72 zbd6NJn}CFp?R!8R1yXJWePS6buU54?(IWxu*0W_P zkL&(|@~veXZd$0F_Ycr5dBA`h#m2^!CU*cf6OCui>w43Y9i49D$>pUitm^qv+34Si z>;&pTomaWq4HG4^#wlfuyYtY6OAVTz`m++<#YB?y6Y$nn;QEkBI4GXq`=&UpEqDZx zv&y-mqLAT`l5y3r-9uTUTq{qd@z%9iFGs*vjbKxP+wZCQQecQ$y;Kd=aGh(EH__d8 ztzFxbDpR9NJygvkVZgeFDg4Z4z0|nk^*L<BSM>qC$c3x3CK{K zPxIw?TZF^xo}`$>!lm=tFW`FT@M~(frOF9Zqe9!vmK*K7oY(5DR4e$#GPzZ$5m+At zT@T{3?|5{O&>iG%j_1mXj>ofjH5Ib?N-}h7%_1L8`6v(Q%bZ)p4dYD*V-JTNK~71g zcM|0&(3p%40PM`)Yc&zV>r(I7w1a5csD3>G^s0VCqXEqb`Sw+o;oM3Zr^$YSZl2 z1`RHsNA<~4gRILtxQ!;f>v89eZmYSluQV=OTCa3R@xMU#5eLaaF9+i;(6WeWm^;ly%m43$qvSz?wEM+juvp(>5fDu zdCyvpNjJc$^7Oz-707_q+CAzB?-iw`@p`>N(@=-mF5=xvj&Tj{7mN-v7JZS7Q^QS` z(3x-ol=beMQyq11#qZ78l1i8IKP7}t*O~N#XSu=E;Bq>w0iCu5&|;E)N68I>KrPQ| zG!&m}Af(-$l!W>*_6NY-dv3FKCv0_fNoL)X`!)SZm>wupupP-**Mhxt}# zqP0bAKh2>V4dNJ*K{WnycNG2(cf05OT{-358qFDXyge{t)Vg-vd>7xD@}aO$>1|b9 zBSU5s7xnHXhBA_D`cB`+x`(y<1DF;(Oj3ascv%Z}FsAIXYJv`;LQEJ8Ekh-?DqTndn@_HyPP#BeZFex_eVIA*6=GeV=B##q(4h~2U+@4G-=iBU^OsV z78T_K$-oyVEFq)RQki^{ZPfJ};4VwM>z}8>bIJtOb{R6}xMzq)6G8Yz_ZJyJ*PsKW zqVwJDt;rapr`01en%YA=LdiGP*z6G|R447}hKwX$AWV5}m0_1b+mB3McZI5v$Gf|O zDi$&_kyonj@!=_FC9Y~XyT1=oUt6-T0 zn32Wn4rdSx#{dg*9%{2@g+T|8Fng~&oGG1-+I`$=Drkpd(?ptY-{rg;rD3#j6j~$P z)7?iDv@ZV?CVIkO)#>@l$j7HAA|j&3xBGK%lq0SOp;D zvx&FT@XHa4&-zPes2A{$z_MxW0ur@JRXvFXg8X?F6Z4k~ws7PnS>z*|cT?9EpAnNy>p*Tazr;IY2rMvnxCha$1LE!6{Fv@R^nsDmuK6g3 z^iq}29swkKCY5lpMZYm2E`@GfsIMz@D;Wkz*(425+eK8GE5K#&r1yP0#sviME)nY8 zVpu`a%{7;&@40E-q#2nO_<#_H2b?p$v=cnIkc$%o0|T#@fMoZT-~!{_kz}ua!ug0D zIILdK%*?+D@nQ>9Cpr}-J(~Iwu7OZK2)PRl2fl}+MD~NALbqvN15-;Y7e&|z^F3(8 zA<%f+{;>DVNnZwaJ&F5Kb02RIqR<;S_7sjQ7WWIUy z)?tnBG%7g2w)|<^9iRTmqNg3K>=i}sAkS5(28YMBxc`$e)A<4G9%U#z_Php$mL^HH z+{P#Egc>4rAaePo=+wbNRgMOY5;l`E%*k4tdsV+k&~eyzFN5thL`(;mTWXqj(y%6M z8)uL`IEb?0El9vOG{}xny)MbLsuF|IX`??t%l&jR+C{m8Dz5T2F1Pb3u$}0s;zZqE zI({Z0_1bbX^L|U9ZZGkKNSjiwg-5((Og@#oN``c|)l5p*Ee11WCP6bsmg%_h8@?rFy8hPMcTbR-(3YsSF$F>{X-BJe|r$f1NqRfFPbRl#f4%2}Nx2+MCYD|F^mpzV58;(OI!ojZIN)a1{p}CgL z@Och>=H1{yY86U8EC$W)q}>FIpEs5u%MiBcNoeA1U=l&^+5jZ6>ARpW;yBWDdB83T$oeXpn`$WQei@zD;BdXATFRuYb3egrLhjz*(}*9 zSSv!T$k(hyw|ARt`LsP|X6X<%`p9dEBmX1mKky6*AsWn>uFk+59!1O9U2%Qo1r`j# zT$2`6lbMBxA!wPMM>o!Q><__5H*Fs7{uH3~MwEaS8Xm9`hQ*+jYbdb_N}MeP8)mgt5x6cl*i z+E6(pHj@^3ZagFu)Zu5PkZu5zT(xz#2)H;5xcG67|I3sw^>w?_$PZhI8)|*-P={)X za^0qhJO)MDlc>2QlK4ZPyHn_WqqcRSFNnQp>~77Yagmkj%xRkVV+%-S1&e-a> znPijMFgtvA%o`%huPt1p zjz6J6ZGA1Ep;ckASDt=>v0d*O;h~yB)_HcX&pb5{)8+m4W+lvMS1QuzHC#582^I(h z!rTN4wZ8$v*#x#>ms3^`C?!e)er_Uuq~ccdJf~frFQO~RjBXo0-qq0!w9$2kN!|`U zqh0S=<(ycoaAdeNY`fKSgLsM2YG9H3Pf?H=oLtn4=+^8oA>U8zP5swK6oqCk4 z(6*X**;mK1TI@O_NIzZm%|mj9jmcZNoRsd~?IoX%$<_18RSP8^;+f~^K_B5#kwAlXu8gXP`eH!_@ zVNG#lE&?tDzYrTwHLQXwrb;~WpjZyOrF>#y8Az(YYu@wo9pNdk99We65_B z#2rXe5e~pmN$ash3Dmq3qhpu*us@l@{DVD2E{jK9@u|ibo(+@aC)g8d4f&Uv<98~l zd43?M-j#4iW57Nh#kN{S0!oS02p*UC8^@*MBxuVJzmCVNa&x+@xgRQ_!+CNClVtUH zbLysGZl>M6gTK(|uwMC$&+B%S?<+LE?R!*Gvj%e8&)10o9s9JW1wLB zFwHAytX#RlI~P(00ZA@#iS5%k_ycKZJv+Eb?{|VF;%7_M%j(er#;rCu7e9-`%i^(_ zSqMFSjYOxu8laa-4zt^-Da$AQk^FA!%|>a69I}+n>lu5#CoI|D{n+H8B*GCn z(GUL|G;1#vD)}c%#flSU6}XQSy6x(>5)jq9K;V_-VA1Tpc^q%QRZcD>!XyAJ1KZeTZW`rB^Il}{1p@e za>4op|HbB54;U>N5+$T~(jJ2&Zd)+qKuW-1m~)^_akw-;15&jf&Mk{o6T89Ahy;=a z3Z`T-Ih~n$;R3VB$H6(N%6Cl3kXWI$ zGDfwDO?z?sNYbEcpuPv9wW;y2trf~=z!`**C_rGLG}GgP(}OY4py=9(u(HKLFexjU zl|zldl(UP5Axyz3Nl8V3Kqj#;Btq+-57owzxAZmkN&A12T$SV-&hAtTi2Tn@F>`*V z$;3&v?J+JfL`)(mwiu>b$c{+SkSh@ztvkoZHtD(e7brAIKnzA5wLxW^Gg&3E+;|K+ zHJTFZB_3mEOtt7@W-GO>!@s*gPjkvURFjRRv5K}y8gC&t@W(QaN$S+P{GRh;MmR$65Klb8Y+y0q z``m?K>)cjT=6Y9{<9PlS z)OeS$>=DK`4ChRY*!L+4{(?rQ zCQ@~o{_oA!uPO%23rlc%%dOA*US?-T?5j#7KfXl-vfRXUw;20&J6Xd&G$76TDG|?a zH!#I@+qP$-N*vGkZ9({ZfWLvD70Ccr)H74NLsnL&81Ma%D~T7IqyNu{ehsRX z62+5R#@VZn^1F0Xu-rg5h&4Xik!{A+VS6ye{Bvza^Du*X!fn*X+p9|3%w7Ny)k8&D z-|!MPUl4x;{z+S?qyrdG;Nz;2YrOZQ;u23)TdwOJCQ5wCSXO#bi_CPst1SANcdfMI zXWb}vqyB4nLOkREY$`1hFQ6?ohU82_rKIrf)C zp~igl&o`DLo)pA?55*hO6G#2a`g)QeiNf*sTJwLkI28X&qA*mIj4b{S0|KhAk{l#a zaI;ejAo;bk8axCxdC$0jq?$F5pq&R;Me}gM9hAaGo3_VDAaN*CuApNfak;J3!e*@M0 zWcjC_6$kp?@6B`&$j{Uzy`d#u*GCHTGOJs^5Q{&n ziWkyB$wk|waO)G|8w#Zbp*UZ^PH-*OZud|5JDaect+r7|V?C3)pRc*K*I}fzJ8phv zFlc`lT2cKU2@03Q1}L>2gsTNjF`+1s$VCdZGbEYW03p?0|55hB1WcvGgs?=ns73DS zNmn*+KfH^|(>Yb@YN^4dYPM{dY|0Jjd%w%cc&+MH?RL!J`P!k_-`h*RD8Jh3DI3Q3 zn3&1sH=W1v!&Z0_!M&IPJ^}QG{O8~!fW*h7^w9we2hS;|w zAKRu5gXDaY&kX9m{%c=Hc*r;gYBXm)|ep~^OI$Sn1sX#b&gL7298-F z-RENh9#=8HN4o@-d4sz88k2r&?Zy*kVGxNbKnhY})UIrU{o|W>5R8-G%8QN>VQ?%M z54HaxA0G-lEqCm98n{&Jee1VsHmV_iy$`bS?nYp*b-mDdU?Cs}@P691f-Z=RXTtNA!q>h%2E zvZ4>sGNtVm`*=J%<^?c5r0h28sc@JhqaeOGN%QrwvEZOC*bsQT_9*7OKSJqZF`3$E z*J^Du0opiIl5G|G+#G6xH?fMw67rK8`mqjkCBM>AesRdLq~xK~x!j+m?hWsv`$eel z3LyWv=%^@=%rT-066j=B0-bm@ABfAzjVsfrpvd5;s(-hT zE37~2lqUkkI9SOC@_!2-ND;8w-?XlfoMA1}>9ox@oJ1Yhes#%W{?S8ZZ&H^mtF0cJ zI46`w*(r zWt`6U!LX)Ox2n+iImb(DiIqHycFTV=&@bc2ujUF8pZ&0_yl9nZ8nZZ^Co_k20+$ zF__>ZhefckitX8KpO>H`J=kW)$)INMnQ|y|CTD2bOPvatq!WI8+ei@DVvR5M>Zfsm7FQ=>F%Xieg3e`^h<1JgT&e>R3Mfp zA0JzEktNoy9|mPA^DBKQ95$Q%;pB9IEG1WyK#SjY*zR;F{AMC$ba>+@Ev8ZkEu~BB zAa5^hYtYz6S9?Fd`ljHTS*F+ac(&O+89W6hzQH?!$%l z#((>^jip({X6{2F(=QT3^s}|`eXg9VYs^?qmzELhzjDBQq`-ITWHM%>n@o;py z(^ED@=KbkL+FpLCeSK?5PubjNu5)WD1wz`95s|=TW8K~3FHT8L$e0_C|4$Ac^67ap zrz@AL(WylDg1zJC1kAIY@4p%N>c!5gCX6sVTT+)V zBIMbTw+yp`3_5 zxkGk1THRavB#XfLNv}M_P+c%nrv0ZGn&7T*hN5q)?Ab~Sx%bUU0!Th3v)1lepwnQj zQfnqHdkdO>Kv2JmG0yl2=@yCM7^Fb`l*70%Ka|D@xP?hm!O?dh2G@D354K|?^2WIYlAb2`r5c9EX-lkO2#1zjBBq)l z>T@87@+ggo8O;Z+RCH^XHIR0feGQN*{JcqTd!HL$=%)vY0yA4Ym7{j)XmVz~mDh?1 zQqNpWHe9AFT}8PGorK%#N;lT6JMz1D!rOP&72c?ykP)Tx?;M@tPtR4g_$YPcywz{3 zX8`y3h>_29I@B|OOvFVdpYz_9cC}Go){fNkexdx{thGsN>-_t0jHZyfqQ9C+bbC_eZm;d=Fcb~_&Zunrw9!-B(a_D zyuod}Aa6k-qzb(pphwVj=o^V8uW^>6#X3v0TW&lY_5|&{8!lN~_T*aJj#COAMZ=K} zBdb7^!^MDEmqCYD>)J|-%iP!R(yMEUl(IXZ#5Sn2nkBrGWH6R+CTX+Qe6)0ZJIqVv z^>*F=*js88={Gx%dj2Onk2TuSVh_rHqsTKW&W*pu);xn4pjMcNplOKE0S{cbxE4h3 z$>?=+8iaw-^>xEoB7VBX+Qk=n`^DOV_Un0FHtSiMWC_2&sriQ>w!jAqZpV!P4X&|; zUj)U)QHA$I-PKmoxGebP{YVr@)cm#aypLY?!{X8ly_Ir%>==wbrCXF*LIw7X)VIAz zm3KDw+6yo6hgz*Qa)K?&*=i$w6=}=UR^wceo$it;9Qe$Pvh$c#t93gMS<5K`dQJiA z?oY_!uNN~i-@0lZ&WCU&GWp|+zh^jfk}-F1sZEsK!3cJ-r95h#uHW6}@T}Sdg5NV< z5UjSk0YL^lc22wM4ILh(kIUYmRwt4f?9x=wS_&7>RWIA4dp;!{KF-pt7z56yAYw!) z<1fx>rkCBEu9v=eNWvYxv^+6i4;HnX&yh-*hX!3!=oNS}sxL`4J%)KwyE zRwM{&F~_Vb#sWkiE_`CQQG42eioV|XvDrIaq07mS>z+@b@ytpZCIS%70wKUQqYzXb zsN014Qz03HA?awRdDR1G5H z!eP#(hv(b%0`J?iqgw$r#(MMA52s6q79c$bbJx>lbp89OuO{i;JL@{#kanQZtl_`p zWw7#Amw+V&w5ce=nRcEZr{o#Tgh;{BqBg<}0x}yKbWh?S-&6%PK(6EWKSNH-MK+gH zJ?d+8l4^c-I4Y`4Uy#(rQV`Efa`ucm;D=RM;VL`Nii?deQ%2oNYO6NW5N_#r!A$wG z&|4242wp@!AicPtyFE1A@*J=96m`GM=;ymC) z16eB51@ny5s|@;mA_sqOJM75<*7rGssJWlKeOUKW+UuX8Vd;FO!qg6EB^Aw6Bbv(( zouWeUS3XsbboxDe*O z<~KcAFdFbb!ATjg3uO8!v049r`T2JO5T-9r_YKP^46K@!dQ)%+dL1By6({YxXgo!K zL__^;`yNtnZ!aYUg;>j5L5EJ;pEzS1a8U3l7^tGTd`}mH#9r(4)xnRrl|A~=TYli_ ztLx@^aS?{!i}aEw=%DhvvbWe;_edhuU&jSI7225d)W%kMQ#SjHFAcI=3aTxc3Z%?Z zXl6Q!+gMxm1k7t38@iuhx}=`s0MP-wP|taWS8+~e?*cJ*M0YjVop;^`@~Ucg;qjDB zLm%P&-Xw2ql5E6)8~c$pb9NB$30vO}D#}-irO7s*;Upa_H#xv-jMONFYeC7Gk*0;< z)33*-1e*lFa%hZLj(;kM+nf#_uFMm{QQY3$E!aob@*L#X!qceKE74pr{wP%`bfI1U zK4FRcXjlx(lSYi+>*`xn;B{K=laO$CCWH)R_7_lUxKs)b5060xQw|Q|FQx}?vNhM+ zU*AxU>nO9W4_<(TB<3gIYv?MSGM&@2KkJVo+%)IBwN!XsC7Fi=FM}-S1J;wqTcp5j zJ3ZdbrY;b}PZxa=r8?9v(8VszpX^ZIXdpE>x+{dM?)L4JNf;gw zU?P`It(aY|*<Cp}y_>y`%#@#shFEJi{PR?mp=E}C@3R_1KQ9bP2VvP?(rz$yG)eHkW1QsN80G3q_lx2CQnOf-;@U%S%r9TB|5?VW=xqCxu`&*Cu+J$4Wi^zRc2?? z-pPmOma`M2L!HK!n9_1^+vKf{)S?jEu6n}_P3iw~JW{;i<{dseO<3pD+<;do2!>mD zuRWkUidL2)lt%afw8obJMN(PTR6>$soJz5w!Vp-#mB{MSSRqqLUUGJNs;AhLUZKrc z$R^WfL0lqutyN}~{D?-jZu3%!k9bSsxac-=naa>*&mcUkVOChT8iRrohkO7TK3IFq z$|ym__v!9}?fo~BRU!lTQ~oN}oSM~-rJPqUl&$o) z8BuUr9ctw4hsQ!XdEC#+slQ5XM!|AY4LBwRvV+wigmI}LVF8*E8MUf~x?);EUk3t7 zyj!I}G0{y&Q!Z$e2P%~mMlTe_q1tp1=}S~LL)WE@&l{0Z#2GLXL>3Jj%CBk^Q>5jj zjtxQJfkF;u8bR>b3}8x9K|w*Tgft>XzaVFJBho-J$iUCnlY*}Yok?B>H0B;0} znO`}z$U$P(eWjElj1T=s(kQ=phsNH>v}lZ&j4SG?>@-d6M7sSUelwHO#J1v*4Gdai zRe&%e8xCwg^dhG z1bph}zJ&Xz3duj%_X+_=M0A^auqIM1ZWXfcubZGPFCD${HSpb++y6kdc2JdetEei{z?LE(DED)vi)K8XJv+pUpM;jJ$ETbd zkGAmqu;H;7-k&$BE}+&am#FB8;1$v7V20@?ccej)|5z@CQzUi*M;?@WN9ZdJQy(-u znL@$^ME{S%Fv!8PJ386bH?C8MclV{HRp8CE& zhe|JN1J|Nj^`v^B%eJgNS_mec~%!K3-X3 zxZ0R34}*p2gIF2F-j_QJ)g5rz9zfrkD3OE4^EfV#9# z*yDL}@FqoIO%G|Q9K!h~gxCjb;W(_p;5moeD${-7jsB+n?;gYWw5E8)RD@zZ$6b0p*z^KzuQ%ERA1vS8&$>Shu z*AO`OafyqlT^bMYuaMh`(K-(&|nN_Pjz@W=|hc*ROz)|f8wY50W9eAiFbh7 zMSH(d5(}D!yxwARAp*gK#`SEY@=8%`xrc%v0y+un%00RkCkL<-UCt^B9S|%pts!j( zk%9N(ELLb2B;(U?z#y^L$q2XhE?ulCiN@Kjq!=Abn{j*J+h>6xBaq%!i7AWFaTj(K z{2K=UUB5F1sA-ZttG6{R%Wou1e6uQ|Hv=059}ND-rB)1@suR)w0`#z+Hv|!2O%YO4 z_3`~UKEkCeTuA|r0ZxU|VzkB!(Oi9$&uec9<3s;WrqW#JJ`{;E2r2XK+m0 z^sd8}UoR;TEr2w{vSuqqz(Xw7mcVpO|{Z_h&e@GppC4O=GXszo!u3 zz*3B4em(shC#}#=?=GalgN*wc&a78@B$UH`P?Al?C0d4$YBzKX=OH)Rdu-5UK&(d; zB*4)>3g%YjyYZMC3g~1?p;w|78oQ7V@rjkp`Wu%G5Ez#zn6?L(;}@=E!5r`3{Liz8z9{Sv2x-)GPPz2!hxtK zX#(L>FwvBi8e9eR?!99u1C~8tU~ZIRMwI@LkS68wG?IG`J(O?}QH{d50gdLvailt+ zaImoPl|wRPx4G!46O8YA}Mq-VNc(*aZ_t|hytH^n;X#%x#3%)&L?qU{14if*8U`v2MFuB@TVKdB86Ni>ReQ=)c~Sg28T>_OTiaF7b@y)!~oI-)T06 z<%94(Bz^_6{1wIPRQU1h0is4QF1z{1I~uv?A6VBUwL|~{Gm=2*GIfh>=$)ruz`~`A zn>p^1%VA6aa~bLgVin}UPWU0^5Kwd`DKCFT=?)NJHew7g3Ajh1F>b0_t@BKK`uC@Y z1Ch|K+o+tA1B|qozZiE@2}%G7yVlyAd>8y*zfh4yj z;mpB36&nssQX>d)@dJ48u2=>&d*wj&{$Ua`{lP~9m-98`l^rd%9jlCu^0|Pi@A~Pj#*FR1N(9QuofT-Y}IpA-vmhPbi z{x`GlTi>5E5pWE=)b7uF1swnX`%;B7EgznrNpkSIbgR|Jsk&|jGI9U))+2-!Vy!RT zyFd;OW(}U5QtR5D&U4-si3*OdrUTq-2}RzSSgKdC;{ zt(4;`#M_T%!D7MGgZ286{MW4rhus-Ykbn2Ym0YXtHJVu?gof=tTn#Ym5QfWlaY9UC zihtA@!eFQ5Q!B+?&px}kPw$*n6#*u_LA2#S*O>dy)8Yn#FrYP`N}7GPU=<1mMq4Y{ z-;^W3@FbE4Q-IVE%J@w5JhwlQmDp&tI9h8uL_tL*`0KfsQ&ppHc2@QI`MJ;PyRV4=N)|ZUf70%>)|6)R(?WwH|8AoG@$k6)e$+%qKL@zS!K;dk z2M32_$rvl%mKVJ344AC7yD4HZK8l@gbXs*^sC7C0=#$XyaJQsu9^L!vi6x2Y33-pp z43E^UizXclNHJQ@b$>?zw7Hxxe~W~aE{bp|w9VRMySTDr6c7}8<{U|E7taS83$G%B zb*%8Jg5xpfZp+e~nIHf%A9*_Sp(7$98nHcG_^!*gvz)p!aFK}A7+?YKY2G4cru}DT z1&I%uy_bDVW%V|Jbr~wfN97IQCxKGK?wGk!-LaXs zG*&yU7zd>aEmV)w)&MjjU+pGq*m$8gJ<1@cyVL~Ai1#;m>S#&wBO?jl#)`y-nrJOW z^OGsfZ;xBqtd~G&w{_o1*wp^1DCy}J>0y)|_)$)6Hro5`a`9WI7H8%g32d3ghk zo&*%6Glt*UT+VZqZnJWe?J^ZmgT~oW`+Uu5^?eb><#r7u;6=+24tKX1@IzzupekwZ zx}DeDw4%&cJhMg9qBgjYRwKp5PGQidVmJ3Kwle7U)u&R(WO%wIjO$t9EHW)_Qt9&6 z_CaQn?tbgiP`0@v|!z7o-B?m5lOAb*%=VenU4n97;ko=Q2 zWat}XI=e-}f=$NzSRzI3h@Y=f2Tl|pR)ti=Xq5n;St8_B>M;BpE*VgiT#is1(m_I{ zmqz@07luR_zv-)6nAEt)U;_+rofO8W_fs0>DT>nVx0MWcf3ZUU%kB7Vt7aBI2o_8rClyG@VCtUMw-jXEmrX zXb}VIR_f2;CCUY5J(Q)&1+I0TH=etam^AoY)(N4p%_&n;rl0bC|Kr~${L8;DKZi}H zt3Wl9!N^opX`!Kbf|ac1p*zXxJUk|gdN2BEp^OQzHm7oL;!sz=-r2mLwt()YIqSR& z-RAnVM1t(>l)f#pt za}-07{nR%+_I<{Di2kgIc@%qgQ$7U7&fic>=X;*cQ!MN=okYAUyCw?!#0FvV1p2oO2yWqa2}pZ;96D;|~SB;9Or zNZ$dxfd-x)X?2qPq*wcl@e}$hk+F(0>D@+OcZTEjQ-!>F)RNc96ouBc4Sa4OAD-X3 z;sd}RX(Vz(w+vKvzfWSIb?CQ?R&LhF5w%xq%~xvbzH|LWD;5ShGEcwQP*-Pp1y`!q znqT$K`WHQ$)k5`3nUU?-!bPcS@oqn|P~urvPRZ^0uaZa(+cn{LZikGMLPA;*Skd(c zOJ!q!tDtXT0fV-di*fbK#_R|o;64`>=6!J{C0)JwBt%u|^XRHs{z!->a1$ImMA^1? z2oGpq65o6tt4^UDumfB^eS!qTW&?s6R-3Bl@6Ma7AM`L+YsFkoSIDKr6o>b|_o`I5 zE06hH1$6uIhK<>+U@$yfdRPfc;(x{#$>nCCw3x_Lg_D7e1PfrZS>alg4OSK`os|F5 zbU3bBlTBg^?x?BFLc-T{mam&g)yw^#)!9DV|6i7Qy#K*6|0%@hcG7A0`k>zPr!~=} z+orrK`rSSKXKsJ1*zz49Tp_RpQf`Tq2YB?GzMlekN(CeMV)bTRDB{ukZ?c$$O%dre z)DubF?iUG3>QT83I6r^HYx4`#)7%x%w0b`n(yFz@IlT8vVbZ7TZM_+}!RKa3WzvI5 zWzgntm>+OdChQ4J=oS{dq9poLaL6Olvnv~nZaShQQD24=u(|hIxaOUm8cXe6m4nE{ z`)@bAw&8!f;R7S1=c)vYWJhECTq$w-?=OhoQx>sgBJt6`y?ROfVURcbPR*`<99*zF zeU?3<>arH{gRar-4Aci{mrV5Ar{m>L?%tPmI*AHW?Ye=NtB^ip>5so0BFk0N*#yyw zOh_eTJ@j=sl&Idlll+~FQIqz!>MucxM4uLwvTip%Ufoj~GMVwV{*^gDy`u40ko~ zECK^5!CqhYCk>U*S6k*P?=v<$EP`K~x?cDE0yR}G``{8O%p3Lf_ zObJ-+G0%t<73g2=f5MD=4q?#0{gTO#`jr7ZyNv;?7Mtl=zn#7JJ4|5oDVxPaMfOF$ z;LxCpO!8vLX}8O-DM`uht8Bn0pgKg8>r>cUsZ|>|r<`5_Xz=LlVm&oCyzbD8ygMnV z6+?kFZ|I6n3&0LTKOD|!tB~e)Y)sjWBF{4Fb672CMPSe}5c}~f4@P^b(qj)(HQN6m zo@u>GOmXD@tN3LO5G)^(H9J$eG?_O*^P&zT5W=Sap*fFSgm%WCKA`9^zNeaVngC2= z!@wlFv(+1vz-?zxs!^Gd#GpMh^8)y;%)Uin(wTWFhK=4F%t~qZa*qN&qvSgg!BUl? zi2KLK!`uA1zC~a;Nw(;-yusIyHsiCdz&ddJ2;uT)@D*hobm6S>g#u#bA+;G$Q@qWYgKq3PpmTR^Ef`1AZUQ zh=WPSEELw_S8%=Y=6dUSbk zqq#y1o(h>f%8Yc9qn}a986tB(8XYZE(t&TY9n6&`MpSzJ#wZZm)$viwMtEq0I7}-oGT*aUApfDmnu9!k7g`dZL%t5qyXkYQCSS+ zIPMJ``ZGTkopxpc^ttyhA~$G4o1av^I;#WAZa~M=#wwfL23b}Hr^N)pq2Jo=A@Y$h za8KI!OFD{FU6k_0SqZP7U%lQEFLyshD2|MJ8b%$kQymBy6eSgzJYLJ_IMD8@YI`~( zCs^bl&iH}+xP-tJ@GQie<>zerbSd|?|6w?e#D~xh1}%rxsZJ$ALk7rJQR(eY8Rc)( zPR=1)uD7U9`b#S3?*$vI$45J>U>j$QFO&d3ZLC6L*U@KxBUy3GgGN7;Wb%V)xyj~B zb|yCT+I9B5SjSFI(OdMIobQz1r2u^15n%2&JIg$jt@h!c}k3>|*II93&SrnPe zOe3K5F)B6hJZ-h+uH&9Cw9n{2#`rHMn_DT2x}&;wos}56_m+0E99T8#}|Dcd_R`p^3Cdw zq1AW4X94TC`@m>9KRV-nV zuUe;+wvrK_2BmKf+}pL(m)d@BKy&Nq028ZBfbVnGH#I53;&ATYfj;aq9K8K>op_}Q5A$qJ_psQTau*M*$-WI^9KGp-ukjL4DtDo7+ zKmBWgG#cG+Q7QGdOw+u3I5h)3??A^;3cVjL!F&hZYYZ{}Pb~4%UTB#u4jP+$#Dt`p z1!M!%ZpI@OfsT>;bPQ6}VV00!kP1?nsg7k`b}`R&N4|vm(~x$)zP>uDT(8A$BG|~* zXc_MY<5fn|=`Fwj=QWXD{kX~!-4?DFOBx`r`EHg+Kq64lsMqeQU#4M0vgyaIJY;V~ zxmQ2gr5qulC)}b;bb%2HW~|5VC)!(P&>8W7_E#sWCl2`Ie{+iMc9~8kVUeuOJ4Gi# z(n<$nY=xqVyq)POFpXC2m70U(n5 z0@U3Bd{S#YjLjdGdF@M7vU>}7KPSwoc#TdPHiU62jXE7u`schgAmmK-+)>R)hR1Q| zb5G}|o5Ok7GF)2~9M$Rk3ta50{mJU}P@`}lsN`=B`9c~=>mtCh>xM%_#6(I;I_mS_ zab@;p$OuZ4M$13Zd>`U7s(w(Rf>O3m z%T!>6gjQiP{!a>C<2MCA%39WuYGF%zoc^8`EuC`InfcH*iYZl5n?a9}MXFB`?aj1@ zY)e7J?-%xqDFLp$5#)nXt9mp6ug=zl+Cp{cMA>yfo8&*6Vd)%*4Vdhm8i0id`~P6! zS^f`Mc;x?ug-8F*!n^+$3s3NW$HK<|EPVX`%EH?+pbl=|U6e9c3t<&1N`=YIw97U# zsmbJcmG@YQbuwbrpl;u6_HTxp}=~OtkM>)S7Pd^SA{OyaUXpjG(_WXb0i(e(M3UEy0Ho74W zgcV3aqQL@$Ta8-m*YebxEX=feEj3N~J#AnCW?+ls5mA6h!P_XAI6@NeEj5S+wAqJ? z8r=bTgN#^cdfWpGS9o`}CD%vxeu1KiNhDO@rwS-h(>2a%N5Yo=mmr!f{F=C!Ffs0^ z5l^r{p3+b4&97|vgY1qwE3`In>*Z?+B*o;+N0WWL+j{137T+nC1?pte99<$*(!VfC z86=C2x{SmH9^%l$6q8rur0P9}!+KX6bn2}@XxGAvfyMrdk7M|nn-Q(FEQ@3mF&0#P+H4P5$m9LR zw%Js!NSgqSxc#i?6#H7p6!%XyK+#&JK_iI6yw;9l6l1MyZxg5vb%BYJG{gxZv+8AF z8W_up*E_w(O0|z;8k`J~f_24CRm`{`gB##nAP%udPtIHSVODWghZZh$O`{fBiEPj& zhb*TYqo%-0pxh49aE*MEIDD1b_}kt0ib$mM)Uq55D_R45eq_Z z&jn8khaP!JKbE78726!)HjSdSnr*Yhw6j%~Fw3b$NPfrCB2k;zHW+K=e?@Y8V4dc? zg1q_^^x)|j6CYkGdzw!dq*@p(K^Ze}r)n$5%g?`SG$7Gh{*xjz9$Fxb-KO@RnHqq| z&))}Z$+{}PIkuyME532V8p%>$HD4H@A}A%`1{q~nhyzaew6upP9!1LEVp8hYBR^U} zulTQ`R1naj9>3Wi%uptW)OgBfOh1W`7=X)$H1@2+FQ93e(u|P%Kb(h3CK8l&+$Gr@ zsV1vTd|ThU0iwTp8Y}sNM=wP0lFp1&Xt!e|UXR>OOV!Z+^2L|VF?{(86%9am{|_d9 z^6UT3#P1#jJ$uuN*ZP4@AAUQ4cAO|R4|>Aw=gs0@p+7JKSc(U~qQFhkcgJ6g3?IQYV2=r4XRUra;BC#5M1+<= z(S694;7rA)mH>~u3myN5R(}#D#AVu=S<0kRPOAkN*Y5Fn@-U81bHfQ5ir8ImP;Uw> z5|Ypm4nQ3#Q#|jGX^Y}0-mRkCv_aTc^o z5pwBIK!T?ZY|kFY`Imv64Qa7Z8LpYHq{VDWAceC1J9zD~cwmMyjzcz|Th&B~|2Ps%MPNGy)q!+lD56s+0&e~h@Uc)%yB{adVxD&AjRTsOiYqg6 zK8U0YpzX2>fsqAe<0Co2ujB541JNXdanv^zwS&n9T_lkv42wciiU2OUPEc5+7K;W& z$v+gCxJq>=fOcTRW#=5qv~cimoO?@({}|&x-0sIzb|c8psl$NJWhYd2IfDpd6#RDz zFw0;P3|PF8jK2#B3yTsxgc3%L3%_fLxPqw`7>N=c3-1v8DCERN-=TmbtLZ!;oIk17 z3Af!tOtMFzNq-?>3Vi=}*wWctEs{op3dl5nQwgG8rO!DvM94`Xy1P-n7vY{tP&M)b zhy5!ccMLSbJW`v(tcaz$%~cvG*D`2!K;Z{uU_wXoVIAW0DQ1+590+s4+4Y^edc= zyL~i~xt1-6A}GkLK#e6ZGE|lJS*Qk!h5?x_EZU0YfE(TfttOFD@H^A{(DnpntkEb^ znlLSR8YXvADu{M>(fs`UD3cV$xsV&EQ!S+rMxFjD9L~R#zAT0zzVGl0FJk>0+=`KU=V9yf}cOKLetegIp6sL&LGMPE_OoHhYIa zD|7}lCF!^Y3@k9YQ{(Uc$smi-81*HlM2afAN?3v6f*80h%p$o{TLf?j7_b9e!Xm;# zy}i9{2pcGCQ5Rw|q7^WsBInO$YDzp9_%v_;Yry97=v+zgL(qK-mqh6KJP5F>gh1J< zHQQ}2xE$**Cf`f`!O(4C0UUh`3Om(R+A|pruE-+rxh2XZQfY%_qr&@-aBDoFez1m6 zBuBv;0(7vf$b+&%Yl$QpT6;7QYrYNsNnoY9RyKB|qZXcm>LTsh z_g}<*LTnmj%L9c}O-Af3P#hK@LQwXTfgFMBg~px0`e?-t8SoYmdglu=5J~bwRT-Pa zS_Bkx`6EU*DSlAAKr=P1BEu&Ro}0@WfeAnmw6QOStAuzRfE%rx|e z1c3x}5yeO@*s6tUnXUpX&0A<3thtOx!1E*gH^5rxvZ8&|a~}5a!1Fi8{0YcTsF@rz zbKBCGQ~QR;e{52i?6Z^;wEd?H#Xbgv5q09i#PUCA{_l{R9aw-K{%}=8`0HX=l>+2% zzpN1<0odoA>tXZliGH6ETFQKdu2JN>S;y;tmvO%jwj{y)LTyq)AL!d^rS6<}lh9Ci zW)(h0+fQQm8~n9$|AW>hG61U)X5jDA7(7iU7m#i8b0;2{7|vRu2D2fE;h~)EOBubT zf7)GG|H$|K@=s0?1eU)IVyh1VUL3IY4+y!@hKcYqJc;vEiu1_%L;lGH1Ai`)?7W5s zRiTxgSHeH!zkbI?0T9i&msY!C|8W%lF2Ig{D=!FjoBmX%34`G#07>r%4~>_3e_n-j zkIQdTH-=I7?~w-xG61(6`mFPy^!K~-4p1oQ4W%&tNlC>0U#A5O|GZYsddEaGh8;<; zM307k;_RaFPq_Q9?*RykBvu+DYPAt-mg~xeNp_)LdizN*G$=WKBxr=y{1}<+)cqnq z!2fpP!m=;}8(oa7_}rt2oNirLm%9Jd7s%+6$ia!r_S7-`O%(D$4J^IRBDI21M>_lU zg>o!sG?F?f9MA+1s4>a?MG6T*&wEov0TLO^ zi_w$p(^_u>yjQoFD4)N*JwQG89&7?qqj*e8`bi{9z)J%V#ZpVf5+wsPX`78OK#Z$2 zS!)AP!{Hdr25UAib>#mU!1sQ)Rbc!PR`A3WEf5i6aa$;vTWD+R>se$z-{2(ZKf*)8 zVccN_w*UUpodS89@axy7`Xaw1Unc{zzq5Kgo@I44c|3kW597TG^BDzo;0PJi1W1=w ztJ32I0)en3emEPDN}Mfq0#*RiPtO3*8bN}p0xT?n{v;$YFkt`q;k^y>nNJnP4n&KD zM7X8BJ*)&Iz*5Ypw*`mz3Iz=U0#rm81?68qM8QB&$pSwE_JUMeAb3&anDXOyR>3_| zihrH$uagEtfc>1n5Krpt?Cky+aDI8256J%GBi~<=#F%&TGN7e}bzWa=OX<{_AgWjC zWuq9PW?-l|c|2TF+AP(C@bgC`)H~@J7I(OQhw^-T1y8P4${>?Mx-Y)i>J|HK{;$3r z%p#M3QwfCF#Es-(1wX~4HZ*HG)$4SOu9R>8)fY0B#A^C(G*t-MY&b54O#HLasmaye zNfKRhB%a$?%1M$?Hy@>Z#*+8*^FyTbw*c6MdjH8OS}7oyg>s z+Mm!r=Oo@%0773%<(m$ zWoopv?^EQuPV?}&ZKw0Zq-fl#2o5v9Bf4+jU#M2-wTJ@DcTtIQ(4IN&)C*BoeM}O) zVFrNVN-`UCY~fUr4O*6*01T;eUoh-_#)l$+wwwYsCa5&Yn`}4v=6J}jn9VQdcDkm% zq~rSOcy{Ia=QpRXK%;U0$Y{6Pl<0>pGiN7t7q%HZ@*&^rj;O=}xG$DBqz|vMs4aKr zjFbu}H}qMZa=Iw@5hyKBThSv6lR=(0aw$M?j_)joQ4fgUQUNW=4gxg=!e)`)gxyhk(0iWY#IYb&b&q zRJE<4)!QdcMr|nFSo4KKK4ChEUzybS`w|P0jF!^9-+M5W*7UNznA&(lAm0UP_`4@Y)Sm~_)yJsi?Z&#?QTS4 z*Qf(BhxO8m=5nfQ-G_;Ncx-Y7yN!+)l`CeV&3cRO46D%VR(b9E9nG95+{=uW#Vbbe}=uhI!7xgK9xiRzm=KTfl$6Yob{K_j`xP{?F%1e<2qSJqoFThpo4 zM1`9cI@iKZ5~X5j%bl#avZq3tk>@@MD4zZ-J3>0~Q)?!wB5X_{$Y?&OST5II!P|@3p$3YZnnj&nyfqm@y*g`DD}ODn7Cgi}usf ziov4S%WdaKe2)KorC~e^23GNC6fPmZeJ~)llxRFsE094iR_*rfra>g&7|DP__*Kb0 z&)qOY_^zQ48kuMs*#ASzsySSz(N>l=fx3Wzcinp)adaYL*Q!)@9!}(-@6)QAHDznT{DR%SmqUCyO`dEx#=c~_RMoI-K_AJft!A4=InBm{ zr~Oxfg8~VvI$ZX>*|1n*Z_81eLk54@v6RVTxpcNPs~%D1T2aEo#XD@Bjt?<(#O$76 zKr#nS2A2>2e3kxpy*nV}B6!m%YvudL{=uTX%N}dzw>P>Bpk2updzG`2c z9Y)8xVc(m8CIaQU+}kU+x;A4&$SIYsI~J1`AZCiLu_k-^YnjPGfk7qx`8;dIadYgr z;{Ha_d4J9%=TFS1foLjLqY5T0l+LS)IGe)gF6t%N9B$WOs^ zP>yWjJi%*YO|4M}a>m?e;nU1$vX$lJDYz^RjYb6tU|rX0YD<8%xL~7ok%^I z52*5I_T*RRYh@D6m5vtV=Q@7K)>|KZ+BJy>Eas10?{x#=KT~b%Z8>t>R{Q-Lp5Bf) zu<47^d42X!pGT)wsE1$Bc|0g4c2?DCb|tUfOTc4YxJ85btX;oXJH;L{nnG`8>zlqV zyoIOM>1pPqZNz0y^ggvpEsA;te?7KlG6OAx*ObU+y)7~%8Ri&EB?8FS3>S;=yu%w_ z%+F^A1%n<rI#@tWD( zraq$bjwMoZ+1dy>+D#zCRj*LfAhf7t(eJPi@w`1Oy9MUFF_*9Gw{NO}wXNtIMq@|- zTA9^kt6MqY@cj_+dhY~(H;~;go4V8UC8O-Z!)PO8OR~=VM$!d|*U4<(l+qg(p)LD@ z&-dAVw9)KGA7$D7{Fo*~Zgg>zjqW`fi9L$kwvzev7tWJhy#o8mz7w-=mz;2Cz^*Yh z=X05Q`l{t!=g7A2uiU%tmK!MzU)SaeWx(4*@{tI2XXj|^(|)`~`o6Y?wc3p#MsX%W zGTWWqsdY+9aB?#eXf1s61%zxLr*Z`kb?RL8VNq$qMDum;Vt{!H$5oy|>b|nJFi$wpe4gvnF;y$Hh* zsI{ex%X)Cpfpqr$N;p(2jq2ijZ^JF^>$C32;qQ!*XkB|(Tx-bC2=`g<+-wW7#Xx@q zJ-y%xiOtrRuLJbwSFKu8<=L+{G%5qqhpnHVs^I8p8qQ-j1#}H9AL=*{m(?3SiuAE> zUOt>B3p37QKtKf8VFcvz!TW*oz@Q*Dy-B`5B|u^agZPSI4CU0f#xk_^6_A>hGmRzP zC~X?`Ok73+`?cUfgpNo{L?%(c1nhB8z`V{(&Fzk-NB4ZjWYmezy0e43o+>a3+V z;#w^CfS=DDT0A}O?^_MN0Z+q|& zMzAdi*iC~yu|f81ERSbVLl3%k`0C`v}6W=8lJukHiANP8l%N3+V|A1CF3p`U7n*cVyHvdm+sQrRp^d zXKV6IKMu$J2=>B;@OzstUwFEx4zr&rySd{qGO}|*fI2M-U|?zfI33>g#3V>4p2WdJ?D6 zn;@~?QfIj7dso$7(Fcrr{X%<>Z~O%(3zdBHnF^O{C~lzq)|jt$Fusu08U}0#v$?$Nv@4kXQEGIUU5N6&yGfx#GB+5 z3c+WtM0x%$^}P2*@WZ+Nr|G)-h+cn8@X3Ku@3$Vjhu0S`6K=jH1wyr1|0&Q&w4dxG zB`=p_(p?sk+F$aMV1IWRDCVf2kAJ1h^EUI#!6K*Z!yAJ+5oB*xbN19#2|PWT{W{E$ zC0g4DLCN^SE&0dpGPHr%-a}aKcaU>tFP?ZofSd8&%?Kn@?S|u&%+g+_D7e1qn z_9=BrQX+B5<>^V~OT~UY6q^MobAvL~;cc!C5Sq9N730>1}DT%)FY~j{Z0@!K63^<`<4Kh0t7m z!ndGQr5I@T3g(esTB>-m!mTL^^2pK_EVA8gvir_Z-|c9jS22!fE!~8z$?J|lY2_sv zznM){RpC3~ipFuX&8i+@7$%rnlb%e5pidQAiV-5fCe_tRij)MqK#aS2Amug|=83%( zwmAG`NmN^1%nLh?aXY?O!2E@1B{@MLaoTahGbEKXN|?6%j$p#r*b>3=n7Pv-lTDk&6W|+;iz?1EOS6d zmLC}RgKeo^%8ybB^o_XfO+0P(*K~Jzl|3R`;iMQq@-;qfSj{vD8m>yrHNLmgsb83# zoa?bC!||LyggnGyaOJJT*3cjoN@j#p@s^#kKU%Pc zf}Gw3fdd*cEmT6ha} z^qTOlK{ewXy92m^B$=niUkTHK0v5&{4rYiL#H|bVzT=mU3qGy@i_qtxn37=nYeZuf zWcUDJDavbev0~|OOkCfjIw&nU1GXEiZy%J);B%`Z)7EP|@KBMdeci6@FBs0J1O2}oNd zCR){!2er}_;a1aLovWuQP&m5}kZnD3)NAr2OO~=J477wxegn3oVBMr^_>k~PUwP}_ z3f_5lN&f8dI$WL&YZd3Mz}8-`VBZ{cT?*A8zLN&XQnr~u$oJ|Cj00D>etH(LNG=-aLRqF^G6(ls>{0Bf%`z+@$OM+?{MPaZRza5!&esV(mJ{6J3{ znI3O%_&T?Lbv318#Jcc}>r|7xm+s~?aTli63>@k<&-^}>=EU*g1`GCM zy)nF?yZJ|N7bvC=p0i0&l}I(R{zfwRBd4}N@0rJTJP2K0PKni)jxrMJ%c5WO_|Z zF{DsT2q^6#0<9R>auAM`NnS`A1IP6G4?8VkYh7gldDF~_hR}j1fC4p&bLz$&221r^7N7rWzwfb9LpK^Mbmo)C&*q2`4BOpO_XjCU>-m+?ebi%O$8)fA1^ z^XaCFSYKvE7Rq*44?(|z$ej_HKSu{+BxD$~q^#EYyuFIaj@`lFD99T{^zd|hJ*CW8 zNS8ONW2X=^FRhY*@b zT2x!MfphIC!S1A{Mw}G}%k`KVwGe4mI#_bT_D=PK!9VVx+}^c3`ai6<+mXK^Vb{7C z6Y8?Rs_A%r+;8LrL;;b`sp%Y}s-)%lZ^9Y%GQ8)>A#4~{%>QiRf+Zjbn8bs>B_f6M_I4WO?aO_m5s5?+tudK2Sm#M|?yRQm z30szCspeDptTn-`2r+q>%Er+U7E8dc5TIWj%AS}Q&pFhLDdw^BOJU|z4u9dv?ADvJ zX(<2-AMe1>?@8My*DOo|YF8>Y`+1$#`)7AR3CZ6^5>f#A9KC;`3TU0 z{cP9w7p8$B4BS?7c6!|-J`8F;J$=T7KtE8r1KMV{UF+Y(>e}V)VL7qpr^X3GBjgBj zoJxb#F0Z~-A&ycZyzJBxPd`-EV>Uq}?tHoy#K>=Z_}b?H;}7$p%j>Sou{{VM)BT*++-dF@Ovj_lI$(YM4rZUU)T9^zQX{wyY`-R_2cmTZ6X)NGr<0K?xlAo0ROggK+4d1&ssz zkCqTz$)693d*{B|sP+jk{R8CfS@id})3v9WaQ%3n+LIKtL zE6yhEyXcAa2(FZe=oHUqxvZHK&PgsbiClwBGfT7ZfQG7T=4-E*L5!e!<`Ax4G*G`g zcx>c)zfA;qN?w7vlcx&oe^4=R*3lSlvPRU^rY;;pi4Hg5RXm;R1z0WJly0 zcJ)x)ef{-|w|v0^_i!&L*X~7f3W#kcw2&1=X%ss7~Yym>YJ1HohRfX5Ja;f>uAFc#4 zF}8x!V_5}cOrq71GFm+u^MH=sX_&^0S)?981b-g^9^C|jgkSq>bmIcEN@^b)RA0V- z5Y&x^tul<7fZJ(cn?H-xAxfc}j3hW2mn_fRrx}A4Zt!9agRWhrTdgfJj1m?#5AA`CRR9yR`}KngerEHJvq|y*S7q_zUuUOYN~E(W6mt5{0>CuZQ6+4H(%fnS-y-iwG=FicJ^Ac60(~k z&_9J{+_^jf%)bL!Y{22;0owx-E(BTFI7?GBTb_~I=&#HS*9`$t8Pm(i8e!~FUt>%} z-UAuaH9n=+f#r{P`bZs7S($C-cEZdX-TB&!t}2TNn5F7_c+Lk8@}7@-kn@g=64T@o zMYdr+&ptNz>kf^5IvE4?7ECo`Qx@G`{;0>7Y}B6nim1sR*tlH^5< zi`&q9UgvRLba8DiC zPMeAd2LOA1A>!b{j^lFFO1_7WAqSZcS(ihB&jnxoVE{!6+qEjWQs)s?2V%)hyIsp{ zV`>Bc0qy6vWgg69UCIGYGm#4^^Jgb}1Z80eD-;G)6dWTN5j=wm>ghZmx1s=OF(^_< zEG(9_;e?%uKopK;qj&KxFab-omOH{7-Hv2P(TK2LHyIc=A!8J z=rn!GpXoaE4LyHZXprGkfX(>AIay2Va+ow)`JQ-jvnI*E=c8y##X#?cK5v`>`xkf~ zcob$qDW`pTjs3D!(!GIMC*I9|A2n-)(I1|70=gfKT0#1QNiI_uzm!lB@|quknMOp3 z)=c~`uwHFy)j5*5!^PWlPpQypjDyVBJ8av^4&6E!ydbC7WMmm~Smxu_x+qgC-Lk8H zCJ;)XYlOF`b1VRdZU)wi*{mE&l$qR?-WP;#fHC-%T{r9{ON$5KwU#;i_Bs5Z_H5;0 zx7((IiUkAvRk28r>a$7YRmc_#bJhh2Jsts!e`kMG!uKx81iKA7qW5g3C=#w@s0Hw8 zZ|}=X8fXqQd7_|Aef=Oqyc=0uOg$7hziXjLN2%8F^PY>K-Tp@)UOAwv%iQ@jER$vyOLg;bGT;Kq>-)L-Z<_R`?MYnK53vZM8zkIBkA~e+*hicpzp^) zl|v~R!QNR5?xAra*_82wE^ID|Wg)+cIgm*e_#_WbzbwXW3&d3GqibZHo7RW-ZDU>2RaK=g9FVh6p>jh` z!a;cS8VrWM%PG-Yo}f^$a)fDEEIxKxt5Lb4Ea0H1E288G=93vHq1(UMdDbHKG(+UhOW=c#jarTo5yzpqN-XGyu!<9Wl0lGZz0 zJXyL#YLnI08?dO=Ez-WS>_yaUyKXGGl`wNC+5=*1|J`b?B!;&M%z+0*!O2mHF-?s; zPjF^a(DObGJd2(8`CG761S@HWu`>VjEkXRpJeh4*x@3mHqYFN9{=mj3+*(p;&?pa{ z$7ZvE^%NV3i=6$|VhagTVOFiCYcngt0sW7QQVp;Q-f^HOYs>JTfeL?ogu*+`>|-Z@ zeT+`-aFqQ3l8$(-G;O{`t*Y4l=nJVRr0p&3n=QMg*91s2P8bNl;`DO>PT+x;NFDYY z5Jw3Qe`2$2b5^K=BOC{6OuP_6K_p&JGe0coY1p}SjT z5DDoP=?*E8?oR1cIusC*?nac7l1Cb z=s}!J+5lmoFm?3>_w&(vK2IE-UX|$!_;4nfHJX^;np|HTnanGttvUZ}vY#?p=wmBr zT&&v^vI7t{49dA?<)RJEO}s~!JiJGRjC)T?1Ln&TI$$DGt-ESX)8R8F%t%Jwj)qjt zmxB?RZO3#;df`lTOG=-f-hayS0h>WQyGyyUIX-%>D4aSg+S@9RTyUA@-ixcMg@pyG z;bvQ-r(Of}#h^l@fk2)B>%>?a6V7G2T=W1bAddHVm!y4!*ued^Z@RGpnGX%bd?5?Q z2t}Qy4(RFKh_F>PH2e_|EB#>J4~(nKnR=#2e|Aa#-J&aKNneQa{%0twM4rt(lB91v zM$L^T3!S}kiBrPLspA8r`S-0s>D8iOQCbO!@J8^!m4i=@xa((8c<={CR(zdZ9F;~D z!@&_I@oXQ5ugKP;Nj|3y16-_=otN)UZ^C)oefK=ub1Om%)eg1Ri*0)@TsA$pe_niB zasD3t$&QweGx^>7_tg|jZXdG)OhO&@zJASZ8ckXK+7tD8h;?pDVtJSt`N$P;?Ke~R z^MpLSknlrT5!l$?E@QJp=@^BA#c7e{7_h<)FJ42w4r{i9?W|c$bblg0Yx@F z+cu+%*^(rYtoi1i{6H^U#4m!V3=bdx&~k18dBkeYft;~`HM%y*5JCC=!JgNbjGw5i z!e}^@%Qguaksoy${mCBZz`t-|&{c^8D}n+#amOw|a_E0x^5tW{Rj&qV890cH_A624 zltcfIOu_e*)p6>+(Y~AqOJ7;$`UDhgw0EypTF<|=zQ1`flFl5fsYHB_Zq%^5x{=1~ zUcI|bwFCD{clRpQOKu*eXi5RtI3411Y9x?SCXk)ua#Rv>enXq@&YnuNwoW!r5G0Mt zF34l8a~wDM=w0udn_rZaGkl~IFr=r={PA4Qe405Sh16WMw^nGTJDD}V9fcCN#x6_~ z2a}t75&ld9-c^4YS$+&9_3QBC00fR)a}J?MV{cKw+Wxn+$<%w!dZ!Xq+B##s#ByD4E8r>QyqBbOk-=0qIb~L7nCRdxW2r zu5sQPLVIHUs(X$@0Qv*nniFcnPXR(*FBTR1 zu)Zj&gT2s$w=|69=N~EtO@0Zo3L%s*MZ^U4^a=;HDTA_=n{Jm!8(;oh*(i(s@(6g|7<$=Lus%TiN94lAW?`$X6@pV3MM?nA|bQ9Gvs^$z`t7U4U>{)XS*B* zb#mv2Qx>|!!fy{2LqeA1mMOBY(fo5{9Ug%q0IQRfae-IhF&Eg}!MF7B`x^F|izxosmY3cw z5KdX!O-`K$X=J3n`?3XJiqB908TZntRZx}R1 z^=@TZ#NJxu$p0PE*4ASQI}Xtr(26lUm& z-i1)3z-HVwZ=>zc0wFyI4B3;LyVK#cBOr;j<~^^+rg%0uW(DSJ>Lim0cRLJMk#{DY zPH@5}M_j9XRgIJF)i@OdPo>{5s#aW#J3pF?-FYnm9RTBv&z20oA@58sA62q(( zPi4Nv3#0812@A=&vQD|RPfjIblx2Ko zGWtX6!)jGeHW=bBe^6S9&iwvLjTTg#M!5BHtT$Rr`#w5O&E%%p6HU5&^qa_G1Isug zxfqVsUtr-KGtrm=$ypKSO4y7sce?_fAK0VhyUV$6I7w=0-8y41WMCNYZz1;@>hBx6 z^FVe$CXrPX-E!N?Qk~3B`{pC-Mu&aG{hG@tddLBV*ex{ce|jF7UwR&p39@3Xoq+5&jy@7QpW9F5SkT&4^V<_X!mlLd`~R&b zamZN{#K9fzS^^x_H;Y&mOo+$_xQanCWT|eybUaIdj%T?5FnC}wmW=cd-UJ#<3VKy2 zDHyNM5pG`V#i~~dMva?UW-+Q#^RNN2l~%8_f$gQ570y!_MSP%uESxwXQXz@G{E9!; z5%Icf2|tDUbt}R((93%K1?@Xv{)>>5Nkg31`IHU(2IhPI^E+VLHIW_SyFn3vS4I!+ zgJlzX$zHRB(lFGBZvqCozmH4I%+K7!;Y9NV@B{HuUe@F>fI~82zPH%AMZsbL6PKU@qxYQCGGQV~ z0sJEE6L6h>L6;mv7|_|3_k2Kl;Sd@C|7t8SOXp2F2rRFRJ@5M(e!Ft<4ccV~S~}z+ z`!wS##?nky)nxqpP+_=AGQ}DxAq3CGa&FqGhr~9Bhd7^6=F_X>uvwA7UNEco05_t9w=dVP z2CxGEv1Zi*?5#_Kog)vs>G&WIp;(7Zylb@X^rqJxU+7T7Ck~RY7Tr$DT)B4~Zwdc3^!7^cCoH4jiNBNv;B4 zY2jQ5w8Tg-#2!C}uH+fS&+V(oDcW>fKb6 zD;_qox@84XfM$1$BI*K%8)0E#t$sf1?|;Ke|FDIFI6yF|FtG9Ka}f$Q@iURHzUYtr zvOA{)l1zdTH0|!`5iLJR;C`;5ZG%-6ax7uU?f|Kfb?`=_rAFG7f$SOb`^S}(7d`(WA4u_E2K&*|VEpA*^oMKFkotiM+c0-VDCGM0nH zK$N?Kgg4OI>qxlmunszy{A|Pf$Z2bwd4lXK8Dtwv5eZC;&bjGe6c8+q$t*~X-yf5=`ZH%oSKZA4qvr z=X>k(F(`@6P>0PvW)oUW=09GgwJ}~1rjO7mf*yom2gU~qS<{S^MYB-p0VZJ$qsa~i z=cR)pY_b6Ge=T+E@dfIAnL=X9v?o%%8UndLKR>VK`YZ{y?`AcKfkILWBzZFb01sjx z5G6;{JFV)~u>H-$1_;>U1X9@HCeN@WmeXXRT?=UTN)5S?66Z%^+5}gZm01s&&ms}Z z5aDab4_?DtO!R@kb0%z3PH{7S*Z>kz6|!U%C|hiYxZqNJ|Iuh^*X;huj$y*ir&-dT za}#Wgcha;}tVpfb^hZ(Cm|$F)pOy1^vcHw?*(AhWuU*uely9tXO!Q?&l4B#xj=8lw z{=%4$Pi5I%@z(Rm`$Ki>CH0TAd1Qi06fgpUIgL{5{fs8(ezth1CLR!k7!*1w!G%Q+qV3o-+ zmEb|MSoPm;d4Nq23S>p@@!I9D*P4%n{+#Gq1O@WbH&2Fl=;7HtE&y96YJ8|I`gFAG zTsjIJluZ5edRNd8-ArD+W}xt9(oIleFR>M^tTf92HwS~$@Ls?A<9qu-;Uq}L5fGCk zp*SP3oY`+hemB72q&sl|JICAf_}}JJKj<<)NGi;MvSGCLj|U*^co15;$4_(wnE!aY z4oBe4R`_iSEdF=^`j`$1lX}-u=A`lG1+GFMv|WzV3PXQ9ApTAY_(>gJ>VMDwU8Vp+ z?4^amfIB`R^UtAm2p50mIn>YW1bRhA|4`cTl^h=3^qj%g|1pF#h;iW?8K<%y=UXB# z+t)K3WZd-WpyD4QD1vik@DS<&;jLz)&3fXGsu5%pg6Qt~oa7 zI{n}G<*%YyW&(Bm^Bj`tM^Ni|?I78|)Sok~oa-+uQF!NJlKdjEsy{4lXQ;1^M@x6^tYU z{Ggn5x~zJ32L&1W}XQ)@Bz*7U05!&9%Tvsi%CDNQ5lRprfD!u$ZL~*?+gr!1sfC z(Jk+HSP`Ujft>LA4n9Zm*-WPb7Kt!mQOTWkEgi`Z9|No{jf>%(Uhy2h)Q@nGQIB}- zP3fPHei+Y@RP@+i#@ku&^qVZ7lPWjhD z;6T=%JOET?k3ZMYFQ*Uwy%K>5AQX(Zp7kWJ#3E^|=aO;rW9SPLr1I(5T(wOpyYUB_ z+WpIvSCP97wnERB76ZrMIf|rm9^kqT{zbV4Bs39iL18qWUVCV9=3gjEYaCbav(Ve3nB-Ymg)2Yrb0>)B<0y_Y~-WgKGibWFQ(VM9CLHE8OCV4=4q7hJ9cbN{~bFPP^uOf^4Y7m zdhjsG43K(|Z#7%S{#^4;if6W5Y++vWmbC>^_Fh2<01^~$z@_>~E)g@pU)CzOk{&#& z3j}oA%FS+-Mc-_$nY3#jFvunQSxr^Q@Ch{J0i0QGvn@Surz7@AN$^1&Qs#!q*19$U3C#t)$*aw4}Aza_*e{wR4ldkx4`U~I;?M;!x%PjUz ze&|+e6X;HHZ+Y&fqj0{b*47ct+Me1t3DU z9(l+EBK90W)}=XRw=+Yo4$~(sohp`MXJ@uNcq2e4;=#>Rf4I)!w9u*$CXQG}3aq9% zb_+tqs{k1?cf|Kbnaz+pcmAbQ>BDozzwen}lL*8I59~(!bgVVbgqlrufXlSdWvd03 zl0U!h8tBUu%0&IB(P@80BOHQF^Q~xL1cfEBGAA}Kq}T-VVkMKK5g)1sp@o42-7Vh= zhWJ`fdN|qinh1JqgN&No6MzVARrxpuE}9rpu3RUJha`O=zpWb`e8J*Xn0O`(sC3zH zH^KyTiq#TF3!Dr5kwZonnsJN8{ad&JF8V^t%QQf{bi$M2dy~=`dAUyaf#TUf@{O|u zr@hg8rv%8lAA&uk0q{4nc8v!VF6t8xj%Q}~0{~k-xxeX5TXfBFcM>zRn%}49{T(P| z8}^|QIb-ipRM!Clriew2qAH*Rv8fYG2IL=GGjA!%^;;Kpzu#UDPfgt{`NlXEL#oqj z9|GL^NyX0G==0R><<4$qOiV03`}x8<30Z8O(3d;ni~WJNuG{YuVVf-orX40n z&BJ@_@>Z5iz;Lv{g75it`E}jJF4s?9@BA~BkrLmAkZ&hO20wrLC{x&N=N%+@B_GJ8 zR~+{$X%}0L&VM6hen{oxkQkEwSiBM@KtRZfH}|5=d`B za$Ss2fTx7LLXckPb#Arwi3=;0oUc?-PFCo}ps!ZU*hqzOV~R}lS%gldJ`g6d2n7}m zZ6g2aJDtL=5#o>3t2Ft?&&FGoa@a$>4Vk7|aw=bX@Hz0NTo$z%Mh&q*CP0!@5`5{(fot(6tBw@wszgqs-iAsg=9`z3o+Inh2{;bG6144 zxv3811-$rj`(njvt$nOH05LNUPj{r$2!u zsZ!Lew;CL@_0*XF!>#Bid+^5O=gW9*b0l*0Y|P_91I>&B(o-BQE^WM~P1l(8JcoVz z6V&neQwgI-TKPID)@u1-u^XFdBwC;A39l3L!Uc(mZy4m?=#^hXh}QxH(nXy8g%t=N zsTRRLKJ15$mT2NCWU>+*?~1(kxh`*aGPas*K@<~TeQlk(9S`t;FHV;j0P?*LkcsvX z(4Azy$6W=`!IAS`!*H4kw&#GcWsl^)CyFzP<&Iiw?$ucwt9ECm1Ie-u)CG;*x?5US+Ia&|JfX^LdDx|8a+T^yA zx@8Y61}*@`(WR%>Wx_fPXiAA5rSsYv_|TB3D_sLNfa6^Exc!%_T{bmzrWdr$8g7s! zqq*!Cnm%Hvv?l{Bs^}??)XHdoliqn=yZPd#UOk8hba*3%*gbOf@KZ_#-m;ckA7sqYV@< zM#p1UUsWq~e{R|RrFGnDPtU5&n$Y{~%wK`=vusJ;$2_m_1&vC^w6LdF_ZURqfg8k>lZLykPDu_2~SN^kj` z#TnX|PzI1F<8R6Fza(Qez16?>!ia#1h~HG8AW zH@-0s4r(=`Ca@X4p%!~z+RWuUlqwQi@kB@hC^qh-Pk+s-9gPtPi*q;E&$Uj(T2;Qh zz$(b&c%i;~gvaD`&pRmI^(#6iA*}?ETNIfGGA^0b0+~Q-0+A0 z2#RDR?^!l*d8#a(ASL@&kK_0%jTU#uCg=5Nd@QJxcpR|JtB3}EJk|Qndr#WD}VuL z@Iz-s06-KH7>E_;BGB=0?aODb{7tVfy|XHJmh23uljzF8P0YAlg#Hd5{mu?X8`EJy zp%0F^-ngbHtSd3mZ4l`d2fQN#N9v`KRFDWiF40odjEqpj=qt`LXE#bm_erwqAa zl(Ffo=5$R*SE#L*XHTfwH(irP9WS{@R>!JUZ7E#^haut*dt!v^z^kUz5)5U=D_g0v zR(uX$9^R#7MK$X*R__M=)j#iOj6zr(YCTo0JNNclx+HQH;SNqM5|p)lG+CrLl^2_& zI_bh77ZhYjP`3@tBD(Ct@U5Kc7mkkVB%_}{M=bAx_!td;0205sbVNLFiDq1XL@dD5 zkDDGx_wQJ}yZ}gIL_D)*seqq+&-%tl-j?YPDpW;4xl01x)dtd4PbQR_r>atvT2p*}7X@7o4{M(Arl)J7A#_S|1aq~dq*6Lj5r#@f(? z{VQ8f4VVB2T%jNC(7tj{lAR1I@71t0@+ZjGq8|KJI=rBb>Kqr>A2>*q&@MkJp#eoxYMx@peZ4cPH8ux2 zJg(LMV=dnuwM|eFJwf2r{FOx)ayyles3Q%gG!r?DPkP9s=THf*0akgyJM*K7kUV?Nq+7oaIsGAuzDRVcr&Abb(p=nlnwwG!IXs$Xkf%F1~Q zJ!3U&hy+;WC0~i7yv3mbJRlJ2JU^ozE)?4F0}%!)dkA=!XzuETy#?UC`HlyExPJ{- z;A?YcCbAe)&z;%&tw7uVA$m2WTu+Pn@js-Br(i@vr#mJTnZDtHc>p-i>i;L4*KvVv z%?hV>Dkh2uQ-zFmTYL&c>GAuN65~BViYfAZF9pRR{NltkLW_ znwFt#`N#O3(11dy?PMUVfPCJf!Mr%o?M5;kmMaw=PlPf_1Ysp$dW3h ze~M=zMe39ezcFhdETCS$R#caj%)d->%Ikzm4GIzGI9$A;W#? z2*O~ys<&f?gY!ol$C?U#6^g0&`@Gz!e8yrU(m2JXBxSQ{RrAFDFFE;gswVWlfx=!KmYF1`e*xK0T$pS^V)CVTAP(MRz})kLN0 zwPgVGaN)&LiO~necTG*HDKE!^c{<))2^kyV+UcnUkOz}^51e6dmmk}g1^%c5BG(i$ zh2L+DrDmIyam+25C9XFN}T7>SC@Sn~V!~>ikwF<*jyd_@- zJ!E}uBAVI3jC!5kN=p=`8y0QMJ#vY=Ci(5>cZZgt1myR@*-zi$)288*0#1GsZ~unf2Ikpsc8h{w%3%&MNvXNA(WT~w(I_a&_WU|Xun*ain$Nk5{C2X6q6F)1 zZ_rb{Oj>8Do!ywtqO(F$&Gxw>7JSbAfG`fj7qmGp#sJEKXcy81Q>UPComxR8px45Q@AZ$T)6NrQuiUq82nZZ4#L%sQC9}1tLJwvth5T#dKYjHe(}6 zi|93pmcWDVig+nD?6XX&prpETju?;L%oz;Lk}@HiMu{%-f>>nLz(2%od4z+vm7O$7H)n;4#w>t?%J~MkX>WCJA!sJhIP!pK^Dv6 zJye`A>Xbu^O1YUgS#B)j8lLp1tlh=^G6Imdkwi2_ z(uifgt`+3(<6m0d7&4#%_JZ1o)|1gFg;@91^Zf|^V}7DhP2?bnj}L;8`@{bW14aTC zPUR{zFi`SM2N)0hcvLjm64zMr8Li)*2@rU#y{zc~w@J5mI9l%$U2YF2v{OuC*LLT3 zV%_|`eGP?!0v0k#w7%NZ9cdC5_=V!M<|Uh|p(Cox>7&v}fDBeBO2#_2fyPPlvLU#u z%LRT_Espt+06KkW{=SNR$AYsK_RuQIskEfGQqZhkF8$E^Ubu1#tA8CdF zz}pJ8#QDR3bJ%E4Ao@_2So_n(^)G2fKMm+3kb`2+57${FP`L>8zLTF9(c1W-hb|-L zeB?J4A-Tq;wOkuWc_7P6+}M5Pl88Rw2<%G~-ua6ME^}rmBXwb~MIVMhzAkATB@1cH zxNc9Y7jz(pKyUkLi=hb=8cXPZw5iyUI@zV3ExXtrM%+qG#C=EC>;eU$4WR$}p2Izo zEV$TBfSu21rleYj#sS!4QXrBGH3*|M8!HHNn_il{M$GRMYG%Cd-3DwL*+dfKLU|CbO`~=79T0P{6elH@JM|n)YtlJD$Yqw{xOF#u zNm1`%%bm%Pr<{CX257!^clwGsN(AzSln_`C;-dBhuqD!-niT`{R2g){5O^mssl&7n zE9VVpH17Z25fTKNS3(3T$VwOcR~eG)kubOx7*mjeVzgs3_!$y8xsp(_lk&8TsAYz| zeiA)35-}1a2>F@RKF0)Qq8C~xzMn1I<7}**j`8lV2%m#RQ41Gly<#aS1d=2m{7@}u zhv$bdyi%-N$oj#>v2{Hz7WbwoZC%Gx1PKgKXRn^>R4-qT-`rT0Nq0O-0U&4Is`CBb z)9~-X^nY!uCNMvMOD=2&J4Wy9E=UKWkk2?p9_d9AX#`4QVj8KJR?Cns>@2@DA)> z#TnT^4W}VSe}~uU$_&IJnxh)^N@lBw$KR93Cz8!sNP>omL*O~k*%yLJJpEwce+ zi3Nu^q`QWR`9QKsW?FT{V#t1A_BudxE~TsU-_J*1uMB?0tXatP;y#$#p*^4qd8Mn} zX(9CAo%OGkFh~^XX*jsY8c5UQ>;_xO4D)qyR37&+czVA@H!&%p@%d!CfC8E0PDPv#eDW-uh2B?gt z6kz3Kw15^`%8pt1VyxIptAz{$taY1GNESPv{8F+Xzijfr7+=12=sjjwPIqyXI z+t0OZEwQ{PnRRq)oVxevM@wWgk(1)4ZNd0$?mI_HYZeEay_+oEcOpo(O~g=#H(~z6 z)-rHoLz*h8`5;F&KY&C%8qdB--muDHF5;e>YZkuASliz~9sgm;J9rYo z({g{Mg8n}YlmGoTXaMR0_J8{;R__n68;rDq2zYLFb!*2v|6{}T*PIH2AT|eq2RoSm zd_ypj8x%tQ(y}=y?8Rc1^Ua)pnikRDqy5*5+w?)(D>?L1Ki}4`@6U3|0-z*^aqBut zXV<(y-~WNtaE2qeTkH=vym7{ASMXdXrLnQ4etNvEaIkf~D5aVStX5ofnuJAR>2qQq z)|mefSjiY02|^8Q&k+!*XLS^)hh)RtFCNfwH7Y}(Z+srq*2laqwwQYAOv^<}Cixqn za_2o35aO3dl2-dUgW0l(uKW)`4`{9nLjmB>{oN}koPYOxzrb5@@ExQG4q%h~eUkqF zGp2ZSFo+GA!XMWG#=S#9sVj2R1OMGx{cr9GxO*f3{#otM&vyq9uCRRvqVJUdT?Oy- z;*dM!5&|*|`OhZfSH*N818`SR=v$`sUti%rMr}dRyGNkY5C24AA=CgqN+nr@{vuN*7a9VH!7Q=6=06-H&1JTVn<1=#1 z*Az94|5;8Te-7w!qUbq^RZwg9ue~dV=hz#vKL$x%sr@Av=I!dXlQ z(7WE;t6trx95LPK%aJ@<=|;O{2_20E;K|X^kBWVBb8|yjoJ;j@r-o}8RmUCcsW7}c zQ^txmhy4S6))-NMT7__fwu$L>m)cbm`gOD4P%{)Og==WFDGAJ0+oruF25ji^o9eAJ zR|0~WeHXpPbDuvF^f;?JKbvYhSnpG>v;90-dCmRo=C7(ZA@xVR_9+Z;{V@jcoi^rY zYL6rvX_$<6Tk0&P><-HUSG@_xMR&PW_g8Um^Y|)_)7A_4pn5L5)8XvKZAuW{DsLc8 z#Cv*aPm@taV`!-bML@+ZjM!NtKH}Ll?SIN@KRhC-wo5l9%az??W7IB zdAf8LXs)$uX-8usNyv2A(dKG4<6Mag$zqf9W%m+u) z2&wGMHs%Q)Z;eLp2o-Dg4nBLB5+FQNHQagshSr$hSCmyrdhM_K0)m6*_tkbc{XMto zB$h$F8+KF{zdx_dj1)kh%gYoy;mmS$Xo(5P!#xZ_nhqo2V+Yz`qz5EFv8rJ06Tz*>BR9Jx6_MMiKr5|snbn>vX$UB? z&TI3b)lvgh>&w$-Imc^W6MyR?+OUBWR^niJ0&-*kK)q3h)FoTr*`%4*fA)n4v*>d-J){<>wjtAy?~x zX62$*kqld_^w1c}w-fzJl*P4olxszXlzr#G@vqt^RhGPyKqG)TGaN8=;2fC@^B zxokAC9jr=>I(M!Uoa#BID0RH5Ju~}~oHAPA#^WpeijaQU`FKm4=N{p}1wZEmpnn!{ z7U0BHC7j8O^S|f)tq%`S4J3tJT|`(JWWyyquFmjQdJtX%h6c3p*VAu{1l!CG&6_;e z*+nb}%;((?ys%GKx;-zPztM~rKPQ;nwa)?U+SE)Jdr*$9>=CJ+9ohnotYRSdD*oe_ zFY_nowHDl_7g$ZMhr~qe8p#?3D$>2tlm&%ssbfHjlVx|)@pyruuJd(yT4S-mWSNm+ zf_KR+a55Wd;;}}*KMc4HYrLH-XZ9E%<@Vm<4qz~6Sz-#0yO;A|qI7gsHpcS0$+a%6 zUJ_uk<=hv(jTff*hrt2m1TTva^H^#1XE4UR7f9|p$q*cNceD5SVsPub{^j&gwCnN( zCQFms?!5d+u53}u%R`NlhXu?E@l01wjxXC#0gfQN(>4>0 zFa-1b+Ux6?qmy!4;(GF&7i%`QWe>^CCC!A{_os9|zE3NF7v)7!@cz6?FV^_puT|wh zi_~5J>_FrD4Y$*@gVjtE(Z}0ep)OC~!Lk{bgRj#4$;<>3#oC$&Xru%N9!G!%rOTFt z#j9VJN%f0 zR5!oaRqKQWGij%eyJRL5O;~B^_OjiIxbOS%Rv4?I&6xEwm?;2xX$apfM8Q}$#;k;X zKx~+FlzU|Qd#7`&XzPVseN1hf#pmRQWOJlR!HUEwKdw)ge6{NIxMrK4KfHqZX#r{;~38N4`g!N zp4Q5!yv$r^KEDyl;T+ik{D^Y+yuDXNF)as?FLATao!8a7Wuk!geW(@X)srory1{MP zSA^k$zDp^hKVw!|O?h4WwG6u1Jq_KwPWr+J+gpK`C9gZemwaGkK8-6;+ci>vQqw5I zSI2zAf{Q>b$ll!VNxg?_Kq9U8#nBPf?R2MCcarm12)4`ZqIdrXF?U5oB&wI5?M2}P z_ca>#nuj;UWHf=ZR>H?zGPIYIXkll`3Cm>->DR5lFhJA%{C)F zeVmeBIyyh!i?G3RHr;+hWXlA5CC}>ngDsOH@;DXq@>KZS{%h*0HH7Qe0wCii?F0Bd&Q5Ce7>m)AD-J`&DzQy(d(gRMxGlADwk( zmsiRbCXd?f7M^zuU2FRq>Yq==wi4LnG!6_znMds{c%EttDJu|THKTOFa`rabzh*E6irrlT~=RNL722*LxQPHFB_R% zVbrvrzB&Q~XqG6y<=rN7REk3RxHQV3IKlh)?fuBC?8hHF;IOlfbIUeFVVUCx6I!gY zk;z=oNmpmuTn!mIig8$Pa&RrbiIBLzF>J6Y+wqlGCg;gX7)U1gd=D-sp`Pp;M#%L> z+FE1Pk7M&tlV)q8h%bRjAI5CGSN)U7CDCYRc|pC>N5r7jqaL4KHi}xh{p#echIxfK zsty4w@jG3s$%+Ed+kE-B(Ofe{%Yk?8OuD;|K_(bFII0Z_*^A?wV;eDCc_m~l@XFe> znVZ&2%cacLqc=uF7q}a$-7+}37^VaE6a5oMi7Crw!l-4$@WLI2{opE2?Mn4-)v;SW zrVwkYuNJ!pA~hKc-y&)u{LlNJ8Ps@{G~q&(`~jS{qRc6;H=6dkCq5eodHAGTc)zL!Sm993_Y=V{;T!he9N z)c|f&?m^srHGr!S&j1RTaBb^csIhny!%XqduD=}oZ7?kq6^p#jV4G{8rCfu2`tf^b z&4ki~^94=7kTasN?4;)u@51Ne2>lUkl}X4tOF}(QT6>^;_WWq*tW7$$8kaK^{FVA+ zg%}Zwt`1u^mHl9K)EC1BH3NIg^7VlUQQ%w5)_r_X7I0AB>q#Hd)66_=p`B07%vFmaFwdGU;2B)(h?oqL4rzvfO@ZF%PA* zMq3jfT2a@lwnqskOeNfFBE?FdMsJ#)A=d&T>9O&Yu?bfRUpV!tqi@1notzV@S(XU#YgB9|L!9==XXmeo zEHCOd2UY4ma|@d&f1+&ry|%5)cfDNrBA%D--r4M& zZ2F~y-bqkIWpfivOioB7drbfj;A<7nX##8VIL9bu@yA-KsHiym`b;1xLE7?gk}aOo zkxtt!olPa%Wpng_87C`Q_WaDXMV&TEgfB$l@?=YrO31aYH1_o9aeh!bIV*xhSO-r} za`wByLBg!c9|@|5ZJkfJAPOw;Y^v4Cdu7^1iA^!^)<6etpLzEaEO{l{>2hieo7*)k zTw*Z(vWQ^~5BLcWfJqjq7_~Jfw;mfkzP!u|dJ2F0i7kx+U@*ty)S%F@8UBDCO;%_c zE)X=SxSuyDh7IGQKQ7k(F*!^?{J?Y3LP`R8iy*F1&rt`@yC9Bel5D?v!I{JW!$f)66?%@4ZUzcL8NLrmDKOz1W8 z`zR@5gImc$hUIZ`*&pYJjjG66NOY2S8u0_s6KeuF(Y4OPZl49LDsWg3KwwA<`SO|# znPD~nGM0g3N|>Xp$qnki#?Okl>_fkEsEMOPm=I#rTim(6i{|~HXz#oS^Yo5P>CyBm zhcJ4)zLfhZ59XY#Z^(#W$kxo4t7zfNEzI@1^c z(5``3F+U%_Q=5<^6BZEQ>`?RfJ?K#&VO&8bWG3-VyO5T^Jnp4)>XO8CngZv@iPa;T$v`0ML{5cFO zSUs~_G?2iAQ^1r^S5|Sd^hnGgo&YW7Y;Vzs#-xZ8lX?(!iXCh}qYwXZ3^ERTxo$Gm zwJ!&>b~uFsO838j{1Itzef-29`G;bPzIz9O`p}3fO$4XlIuc&$(;dPeW$+*YVvnq}y9;3nyf*rRIzCP~B zhkE$=-e65d61Mx#3dnogO2bz4+A!JzvW7m@WNiO)mcOB`N=mSFoJjyH(U;Ah`ISg; z+5pRXJkJ7YQTF9#zt1+g(9Xe zsYY`t^9sS3&KI0WNpV)e5jit=#tyr8v{7UVa7+iP$zch?yPU|PIK$OE@rd-UhkJvzxFP3czTsmyooYk z)xm8wpk1tmwNNQd!66ER)jvG*!>+W$r`bNN%a52OTDqRot`D#=O_tN@%rMc8gsFW~ zN&2Di577i^<<=vXr#G#H3^j32=jZC%KhKC`I&SxPFi45}1r>#{#fJ@1fiNL&NW_<& zhBA_n8%fStq?Vt2vRVs%tHBkm{V8p)R;|1@co^aIAs8PL0?z=)9G%O z{_HM7G~|Ti?DY66{|j)pf(NqrVyH#ki@E1}8l)qLv<=+OUK--hTTgLHws{>Z)Vdj1 z`?CGGXpA|fuUm3hS59P2DGfQ@v~nv^NSoB=ig&gcQb+8T<3$pznriIG!D!#d_1T*f zaHbDa!DNUmJNLoUCt>tA&x&<4=*oZxJDU%8h2k&Ayl9wB9`0Ulyz(l zJR5B8yPEjLe1{Jy&z1r7Ca^z&qgTm^^5gLCe05woIpcHW-h`${fvNYE$(_p(K#Fig z*!=ycXoF4;Cm7K@exJ#bEg|kn9+avW{^-@XRiw{Hr-=PmA#}T53RG`D$G#!1#)MGg zdE!0NVO`)>TY^+&gJgQRytq5*{5$rfDC6MwL}KO|z1@{T6Wu#t|0@Stn4iFzy5+6u z$7r)?b)FNG&%DB_9m}h2Xdj=lP=J!B?5}RxTzr?g7*%d$_bx6oLgVP5YiKF&9imf7 zP8vIPJv!FyY8`(Lw-gP%_TNXAz_IvNz4-afy@51p>W=~%i@og*482rXtNci33SDKP zY16ZTowHSaJ`Srr*e7G@ML~!liR-@e>J<`<2SYPR4F@r6`x03+0zc(Vg_Sq;G5yf+ zsa-^I+Y?;+_QlUMZu{bgD^-o1n;zNjj?EIMV}j6LH=YVjBDruEF?UNIi-Te(SZO?crdIn!l3rdU)X0+=^-;la2Zwot*jM^& z_aIL&_YHIaeb2rXZVPbe66$ICg_*#oJ2R5DhK(yqDi%9b7ZJxSP6MA}yJh2l5C#-Z zRnE=kObAT(-snclg1a+{@^PM=QlIT(CwfDXM{nnZvZjkm znQ)exV6b9xf`*u_T(&o1c@#P(BY@?Ju!m%IhBFHQKkYcKA_F;Iq+7s49NNW5*dpg@>=a@6|YO;w~!quzTMtZb?2Q=WucUbgg-;&3RW~{j>^w z^(u-d+V^HgqhyqvB@|Z2kTrC@M%M8?{Ch&> zV$a;%CR-ak8|@W}m@^T+SNAqwiF(c*JL_sj=g0XX|J##k-VF-(vlNb*m6Yzbm)~g>e{6G!tevtprL%mgp1GAE7dlHc z?%e0lI!8MWM>frC({ZwL>MsJTvyf(yp6*HM9DloN`7GA_alw4^No4%arm@Q9t!CD= z{ito9+kxWy&fJNs4sijdVqPC#-z;V^D%Xu$4bAY;E|$t&hnzNtS{^T=rOX=fuVuO@ z*@#=u$K39J56wlqCQr{(0!fMA{aMk9myD)eo_MUIyF&B%Ij&xIel8j1Z0?CiuRc2l zqrGb^nler*vF6Z|Y26Pon=qzvy6nZdm#N?tc`A0*amtlz9;8WG9%b|W*p#NF3ieo7 z%SDp+Nr~3O=ck90(?ZXFW*7CR+@T-PJa4)FGesM6PJ*j)`(58AuPZ_yR1#gsn9 zof1IcUPBx23$l>&%F>lEud6O)WabBs>XJ&#C`9RM8ahcsBWHc9YTVQ9#kFc z2==9qTZ6nos@yYt)?%PYgTeGx_Yz{gh=>TEk2G>Cn0P%anRw42I*U!CKUjJjL$4D7 z^=~33CMGe{{BADO#Z*PvYc;wFw-;&l-1G+5Thkg10U7$z=W{ydeVO-WoK_Dp&uSC2 zMy^$Bb>whE9Nq0B%2|Wfc9)c+(0Tk_f9x^06Q4}ff&oAU_#21$;p33IzNa`6Y$n!D z#0Wk3Iz?X*@oY6v^3zHCK#sy2{q{*xekU>g6zsP*I~f__Q{w&6dk>G(?-g-H8t#zE zyY@$+L*G;4(~*}b=Ah3z$2_pnecbe4w<8<<~BCZ;uYpY&Vk%=+f#UnUaI@s+-h z59Rt~H$q9NZz`ZXdrWR7Yq(=>W$fF0uf%xI9aMdmTCVdno_pym1!QofHEumXgLT$Q zyYZaZ+Q{g(g}pa-4f^!Gg#Aeaed2K#8Xn&zrHe2g1o79OzYW3JNPofU^w#MJNBe#& zxa^*D(h43Xw8@jt;#bP6vyY~x_@yuh%Y5GHNWbl!+x!+1BC z9k;<9GAwDEySt7rlvu;2N+j=B5WB59>ysh!X5*0}~Hrz5KeAwcz5C7{D zD5SGTQo?>{STw2AD6IbPr}^jq&yxHC!nOx91|R=*EC26?)}YOX(h+S77XS5A{_pw! dZ-4SWuBLe52+bRkBLv_-d1)1?3K%@#{{f5_W+DIp literal 0 HcmV?d00001 diff --git a/assets/validation-example2.png b/assets/validation-example2.png new file mode 100644 index 0000000000000000000000000000000000000000..d870c39e2ff9dee3c3ffb0d28dca281d878fc5e9 GIT binary patch literal 44017 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 F_dm;R#ZCYK literal 0 HcmV?d00001 diff --git a/docs/POLICY_RULES.md b/docs/POLICY_RULES.md new file mode 100644 index 000000000..dce7257b6 --- /dev/null +++ b/docs/POLICY_RULES.md @@ -0,0 +1,78 @@ + +# API rules validation + +This feature allows you to define set of simple rules, and test the API against them. +Such validation may test response for specific JSON fields, headers, etc. + +## Examples + + +Example 1: HTTP request (REST API call) that didn’t pass validation is highlighted in red + +![Simple UI](../assets/validation-example1.png) + +- - - + + +Example 2: Details pane shows the validation rule details and whether it passed or failed + +![Simple UI](../assets/validation-example2.png) + + +## How to use +To use this feature - create simple rules file (see details below) and pass this file as parameter to `mizu tap` command. For example, if rules are stored in file named `rules.yaml` — run the following command: + + +```shell +mizu tap --test-rules rules.yaml PODNAME +``` + + + +## Rules file structure + +The structure of the test-rules-file is: + +* `name`: string, name of the rule +* `type`: string, type of the rule, must be `json` or `header` or `latency` +* `key`: string, [jsonpath](https://code.google.com/archive/p/jsonpath/wikis/Javascript.wiki) used only in `json` or `header` type +* `value`: string, [regex](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) used only in `json` or `header` type +* `service`: string, [regex](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) service name to filter +* `path`: string, [regex](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) URL path to filter +* `latency`: integer, time in ms of the expected latency. + + +### For example: + +```yaml +rules: +- name: holy-in-name-property + type: json + key: "$.name" + value: "Holy" + service: "catalogue.*" + path: "catalogue.*" +- name: content-length-header + type: header + key: "Content-Le.*" + value: "(\\d+(?:\\.\\d+)?)" +- name: latency-test + type: latency + latency: 1 + service: "carts.*" +``` + +### Explanation: + +* First rule `holy-in-name-property`: + + > This rule will be applied to all request made to `catalogue.*` services with `catalogue.*` on the URL path with a json response containing a `$.name` field. If the value of `$.name` is `Holy` than is marked as success, marked as failure otherwise. + +* Second rule `content-length-header`: + + > This rule will be applied to all request that has `Content-Le.*` on header. If the value of `Content-Le.*` is `(\\d+(?:\\.\\d+)?)` (number), will be marked as success, marked as failure otherwise. + +* Third rule `latency-test`: + + > This rule will be applied to all request made to `carts.*` services. If the latency of the response is greater than `1` will be marked as failure, marked as success otherwise. + From c8e5886a967fe3857288d03fbee362e782768d48 Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Wed, 11 Aug 2021 15:57:41 +0300 Subject: [PATCH 16/43] added telemetry api calls (#201) --- .github/workflows/test.yaml | 4 + Makefile | 1 + agent/Makefile | 2 + agent/pkg/api/main.go | 2 + agent/pkg/controllers/entries_controller.go | 9 +- agent/pkg/providers/stats_provider.go | 36 +++++++ agent/pkg/providers/stats_provider_test.go | 35 +++++++ cli/cmd/config.go | 3 + cli/cmd/fetch.go | 1 + cli/cmd/logs.go | 3 + cli/cmd/tapRunner.go | 9 +- cli/cmd/version.go | 1 + cli/telemetry/telemetry.go | 100 ++++++++++++++++---- 13 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 agent/Makefile create mode 100644 agent/pkg/providers/stats_provider.go create mode 100644 agent/pkg/providers/stats_provider_test.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 400bf3bdc..312db265d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,10 @@ on: branches: - 'develop' - 'main' + push: + branches: + - 'develop' + - 'main' jobs: build: name: Build diff --git a/Makefile b/Makefile index a3515ff83..3d66f655f 100644 --- a/Makefile +++ b/Makefile @@ -67,3 +67,4 @@ clean-docker: test: ## Run tests. @echo "running cli tests"; cd cli && $(MAKE) test + @echo "running agent tests"; cd agent && $(MAKE) test diff --git a/agent/Makefile b/agent/Makefile new file mode 100644 index 000000000..c27ad9c63 --- /dev/null +++ b/agent/Makefile @@ -0,0 +1,2 @@ +test: ## Run agent tests. + @go test ./... -race -coverprofile=coverage.out -covermode=atomic diff --git a/agent/pkg/api/main.go b/agent/pkg/api/main.go index 942f09569..90a411ee2 100644 --- a/agent/pkg/api/main.go +++ b/agent/pkg/api/main.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "mizuserver/pkg/holder" + "mizuserver/pkg/providers" "net/url" "os" "path" @@ -108,6 +109,7 @@ func startReadingChannel(outputItems <-chan *tap.OutputChannelItem) { } for item := range outputItems { + providers.EntryAdded() saveHarToDb(item.HarEntry, item.ConnectionInfo) } } diff --git a/agent/pkg/controllers/entries_controller.go b/agent/pkg/controllers/entries_controller.go index a1ad22d42..99bb8d8fb 100644 --- a/agent/pkg/controllers/entries_controller.go +++ b/agent/pkg/controllers/entries_controller.go @@ -241,14 +241,7 @@ func DeleteAllEntries(c *gin.Context) { } func GetGeneralStats(c *gin.Context) { - sqlQuery := "SELECT count(*) as count, min(timestamp) as min, max(timestamp) as max from mizu_entries" - var result struct { - Count int - Min int - Max int - } - database.GetEntriesTable().Raw(sqlQuery).Scan(&result) - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, providers.GetGeneralStats()) } func GetTappingStatus(c *gin.Context) { diff --git a/agent/pkg/providers/stats_provider.go b/agent/pkg/providers/stats_provider.go new file mode 100644 index 000000000..0f42f0fb8 --- /dev/null +++ b/agent/pkg/providers/stats_provider.go @@ -0,0 +1,36 @@ +package providers + +import ( + "reflect" + "time" +) + +type GeneralStats struct { + EntriesCount int + FirstEntryTimestamp int + LastEntryTimestamp int +} + +var generalStats = GeneralStats{} + +func ResetGeneralStats() { + generalStats = GeneralStats{} +} + +func GetGeneralStats() GeneralStats { + return generalStats +} + +func EntryAdded() { + generalStats.EntriesCount++ + + currentTimestamp := int(time.Now().Unix()) + + if reflect.Value.IsZero(reflect.ValueOf(generalStats.FirstEntryTimestamp)) { + generalStats.FirstEntryTimestamp = currentTimestamp + } + + generalStats.LastEntryTimestamp = currentTimestamp +} + + diff --git a/agent/pkg/providers/stats_provider_test.go b/agent/pkg/providers/stats_provider_test.go new file mode 100644 index 000000000..a35f7ca68 --- /dev/null +++ b/agent/pkg/providers/stats_provider_test.go @@ -0,0 +1,35 @@ +package providers_test + +import ( + "fmt" + "mizuserver/pkg/providers" + "testing" +) + +func TestNoEntryAddedCount(t *testing.T) { + entriesStats := providers.GetGeneralStats() + + if entriesStats.EntriesCount != 0 { + t.Errorf("unexpected result - expected: %v, actual: %v", 0, entriesStats.EntriesCount) + } +} + +func TestEntryAddedCount(t *testing.T) { + tests := []int{1, 5, 10, 100, 500, 1000} + + for _, entriesCount := range tests { + t.Run(fmt.Sprintf("EntriesCount%v", entriesCount), func(t *testing.T) { + t.Cleanup(providers.ResetGeneralStats) + + for i := 0; i < entriesCount; i++ { + providers.EntryAdded() + } + + entriesStats := providers.GetGeneralStats() + + if entriesStats.EntriesCount != entriesCount { + t.Errorf("unexpected result - expected: %v, actual: %v", entriesCount, entriesStats.EntriesCount) + } + }) + } +} diff --git a/cli/cmd/config.go b/cli/cmd/config.go index 6fc666fb5..0a6dab3ea 100644 --- a/cli/cmd/config.go +++ b/cli/cmd/config.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" "github.com/up9inc/mizu/cli/config" "github.com/up9inc/mizu/cli/logger" + "github.com/up9inc/mizu/cli/telemetry" "github.com/up9inc/mizu/cli/uiUtils" "io/ioutil" ) @@ -15,6 +16,8 @@ var configCmd = &cobra.Command{ Use: "config", Short: "Generate config with default values", RunE: func(cmd *cobra.Command, args []string) error { + go telemetry.ReportRun("config", config.Config) + template, err := config.GetConfigWithDefaults() if err != nil { logger.Log.Errorf("Failed generating config with defaults %v", err) diff --git a/cli/cmd/fetch.go b/cli/cmd/fetch.go index f26f43c44..f18e5bf3f 100644 --- a/cli/cmd/fetch.go +++ b/cli/cmd/fetch.go @@ -14,6 +14,7 @@ var fetchCmd = &cobra.Command{ Short: "Download recorded traffic to files", RunE: func(cmd *cobra.Command, args []string) error { go telemetry.ReportRun("fetch", config.Config.Fetch) + if isCompatible, err := version.CheckVersionCompatibility(config.Config.Fetch.GuiPort); err != nil { return err } else if !isCompatible { diff --git a/cli/cmd/logs.go b/cli/cmd/logs.go index 8f7a2f42e..838fc8dbb 100644 --- a/cli/cmd/logs.go +++ b/cli/cmd/logs.go @@ -7,6 +7,7 @@ import ( "github.com/up9inc/mizu/cli/kubernetes" "github.com/up9inc/mizu/cli/logger" "github.com/up9inc/mizu/cli/mizu/fsUtils" + "github.com/up9inc/mizu/cli/telemetry" "os" "path" ) @@ -17,6 +18,8 @@ var logsCmd = &cobra.Command{ Use: "logs", Short: "Create a zip file with logs for Github issue or troubleshoot", RunE: func(cmd *cobra.Command, args []string) error { + go telemetry.ReportRun("logs", config.Config) + kubernetesProvider, err := kubernetes.NewProvider(config.Config.View.KubeConfigPath) if err != nil { return nil diff --git a/cli/cmd/tapRunner.go b/cli/cmd/tapRunner.go index be0aac841..6255bea18 100644 --- a/cli/cmd/tapRunner.go +++ b/cli/cmd/tapRunner.go @@ -11,6 +11,7 @@ import ( "github.com/up9inc/mizu/cli/mizu/fsUtils" "github.com/up9inc/mizu/cli/mizu/goUtils" "github.com/up9inc/mizu/cli/mizu/version" + "github.com/up9inc/mizu/cli/telemetry" "net/http" "net/url" "os" @@ -100,7 +101,7 @@ func RunMizuTap() { nodeToTappedPodIPMap := getNodeHostToTappedPodIpsMap(state.currentlyTappedPods) - defer cleanUpMizuResources(kubernetesProvider) + defer cleanUpMizu(kubernetesProvider) if err := createMizuResources(ctx, kubernetesProvider, nodeToTappedPodIPMap, mizuApiFilteringOptions, mizuValidationRules); err != nil { logger.Log.Errorf(uiUtils.Error, fmt.Sprintf("Error creating resources: %v", errormessage.FormatError(err))) return @@ -249,8 +250,12 @@ func updateMizuTappers(ctx context.Context, kubernetesProvider *kubernetes.Provi return nil } -func cleanUpMizuResources(kubernetesProvider *kubernetes.Provider) { +func cleanUpMizu(kubernetesProvider *kubernetes.Provider) { + telemetry.ReportAPICalls(config.Config.Tap.GuiPort) + cleanUpMizuResources(kubernetesProvider) +} +func cleanUpMizuResources(kubernetesProvider *kubernetes.Provider) { removalCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) defer cancel() diff --git a/cli/cmd/version.go b/cli/cmd/version.go index 7d3f9029d..816f92865 100644 --- a/cli/cmd/version.go +++ b/cli/cmd/version.go @@ -18,6 +18,7 @@ var versionCmd = &cobra.Command{ Short: "Print version info", RunE: func(cmd *cobra.Command, args []string) error { go telemetry.ReportRun("version", config.Config.Version) + if config.Config.Version.DebugInfo { timeStampInt, _ := strconv.ParseInt(mizu.BuildTimestamp, 10, 0) logger.Log.Infof("Version: %s \nBranch: %s (%s)", mizu.SemVer, mizu.Branch, mizu.GitCommitHash) diff --git a/cli/telemetry/telemetry.go b/cli/telemetry/telemetry.go index 6dd438877..258d88dfa 100644 --- a/cli/telemetry/telemetry.go +++ b/cli/telemetry/telemetry.go @@ -5,39 +5,105 @@ import ( "encoding/json" "fmt" "github.com/up9inc/mizu/cli/config" + "github.com/up9inc/mizu/cli/kubernetes" "github.com/up9inc/mizu/cli/logger" "github.com/up9inc/mizu/cli/mizu" + "io/ioutil" "net/http" ) const telemetryUrl = "https://us-east4-up9-prod.cloudfunctions.net/mizu-telemetry" func ReportRun(cmd string, args interface{}) { - if !config.Config.Telemetry { - logger.Log.Debugf("not reporting due to config value") + if !shouldRunTelemetry() { + logger.Log.Debugf("not reporting telemetry") return } - if mizu.Branch != "main" && mizu.Branch != "develop" { - logger.Log.Debugf("not reporting telemetry on private branches") + argsBytes, _ := json.Marshal(args) + argsMap := map[string]interface{}{ + "cmd": cmd, + "args": string(argsBytes), } - argsBytes, _ := json.Marshal(args) - argsMap := map[string]string{ - "telemetry_type": "execution", - "cmd": cmd, - "args": string(argsBytes), - "component": "mizu_cli", - "BuildTimestamp": mizu.BuildTimestamp, - "Branch": mizu.Branch, - "version": mizu.SemVer} - argsMap["message"] = fmt.Sprintf("mizu %v - %v", argsMap["cmd"], string(argsBytes)) + if err := sendTelemetry("Execution", argsMap); err != nil { + logger.Log.Debug(err) + return + } + + logger.Log.Debugf("successfully reported telemetry for cmd %v", cmd) +} + +func ReportAPICalls(mizuPort uint16) { + if !shouldRunTelemetry() { + logger.Log.Debugf("not reporting telemetry") + return + } + + mizuProxiedUrl := kubernetes.GetMizuApiServerProxiedHostAndPath(mizuPort) + generalStatsUrl := fmt.Sprintf("http://%s/api/generalStats", mizuProxiedUrl) + + response, requestErr := http.Get(generalStatsUrl) + if requestErr != nil { + logger.Log.Debugf("ERROR: failed to get general stats for telemetry, err: %v", requestErr) + return + } else if response.StatusCode != 200 { + logger.Log.Debugf("ERROR: failed to get general stats for telemetry, status code: %v", response.StatusCode) + return + } + + defer func() { _ = response.Body.Close() }() + + data, readErr := ioutil.ReadAll(response.Body) + if readErr != nil { + logger.Log.Debugf("ERROR: failed to read general stats for telemetry, err: %v", readErr) + return + } + + var generalStats map[string]interface{} + if parseErr := json.Unmarshal(data, &generalStats); parseErr != nil { + logger.Log.Debugf("ERROR: failed to parse general stats for telemetry, err: %v", parseErr) + return + } + + argsMap := map[string]interface{}{ + "apiCallsCount": generalStats["EntriesCount"], + "firstAPICallTimestamp": generalStats["FirstEntryTimestamp"], + "lastAPICallTimestamp": generalStats["LastEntryTimestamp"], + } + + if err := sendTelemetry("APICalls", argsMap); err != nil { + logger.Log.Debug(err) + return + } + + logger.Log.Debugf("successfully reported telemetry of api calls") +} + +func shouldRunTelemetry() bool { + if !config.Config.Telemetry { + return false + } + + if mizu.Branch != "main" && mizu.Branch != "develop" { + return false + } + + return true +} + +func sendTelemetry(telemetryType string, argsMap map[string]interface{}) error { + argsMap["telemetryType"] = telemetryType + argsMap["component"] = "mizu_cli" + argsMap["buildTimestamp"] = mizu.BuildTimestamp + argsMap["branch"] = mizu.Branch + argsMap["version"] = mizu.SemVer jsonValue, _ := json.Marshal(argsMap) if resp, err := http.Post(telemetryUrl, "application/json", bytes.NewBuffer(jsonValue)); err != nil { - logger.Log.Debugf("error sending telemetry err: %v, response %v", err, resp) - } else { - logger.Log.Debugf("Successfully reported telemetry") + return fmt.Errorf("ERROR: failed sending telemetry, err: %v, response %v", err, resp) } + + return nil } From 241477fb5c0856118179714e11497e3819e903d8 Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Wed, 11 Aug 2021 17:52:44 +0300 Subject: [PATCH 17/43] Code owners to UI folder (#203) * Code owners to UI folder --- .github/CODEOWNERS | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..92f7fc044 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# This is a comment. +# Each line is a file pattern followed by one or more owners. + +/ui/ @up9inc/frontend From e2db5087b8fa50dc497182102f9563b3699d116d Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Thu, 12 Aug 2021 09:23:48 +0300 Subject: [PATCH 18/43] Adding front end team as a code owners to ui folder (#204) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 92f7fc044..694076f95 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ # This is a comment. # Each line is a file pattern followed by one or more owners. -/ui/ @up9inc/frontend +/ui/ @frontend From 1d1b62ec4f8d1996bde06221d18c79bf0c06730e Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Thu, 12 Aug 2021 09:32:35 +0300 Subject: [PATCH 19/43] Improving log dump feature logs (#207) --- cli/mizu/fsUtils/mizuLogsUtils.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/mizu/fsUtils/mizuLogsUtils.go b/cli/mizu/fsUtils/mizuLogsUtils.go index e063fb84c..5edb5d41b 100644 --- a/cli/mizu/fsUtils/mizuLogsUtils.go +++ b/cli/mizu/fsUtils/mizuLogsUtils.go @@ -42,19 +42,19 @@ func DumpLogs(provider *kubernetes.Provider, ctx context.Context, filePath strin if err := AddStrToZip(zipWriter, logs, fmt.Sprintf("%s.%s.log", pod.Namespace, pod.Name)); err != nil { logger.Log.Errorf("Failed write logs, %v", err) } else { - logger.Log.Infof("Successfully added log length %d from pod: %s.%s", len(logs), pod.Namespace, pod.Name) + logger.Log.Debugf("Successfully added log length %d from pod: %s.%s", len(logs), pod.Namespace, pod.Name) } } if err := AddFileToZip(zipWriter, config.GetConfigFilePath()); err != nil { logger.Log.Debugf("Failed write file, %v", err) } else { - logger.Log.Infof("Successfully added file %s", config.GetConfigFilePath()) + logger.Log.Debugf("Successfully added file %s", config.GetConfigFilePath()) } if err := AddFileToZip(zipWriter, logger.GetLogFilePath()); err != nil { logger.Log.Debugf("Failed write file, %v", err) } else { - logger.Log.Infof("Successfully added file %s", logger.GetLogFilePath()) + logger.Log.Debugf("Successfully added file %s", logger.GetLogFilePath()) } - logger.Log.Infof("You can find the zip with all logs in %s\n", filePath) + logger.Log.Infof("You can find the zip file with all logs in %s\n", filePath) return nil } From 0afab6c06847fafc317f1964fcdd424345e5a5e2 Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Thu, 12 Aug 2021 16:01:33 +0300 Subject: [PATCH 20/43] added set hierarchy, removed allowed set flags (#205) --- cli/config/config.go | 146 +++++------ cli/config/configStruct.go | 4 - cli/config/configStructs/tapConfig.go | 3 - cli/config/config_internal_test.go | 340 ++++++++++++++++++++++++++ cli/config/config_test.go | 6 +- 5 files changed, 420 insertions(+), 79 deletions(-) create mode 100644 cli/config/config_internal_test.go diff --git a/cli/config/config.go b/cli/config/config.go index 43c4749e6..9c2467414 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -27,18 +27,10 @@ const ( ReadonlyTag = "readonly" ) -var allowedSetFlags = []string{ - AgentImageConfigName, - MizuResourcesNamespaceConfigName, - TelemetryConfigName, - DumpLogsConfigName, - KubeConfigPathName, - configStructs.AnalysisDestinationTapName, - configStructs.SleepIntervalSecTapName, - configStructs.IgnoredUserAgentsTapName, -} - -var Config = ConfigStruct{} +var ( + Config = ConfigStruct{} + cmdName string +) func (config *ConfigStruct) Validate() error { if config.IsNsRestrictedMode() { @@ -52,6 +44,8 @@ func (config *ConfigStruct) Validate() error { } func InitConfig(cmd *cobra.Command) error { + cmdName = cmd.Name() + if err := defaults.Set(&Config); err != nil { return err } @@ -105,121 +99,135 @@ func mergeConfigFile() error { } func initFlag(f *pflag.Flag) { - configElem := reflect.ValueOf(&Config).Elem() + configElemValue := reflect.ValueOf(&Config).Elem() + + flagPath := []string {cmdName, f.Name} sliceValue, isSliceValue := f.Value.(pflag.SliceValue) if !isSliceValue { - mergeFlagValue(configElem, f.Name, f.Value.String()) + if err := mergeFlagValue(configElemValue, flagPath, strings.Join(flagPath, "."), f.Value.String()); err != nil { + logger.Log.Warningf(uiUtils.Warning, err) + } return } if f.Name == SetCommandName { - mergeSetFlag(configElem, sliceValue.GetSlice()) + if err := mergeSetFlag(configElemValue, sliceValue.GetSlice()); err != nil { + logger.Log.Warningf(uiUtils.Warning, err) + } return } - mergeFlagValues(configElem, f.Name, sliceValue.GetSlice()) + if err := mergeFlagValues(configElemValue, flagPath, strings.Join(flagPath, "."), sliceValue.GetSlice()); err != nil { + logger.Log.Warningf(uiUtils.Warning, err) + } } -func mergeSetFlag(configElem reflect.Value, setValues []string) { +func mergeSetFlag(configElemValue reflect.Value, setValues []string) error { + var setErrors []string setMap := map[string][]string{} for _, setValue := range setValues { if !strings.Contains(setValue, Separator) { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) + setErrors = append(setErrors, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) continue } split := strings.SplitN(setValue, Separator, 2) - if len(split) != 2 { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: =)", setValue)) - continue - } - argumentKey, argumentValue := split[0], split[1] setMap[argumentKey] = append(setMap[argumentKey], argumentValue) } for argumentKey, argumentValues := range setMap { - if !mizu.Contains(allowedSetFlags, argumentKey) { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument name \"%s\", flag name must be one of the following: \"%s\"", argumentKey, strings.Join(allowedSetFlags, "\", \""))) - continue - } + flagPath := strings.Split(argumentKey, ".") if len(argumentValues) > 1 { - mergeFlagValues(configElem, argumentKey, argumentValues) + if err := mergeFlagValues(configElemValue, flagPath, argumentKey, argumentValues); err != nil { + setErrors = append(setErrors, fmt.Sprintf("%v", err)) + } } else { - mergeFlagValue(configElem, argumentKey, argumentValues[0]) + if err := mergeFlagValue(configElemValue, flagPath, argumentKey, argumentValues[0]); err != nil { + setErrors = append(setErrors, fmt.Sprintf("%v", err)) + } } } + + if len(setErrors) > 0 { + return fmt.Errorf(strings.Join(setErrors, "\n")) + } + + return nil } -func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string) { - for i := 0; i < currentElem.NumField(); i++ { - currentField := currentElem.Type().Field(i) - currentFieldByName := currentElem.FieldByName(currentField.Name) - currentFieldKind := currentField.Type.Kind() - - if currentFieldKind == reflect.Struct { - mergeFlagValue(currentFieldByName, flagKey, flagValue) - continue - } - - if getFieldNameByTag(currentField) != flagKey { - continue - } +func mergeFlagValue(configElemValue reflect.Value, flagPath []string, fullFlagName string, flagValue string) error { + mergeFunction := func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error { + currentFieldKind := currentFieldStruct.Type.Kind() if currentFieldKind == reflect.Slice { - mergeFlagValues(currentElem, flagKey, []string{flagValue}) - return + return mergeFlagValues(currentElemValue, []string{flagName}, fullFlagName, []string{flagValue}) } parsedValue, err := getParsedValue(currentFieldKind, flagValue) if err != nil { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, currentFieldKind)) - return + return fmt.Errorf("invalid value %s for flag name %s, expected %s", flagValue, flagName, currentFieldKind) } - currentFieldByName.Set(parsedValue) + currentFieldElemValue.Set(parsedValue) + return nil } + + return mergeFlag(configElemValue, flagPath, fullFlagName, mergeFunction) } -func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []string) { - for i := 0; i < currentElem.NumField(); i++ { - currentField := currentElem.Type().Field(i) - currentFieldByName := currentElem.FieldByName(currentField.Name) - currentFieldKind := currentField.Type.Kind() - - if currentFieldKind == reflect.Struct { - mergeFlagValues(currentFieldByName, flagKey, flagValues) - continue - } - - if getFieldNameByTag(currentField) != flagKey { - continue - } +func mergeFlagValues(configElemValue reflect.Value, flagPath []string, fullFlagName string, flagValues []string) error { + mergeFunction := func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error { + currentFieldKind := currentFieldStruct.Type.Kind() if currentFieldKind != reflect.Slice { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagKey, currentFieldKind)) - return + return fmt.Errorf("invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagName, currentFieldKind) } - flagValueKind := currentField.Type.Elem().Kind() + flagValueKind := currentFieldStruct.Type.Elem().Kind() - parsedValues := reflect.MakeSlice(reflect.SliceOf(currentField.Type.Elem()), 0, 0) + parsedValues := reflect.MakeSlice(reflect.SliceOf(currentFieldStruct.Type.Elem()), 0, 0) for _, flagValue := range flagValues { parsedValue, err := getParsedValue(flagValueKind, flagValue) if err != nil { - logger.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, flagValueKind)) - return + return fmt.Errorf("invalid value %s for flag name %s, expected %s", flagValue, flagName, flagValueKind) } parsedValues = reflect.Append(parsedValues, parsedValue) } - currentFieldByName.Set(parsedValues) + currentFieldElemValue.Set(parsedValues) + return nil } + + return mergeFlag(configElemValue, flagPath, fullFlagName, mergeFunction) +} + +func mergeFlag(currentElemValue reflect.Value, currentFlagPath []string, fullFlagName string, mergeFunction func(flagName string, currentFieldStruct reflect.StructField, currentFieldElemValue reflect.Value, currentElemValue reflect.Value) error) error { + if len(currentFlagPath) == 0 { + return fmt.Errorf("flag \"%s\" not found", fullFlagName) + } + + for i := 0; i < currentElemValue.NumField(); i++ { + currentFieldStruct := currentElemValue.Type().Field(i) + currentFieldElemValue := currentElemValue.FieldByName(currentFieldStruct.Name) + + if currentFieldStruct.Type.Kind() == reflect.Struct && getFieldNameByTag(currentFieldStruct) == currentFlagPath[0] { + return mergeFlag(currentFieldElemValue, currentFlagPath[1:], fullFlagName, mergeFunction) + } + + if len(currentFlagPath) > 1 || getFieldNameByTag(currentFieldStruct) != currentFlagPath[0] { + continue + } + + return mergeFunction(currentFlagPath[0], currentFieldStruct, currentFieldElemValue, currentElemValue) + } + + return fmt.Errorf("flag \"%s\" not found", fullFlagName) } func getFieldNameByTag(field reflect.StructField) string { diff --git a/cli/config/configStruct.go b/cli/config/configStruct.go index c8947cf52..db7aad9b7 100644 --- a/cli/config/configStruct.go +++ b/cli/config/configStruct.go @@ -7,11 +7,7 @@ import ( ) const ( - AgentImageConfigName = "agent-image" MizuResourcesNamespaceConfigName = "mizu-resources-namespace" - TelemetryConfigName = "telemetry" - DumpLogsConfigName = "dump-logs" - KubeConfigPathName = "kube-config-path" ) type ConfigStruct struct { diff --git a/cli/config/configStructs/tapConfig.go b/cli/config/configStructs/tapConfig.go index 16403ca57..5f7a68eae 100644 --- a/cli/config/configStructs/tapConfig.go +++ b/cli/config/configStructs/tapConfig.go @@ -10,15 +10,12 @@ import ( ) const ( - AnalysisDestinationTapName = "dest" - SleepIntervalSecTapName = "upload-interval" GuiPortTapName = "gui-port" NamespacesTapName = "namespaces" AnalysisTapName = "analysis" AllNamespacesTapName = "all-namespaces" PlainTextFilterRegexesTapName = "regex-masking" DisableRedactionTapName = "no-redact" - IgnoredUserAgentsTapName = "ignored-user-agents" HumanMaxEntriesDBSizeTapName = "max-entries-db-size" DirectionTapName = "direction" DryRunTapName = "dry-run" diff --git a/cli/config/config_internal_test.go b/cli/config/config_internal_test.go new file mode 100644 index 000000000..101addb6f --- /dev/null +++ b/cli/config/config_internal_test.go @@ -0,0 +1,340 @@ +package config + +import ( + "reflect" + "testing" +) + +type ConfigMock struct { + SectionMock SectionMock `yaml:"section"` + Test string `yaml:"test"` + StringField string `yaml:"string-field"` + IntField int `yaml:"int-field"` + BoolField bool `yaml:"bool-field"` + UintField uint `yaml:"uint-field"` + StringSliceField []string `yaml:"string-slice-field"` + IntSliceField []int `yaml:"int-slice-field"` + BoolSliceField []bool `yaml:"bool-slice-field"` + UintSliceField []uint `yaml:"uint-slice-field"` +} + +type SectionMock struct { + Test string `yaml:"test"` +} + +func TestMergeSetFlagNoSeparator(t *testing.T) { + tests := [][]string{{""}, {"t"}, {"", "t"}, {"t", "test", "test:true"}, {"test", "test:true", "testing!", "true"}} + + for _, setValues := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, setValues) + + if err == nil { + t.Errorf("unexpected unhandled error - setValues: %v", setValues) + continue + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected value with not default value - setValues: %v", setValues) + } + } + } +} + +func TestMergeSetFlagInvalidFlagName(t *testing.T) { + tests := [][]string{{"invalid_flag=true"}, {"section.invalid_flag=test"}, {"section=test"}, {"=true"}, {"invalid_flag=true", "config.invalid_flag=test", "section=test", "=true"}} + + for _, setValues := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, setValues) + + if err == nil { + t.Errorf("unexpected unhandled error - setValues: %v", setValues) + continue + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected case - setValues: %v", setValues) + } + } + } +} + +func TestMergeSetFlagInvalidFlagValue(t *testing.T) { + tests := [][]string{{"int-field=true"}, {"bool-field:5"}, {"uint-field=-1"}, {"int-slice-field=true"}, {"bool-slice-field=5"}, {"uint-slice-field=-1"}, {"int-field=6", "int-field=66"}} + + for _, setValues := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + err := mergeSetFlag(configMockElemValue, setValues) + + if err == nil { + t.Errorf("unexpected unhandled error - setValues: %v", setValues) + continue + } + + for i := 0; i < configMockElemValue.NumField(); i++ { + currentField := configMockElemValue.Type().Field(i) + currentFieldByName := configMockElemValue.FieldByName(currentField.Name) + + if !currentFieldByName.IsZero() { + t.Errorf("unexpected case - setValues: %v", setValues) + } + } + } +} + +func TestMergeSetFlagNotSliceValues(t *testing.T) { + tests := [][]struct { + SetValue string + FieldName string + FieldValue interface{} + }{ + {{SetValue: "string-field=test", FieldName: "StringField", FieldValue: "test"}}, + {{SetValue: "int-field=6", FieldName: "IntField", FieldValue: 6}}, + {{SetValue: "bool-field=true", FieldName: "BoolField", FieldValue: true}}, + {{SetValue: "uint-field=6", FieldName: "UintField", FieldValue: uint(6)}}, + { + {SetValue: "string-field=test", FieldName: "StringField", FieldValue: "test"}, + {SetValue: "int-field=6", FieldName: "IntField", FieldValue: 6}, + {SetValue: "bool-field=true", FieldName: "BoolField", FieldValue: true}, + {SetValue: "uint-field=6", FieldName: "UintField", FieldValue: uint(6)}, + }, + } + + for _, test := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, setValueInfo := range test { + setValues = append(setValues, setValueInfo.SetValue) + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + continue + } + + for _, setValueInfo := range test { + fieldValue := configMockElemValue.FieldByName(setValueInfo.FieldName).Interface() + if fieldValue != setValueInfo.FieldValue { + t.Errorf("unexpected result - expected: %v, actual: %v", setValueInfo.FieldValue, fieldValue) + } + } + } +} + +func TestMergeSetFlagSliceValues(t *testing.T) { + tests := [][]struct { + SetValues []string + FieldName string + FieldValue interface{} + }{ + {{SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}}, + {{SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}}, + {{SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}}, + {{SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}}, + { + {SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}, + {SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}, + {SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}, + {SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}, + }, + {{SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}}, + {{SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}}, + {{SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}}, + {{SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}}, + { + {SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}, + {SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}, + {SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}, + {SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}, + }, + } + + for _, test := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, setValueInfo := range test { + for _, setValue := range setValueInfo.SetValues { + setValues = append(setValues, setValue) + } + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + continue + } + + for _, setValueInfo := range test { + fieldValue := configMockElemValue.FieldByName(setValueInfo.FieldName).Interface() + if !reflect.DeepEqual(fieldValue, setValueInfo.FieldValue) { + t.Errorf("unexpected result - expected: %v, actual: %v", setValueInfo.FieldValue, fieldValue) + } + } + } +} + +func TestMergeSetFlagMixValues(t *testing.T) { + tests := [][]struct { + SetValues []string + FieldName string + FieldValue interface{} + }{ + { + {SetValues: []string{"string-slice-field=test"}, FieldName: "StringSliceField", FieldValue: []string{"test"}}, + {SetValues: []string{"int-slice-field=6"}, FieldName: "IntSliceField", FieldValue: []int{6}}, + {SetValues: []string{"bool-slice-field=true"}, FieldName: "BoolSliceField", FieldValue: []bool{true}}, + {SetValues: []string{"uint-slice-field=6"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6)}}, + {SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}, + {SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}, + {SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}, + {SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}, + }, + { + {SetValues: []string{"string-slice-field=test", "string-slice-field=test2"}, FieldName: "StringSliceField", FieldValue: []string{"test", "test2"}}, + {SetValues: []string{"int-slice-field=6", "int-slice-field=66"}, FieldName: "IntSliceField", FieldValue: []int{6, 66}}, + {SetValues: []string{"bool-slice-field=true", "bool-slice-field=false"}, FieldName: "BoolSliceField", FieldValue: []bool{true, false}}, + {SetValues: []string{"uint-slice-field=6", "uint-slice-field=66"}, FieldName: "UintSliceField", FieldValue: []uint{uint(6), uint(66)}}, + {SetValues: []string{"string-field=test"}, FieldName: "StringField", FieldValue: "test"}, + {SetValues: []string{"int-field=6"}, FieldName: "IntField", FieldValue: 6}, + {SetValues: []string{"bool-field=true"}, FieldName: "BoolField", FieldValue: true}, + {SetValues: []string{"uint-field=6"}, FieldName: "UintField", FieldValue: uint(6)}, + }, + } + + for _, test := range tests { + configMock := ConfigMock{} + configMockElemValue := reflect.ValueOf(&configMock).Elem() + + var setValues []string + for _, setValueInfo := range test { + for _, setValue := range setValueInfo.SetValues { + setValues = append(setValues, setValue) + } + } + + err := mergeSetFlag(configMockElemValue, setValues) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + continue + } + + for _, setValueInfo := range test { + fieldValue := configMockElemValue.FieldByName(setValueInfo.FieldName).Interface() + if !reflect.DeepEqual(fieldValue, setValueInfo.FieldValue) { + t.Errorf("unexpected result - expected: %v, actual: %v", setValueInfo.FieldValue, fieldValue) + } + } + } +} + +func TestGetParsedValueValidValue(t *testing.T) { + tests := []struct { + StringValue string + Kind reflect.Kind + ActualValue interface{} + }{ + {StringValue: "test", Kind: reflect.String, ActualValue: "test"}, + {StringValue: "123", Kind: reflect.String, ActualValue: "123"}, + {StringValue: "true", Kind: reflect.Bool, ActualValue: true}, + {StringValue: "false", Kind: reflect.Bool, ActualValue: false}, + {StringValue: "6", Kind: reflect.Int, ActualValue: 6}, + {StringValue: "-6", Kind: reflect.Int, ActualValue: -6}, + {StringValue: "6", Kind: reflect.Int8, ActualValue: int8(6)}, + {StringValue: "-6", Kind: reflect.Int8, ActualValue: int8(-6)}, + {StringValue: "6", Kind: reflect.Int16, ActualValue: int16(6)}, + {StringValue: "-6", Kind: reflect.Int16, ActualValue: int16(-6)}, + {StringValue: "6", Kind: reflect.Int32, ActualValue: int32(6)}, + {StringValue: "-6", Kind: reflect.Int32, ActualValue: int32(-6)}, + {StringValue: "6", Kind: reflect.Int64, ActualValue: int64(6)}, + {StringValue: "-6", Kind: reflect.Int64, ActualValue: int64(-6)}, + {StringValue: "6", Kind: reflect.Uint, ActualValue: uint(6)}, + {StringValue: "66", Kind: reflect.Uint, ActualValue: uint(66)}, + {StringValue: "6", Kind: reflect.Uint8, ActualValue: uint8(6)}, + {StringValue: "66", Kind: reflect.Uint8, ActualValue: uint8(66)}, + {StringValue: "6", Kind: reflect.Uint16, ActualValue: uint16(6)}, + {StringValue: "66", Kind: reflect.Uint16, ActualValue: uint16(66)}, + {StringValue: "6", Kind: reflect.Uint32, ActualValue: uint32(6)}, + {StringValue: "66", Kind: reflect.Uint32, ActualValue: uint32(66)}, + {StringValue: "6", Kind: reflect.Uint64, ActualValue: uint64(6)}, + {StringValue: "66", Kind: reflect.Uint64, ActualValue: uint64(66)}, + } + + for _, test := range tests { + parsedValue, err := getParsedValue(test.Kind, test.StringValue) + + if err != nil { + t.Errorf("unexpected error result - err: %v", err) + continue + } + + if parsedValue.Interface() != test.ActualValue { + t.Errorf("unexpected result - expected: %v, actual: %v", test.ActualValue, parsedValue) + } + } +} + +func TestGetParsedValueInvalidValue(t *testing.T) { + tests := []struct { + StringValue string + Kind reflect.Kind + }{ + {StringValue: "test", Kind: reflect.Bool}, + {StringValue: "123", Kind: reflect.Bool}, + {StringValue: "test", Kind: reflect.Int}, + {StringValue: "true", Kind: reflect.Int}, + {StringValue: "test", Kind: reflect.Int8}, + {StringValue: "true", Kind: reflect.Int8}, + {StringValue: "test", Kind: reflect.Int16}, + {StringValue: "true", Kind: reflect.Int16}, + {StringValue: "test", Kind: reflect.Int32}, + {StringValue: "true", Kind: reflect.Int32}, + {StringValue: "test", Kind: reflect.Int64}, + {StringValue: "true", Kind: reflect.Int64}, + {StringValue: "test", Kind: reflect.Uint}, + {StringValue: "-6", Kind: reflect.Uint}, + {StringValue: "test", Kind: reflect.Uint8}, + {StringValue: "-6", Kind: reflect.Uint8}, + {StringValue: "test", Kind: reflect.Uint16}, + {StringValue: "-6", Kind: reflect.Uint16}, + {StringValue: "test", Kind: reflect.Uint32}, + {StringValue: "-6", Kind: reflect.Uint32}, + {StringValue: "test", Kind: reflect.Uint64}, + {StringValue: "-6", Kind: reflect.Uint64}, + } + + for _, test := range tests { + parsedValue, err := getParsedValue(test.Kind, test.StringValue) + + if err == nil { + t.Errorf("unexpected unhandled error - stringValue: %v, Kind: %v", test.StringValue, test.Kind) + continue + } + + if parsedValue != reflect.ValueOf(nil) { + t.Errorf("unexpected parsed value - parsedValue: %v", parsedValue) + } + } +} diff --git a/cli/config/config_test.go b/cli/config/config_test.go index 286d497b4..8e3fcbb38 100644 --- a/cli/config/config_test.go +++ b/cli/config/config_test.go @@ -13,10 +13,10 @@ func TestConfigWriteIgnoresReadonlyFields(t *testing.T) { configElem := reflect.ValueOf(&config.ConfigStruct{}).Elem() getFieldsWithReadonlyTag(configElem, &readonlyFields) - config, _ := config.GetConfigWithDefaults() + configWithDefaults, _ := config.GetConfigWithDefaults() for _, readonlyField := range readonlyFields { - if strings.Contains(config, readonlyField) { - t.Errorf("unexpected result - readonly field: %v, config: %v", readonlyField, config) + if strings.Contains(configWithDefaults, readonlyField) { + t.Errorf("unexpected result - readonly field: %v, config: %v", readonlyField, configWithDefaults) } } } From f9677dbaa1193edff5642fc8eb1eae9728388076 Mon Sep 17 00:00:00 2001 From: RoyUP9 <87927115+RoyUP9@users.noreply.github.com> Date: Thu, 12 Aug 2021 16:33:32 +0300 Subject: [PATCH 21/43] added resources to config (#208) --- cli/cmd/tapRunner.go | 3 ++- cli/config/configStructs/tapConfig.go | 37 +++++++++++++++++---------- cli/kubernetes/provider.go | 21 +++++++-------- 3 files changed, 36 insertions(+), 25 deletions(-) diff --git a/cli/cmd/tapRunner.go b/cli/cmd/tapRunner.go index 6255bea18..c3c8713a7 100644 --- a/cli/cmd/tapRunner.go +++ b/cli/cmd/tapRunner.go @@ -182,7 +182,7 @@ func createMizuApiServer(ctx context.Context, kubernetesProvider *kubernetes.Pro MizuApiFilteringOptions: mizuApiFilteringOptions, MaxEntriesDBSizeBytes: config.Config.Tap.MaxEntriesDBSizeBytes(), } - _, err = kubernetesProvider.CreateMizuApiServerPod(ctx, opts) + _, err = kubernetesProvider.CreateMizuApiServerPod(ctx, opts, config.Config.Tap.ApiServerResources) if err != nil { return err } @@ -237,6 +237,7 @@ func updateMizuTappers(ctx context.Context, kubernetesProvider *kubernetes.Provi nodeToTappedPodIPMap, serviceAccountName, config.Config.Tap.TapOutgoing(), + config.Config.Tap.TapperResources, ); err != nil { return err } diff --git a/cli/config/configStructs/tapConfig.go b/cli/config/configStructs/tapConfig.go index 5f7a68eae..d80163b27 100644 --- a/cli/config/configStructs/tapConfig.go +++ b/cli/config/configStructs/tapConfig.go @@ -23,20 +23,29 @@ const ( ) type TapConfig struct { - AnalysisDestination string `yaml:"dest" default:"up9.app"` - SleepIntervalSec int `yaml:"upload-interval" default:"10"` - PodRegexStr string `yaml:"regex" default:".*"` - GuiPort uint16 `yaml:"gui-port" default:"8899"` - Namespaces []string `yaml:"namespaces"` - Analysis bool `yaml:"analysis" default:"false"` - AllNamespaces bool `yaml:"all-namespaces" default:"false"` - PlainTextFilterRegexes []string `yaml:"regex-masking"` - HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents"` - DisableRedaction bool `yaml:"no-redact" default:"false"` - HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"` - Direction string `yaml:"direction" default:"in"` - DryRun bool `yaml:"dry-run" default:"false"` - EnforcePolicyFile string `yaml:"test-rules"` + AnalysisDestination string `yaml:"dest" default:"up9.app"` + SleepIntervalSec int `yaml:"upload-interval" default:"10"` + PodRegexStr string `yaml:"regex" default:".*"` + GuiPort uint16 `yaml:"gui-port" default:"8899"` + Namespaces []string `yaml:"namespaces"` + Analysis bool `yaml:"analysis" default:"false"` + AllNamespaces bool `yaml:"all-namespaces" default:"false"` + PlainTextFilterRegexes []string `yaml:"regex-masking"` + HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents"` + DisableRedaction bool `yaml:"no-redact" default:"false"` + HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"` + Direction string `yaml:"direction" default:"in"` + DryRun bool `yaml:"dry-run" default:"false"` + EnforcePolicyFile string `yaml:"test-rules"` + ApiServerResources Resources `yaml:"api-server-resources"` + TapperResources Resources `yaml:"tapper-resources"` +} + +type Resources struct { + CpuLimit string `yaml:"cpu-limit" default:"750m"` + MemoryLimit string `yaml:"memory-limit" default:"1Gi"` + CpuRequests string `yaml:"cpu-requests" default:"50m"` + MemoryRequests string `yaml:"memory-requests" default:"50Mi"` } func (config *TapConfig) PodRegex() *regexp.Regexp { diff --git a/cli/kubernetes/provider.go b/cli/kubernetes/provider.go index 91f745b98..5559b0685 100644 --- a/cli/kubernetes/provider.go +++ b/cli/kubernetes/provider.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/up9inc/mizu/cli/config/configStructs" "github.com/up9inc/mizu/cli/logger" "os" "path/filepath" @@ -143,7 +144,7 @@ type ApiServerOptions struct { MaxEntriesDBSizeBytes int64 } -func (provider *Provider) CreateMizuApiServerPod(ctx context.Context, opts *ApiServerOptions) (*core.Pod, error) { +func (provider *Provider) CreateMizuApiServerPod(ctx context.Context, opts *ApiServerOptions, resources configStructs.Resources) (*core.Pod, error) { marshaledFilteringOptions, err := json.Marshal(opts.MizuApiFilteringOptions) if err != nil { return nil, err @@ -153,19 +154,19 @@ func (provider *Provider) CreateMizuApiServerPod(ctx context.Context, opts *ApiS configMapOptional := true configMapVolumeName.Optional = &configMapOptional - cpuLimit, err := resource.ParseQuantity("750m") + cpuLimit, err := resource.ParseQuantity(resources.CpuLimit) if err != nil { return nil, errors.New(fmt.Sprintf("invalid cpu limit for %s container", opts.PodName)) } - memLimit, err := resource.ParseQuantity("512Mi") + memLimit, err := resource.ParseQuantity(resources.MemoryLimit) if err != nil { return nil, errors.New(fmt.Sprintf("invalid memory limit for %s container", opts.PodName)) } - cpuRequests, err := resource.ParseQuantity("50m") + cpuRequests, err := resource.ParseQuantity(resources.CpuRequests) if err != nil { return nil, errors.New(fmt.Sprintf("invalid cpu request for %s container", opts.PodName)) } - memRequests, err := resource.ParseQuantity("50Mi") + memRequests, err := resource.ParseQuantity(resources.MemoryRequests) if err != nil { return nil, errors.New(fmt.Sprintf("invalid memory request for %s container", opts.PodName)) } @@ -562,7 +563,7 @@ func (provider *Provider) CreateConfigMap(ctx context.Context, namespace string, return nil } -func (provider *Provider) ApplyMizuTapperDaemonSet(ctx context.Context, namespace string, daemonSetName string, podImage string, tapperPodName string, apiServerPodIp string, nodeToTappedPodIPMap map[string][]string, serviceAccountName string, tapOutgoing bool) error { +func (provider *Provider) ApplyMizuTapperDaemonSet(ctx context.Context, namespace string, daemonSetName string, podImage string, tapperPodName string, apiServerPodIp string, nodeToTappedPodIPMap map[string][]string, serviceAccountName string, tapOutgoing bool, resources configStructs.Resources) error { logger.Log.Debugf("Applying %d tapper deamonsets, ns: %s, daemonSetName: %s, podImage: %s, tapperPodName: %s", len(nodeToTappedPodIPMap), namespace, daemonSetName, podImage, tapperPodName) if len(nodeToTappedPodIPMap) == 0 { @@ -601,19 +602,19 @@ func (provider *Provider) ApplyMizuTapperDaemonSet(ctx context.Context, namespac ), ), ) - cpuLimit, err := resource.ParseQuantity("500m") + cpuLimit, err := resource.ParseQuantity(resources.CpuLimit) if err != nil { return errors.New(fmt.Sprintf("invalid cpu limit for %s container", tapperPodName)) } - memLimit, err := resource.ParseQuantity("1Gi") + memLimit, err := resource.ParseQuantity(resources.MemoryLimit) if err != nil { return errors.New(fmt.Sprintf("invalid memory limit for %s container", tapperPodName)) } - cpuRequests, err := resource.ParseQuantity("50m") + cpuRequests, err := resource.ParseQuantity(resources.CpuRequests) if err != nil { return errors.New(fmt.Sprintf("invalid cpu request for %s container", tapperPodName)) } - memRequests, err := resource.ParseQuantity("50Mi") + memRequests, err := resource.ParseQuantity(resources.MemoryRequests) if err != nil { return errors.New(fmt.Sprintf("invalid memory request for %s container", tapperPodName)) } From e4ff4a07454969a5682a23ee4968d9e2478540de Mon Sep 17 00:00:00 2001 From: Igor Gov Date: Thu, 12 Aug 2021 18:04:57 +0300 Subject: [PATCH 22/43] Run CI checks in parallel (#210) --- .github/workflows/test.yaml | 39 -------------- .github/workflows/validation.yaml | 84 +++++++++++++++++++++++++++++++ Makefile | 4 +- 3 files changed, 87 insertions(+), 40 deletions(-) delete mode 100644 .github/workflows/test.yaml create mode 100644 .github/workflows/validation.yaml diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 312db265d..000000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: test -on: - pull_request: - branches: - - 'develop' - - 'main' - push: - branches: - - 'develop' - - 'main' -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Set up Go 1.16 - uses: actions/setup-go@v2 - with: - go-version: '^1.16' - - run: go version - - - name: Check out code into the Go module directory - uses: actions/checkout@v2 - - - name: Build CLI - run: make cli - - - shell: bash - run: | - sudo apt-get install libpcap-dev - - - name: Build Agent - run: make agent - - - name: Test - run: make test - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml new file mode 100644 index 000000000..fdc22923e --- /dev/null +++ b/.github/workflows/validation.yaml @@ -0,0 +1,84 @@ +name: Validations +on: + pull_request: + branches: + - 'develop' + - 'main' + push: + branches: + - 'develop' + - 'main' +jobs: + build-cli: + name: Build CLI + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: '^1.16' + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Build CLI + run: make cli + + build-agent: + name: Build Agent + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: '^1.16' + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - shell: bash + run: | + sudo apt-get install libpcap-dev + + - name: Build Agent + run: make agent + + run-tests-cli: + name: Run CLI tests + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: '^1.16' + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Test + run: make test-cli + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + + run-tests-agent: + name: Run Agent tests + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.16 + uses: actions/setup-go@v2 + with: + go-version: '^1.16' + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - shell: bash + run: | + sudo apt-get install libpcap-dev + + - name: Test + run: make test-agent + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 diff --git a/Makefile b/Makefile index 3d66f655f..6a83d79ff 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,8 @@ clean-cli: ## Clean CLI. clean-docker: @(echo "DOCKER cleanup - NOT IMPLEMENTED YET " ) -test: ## Run tests. +test-cli: ## Run tests. @echo "running cli tests"; cd cli && $(MAKE) test + +test-agent: ## Run tests. @echo "running agent tests"; cd agent && $(MAKE) test From 6d2e9af5d72f675a45f2b11c98565c0b62a437df Mon Sep 17 00:00:00 2001 From: Neim Elezi <49072837+imceZZ@users.noreply.github.com> Date: Sun, 15 Aug 2021 09:58:16 +0200 Subject: [PATCH 23/43] Feature/tra 3475 scroll to end (#206) * configuration changed * testing scroll with button * back to scroll button feature is done * scroll to the end of entries feature is done * config of docker image is reverted back * path of docker image is changed in configStruct.go --- ui/src/components/HarEntriesList.tsx | 19 +++++++++++++++--- ui/src/components/HarPage.tsx | 20 +++++++++++++++++++ ui/src/components/assets/union.svg | 3 +++ .../style/HarEntriesList.module.sass | 20 ++++++++++++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/assets/union.svg diff --git a/ui/src/components/HarEntriesList.tsx b/ui/src/components/HarEntriesList.tsx index cea3c2cef..9ba52450f 100644 --- a/ui/src/components/HarEntriesList.tsx +++ b/ui/src/components/HarEntriesList.tsx @@ -5,6 +5,7 @@ import spinner from './assets/spinner.svg'; import ScrollableFeed from "react-scrollable-feed"; import {StatusType} from "./HarFilters"; import Api from "../helpers/api"; +import uninon from "./assets/union.svg"; interface HarEntriesListProps { entries: any[]; @@ -19,6 +20,9 @@ interface HarEntriesListProps { methodsFilter: Array; statusFilter: Array; pathFilter: string + listEntryREF: any; + onScrollEvent: (isAtBottom:boolean) => void; + scrollableList: boolean; } enum FetchOperator { @@ -28,7 +32,7 @@ enum FetchOperator { const api = new Api(); -export const HarEntriesList: React.FC = ({entries, setEntries, focusedEntryId, setFocusedEntryId, connectionOpen, noMoreDataTop, setNoMoreDataTop, noMoreDataBottom, setNoMoreDataBottom, methodsFilter, statusFilter, pathFilter}) => { +export const HarEntriesList: React.FC = ({entries, setEntries, focusedEntryId, setFocusedEntryId, connectionOpen, noMoreDataTop, setNoMoreDataTop, noMoreDataBottom, setNoMoreDataBottom, methodsFilter, statusFilter, pathFilter, listEntryREF, onScrollEvent, scrollableList}) => { const [loadMoreTop, setLoadMoreTop] = useState(false); const [isLoadingTop, setIsLoadingTop] = useState(false); @@ -106,11 +110,11 @@ export const HarEntriesList: React.FC = ({entries, setEntri return <>
-
+
{isLoadingTop &&
spinner
} - + onScrollEvent(isAtBottom)}> {noMoreDataTop && !connectionOpen &&
No more data available
} {filteredEntries.map(entry => = ({entries, setEntri
getNewEntries()}>Fetch more entries
} +
{entries?.length > 0 &&
diff --git a/ui/src/components/HarPage.tsx b/ui/src/components/HarPage.tsx index bfa7dbfb3..3cf42d5ca 100644 --- a/ui/src/components/HarPage.tsx +++ b/ui/src/components/HarPage.tsx @@ -60,8 +60,12 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected const [tappingStatus, setTappingStatus] = useState(null); + const [disableScrollList, setDisableScrollList] = useState(false); + const ws = useRef(null); + const listEntry = useRef(null); + const openWebSocket = () => { ws.current = new WebSocket(MizuWebsocketURL); ws.current.onopen = () => setConnection(ConnectionStatus.Connected); @@ -86,6 +90,11 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected setNoMoreDataTop(false); } setEntries([...newEntries, entry]) + if(listEntry.current) { + if(isScrollable(listEntry.current.firstChild)) { + setDisableScrollList(true) + } + } break case "status": setTappingStatus(message.tappingStatus); @@ -158,6 +167,14 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected } } + const onScrollEvent = (isAtBottom) => { + isAtBottom ? setDisableScrollList(false) : setDisableScrollList(true) + } + + const isScrollable = (element) => { + return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight; + }; + return (
@@ -192,6 +209,9 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected methodsFilter={methodsFilter} statusFilter={statusFilter} pathFilter={pathFilter} + listEntryREF={listEntry} + onScrollEvent={onScrollEvent} + scrollableList={disableScrollList} />
diff --git a/ui/src/components/assets/union.svg b/ui/src/components/assets/union.svg new file mode 100644 index 000000000..f37699d88 --- /dev/null +++ b/ui/src/components/assets/union.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/src/components/style/HarEntriesList.module.sass b/ui/src/components/style/HarEntriesList.module.sass index 97006b86f..54d31f359 100644 --- a/ui/src/components/style/HarEntriesList.module.sass +++ b/ui/src/components/style/HarEntriesList.module.sass @@ -6,6 +6,7 @@ flex-grow: 1 flex-direction: column justify-content: space-between + position: relative .container position: relative @@ -53,4 +54,21 @@ justify-content: center margin-top: 12px font-weight: 600 - color: rgba(255,255,255,0.75) \ No newline at end of file + color: rgba(255,255,255,0.75) + +.btnLive + position: absolute + bottom: 10px + right: 10px + background: #205CF5 + border-radius: 50% + height: 35px + width: 35px + border: none + cursor: pointer + img + height: 10px +.hideButton + display: none +.showButton + display: block From f74a52d4dcba55aca0917f9deaf7da55a20a6f8e Mon Sep 17 00:00:00 2001 From: lirazyehezkel <61656597+lirazyehezkel@users.noreply.github.com> Date: Sun, 15 Aug 2021 12:09:56 +0300 Subject: [PATCH 24/43] UI Infra - Support multiple entry types + refactoring (#211) * no message * change local api path * generic entry list item + rename files and vars * entry detailed generic * fix api file * clean warnings * switch * empty lines * fix scroll to end feature Co-authored-by: Roee Gadot --- README.md | 13 + agent/main.go | 14 +- ui/src/App.sass | 2 +- ui/src/App.tsx | 7 +- .../{HarEntriesList.tsx => EntriesList.tsx} | 37 ++- .../EntryDetailed/EntryDetailed.module.sass | 23 ++ .../EntryDetailed/EntryDetailed.tsx | 56 ++++ .../EntrySections.module.sass} | 2 +- .../EntryDetailed/EntrySections.tsx | 213 ++++++++++++++ .../Kafka/KafkaEntryDetailsContent.tsx | 6 + .../Kafka/KafkaEntryDetailsTitle.tsx | 6 + .../Rest/RestEntryDetailsContent.tsx | 43 +++ .../Rest/RestEntryDetailsTitle.tsx | 27 ++ .../EntryListItem.module.sass} | 37 ++- .../EntryListItem/EntryListItem.tsx | 85 ++++++ .../EntryListItem/KafkaEntryContent.tsx | 15 + .../EntryListItem/RestEntryContent.tsx | 82 ++++++ .../{HarFilters.tsx => Filters.tsx} | 12 +- ui/src/components/HarEntry.tsx | 116 -------- ui/src/components/HarEntryDetailed.tsx | 61 ---- .../HarEntryViewer/HAREntrySections.tsx | 266 ------------------ .../HarEntryViewer/HAREntryViewer.module.sass | 60 ---- .../HarEntryViewer/HAREntryViewer.tsx | 71 ----- ui/src/components/HarPaging.tsx | 27 -- .../{HarPage.tsx => TrafficPage.tsx} | 79 +++--- ui/src/components/{ => UI}/Checkbox.tsx | 0 .../{ => UI}/CollapsibleContainer.tsx | 4 +- ui/src/components/{ => UI}/EndpointPath.tsx | 0 .../components/{ => UI}/FancyTextDisplay.tsx | 2 +- .../FilterSelect.tsx} | 4 +- ui/src/components/{ => UI}/Select.tsx | 2 +- ui/src/components/{ => UI}/StatusBar.tsx | 0 ui/src/components/{ => UI}/StatusCode.tsx | 0 .../SyntaxHighlighter/highlighterStyle.ts | 0 .../{ => UI}/SyntaxHighlighter/index.scss | 0 .../{ => UI}/SyntaxHighlighter/index.tsx | 0 ui/src/components/{ => UI}/Tabs.tsx | 2 +- ui/src/components/{ => UI}/Tooltip.tsx | 0 .../{ => UI}/style/CollapsibleContainer.sass | 0 .../{ => UI}/style/EndpointPath.module.sass | 0 .../{ => UI}/style/FancyTextDisplay.sass | 0 .../style/FilterSelect.module.sass} | 0 .../{ => UI}/style/Select.module.sass | 0 .../components/{ => UI}/style/StatusBar.sass | 2 +- .../{ => UI}/style/StatusCode.module.sass | 2 +- .../{ => UI}/style/misc.module.sass | 2 +- .../assets/{union.svg => downImg.svg} | 0 ui/src/components/assets/kafkaIcon.svg | 16 ++ ui/src/components/assets/restIcon.svg | 9 + ...st.module.sass => EntriesList.module.sass} | 2 +- ...ilters.module.sass => Filters.module.sass} | 2 +- .../style/HarEntryDetailed.module.sass | 7 - ui/src/components/style/HarPaging.module.sass | 16 -- .../style/{HarPage.sass => TrafficPage.sass} | 2 +- ui/src/helpers/api.js | 4 +- ui/src/{components => helpers}/utils.ts | 0 ui/src/hooks/use-toggle.ts | 10 - ui/src/index.sass | 2 +- .../style => }/variables.module.scss | 0 59 files changed, 712 insertions(+), 738 deletions(-) rename ui/src/components/{HarEntriesList.tsx => EntriesList.tsx} (80%) create mode 100644 ui/src/components/EntryDetailed/EntryDetailed.module.sass create mode 100644 ui/src/components/EntryDetailed/EntryDetailed.tsx rename ui/src/components/{HarEntryViewer/HAREntrySections.module.sass => EntryDetailed/EntrySections.module.sass} (98%) create mode 100644 ui/src/components/EntryDetailed/EntrySections.tsx create mode 100644 ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsContent.tsx create mode 100644 ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsTitle.tsx create mode 100644 ui/src/components/EntryDetailed/Rest/RestEntryDetailsContent.tsx create mode 100644 ui/src/components/EntryDetailed/Rest/RestEntryDetailsTitle.tsx rename ui/src/components/{style/HarEntry.module.sass => EntryListItem/EntryListItem.module.sass} (74%) create mode 100644 ui/src/components/EntryListItem/EntryListItem.tsx create mode 100644 ui/src/components/EntryListItem/KafkaEntryContent.tsx create mode 100644 ui/src/components/EntryListItem/RestEntryContent.tsx rename ui/src/components/{HarFilters.tsx => Filters.tsx} (90%) delete mode 100644 ui/src/components/HarEntry.tsx delete mode 100644 ui/src/components/HarEntryDetailed.tsx delete mode 100644 ui/src/components/HarEntryViewer/HAREntrySections.tsx delete mode 100644 ui/src/components/HarEntryViewer/HAREntryViewer.module.sass delete mode 100644 ui/src/components/HarEntryViewer/HAREntryViewer.tsx delete mode 100644 ui/src/components/HarPaging.tsx rename ui/src/components/{HarPage.tsx => TrafficPage.tsx} (71%) rename ui/src/components/{ => UI}/Checkbox.tsx (100%) rename ui/src/components/{ => UI}/CollapsibleContainer.tsx (95%) rename ui/src/components/{ => UI}/EndpointPath.tsx (100%) rename ui/src/components/{ => UI}/FancyTextDisplay.tsx (97%) rename ui/src/components/{HARFilterSelect.tsx => UI/FilterSelect.tsx} (79%) rename ui/src/components/{ => UI}/Select.tsx (97%) rename ui/src/components/{ => UI}/StatusBar.tsx (100%) rename ui/src/components/{ => UI}/StatusCode.tsx (100%) rename ui/src/components/{ => UI}/SyntaxHighlighter/highlighterStyle.ts (100%) rename ui/src/components/{ => UI}/SyntaxHighlighter/index.scss (100%) rename ui/src/components/{ => UI}/SyntaxHighlighter/index.tsx (100%) rename ui/src/components/{ => UI}/Tabs.tsx (97%) rename ui/src/components/{ => UI}/Tooltip.tsx (100%) rename ui/src/components/{ => UI}/style/CollapsibleContainer.sass (100%) rename ui/src/components/{ => UI}/style/EndpointPath.module.sass (100%) rename ui/src/components/{ => UI}/style/FancyTextDisplay.sass (100%) rename ui/src/components/{style/HARFilterSelect.module.sass => UI/style/FilterSelect.module.sass} (100%) rename ui/src/components/{ => UI}/style/Select.module.sass (100%) rename ui/src/components/{ => UI}/style/StatusBar.sass (95%) rename ui/src/components/{ => UI}/style/StatusCode.module.sass (91%) rename ui/src/components/{ => UI}/style/misc.module.sass (94%) rename ui/src/components/assets/{union.svg => downImg.svg} (100%) create mode 100644 ui/src/components/assets/kafkaIcon.svg create mode 100644 ui/src/components/assets/restIcon.svg rename ui/src/components/style/{HarEntriesList.module.sass => EntriesList.module.sass} (97%) rename ui/src/components/style/{HarFilters.module.sass => Filters.module.sass} (95%) delete mode 100644 ui/src/components/style/HarEntryDetailed.module.sass delete mode 100644 ui/src/components/style/HarPaging.module.sass rename ui/src/components/style/{HarPage.sass => TrafficPage.sass} (98%) rename ui/src/{components => helpers}/utils.ts (100%) delete mode 100644 ui/src/hooks/use-toggle.ts rename ui/src/{components/style => }/variables.module.scss (100%) diff --git a/README.md b/README.md index e540401a4..6993e029b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ Web interface is now available at http://localhost:8899 ^C ``` + Any request that contains `User-Agent` header with one of the specified values (`kube-probe` or `prometheus`) will not be captured ### API Rules validation @@ -155,3 +156,15 @@ Such validation may test response for specific JSON fields, headers, etc. Please see [API RULES](docs/POLICY_RULES.md) page for more details and syntax. + +## How to Run local UI + +- run from mizu/agent `go run main.go --hars-read --hars-dir ` + +- copy Har files into the folder from last command + +- change `MizuWebsocketURL` and `apiURL` in `api.js` file + +- run from mizu/ui - `npm run start` + +- open browser on `localhost:3000` diff --git a/agent/main.go b/agent/main.go index f87cba902..8f0ffd4d6 100644 --- a/agent/main.go +++ b/agent/main.go @@ -26,14 +26,17 @@ var apiServerMode = flag.Bool("api-server", false, "Run in API server mode with var standaloneMode = flag.Bool("standalone", false, "Run in standalone tapper and API mode") var apiServerAddress = flag.String("api-server-address", "", "Address of mizu API server") var namespace = flag.String("namespace", "", "Resolve IPs if they belong to resources in this namespace (default is all)") +var harsReaderMode = flag.Bool("hars-read", false, "Run in hars-read mode") +var harsDir = flag.String("hars-dir", "", "Directory to read hars from") func main() { flag.Parse() hostMode := os.Getenv(shared.HostModeEnvVar) == "1" tapOpts := &tap.TapOpts{HostMode: hostMode} - if !*tapperMode && !*apiServerMode && !*standaloneMode { - panic("One of the flags --tap, --api or --standalone must be provided") + + if !*tapperMode && !*apiServerMode && !*standaloneMode && !*harsReaderMode{ + panic("One of the flags --tap, --api or --standalone or --hars-read must be provided") } if *standaloneMode { @@ -77,6 +80,13 @@ func main() { go api.StartReadingEntries(filteredHarChannel, nil) hostApi(socketHarOutChannel) + } else if *harsReaderMode { + socketHarOutChannel := make(chan *tap.OutputChannelItem, 1000) + filteredHarChannel := make(chan *tap.OutputChannelItem) + + go filterHarItems(socketHarOutChannel, filteredHarChannel, getTrafficFilteringOptions()) + go api.StartReadingEntries(filteredHarChannel, harsDir) + hostApi(nil) } signalChan := make(chan os.Signal, 1) diff --git a/ui/src/App.sass b/ui/src/App.sass index 629011eac..0b409a236 100644 --- a/ui/src/App.sass +++ b/ui/src/App.sass @@ -1,4 +1,4 @@ -@import 'components/style/variables.module' +@import 'src/variables.module' .mizuApp background-color: $main-background-color diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c30d09083..fa5b8153c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,8 +2,8 @@ import React, {useEffect, useState} from 'react'; import './App.sass'; import logo from './components/assets/Mizu-logo.svg'; import {Button, Snackbar} from "@material-ui/core"; -import {HarPage} from "./components/HarPage"; -import Tooltip from "./components/Tooltip"; +import {TrafficPage} from "./components/TrafficPage"; +import Tooltip from "./components/UI/Tooltip"; import {makeStyles} from "@material-ui/core/styles"; import MuiAlert from '@material-ui/lab/Alert'; import Api from "./helpers/api"; @@ -38,6 +38,7 @@ const App = () => { } })(); + // eslint-disable-next-line }, []); const onTLSDetected = (destAddress: string) => { @@ -116,7 +117,7 @@ const App = () => { }
- + setUserDismissedTLSWarning(true)} severity="warning"> Mizu is detecting TLS traffic{addressesWithTLS.size ? ` (directed to ${Array.from(addressesWithTLS).join(", ")})` : ''}, this type of traffic will not be displayed. diff --git a/ui/src/components/HarEntriesList.tsx b/ui/src/components/EntriesList.tsx similarity index 80% rename from ui/src/components/HarEntriesList.tsx rename to ui/src/components/EntriesList.tsx index 9ba52450f..31c2634f3 100644 --- a/ui/src/components/HarEntriesList.tsx +++ b/ui/src/components/EntriesList.tsx @@ -1,17 +1,17 @@ -import {HarEntry} from "./HarEntry"; -import React, {useCallback, useEffect, useMemo, useState} from "react"; -import styles from './style/HarEntriesList.module.sass'; +import {EntryItem} from "./EntryListItem/EntryListItem"; +import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; +import styles from './style/EntriesList.module.sass'; import spinner from './assets/spinner.svg'; import ScrollableFeed from "react-scrollable-feed"; -import {StatusType} from "./HarFilters"; +import {StatusType} from "./Filters"; import Api from "../helpers/api"; -import uninon from "./assets/union.svg"; +import down from "./assets/downImg.svg"; interface HarEntriesListProps { entries: any[]; setEntries: (entries: any[]) => void; - focusedEntryId: string; - setFocusedEntryId: (id: string) => void; + focusedEntry: any; + setFocusedEntry: (entry: any) => void; connectionOpen: boolean; noMoreDataTop: boolean; setNoMoreDataTop: (flag: boolean) => void; @@ -32,11 +32,12 @@ enum FetchOperator { const api = new Api(); -export const HarEntriesList: React.FC = ({entries, setEntries, focusedEntryId, setFocusedEntryId, connectionOpen, noMoreDataTop, setNoMoreDataTop, noMoreDataBottom, setNoMoreDataBottom, methodsFilter, statusFilter, pathFilter, listEntryREF, onScrollEvent, scrollableList}) => { +export const EntriesList: React.FC = ({entries, setEntries, focusedEntry, setFocusedEntry, connectionOpen, noMoreDataTop, setNoMoreDataTop, noMoreDataBottom, setNoMoreDataBottom, methodsFilter, statusFilter, pathFilter, listEntryREF, onScrollEvent, scrollableList}) => { const [loadMoreTop, setLoadMoreTop] = useState(false); const [isLoadingTop, setIsLoadingTop] = useState(false); - + const scrollableRef = useRef(null); + useEffect(() => { const list = document.getElementById('list').firstElementChild; list.addEventListener('scroll', (e) => { @@ -110,28 +111,24 @@ export const HarEntriesList: React.FC = ({entries, setEntri return <>
-
+
{isLoadingTop &&
spinner
} - onScrollEvent(isAtBottom)}> + onScrollEvent(isAtBottom)}> {noMoreDataTop && !connectionOpen &&
No more data available
} - {filteredEntries.map(entry => )} + setFocusedEntry = {setFocusedEntry} + isSelected={focusedEntry.id === entry.id}/>)} {!connectionOpen && !noMoreDataBottom &&
getNewEntries()}>Fetch more entries
}
diff --git a/ui/src/components/EntryDetailed/EntryDetailed.module.sass b/ui/src/components/EntryDetailed/EntryDetailed.module.sass new file mode 100644 index 000000000..2af3d6a54 --- /dev/null +++ b/ui/src/components/EntryDetailed/EntryDetailed.module.sass @@ -0,0 +1,23 @@ +@import "src/variables.module" + +.content + font-family: "Source Sans Pro", Lucida Grande, Tahoma, sans-serif + height: calc(100% - 56px) + overflow-y: auto + width: 100% + + .body + background: $main-background-color + color: $blue-gray + border-radius: 4px + padding: 10px + .bodyHeader + padding: 0 1rem + .endpointURL + font-size: .75rem + display: block + color: $blue-color + text-decoration: none + margin-bottom: .5rem + overflow-wrap: anywhere + padding: 5px 0 \ No newline at end of file diff --git a/ui/src/components/EntryDetailed/EntryDetailed.tsx b/ui/src/components/EntryDetailed/EntryDetailed.tsx new file mode 100644 index 000000000..0db1d1a6a --- /dev/null +++ b/ui/src/components/EntryDetailed/EntryDetailed.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import styles from './EntryDetailed.module.sass'; +import {makeStyles} from "@material-ui/core"; +import {EntryType} from "../EntryListItem/EntryListItem"; +import {RestEntryDetailsTitle} from "./Rest/RestEntryDetailsTitle"; +import {KafkaEntryDetailsTitle} from "./Kafka/KafkaEntryDetailsTitle"; +import {RestEntryDetailsContent} from "./Rest/RestEntryDetailsContent"; +import {KafkaEntryDetailsContent} from "./Kafka/KafkaEntryDetailsContent"; + +const useStyles = makeStyles(() => ({ + entryTitle: { + display: 'flex', + minHeight: 46, + maxHeight: 46, + alignItems: 'center', + marginBottom: 8, + padding: 5, + paddingBottom: 0 + } +})); + +interface EntryDetailedProps { + entryData: any; + classes?: any; + entryType: string; +} + +export const EntryDetailed: React.FC = ({classes, entryData, entryType}) => { + const classesTitle = useStyles(); + + let title, content; + + switch (entryType) { + case EntryType.Rest: + title = ; + content = ; + break; + case EntryType.Kafka: + title = ; + content = ; + break; + default: + title = ; + content = ; + break; + } + + return <> +
{title}
+
+
+ {content} +
+
+ +}; \ No newline at end of file diff --git a/ui/src/components/HarEntryViewer/HAREntrySections.module.sass b/ui/src/components/EntryDetailed/EntrySections.module.sass similarity index 98% rename from ui/src/components/HarEntryViewer/HAREntrySections.module.sass rename to ui/src/components/EntryDetailed/EntrySections.module.sass index 06c19f302..f6d73bc7c 100644 --- a/ui/src/components/HarEntryViewer/HAREntrySections.module.sass +++ b/ui/src/components/EntryDetailed/EntrySections.module.sass @@ -1,4 +1,4 @@ -@import '../style/variables.module' +@import 'src/variables.module' .title display: flex diff --git a/ui/src/components/EntryDetailed/EntrySections.tsx b/ui/src/components/EntryDetailed/EntrySections.tsx new file mode 100644 index 000000000..3efd01955 --- /dev/null +++ b/ui/src/components/EntryDetailed/EntrySections.tsx @@ -0,0 +1,213 @@ +import styles from "./EntrySections.module.sass"; +import React, {useState} from "react"; +import {SyntaxHighlighter} from "../UI/SyntaxHighlighter"; +import CollapsibleContainer from "../UI/CollapsibleContainer"; +import FancyTextDisplay from "../UI/FancyTextDisplay"; +import Checkbox from "../UI/Checkbox"; +import ProtobufDecoder from "protobuf-decoder"; + +interface ViewLineProps { + label: string; + value: number | string; +} + +const ViewLine: React.FC = ({label, value}) => { + return (label && value && + {label} + + + + ) || null; +} + +interface SectionCollapsibleTitleProps { + title: string; + isExpanded: boolean; +} + +const SectionCollapsibleTitle: React.FC = ({title, isExpanded}) => { + return
+ + {isExpanded ? '-' : '+'} + + {title} +
+} + +interface SectionContainerProps { + title: string; +} + +export const SectionContainer: React.FC = ({title, children}) => { + const [expanded, setExpanded] = useState(true); + return setExpanded(!expanded)} + title={} + > + {children} + +} + +interface BodySectionProps { + content: any; + encoding?: string; + contentType?: string; +} + +export const BodySection: React.FC = ({content, encoding, contentType}) => { + const MAXIMUM_BYTES_TO_HIGHLIGHT = 10000; // The maximum of chars to highlight in body, in case the response can be megabytes + const supportedLanguages = [['html', 'html'], ['json', 'json'], ['application/grpc', 'json']]; // [[indicator, languageToUse],...] + const jsonLikeFormats = ['json']; + const protobufFormats = ['application/grpc']; + const [isWrapped, setIsWrapped] = useState(false); + + const formatTextBody = (body): string => { + const chunk = body.slice(0, MAXIMUM_BYTES_TO_HIGHLIGHT); + const bodyBuf = encoding === 'base64' ? atob(chunk) : chunk; + + try { + if (jsonLikeFormats.some(format => content?.mimeType?.indexOf(format) > -1)) { + return JSON.stringify(JSON.parse(bodyBuf), null, 2); + } else if (protobufFormats.some(format => content?.mimeType?.indexOf(format) > -1)) { + // Replace all non printable characters (ASCII) + const protobufDecoder = new ProtobufDecoder(bodyBuf, true); + return JSON.stringify(protobufDecoder.decode().toSimple(), null, 2); + } + } catch (error) { + console.error(error); + } + return bodyBuf; + } + + const getLanguage = (mimetype) => { + const chunk = content.text?.slice(0, 100); + if (chunk.indexOf('html') > 0 || chunk.indexOf('HTML') > 0) return supportedLanguages[0][1]; + const language = supportedLanguages.find(el => (mimetype + contentType).indexOf(el[0]) > -1); + return language ? language[1] : 'default'; + } + + return + {content && content.text?.length > 0 && + + + + + +
+ +
setIsWrapped(!isWrapped)}> +
+ {}}/> +
+ Wrap text +
+ + +
} +
+} + +interface TableSectionProps { + title: string, + arrayToIterate: any[], +} + +export const TableSection: React.FC = ({title, arrayToIterate}) => { + return + { + arrayToIterate && arrayToIterate.length > 0 ? + + + + {arrayToIterate.map(({name, value}, index) => )} + +
+
: + } +
+} + +interface HAREntryPolicySectionProps { + service: string, + title: string, + response: any, + latency?: number, + arrayToIterate: any[], +} + + +interface HAREntryPolicySectionCollapsibleTitleProps { + label: string; + matched: string; + isExpanded: boolean; +} + +const HAREntryPolicySectionCollapsibleTitle: React.FC = ({label, matched, isExpanded}) => { + return
+ + {isExpanded ? '-' : '+'} + + + + {label} + {matched} + + +
+} + +interface HAREntryPolicySectionContainerProps { + label: string; + matched: string; + children?: any; +} + +export const HAREntryPolicySectionContainer: React.FC = ({label, matched, children}) => { + const [expanded, setExpanded] = useState(false); + return setExpanded(!expanded)} + title={} + > + {children} + +} + +export const HAREntryTablePolicySection: React.FC = ({service, title, response, latency, arrayToIterate}) => { + return + {arrayToIterate && arrayToIterate.length > 0 ? <> + + + + {arrayToIterate.map(({rule, matched}, index) => { + return (= latency : true)? "Success" : "Failure"}> + <> + {rule.Key && } + {rule.Latency && } + {rule.Method && } + {rule.Path && } + {rule.Service && } + {rule.Type && } + {rule.Value && } + + )})} + +
Key:{rule.Key}
Latency: {rule.Latency}
Method: {rule.Method}
Path: {rule.Path}
Service: {service}
Type: {rule.Type}
Value: {rule.Value}
+
+ : No rules could be applied to this request.} +
+} \ No newline at end of file diff --git a/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsContent.tsx b/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsContent.tsx new file mode 100644 index 000000000..7fe97954c --- /dev/null +++ b/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsContent.tsx @@ -0,0 +1,6 @@ +import React from "react"; + +export const KafkaEntryDetailsContent: React.FC = ({entryData}) => { + + return <>; +} diff --git a/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsTitle.tsx b/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsTitle.tsx new file mode 100644 index 000000000..4d1aeee2f --- /dev/null +++ b/ui/src/components/EntryDetailed/Kafka/KafkaEntryDetailsTitle.tsx @@ -0,0 +1,6 @@ +import React from "react"; + +export const KafkaEntryDetailsTitle: React.FC = ({entryData}) => { + + return <> +} \ No newline at end of file diff --git a/ui/src/components/EntryDetailed/Rest/RestEntryDetailsContent.tsx b/ui/src/components/EntryDetailed/Rest/RestEntryDetailsContent.tsx new file mode 100644 index 000000000..fe00f15a0 --- /dev/null +++ b/ui/src/components/EntryDetailed/Rest/RestEntryDetailsContent.tsx @@ -0,0 +1,43 @@ +import React, {useState} from "react"; +import styles from "../EntryDetailed.module.sass"; +import Tabs from "../../UI/Tabs"; +import {BodySection, HAREntryTablePolicySection, TableSection} from "../EntrySections"; +import {singleEntryToHAR} from "../../../helpers/utils"; + +const MIME_TYPE_KEY = 'mimeType'; + +export const RestEntryDetailsContent: React.FC = ({entryData}) => { + + const har = singleEntryToHAR(entryData); + const {request, response, timings: {receive}} = har.log.entries[0].entry; + const rulesMatched = har.log.entries[0].rulesMatched + const TABS = [ + {tab: 'request'}, + {tab: 'response'}, + {tab: 'Rules'}, + ]; + + const [currentTab, setCurrentTab] = useState(TABS[0].tab); + + return <> +
+ + {request?.url && {request.url}} +
+ {currentTab === TABS[0].tab && <> + + + {request?.postData && } + + + } + {currentTab === TABS[1].tab && <> + + + + } + {currentTab === TABS[2].tab && <> + + } + ; +} diff --git a/ui/src/components/EntryDetailed/Rest/RestEntryDetailsTitle.tsx b/ui/src/components/EntryDetailed/Rest/RestEntryDetailsTitle.tsx new file mode 100644 index 000000000..e10aa1cf3 --- /dev/null +++ b/ui/src/components/EntryDetailed/Rest/RestEntryDetailsTitle.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import {singleEntryToHAR} from "../../../helpers/utils"; +import StatusCode from "../../UI/StatusCode"; +import {EndpointPath} from "../../UI/EndpointPath"; + +const formatSize = (n: number) => n > 1000 ? `${Math.round(n / 1000)}KB` : `${n} B`; + +export const RestEntryDetailsTitle: React.FC = ({entryData}) => { + + const har = singleEntryToHAR(entryData); + const {log: {entries}} = har; + const {response, request, timings: {receive}} = entries[0].entry; + const {status, statusText, bodySize} = response; + + return har && <> + {status &&
+ +
} +
+ +
+
{formatSize(bodySize)}
+
{status} {statusText}
+
{Math.round(receive)}ms
+
{'rulesMatched' in entries[0] ? entries[0].rulesMatched?.length : '0'} Rules Applied
+ +} \ No newline at end of file diff --git a/ui/src/components/style/HarEntry.module.sass b/ui/src/components/EntryListItem/EntryListItem.module.sass similarity index 74% rename from ui/src/components/style/HarEntry.module.sass rename to ui/src/components/EntryListItem/EntryListItem.module.sass index 6a20447ff..23a5421df 100644 --- a/ui/src/components/style/HarEntry.module.sass +++ b/ui/src/components/EntryListItem/EntryListItem.module.sass @@ -1,4 +1,4 @@ -@import 'variables.module' +@import 'src/variables.module' .row display: flex @@ -43,20 +43,20 @@ .ruleNumberTextFailure color: #DB2156 - font-family: Source Sans Pro; - font-style: normal; - font-weight: 600; - font-size: 12px; - line-height: 15px; + font-family: Source Sans Pro + font-style: normal + font-weight: 600 + font-size: 12px + line-height: 15px padding-right: 12px .ruleNumberTextSuccess color: #219653 - font-family: Source Sans Pro; - font-style: normal; - font-weight: 600; - font-size: 12px; - line-height: 15px; + font-family: Source Sans Pro + font-style: normal + font-weight: 600 + font-size: 12px + line-height: 15px padding-right: 12px .service @@ -73,10 +73,11 @@ .timestamp font-size: 12px color: $secondary-font-color - padding-left: 12px flex-shrink: 0 width: 145px text-align: left + border-left: 1px solid $data-background-color + padding: 6px 0 6px 12px .endpointServiceContainer display: flex @@ -88,6 +89,12 @@ .directionContainer display: flex - border-right: 1px solid $data-background-color - padding: 4px - padding-right: 12px + padding: 4px 12px 4px 4px + +.icon + height: 14px + width: 50px + padding: 5px + background-color: white + border-radius: 15px + box-shadow: 1px 1px 9px -4px black \ No newline at end of file diff --git a/ui/src/components/EntryListItem/EntryListItem.tsx b/ui/src/components/EntryListItem/EntryListItem.tsx new file mode 100644 index 000000000..c66528e18 --- /dev/null +++ b/ui/src/components/EntryListItem/EntryListItem.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import styles from './EntryListItem.module.sass'; +import restIcon from '../assets/restIcon.svg'; +import kafkaIcon from '../assets/kafkaIcon.svg'; +import {RestEntry, RestEntryContent} from "./RestEntryContent"; +import {KafkaEntry, KafkaEntryContent} from "./KafkaEntryContent"; + +export interface BaseEntry { + type: string; + timestamp: Date; + id: string; + rules: Rules; + latency: number; +} + +interface Rules { + status: boolean; + latency: number; + numberOfRules: number; +} + +interface EntryProps { + entry: RestEntry | KafkaEntry | any; + setFocusedEntry: (entry: RestEntry | KafkaEntry) => void; + isSelected?: boolean; +} + +export enum EntryType { + Rest = "rest", + Kafka = "kafka" +} + +export const EntryItem: React.FC = ({entry, setFocusedEntry, isSelected}) => { + + let additionalRulesProperties = ""; + let rule = 'latency' in entry.rules + if (rule) { + if (entry.rules.latency !== -1) { + if (entry.rules.latency >= entry.latency) { + additionalRulesProperties = styles.ruleSuccessRow + } else { + additionalRulesProperties = styles.ruleFailureRow + } + if (isSelected) { + additionalRulesProperties += ` ${entry.rules.latency >= entry.latency ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` + } + } else { + if (entry.rules.status) { + additionalRulesProperties = styles.ruleSuccessRow + } else { + additionalRulesProperties = styles.ruleFailureRow + } + if (isSelected) { + additionalRulesProperties += ` ${entry.rules.status ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` + } + } + } + + let icon, content; + + switch (entry.type) { + case EntryType.Rest: + content = ; + icon = restIcon; + break; + case EntryType.Kafka: + content = ; + icon = kafkaIcon; + break; + default: + content = ; + icon = restIcon; + break; + } + + return <> +
setFocusedEntry(entry)}> + {icon &&
{icon}
} + {content} +
{new Date(+entry.timestamp)?.toLocaleString()}
+
+ +}; + diff --git a/ui/src/components/EntryListItem/KafkaEntryContent.tsx b/ui/src/components/EntryListItem/KafkaEntryContent.tsx new file mode 100644 index 000000000..b461aef35 --- /dev/null +++ b/ui/src/components/EntryListItem/KafkaEntryContent.tsx @@ -0,0 +1,15 @@ +import {BaseEntry} from "./EntryListItem"; +import React from "react"; + +export interface KafkaEntry extends BaseEntry{ +} + +interface KafkaEntryContentProps { + entry: KafkaEntry; +} + +export const KafkaEntryContent: React.FC = ({entry}) => { + + return <> + +} \ No newline at end of file diff --git a/ui/src/components/EntryListItem/RestEntryContent.tsx b/ui/src/components/EntryListItem/RestEntryContent.tsx new file mode 100644 index 000000000..fb51bff87 --- /dev/null +++ b/ui/src/components/EntryListItem/RestEntryContent.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import StatusCode, {getClassification, StatusCodeClassification} from "../UI/StatusCode"; +import ingoingIconSuccess from "../assets/ingoing-traffic-success.svg"; +import outgoingIconSuccess from "../assets/outgoing-traffic-success.svg"; +import ingoingIconFailure from "../assets/ingoing-traffic-failure.svg"; +import outgoingIconFailure from "../assets/outgoing-traffic-failure.svg"; +import ingoingIconNeutral from "../assets/ingoing-traffic-neutral.svg"; +import outgoingIconNeutral from "../assets/outgoing-traffic-neutral.svg"; +import styles from "./EntryListItem.module.sass"; +import {EndpointPath} from "../UI/EndpointPath"; +import {BaseEntry} from "./EntryListItem"; + +export interface RestEntry extends BaseEntry{ + method?: string, + path: string, + service: string, + statusCode?: number; + url?: string; + isCurrentRevision?: boolean; + isOutgoing?: boolean; +} + +interface RestEntryContentProps { + entry: RestEntry; +} + +export const RestEntryContent: React.FC = ({entry}) => { + const classification = getClassification(entry.statusCode) + const numberOfRules = entry.rules.numberOfRules + + let ingoingIcon; + let outgoingIcon; + switch (classification) { + case StatusCodeClassification.SUCCESS: { + ingoingIcon = ingoingIconSuccess; + outgoingIcon = outgoingIconSuccess; + break; + } + case StatusCodeClassification.FAILURE: { + ingoingIcon = ingoingIconFailure; + outgoingIcon = outgoingIconFailure; + break; + } + case StatusCodeClassification.NEUTRAL: { + ingoingIcon = ingoingIconNeutral; + outgoingIcon = outgoingIconNeutral; + break; + } + } + + let ruleSuccess: boolean; + let rule = 'latency' in entry.rules + if (rule) { + if (entry.rules.latency !== -1) { + ruleSuccess = entry.rules.latency >= entry.latency; + } else { + ruleSuccess = entry.rules.status; + } + } + + return <> + {entry.statusCode &&
+ +
} +
+ +
+ {entry.service} +
+
+ {rule &&
+ {`Rules (${numberOfRules})`} +
} +
+ {entry.isOutgoing ? + outgoing traffic + : + ingoing traffic + } +
+ +} \ No newline at end of file diff --git a/ui/src/components/HarFilters.tsx b/ui/src/components/Filters.tsx similarity index 90% rename from ui/src/components/HarFilters.tsx rename to ui/src/components/Filters.tsx index 5dee2b564..39430a2cb 100644 --- a/ui/src/components/HarFilters.tsx +++ b/ui/src/components/Filters.tsx @@ -1,8 +1,8 @@ import React from "react"; -import styles from './style/HarFilters.module.sass'; -import {HARFilterSelect} from "./HARFilterSelect"; +import styles from './style/Filters.module.sass'; +import {FilterSelect} from "./UI/FilterSelect"; import {TextField} from "@material-ui/core"; -import {ALL_KEY} from "./Select"; +import {ALL_KEY} from "./UI/Select"; interface HarFiltersProps { methodsFilter: Array; @@ -13,7 +13,7 @@ interface HarFiltersProps { setPathFilter: (val: string) => void; } -export const HarFilters: React.FC = ({methodsFilter, setMethodsFilter, statusFilter, setStatusFilter, pathFilter, setPathFilter}) => { +export const Filters: React.FC = ({methodsFilter, setMethodsFilter, statusFilter, setStatusFilter, pathFilter, setPathFilter}) => { return
@@ -59,7 +59,7 @@ const MethodFilter: React.FC = ({methodsFilter, setMethodsFil } return - = ({statusFilter, setS } return - void; - isSelected?: boolean; -} - -export const HarEntry: React.FC = ({entry, setFocusedEntryId, isSelected}) => { - const classification = getClassification(entry.statusCode) - const numberOfRules = entry.rules.numberOfRules - let ingoingIcon; - let outgoingIcon; - switch(classification) { - case StatusCodeClassification.SUCCESS: { - ingoingIcon = ingoingIconSuccess; - outgoingIcon = outgoingIconSuccess; - break; - } - case StatusCodeClassification.FAILURE: { - ingoingIcon = ingoingIconFailure; - outgoingIcon = outgoingIconFailure; - break; - } - case StatusCodeClassification.NEUTRAL: { - ingoingIcon = ingoingIconNeutral; - outgoingIcon = outgoingIconNeutral; - break; - } - } - let additionalRulesProperties = ""; - let ruleSuccess: boolean; - let rule = 'latency' in entry.rules - if (rule) { - if (entry.rules.latency !== -1) { - if (entry.rules.latency >= entry.latency) { - additionalRulesProperties = styles.ruleSuccessRow - ruleSuccess = true - } else { - additionalRulesProperties = styles.ruleFailureRow - ruleSuccess = false - } - if (isSelected) { - additionalRulesProperties += ` ${entry.rules.latency >= entry.latency ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` - } - } else { - if (entry.rules.status) { - additionalRulesProperties = styles.ruleSuccessRow - ruleSuccess = true - } else { - additionalRulesProperties = styles.ruleFailureRow - ruleSuccess = false - } - if (isSelected) { - additionalRulesProperties += ` ${entry.rules.status ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}` - } - } - } - return <> -
setFocusedEntryId(entry.id)}> - {entry.statusCode &&
- -
} -
- -
- {entry.service} -
-
- { - rule ? -
- {`Rules (${numberOfRules})`} -
- : "" - } -
- {entry.isOutgoing ? - outgoing traffic - : - ingoing traffic - } -
-
{new Date(+entry.timestamp)?.toLocaleString()}
-
- -}; diff --git a/ui/src/components/HarEntryDetailed.tsx b/ui/src/components/HarEntryDetailed.tsx deleted file mode 100644 index 82b6f029b..000000000 --- a/ui/src/components/HarEntryDetailed.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; -import {singleEntryToHAR} from "./utils"; -import styles from './style/HarEntryDetailed.module.sass'; -import HAREntryViewer from "./HarEntryViewer/HAREntryViewer"; -import {makeStyles} from "@material-ui/core"; -import StatusCode from "./StatusCode"; -import {EndpointPath} from "./EndpointPath"; - -const useStyles = makeStyles(() => ({ - entryTitle: { - display: 'flex', - minHeight: 46, - maxHeight: 46, - alignItems: 'center', - marginBottom: 8, - padding: 5, - paddingBottom: 0 - } -})); - -interface HarEntryDetailedProps { - harEntry: any; - classes?: any; -} - -export const formatSize = (n: number) => n > 1000 ? `${Math.round(n / 1000)}KB` : `${n} B`; - -const HarEntryTitle: React.FC = ({har}) => { - const classes = useStyles(); - - const {log: {entries}} = har; - const {response, request, timings: {receive}} = entries[0].entry; - const {status, statusText, bodySize} = response; - - - return
- {status &&
- -
} -
- -
-
{formatSize(bodySize)}
-
{status} {statusText}
-
{Math.round(receive)}ms
-
; -}; - -export const HAREntryDetailed: React.FC = ({classes, harEntry}) => { - const har = singleEntryToHAR(harEntry); - - return <> - {har && } - <> - {har && } - - -}; diff --git a/ui/src/components/HarEntryViewer/HAREntrySections.tsx b/ui/src/components/HarEntryViewer/HAREntrySections.tsx deleted file mode 100644 index 3b5bd1d87..000000000 --- a/ui/src/components/HarEntryViewer/HAREntrySections.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import styles from "./HAREntrySections.module.sass"; -import React, {useState} from "react"; -import {SyntaxHighlighter} from "../SyntaxHighlighter/index"; -import CollapsibleContainer from "../CollapsibleContainer"; -import FancyTextDisplay from "../FancyTextDisplay"; -import Checkbox from "../Checkbox"; -import ProtobufDecoder from "protobuf-decoder"; -var jp = require('jsonpath'); - -interface HAREntryViewLineProps { - label: string; - value: number | string; -} - -const HAREntryViewLine: React.FC = ({label, value}) => { - return (label && value && - {label} - - - - ) || null; -} - - -interface HAREntrySectionCollapsibleTitleProps { - title: string; - isExpanded: boolean; -} - -const HAREntrySectionCollapsibleTitle: React.FC = ({title, isExpanded}) => { - return
- - {isExpanded ? '-' : '+'} - - {title} -
-} - -interface HAREntrySectionContainerProps { - title: string; -} - -export const HAREntrySectionContainer: React.FC = ({title, children}) => { - const [expanded, setExpanded] = useState(true); - return setExpanded(!expanded)} - title={} - > - {children} - -} - -interface HAREntryBodySectionProps { - content: any; - encoding?: string; - contentType?: string; -} - -export const HAREntryBodySection: React.FC = ({ - content, - encoding, - contentType, - }) => { - const MAXIMUM_BYTES_TO_HIGHLIGHT = 10000; // The maximum of chars to highlight in body, in case the response can be megabytes - const supportedLanguages = [['html', 'html'], ['json', 'json'], ['application/grpc', 'json']]; // [[indicator, languageToUse],...] - const jsonLikeFormats = ['json']; - const protobufFormats = ['application/grpc']; - const [isWrapped, setIsWrapped] = useState(false); - - const formatTextBody = (body): string => { - const chunk = body.slice(0, MAXIMUM_BYTES_TO_HIGHLIGHT); - const bodyBuf = encoding === 'base64' ? atob(chunk) : chunk; - - try { - if (jsonLikeFormats.some(format => content?.mimeType?.indexOf(format) > -1)) { - return JSON.stringify(JSON.parse(bodyBuf), null, 2); - } else if (protobufFormats.some(format => content?.mimeType?.indexOf(format) > -1)) { - // Replace all non printable characters (ASCII) - const protobufDecoder = new ProtobufDecoder(bodyBuf, true); - return JSON.stringify(protobufDecoder.decode().toSimple(), null, 2); - } - } catch (error) { - console.error(error); - } - return bodyBuf; - } - - const getLanguage = (mimetype) => { - const chunk = content.text?.slice(0, 100); - if (chunk.indexOf('html') > 0 || chunk.indexOf('HTML') > 0) return supportedLanguages[0][1]; - const language = supportedLanguages.find(el => (mimetype + contentType).indexOf(el[0]) > -1); - return language ? language[1] : 'default'; - } - - return - {content && content.text?.length > 0 && - - - - - -
- -
setIsWrapped(!isWrapped)}> -
- {}}/> -
- Wrap text -
- - -
} -
-} - -interface HAREntrySectionProps { - title: string, - arrayToIterate: any[], -} - -export const HAREntryTableSection: React.FC = ({title, arrayToIterate}) => { - return - { - arrayToIterate && arrayToIterate.length > 0 ? - - - - {arrayToIterate.map(({name, value}, index) => )} - -
-
: - } -
-} - - - -interface HAREntryPolicySectionProps { - service: string, - title: string, - response: any, - latency?: number, - arrayToIterate: any[], -} - - -interface HAREntryPolicySectionCollapsibleTitleProps { - label: string; - matched: string; - isExpanded: boolean; -} - -const HAREntryPolicySectionCollapsibleTitle: React.FC = ({label, matched, isExpanded}) => { - return
- - {isExpanded ? '-' : '+'} - - - - {label} - {matched} - - -
-} - -interface HAREntryPolicySectionContainerProps { - label: string; - matched: string; - children?: any; -} - -export const HAREntryPolicySectionContainer: React.FC = ({label, matched, children}) => { - const [expanded, setExpanded] = useState(false); - return setExpanded(!expanded)} - title={} - > - {children} - -} - -export const HAREntryTablePolicySection: React.FC = ({service, title, response, latency, arrayToIterate}) => { - const base64ToJson = response.content.mimeType === "application/json; charset=utf-8" ? JSON.parse(Buffer.from(response.content.text, "base64").toString()) : {}; - return - { - arrayToIterate && arrayToIterate.length > 0 ? - <> - - - - {arrayToIterate.map(({rule, matched}, index) => { - - - return ( - = latency : true)? "Success" : "Failure"}> - { - - <> - { - rule.Key != "" ? - - : null - } - { - rule.Latency != "" ? - - : null - } - { - rule.Method != "" ? - - : null - } - { - rule.Path != "" ? - - : null - } - { - rule.Service != "" ? - - : null - } - { - rule.Type != "" ? - - : null - } - { - rule.Value != "" ? - - : null - } - - } - - - - ) - } - ) - } - -
Key:{rule.Key}
Latency: {rule.Latency}
Method: {rule.Method}
Path: {rule.Path}
Service: {service}
Type: {rule.Type}
Value: {rule.Value}
-
- - : No rules could be applied to this request. - } -
-} \ No newline at end of file diff --git a/ui/src/components/HarEntryViewer/HAREntryViewer.module.sass b/ui/src/components/HarEntryViewer/HAREntryViewer.module.sass deleted file mode 100644 index 1dc344781..000000000 --- a/ui/src/components/HarEntryViewer/HAREntryViewer.module.sass +++ /dev/null @@ -1,60 +0,0 @@ -@import "../style/variables.module" - -.harEntry - font-family: "Source Sans Pro", Lucida Grande, Tahoma, sans-serif - height: 100% - width: 100% - - h3, - h4 - font-family: "Source Sans Pro", Lucida Grande, Tahoma, sans-serif - - .header - background-color: rgb(55, 65, 111) - padding: 0.5rem .75rem .65rem .75rem - border-top-left-radius: 0.25rem - border-top-right-radius: 0.25rem - display: flex - font-size: .75rem - align-items: center - .description - min-width: 25rem - display: flex - align-items: center - justify-content: space-between - .method - padding: 0 .25rem - font-size: 0.75rem - font-weight: bold - border-radius: 0.25rem - border: 0.0625rem solid rgba(255, 255, 255, 0.16) - margin-right: .5rem - > span - margin-left: .5rem - .timing - border-left: 1px solid #627ef7 - margin-left: .3rem - padding-left: .3rem - - .headerClickable - cursor: pointer - &:hover - background: lighten(rgb(55, 65, 111), 10%) - border-top-left-radius: 0 - border-top-right-radius: 0 - - .body - background: $main-background-color - color: $blue-gray - border-radius: 4px - padding: 10px - .bodyHeader - padding: 0 1rem - .endpointURL - font-size: .75rem - display: block - color: $blue-color - text-decoration: none - margin-bottom: .5rem - overflow-wrap: anywhere - padding: 5px 0 \ No newline at end of file diff --git a/ui/src/components/HarEntryViewer/HAREntryViewer.tsx b/ui/src/components/HarEntryViewer/HAREntryViewer.tsx deleted file mode 100644 index e0450e1e7..000000000 --- a/ui/src/components/HarEntryViewer/HAREntryViewer.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, {useState} from 'react'; -import styles from './HAREntryViewer.module.sass'; -import Tabs from "../Tabs"; -import {HAREntryTableSection, HAREntryBodySection, HAREntryTablePolicySection} from "./HAREntrySections"; - -const MIME_TYPE_KEY = 'mimeType'; - -const HAREntryDisplay: React.FC = ({har, entry, isCollapsed: initialIsCollapsed, isResponseMocked}) => { - const {request, response, timings: {receive}} = entry; - const rulesMatched = har.log.entries[0].rulesMatched - const TABS = [ - {tab: 'request'}, - { - tab: 'response', - badge: <>{isResponseMocked && MOCK} - }, - { - tab: 'Rules', - }, - ]; - - const [currentTab, setCurrentTab] = useState(TABS[0].tab); - - return
- - {!initialIsCollapsed &&
-
- - {request?.url && {request.url}} -
- { - currentTab === TABS[0].tab && - - - - - {request?.postData && } - - - - } - {currentTab === TABS[1].tab && - - - - - - } - {currentTab === TABS[2].tab && - - } -
} -
; -} - -interface Props { - harObject: any; - className?: string; - isResponseMocked?: boolean; - showTitle?: boolean; -} - -const HAREntryViewer: React.FC = ({harObject, className, isResponseMocked, showTitle=true}) => { - const {log: {entries}} = harObject; - const isCollapsed = entries.length > 1; - return
- {Object.keys(entries).map((entry: any, index) => )} -
-}; - -export default HAREntryViewer; diff --git a/ui/src/components/HarPaging.tsx b/ui/src/components/HarPaging.tsx deleted file mode 100644 index 7fb410bee..000000000 --- a/ui/src/components/HarPaging.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import prevIcon from "./assets/icon-prev.svg"; -import nextIcon from "./assets/icon-next.svg"; -import {Box} from "@material-ui/core"; -import React from "react"; -import styles from './style/HarPaging.module.sass' -import numeral from 'numeral'; - -interface HarPagingProps { - showPageNumber?: boolean; -} - -export const HarPaging: React.FC = ({showPageNumber=false}) => { - - return - { - // harStore.data.moveBack(); todo - }} alt="back"/> - {showPageNumber && - Page - {/*{numeral(harStore.data.currentPage).format(0, 0)}*/} //todo - - } - { - // harStore.data.moveNext(); todo - }} alt="next"/> - -}; \ No newline at end of file diff --git a/ui/src/components/HarPage.tsx b/ui/src/components/TrafficPage.tsx similarity index 71% rename from ui/src/components/HarPage.tsx rename to ui/src/components/TrafficPage.tsx index 3cf42d5ca..8ecfd46f0 100644 --- a/ui/src/components/HarPage.tsx +++ b/ui/src/components/TrafficPage.tsx @@ -1,14 +1,14 @@ import React, {useEffect, useRef, useState} from "react"; -import {HarFilters} from "./HarFilters"; -import {HarEntriesList} from "./HarEntriesList"; +import {Filters} from "./Filters"; +import {EntriesList} from "./EntriesList"; import {makeStyles} from "@material-ui/core"; -import "./style/HarPage.sass"; -import styles from './style/HarEntriesList.module.sass'; -import {HAREntryDetailed} from "./HarEntryDetailed"; +import "./style/TrafficPage.sass"; +import styles from './style/EntriesList.module.sass'; +import {EntryDetailed} from "./EntryDetailed/EntryDetailed"; import playIcon from './assets/run.svg'; import pauseIcon from './assets/pause.svg'; -import variables from './style/variables.module.scss'; -import {StatusBar} from "./StatusBar"; +import variables from '../variables.module.scss'; +import {StatusBar} from "./UI/StatusBar"; import Api, {MizuWebsocketURL} from "../helpers/api"; const useLayoutStyles = makeStyles(() => ({ @@ -43,13 +43,13 @@ interface HarPageProps { const api = new Api(); -export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected}) => { +export const TrafficPage: React.FC = ({setAnalyzeStatus, onTLSDetected}) => { const classes = useLayoutStyles(); const [entries, setEntries] = useState([] as any); - const [focusedEntryId, setFocusedEntryId] = useState(null); - const [selectedHarEntry, setSelectedHarEntry] = useState(null); + const [focusedEntry, setFocusedEntry] = useState(null); + const [selectedEntryData, setSelectedEntryData] = useState(null); const [connection, setConnection] = useState(ConnectionStatus.Closed); const [noMoreDataTop, setNoMoreDataTop] = useState(false); const [noMoreDataBottom, setNoMoreDataBottom] = useState(false); @@ -83,7 +83,7 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected setNoMoreDataBottom(false) return; } - if (!focusedEntryId) setFocusedEntryId(entry.id) + if (!focusedEntry) setFocusedEntry(entry) let newEntries = [...entries]; if (entries.length === 1000) { newEntries = newEntries.splice(1); @@ -128,17 +128,17 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected useEffect(() => { - if (!focusedEntryId) return; - setSelectedHarEntry(null); + if (!focusedEntry) return; + setSelectedEntryData(null); (async () => { try { - const entryData = await api.getEntry(focusedEntryId); - setSelectedHarEntry(entryData); + const entryData = await api.getEntry(focusedEntry.id); + setSelectedEntryData(entryData); } catch (error) { console.error(error); } })() - }, [focusedEntryId]) + }, [focusedEntry]) const toggleConnection = () => { setConnection(connection === ConnectionStatus.Connected ? ConnectionStatus.Paused : ConnectionStatus.Connected); @@ -172,7 +172,7 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected } const isScrollable = (element) => { - return element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight; + return element.scrollHeight > element.clientHeight; }; return ( @@ -189,35 +189,34 @@ export const HarPage: React.FC = ({setAnalyzeStatus, onTLSDetected
{entries.length > 0 &&
-
-
- {selectedHarEntry && - } + {selectedEntryData && }
} {tappingStatus?.pods != null && } diff --git a/ui/src/components/Checkbox.tsx b/ui/src/components/UI/Checkbox.tsx similarity index 100% rename from ui/src/components/Checkbox.tsx rename to ui/src/components/UI/Checkbox.tsx diff --git a/ui/src/components/CollapsibleContainer.tsx b/ui/src/components/UI/CollapsibleContainer.tsx similarity index 95% rename from ui/src/components/CollapsibleContainer.tsx rename to ui/src/components/UI/CollapsibleContainer.tsx index aad6b1552..4c0452623 100644 --- a/ui/src/components/CollapsibleContainer.tsx +++ b/ui/src/components/UI/CollapsibleContainer.tsx @@ -1,6 +1,6 @@ import React, {useState} from "react"; -import collapsedImg from "./assets/collapsed.svg"; -import expandedImg from "./assets/expanded.svg"; +import collapsedImg from "../assets/collapsed.svg"; +import expandedImg from "../assets/expanded.svg"; import "./style/CollapsibleContainer.sass"; interface Props { diff --git a/ui/src/components/EndpointPath.tsx b/ui/src/components/UI/EndpointPath.tsx similarity index 100% rename from ui/src/components/EndpointPath.tsx rename to ui/src/components/UI/EndpointPath.tsx diff --git a/ui/src/components/FancyTextDisplay.tsx b/ui/src/components/UI/FancyTextDisplay.tsx similarity index 97% rename from ui/src/components/FancyTextDisplay.tsx rename to ui/src/components/UI/FancyTextDisplay.tsx index c61a85bd5..91f10f4bf 100644 --- a/ui/src/components/FancyTextDisplay.tsx +++ b/ui/src/components/UI/FancyTextDisplay.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { CopyToClipboard } from 'react-copy-to-clipboard'; -import duplicateImg from "./assets/duplicate.svg"; +import duplicateImg from "../assets/duplicate.svg"; import './style/FancyTextDisplay.sass'; interface Props { diff --git a/ui/src/components/HARFilterSelect.tsx b/ui/src/components/UI/FilterSelect.tsx similarity index 79% rename from ui/src/components/HARFilterSelect.tsx rename to ui/src/components/UI/FilterSelect.tsx index c4bc51804..a2247b6d8 100644 --- a/ui/src/components/HARFilterSelect.tsx +++ b/ui/src/components/UI/FilterSelect.tsx @@ -1,6 +1,6 @@ import React from "react"; import { MenuItem } from '@material-ui/core'; -import style from './style/HARFilterSelect.module.sass'; +import style from './style/FilterSelect.module.sass'; import { Select, SelectProps } from "./Select"; interface HARFilterSelectProps extends SelectProps { @@ -12,7 +12,7 @@ interface HARFilterSelectProps extends SelectProps { transformDisplay?: (string) => string; } -export const HARFilterSelect: React.FC = ({items, value, onChange, label, allowMultiple= false, transformDisplay}) => { +export const FilterSelect: React.FC = ({items, value, onChange, label, allowMultiple= false, transformDisplay}) => { return