diff --git a/CHANGELOG.md b/CHANGELOG.md index b8dd4c5aaa..319cbdf502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes to `src-cli` are documented in this file. - `src codeintel upload` will now upload SCIP indexes (over LSIF indexes) when the target instance supports it. [#897](https://github.com/sourcegraph/src-cli/pull/897) +- `src validate kube` adds support for validating Sourcegraph deployments on Kubernetes. Validations include Pods, Services, PVCs, and network connectivity. [#926](https://github.com/sourcegraph/src-cli/pull/926) + ### Changed ### Fixed diff --git a/cmd/src/validate.go b/cmd/src/validate.go index 4e097de5ce..ee67f5fa9a 100644 --- a/cmd/src/validate.go +++ b/cmd/src/validate.go @@ -21,6 +21,7 @@ Usage: The commands are: install validates a Sourcegraph installation + kube validates a Sourcegraph deployment on a Kubernetes cluster Use "src validate [command] -h" for more information about a command. ` diff --git a/cmd/src/validate_install.go b/cmd/src/validate_install.go index 0bab7d354a..02a0a26f99 100644 --- a/cmd/src/validate_install.go +++ b/cmd/src/validate_install.go @@ -9,15 +9,12 @@ import ( "strings" "github.com/mattn/go-isatty" + "github.com/sourcegraph/src-cli/internal/api" "github.com/sourcegraph/src-cli/internal/validate" + "github.com/sourcegraph/src-cli/internal/validate/install" "github.com/sourcegraph/sourcegraph/lib/errors" - "github.com/sourcegraph/sourcegraph/lib/output" -) - -var ( - validateWarningEmoji = output.EmojiWarning ) func init() { @@ -60,7 +57,7 @@ Environmental variables client := cfg.apiClient(apiFlags, flagSet.Output()) - var validationSpec *validate.ValidationSpec + var validationSpec *install.ValidationSpec if len(flagSet.Args()) == 1 { fileName := flagSet.Arg(0) @@ -70,14 +67,14 @@ Environmental variables } if strings.HasSuffix(fileName, ".yaml") || strings.HasSuffix(fileName, ".yml") { - validationSpec, err = validate.LoadYamlConfig(f) + validationSpec, err = install.LoadYamlConfig(f) if err != nil { return err } } if strings.HasSuffix(fileName, ".json") { - validationSpec, err = validate.LoadJsonConfig(f) + validationSpec, err = install.LoadJsonConfig(f) if err != nil { return err } @@ -90,26 +87,26 @@ Environmental variables if err != nil { return errors.Wrap(err, "failed to read installation validation config from pipe") } - validationSpec, err = validate.LoadYamlConfig(input) + validationSpec, err = install.LoadYamlConfig(input) if err != nil { return err } } if validationSpec == nil { - validationSpec = validate.DefaultConfig() + validationSpec = install.DefaultConfig() } envGithubToken := os.Getenv("SRC_GITHUB_TOKEN") // will work for now with only GitHub supported but will need to be revisited when other code hosts are supported if envGithubToken == "" { - return errors.Newf("%s failed to read `SRC_GITHUB_TOKEN` environment variable", validateWarningEmoji) + return errors.Newf("%s failed to read `SRC_GITHUB_TOKEN` environment variable", validate.WarningSign) } validationSpec.ExternalService.Config.GitHub.Token = envGithubToken - return validate.Installation(context.Background(), client, validationSpec) + return install.Validate(context.Background(), client, validationSpec) } validateCommands = append(validateCommands, &command{ diff --git a/cmd/src/validate_kube.go b/cmd/src/validate_kube.go new file mode 100644 index 0000000000..2fab5a760a --- /dev/null +++ b/cmd/src/validate_kube.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "flag" + "fmt" + "path/filepath" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" + + "github.com/sourcegraph/src-cli/internal/validate/kube" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +func init() { + usage := `'src validate kube' is a tool that validates a Kubernetes based Sourcegraph deployment + +Examples: + + Run default deployment validation: + $ src validate kube + + Specify Kubernetes namespace: + $ src validate kube --namespace sourcegraph + + Specify the kubeconfig file location: + $ src validate kube --kubeconfig ~/.kube/config + + Suppress output (useful for CI/CD pipelines) + $ src validate kube --quiet +` + + flagSet := flag.NewFlagSet("kube", flag.ExitOnError) + usageFunc := func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of 'src validate %s':\n", flagSet.Name()) + flagSet.PrintDefaults() + fmt.Println(usage) + } + + var ( + kubeConfig *string + namespace = flagSet.String("namespace", "", "(optional) specify the kubernetes namespace to use") + quiet = flagSet.Bool("quiet", false, "(optional) suppress output and return exit status only") + ) + + if home := homedir.HomeDir(); home != "" { + kubeConfig = flagSet.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file") + } else { + kubeConfig = flagSet.String("kubeconfig", "", "absolute path to the kubeconfig file") + } + + handler := func(args []string) error { + if err := flagSet.Parse(args); err != nil { + return err + } + + // use the current context in kubeConfig + config, err := clientcmd.BuildConfigFromFlags("", *kubeConfig) + if err != nil { + return errors.Wrap(err, "failed to load kubernetes config") + } + + clientSet, err := kubernetes.NewForConfig(config) + if err != nil { + return errors.Wrap(err, "failed to create kubernetes client") + } + + // parse through flag options + var options []kube.Option + + if *namespace != "" { + options = append(options, kube.WithNamespace(*namespace)) + } + + if *quiet { + options = append(options, kube.Quiet()) + } + + return kube.Validate(context.Background(), clientSet, config, options...) + } + + validateCommands = append(validateCommands, &command{ + flagSet: flagSet, + handler: handler, + usageFunc: usageFunc, + }) +} diff --git a/go.mod b/go.mod index 7b76510e7c..e661c3b971 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,9 @@ require ( google.golang.org/protobuf v1.28.1 gopkg.in/yaml.v3 v3.0.1 jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 + k8s.io/api v0.26.1 + k8s.io/apimachinery v0.26.1 + k8s.io/client-go v0.26.1 ) require ( @@ -50,15 +53,22 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/envoyproxy/protoc-gen-validate v0.3.0-java // indirect github.com/fatih/color v1.13.0 // indirect github.com/getsentry/sentry-go v0.13.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/swag v0.19.14 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect + github.com/google/gnostic v0.5.7-v3refs // indirect + github.com/google/gofuzz v1.1.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect github.com/googleapis/gax-go/v2 v2.6.0 // indirect @@ -66,25 +76,29 @@ require ( github.com/hexops/gotextdiff v1.0.3 // indirect github.com/hexops/valast v1.4.1 // indirect github.com/huandu/xstrings v1.0.0 // indirect - github.com/imdario/mergo v0.3.4 // indirect + github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jdxcode/netrc v0.0.0-20210204082910-926c7f70242a // indirect github.com/jhump/protocompile v0.0.0-20220216033700-d705409f108f // indirect github.com/jhump/protoreflect v1.12.1-0.20220417024638-438db461d753 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.15.1 // indirect github.com/klauspost/pgzip v1.2.5 // indirect github.com/kr/pretty v0.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/microcosm-cc/bluemonday v1.0.17 // indirect + github.com/moby/spdystream v0.2.0 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.12.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007 // indirect github.com/nightlyone/lockfile v1.0.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect @@ -117,13 +131,21 @@ require ( golang.org/x/sys v0.4.0 // indirect golang.org/x/term v0.4.0 // indirect golang.org/x/text v0.6.0 // indirect + golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect golang.org/x/tools v0.5.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e // indirect google.golang.org/grpc v1.50.1 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/klog/v2 v2.80.1 // indirect + k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect + k8s.io/utils v0.0.0-20221107191617-1a15be271d1d // indirect mvdan.cc/gofumpt v0.2.1 // indirect + sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) // See: https://github.com/ghodss/yaml/pull/65 diff --git a/go.sum b/go.sum index 3a302ac8cb..65fa4f2f78 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,7 @@ github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQq github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= @@ -79,9 +80,13 @@ github.com/dineshappavoo/basex v0.0.0-20170425072625-481a6f6dc663 h1:fctNkSsavbX github.com/dineshappavoo/basex v0.0.0-20170425072625-481a6f6dc663/go.mod h1:Kad2hux31v/IyD4Rf4wAwIyK48995rs3qAl9IUAhc2k= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7foRItutHYUIhlcKzcSf5vDpdhQAKTc= +github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= +github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -108,7 +113,18 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= +github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= @@ -147,6 +163,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= +github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -161,6 +179,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 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/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -174,6 +194,7 @@ github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/regexp v0.0.0-20220304100321-149c8afcd6cb h1:wwzNkyaQwcXCzQuKoWz3lwngetmcyg+EhW0fF5lz73M= github.com/grafana/regexp v0.0.0-20220304100321-149c8afcd6cb/go.mod h1:M5qHK+eWfAv8VR/265dIuEpL3fNfeC21tXXp9itM24A= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -191,8 +212,8 @@ github.com/huandu/xstrings v1.0.0 h1:pO2K/gKgKaat5LdpAhxhluX2GPQMaI3W5FUz/I/UnWk github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo= github.com/hydrogen18/memlistener v0.0.0-20141126152155-54553eb933fb/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= -github.com/imdario/mergo v0.3.4 h1:mKkfHkZWD8dC7WxKx3N9WCF0Y+dLau45704YQmY6H94= -github.com/imdario/mergo v0.3.4/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -216,6 +237,8 @@ github.com/jhump/protoreflect v1.12.1-0.20220417024638-438db461d753 h1:uFlcJKZPL github.com/jhump/protoreflect v1.12.1-0.20220417024638-438db461d753/go.mod h1:JytZfP5d0r8pVNLZvai7U/MCuTWITgrI4tTg7puQFKI= github.com/jig/teereadcloser v0.0.0-20181016160506-953720c48e05 h1:dSwwtWuwMyarzsbVWOq4QJ8xVy9wgcNomvWyGtrKe+E= github.com/jig/teereadcloser v0.0.0-20181016160506-953720c48e05/go.mod h1:sRUFlj+HCejvoCRpuhU0EYnNw5FG+YJpz8UFfCf0F2U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -248,6 +271,7 @@ github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo github.com/klauspost/pgzip v1.2.5 h1:qnWYvvKqedOF2ulHpMG72XQol4ILEJ8k2wwRl/Km8oE= github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -261,6 +285,10 @@ github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -290,6 +318,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -305,6 +335,8 @@ github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKt github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007 h1:28i1IjGcx8AofiB4N3q5Yls55VEaitzuEPkFJEVgGkA= github.com/mwitkow/go-proto-validators v0.0.0-20180403085117-0950a7990007/go.mod h1:m2XC9Qq0AlmmVksL6FktJCdTYyLk7V3fKyp0sl1yWQo= github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= @@ -325,9 +357,12 @@ github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.13.0 h1:M76yO2HkZASFjXL0HSoZJ1AYEmQxNJmY41Jx1zNUq1Y= github.com/onsi/ginkgo v1.13.0/go.mod h1:+REjRxOmWfHCjfv9TTWB1jD1Frx4XydAD3zm1lskyM0= +github.com/onsi/ginkgo/v2 v2.4.0 h1:+Ig9nvqgS5OBSACXNk15PLdp0U9XPYROt9CFzVdFGIs= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.23.0 h1:/oxKu9c2HVap+F3PfKort2Hw5DEU+HGlW8n+tguWsys= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= @@ -394,6 +429,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -557,6 +593,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -595,6 +633,7 @@ google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e h1:S9GbmC1iCgvbLyAokVCwiO6tVIrU9Y7c5oMx1V/ki/Y= google.golang.org/genproto v0.0.0-20221024183307-1bc688fe9f3e/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= @@ -616,6 +655,7 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= @@ -625,12 +665,15 @@ google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -642,6 +685,7 @@ 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-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -651,8 +695,26 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:mub0MmFLOn8XLikZOAhgLD1kXJq8jgftSrrv7m00xFo= jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4= +k8s.io/api v0.26.1 h1:f+SWYiPd/GsiWwVRz+NbFyCgvv75Pk9NK6dlkZgpCRQ= +k8s.io/api v0.26.1/go.mod h1:xd/GBNgR0f707+ATNyPmQ1oyKSgndzXij81FzWGsejg= +k8s.io/apimachinery v0.26.1 h1:8EZ/eGJL+hY/MYCNwhmDzVqq2lPl3N3Bo8rvweJwXUQ= +k8s.io/apimachinery v0.26.1/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74= +k8s.io/client-go v0.26.1 h1:87CXzYJnAMGaa/IDDfRdhTzxk/wzGZ+/HUQpqgVSZXU= +k8s.io/client-go v0.26.1/go.mod h1:IWNSglg+rQ3OcvDkhY6+QLeasV4OYHDjdqeWkDQZwGE= +k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4= +k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E= +k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4= +k8s.io/utils v0.0.0-20221107191617-1a15be271d1d h1:0Smp/HP1OH4Rvhe+4B8nWGERtlqAGSftbSbbmm45oFs= +k8s.io/utils v0.0.0-20221107191617-1a15be271d1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= mvdan.cc/gofumpt v0.0.0-20210107193838-d24d34e18d44/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48= mvdan.cc/gofumpt v0.1.0/go.mod h1:yXG1r1WqZVKWbVRtBWKWX9+CxGYfA51nSomhM0woR48= mvdan.cc/gofumpt v0.2.0/go.mod h1:TiGmrf914DAuT6+hDIxOqoDb4QXIzAuEUSXqEf9hGKY= mvdan.cc/gofumpt v0.2.1 h1:7jakRGkQcLAJdT+C8Bwc9d0BANkVPSkHZkzNv07pJAs= mvdan.cc/gofumpt v0.2.1/go.mod h1:a/rvZPhsNaedOJBzqRD9omnwVwHZsBdJirXHa9Gh9Ig= +sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k= +sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/validate/README.md b/internal/validate/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/internal/validate/install.go b/internal/validate/install/install.go similarity index 88% rename from internal/validate/install.go rename to internal/validate/install/install.go index 3f6d538448..eb3a6503a2 100644 --- a/internal/validate/install.go +++ b/internal/validate/install/install.go @@ -1,4 +1,4 @@ -package validate +package install import ( "context" @@ -7,18 +7,12 @@ import ( "strings" "time" - "github.com/sourcegraph/src-cli/internal/api" "gopkg.in/yaml.v3" - "github.com/sourcegraph/sourcegraph/lib/errors" - "github.com/sourcegraph/sourcegraph/lib/output" -) + "github.com/sourcegraph/src-cli/internal/api" + "github.com/sourcegraph/src-cli/internal/validate" -var ( - validateEmojiFingerPointRight = output.EmojiFingerPointRight - validateFailureEmoji = output.EmojiFailure - validateHourglassEmoji = output.EmojiHourglass - validateSuccessEmoji = output.EmojiSuccess + "github.com/sourcegraph/sourcegraph/lib/errors" ) type ExternalService struct { @@ -152,10 +146,10 @@ func LoadJsonConfig(userConfig []byte) (*ValidationSpec, error) { return &config, nil } -// Installation runs a series of validation checks such as cloning a repository, running search queries, and +// Validate runs a series of validation checks such as cloning a repository, running search queries, and // creating insights, based on the configuration provided. -func Installation(ctx context.Context, client api.Client, config *ValidationSpec) error { - log.Printf("%s validating external service", validateEmojiFingerPointRight) +func Validate(ctx context.Context, client api.Client, config *ValidationSpec) error { + log.Printf("%s validating external service", validate.EmojiFingerPointRight) if config.ExternalService.DisplayName != "" { srvID, err := addExternalService(ctx, client, config.ExternalService) @@ -163,29 +157,29 @@ func Installation(ctx context.Context, client api.Client, config *ValidationSpec return err } - log.Printf("%s external service %s is being added", validateHourglassEmoji, config.ExternalService.DisplayName) + log.Printf("%s external service %s is being added", validate.HourglassEmoji, config.ExternalService.DisplayName) defer func() { if srvID != "" && config.ExternalService.DeleteWhenDone { _ = removeExternalService(ctx, client, srvID) - log.Printf("%s external service %s has been removed", validateSuccessEmoji, config.ExternalService.DisplayName) + log.Printf("%s external service %s has been removed", validate.SuccessEmoji, config.ExternalService.DisplayName) } }() } - log.Printf("%s cloning repository", validateHourglassEmoji) + log.Printf("%s cloning repository", validate.HourglassEmoji) cloned, err := repoCloneTimeout(ctx, client, config.ExternalService) if err != nil { return err //TODO make sure errors are wrapped once } if !cloned { - return errors.Newf("%s validate failed, repo did not clone\n", validateFailureEmoji) + return errors.Newf("%s validate failed, repo did not clone\n", validate.FailureEmoji) } - log.Printf("%s repositry successfully cloned", validateSuccessEmoji) + log.Printf("%s repositry successfully cloned", validate.SuccessEmoji) - log.Printf("%s validating search queries", validateEmojiFingerPointRight) + log.Printf("%s validating search queries", validate.EmojiFingerPointRight) if config.SearchQuery != nil { for i := 0; i < len(config.SearchQuery); i++ { @@ -196,26 +190,26 @@ func Installation(ctx context.Context, client api.Client, config *ValidationSpec if matchCount == 0 { return errors.Newf("validate failed, search query %s returned no results", config.SearchQuery[i]) } - log.Printf("%s search query '%s' was successful", validateSuccessEmoji, config.SearchQuery[i]) + log.Printf("%s search query '%s' was successful", validate.SuccessEmoji, config.SearchQuery[i]) } } - log.Printf("%s validating code insight", validateEmojiFingerPointRight) + log.Printf("%s validating code insight", validate.EmojiFingerPointRight) if config.Insight.Title != "" { - log.Printf("%s insight %s is being added", validateHourglassEmoji, config.Insight.Title) + log.Printf("%s insight %s is being added", validate.HourglassEmoji, config.Insight.Title) insightId, err := createInsight(ctx, client, config.Insight) if err != nil { return err } - log.Printf("%s insight successfully added", validateSuccessEmoji) + log.Printf("%s insight successfully added", validate.SuccessEmoji) defer func() { if insightId != "" { _ = removeInsight(ctx, client, insightId) - log.Printf("%s insight %s has been removed", validateSuccessEmoji, config.Insight.Title) + log.Printf("%s insight %s has been removed", validate.SuccessEmoji, config.Insight.Title) } }() diff --git a/internal/validate/kube/kube.go b/internal/validate/kube/kube.go new file mode 100644 index 0000000000..6a59dd016c --- /dev/null +++ b/internal/validate/kube/kube.go @@ -0,0 +1,389 @@ +package kube + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "regexp" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + + "github.com/sourcegraph/src-cli/internal/validate" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +var ( + sourcegraphFrontend = regexp.MustCompile(`^sourcegraph-frontend-.*`) + sourcegraphRepoUpdater = regexp.MustCompile(`^repo-updater-.*`) + sourcegraphWorker = regexp.MustCompile(`^worker-.*`) +) + +type Option = func(config *Config) + +type Config struct { + namespace string + output io.Writer + exitStatus bool + clientSet *kubernetes.Clientset + restConfig *rest.Config +} + +func WithNamespace(namespace string) Option { + return func(config *Config) { + config.namespace = namespace + } +} + +func Quiet() Option { + return func(config *Config) { + config.output = io.Discard + config.exitStatus = true + } +} + +type validation func(ctx context.Context, config *Config) ([]validate.Result, error) + +// Validate will call a series of validation functions in a table driven tests style. +func Validate(ctx context.Context, clientSet *kubernetes.Clientset, restConfig *rest.Config, opts ...Option) error { + config := &Config{ + namespace: "default", + output: os.Stdout, + exitStatus: false, + clientSet: clientSet, + restConfig: restConfig, + } + + for _, opt := range opts { + opt(config) + } + + log.SetOutput(config.output) + + var validations = []struct { + Validate validation + WaitMsg string + SuccessMsg string + ErrMsg string + }{ + {Pods, "validating pods", "pods validated", "validating pods failed"}, + {Services, "validating services", "services validated", "validating services failed"}, + {PVCs, "validating pvcs", "pvcs validated", "validating pvcs failed"}, + {Connections, "validating connections", "connections validated", "validating connections failed"}, + } + + var totalFailCount int + + for _, v := range validations { + log.Printf("%s %s...", validate.HourglassEmoji, v.WaitMsg) + results, err := v.Validate(ctx, config) + if err != nil { + return errors.Wrapf(err, v.ErrMsg) + } + + var failCount int + var warnCount int + var succCount int + + for _, r := range results { + switch r.Status { + case validate.Failure: + log.Printf(" %s failure: %s", validate.FailureEmoji, r.Message) + failCount++ + case validate.Warning: + log.Printf(" %s warning: %s", validate.WarningSign, r.Message) + warnCount++ + case validate.Success: + succCount++ + } + } + + if failCount > 0 || warnCount > 0 { + log.Printf("\n%s %s", validate.FlashingLightEmoji, v.ErrMsg) + } + + if failCount > 0 { + log.Printf(" %s %d total failure(s)", validate.EmojiFingerPointRight, failCount) + + totalFailCount = totalFailCount + failCount + } + + if warnCount > 0 { + log.Printf(" %s %d total warning(s)", validate.EmojiFingerPointRight, warnCount) + } + + if failCount == 0 && warnCount == 0 { + log.Printf("%s %s!", validate.SuccessEmoji, v.SuccessMsg) + } + } + + if totalFailCount > 0 { + return errors.Newf("validation failed: %d failures", totalFailCount) + } + + return nil +} + +// Pods will validate all pods in a given namespace. +func Pods(ctx context.Context, config *Config) ([]validate.Result, error) { + pods, err := config.clientSet.CoreV1().Pods(config.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var results []validate.Result + + for _, pod := range pods.Items { + r := validatePod(&pod) + results = append(results, r...) + } + + return results, nil +} + +func validatePod(pod *corev1.Pod) []validate.Result { + var results []validate.Result + + if pod.Name == "" { + results = append(results, validate.Result{Status: validate.Failure, Message: "pod.Name is empty"}) + } + + if pod.Namespace == "" { + results = append(results, validate.Result{Status: validate.Failure, Message: "pod.Namespace is empty"}) + } + + if len(pod.Spec.Containers) == 0 { + results = append(results, validate.Result{Status: validate.Failure, Message: "spec.Containers is empty"}) + } + + switch pod.Status.Phase { + case corev1.PodPending: + results = append(results, validate.Result{ + Status: validate.Failure, + Message: fmt.Sprintf("pod '%s' has a status 'pending'", pod.Name), + }) + case corev1.PodFailed: + results = append(results, validate.Result{ + Status: validate.Failure, + Message: fmt.Sprintf("pod '%s' has a status 'failed'", pod.Name), + }) + } + + for _, container := range pod.Spec.Containers { + if container.Name == "" { + results = append(results, validate.Result{ + Status: validate.Failure, + Message: fmt.Sprintf("container.Name is empty, pod '%s'", pod.Name), + }) + } + + if container.Image == "" { + results = append(results, validate.Result{ + Status: validate.Failure, + Message: fmt.Sprintf("container.Image is empty, pod '%s'", pod.Name), + }) + } + } + + for _, c := range pod.Status.ContainerStatuses { + if !c.Ready { + results = append(results, validate.Result{ + Status: validate.Failure, + Message: fmt.Sprintf("container '%s' is not ready, pod '%s'", c.ContainerID, pod.Name), + }) + } + + if c.RestartCount > 50 { + results = append(results, validate.Result{ + Status: validate.Warning, + Message: fmt.Sprintf("container '%s' has high restart count: %d restarts", c.ContainerID, c.RestartCount), + }) + } + } + + return results +} + +// Services will validate all services in a given namespace. +func Services(ctx context.Context, config *Config) ([]validate.Result, error) { + services, err := config.clientSet.CoreV1().Services(config.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var results []validate.Result + + for _, service := range services.Items { + r := validateService(&service) + results = append(results, r...) + } + + return results, nil +} + +func validateService(service *corev1.Service) []validate.Result { + var results []validate.Result + + if service.Name == "" { + results = append(results, validate.Result{Status: validate.Failure, Message: "service.Name is empty"}) + } + + if service.Namespace == "" { + results = append(results, validate.Result{Status: validate.Failure, Message: "service.Namespace is empty"}) + } + + if len(service.Spec.Ports) == 0 { + results = append(results, validate.Result{Status: validate.Failure, Message: "service.Ports is empty"}) + } + + return results +} + +// PVCs will validate all persistent volume claims on a given namespace +func PVCs(ctx context.Context, config *Config) ([]validate.Result, error) { + pvcs, err := config.clientSet.CoreV1().PersistentVolumeClaims(config.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var results []validate.Result + + for _, pvc := range pvcs.Items { + r := validatePVC(&pvc) + results = append(results, r...) + } + + return results, nil +} + +func validatePVC(pvc *corev1.PersistentVolumeClaim) []validate.Result { + var results []validate.Result + + if pvc.Name == "" { + results = append(results, validate.Result{Status: validate.Failure, Message: "pvc.Name is empty"}) + } + + if pvc.Status.Phase != "Bound" { + results = append(results, validate.Result{Status: validate.Failure, Message: "pvc.Status is not bound"}) + } + + return results +} + +type connection struct { + src corev1.Pod + dest []dest +} + +type dest struct { + addr string + port string +} + +// Connections will validate that Sourcegraph services can reach each other over the network. +func Connections(ctx context.Context, config *Config) ([]validate.Result, error) { + var results []validate.Result + var connections []connection + + pods, err := config.clientSet.CoreV1().Pods(config.namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + // iterate through pods looking for specific pod name prefixes, then construct + // a relationship map between pods that should have connectivity with each other + for _, pod := range pods.Items { + switch name := pod.Name; { + case sourcegraphFrontend.MatchString(name): // pod is one of the sourcegraph front-end pods + connections = append(connections, connection{ + src: pod, + dest: []dest{ + { + addr: "pgsql", + port: "5432", + }, + { + addr: "indexed-search", + port: "6070", + }, + { + addr: "repo-updater", + port: "3182", + }, + { + addr: "syntect-server", + port: "9238", + }, + }, + }) + case sourcegraphWorker.MatchString(name): // pod is a worker pod + connections = append(connections, connection{ + src: pod, + dest: []dest{ + { + addr: "pgsql", + port: "5432", + }, + }, + }) + case sourcegraphRepoUpdater.MatchString(name): + connections = append(connections, connection{ + src: pod, + dest: []dest{ + { + addr: "pgsql", + port: "5432", + }, + }, + }) + } + } + + // use network relationships constructed above to test network connection for each relationship + for _, c := range connections { + for _, d := range c.dest { + req := config.clientSet.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(c.src.Name). + Namespace(c.src.Namespace). + SubResource("exec") + + req.VersionedParams(&corev1.PodExecOptions{ + Command: []string{"/usr/bin/nc", "-z", d.addr, d.port}, + Stdin: false, + Stdout: true, + Stderr: true, + TTY: false, + }, scheme.ParameterCodec) + + exec, err := remotecommand.NewSPDYExecutor(config.restConfig, "POST", req.URL()) + if err != nil { + return nil, err + } + + var stdout, stderr bytes.Buffer + + err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + return nil, err + } + + if stderr.String() != "" { + results = append(results, validate.Result{Status: validate.Failure, Message: stderr.String()}) + } + } + } + + return results, nil +} diff --git a/internal/validate/kube/kube_test.go b/internal/validate/kube/kube_test.go new file mode 100644 index 0000000000..cd8a778cec --- /dev/null +++ b/internal/validate/kube/kube_test.go @@ -0,0 +1,395 @@ +package kube + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/sourcegraph/src-cli/internal/validate" +) + +func TestValidatePod(t *testing.T) { + cases := []struct { + name string + pod func(pod *corev1.Pod) + result []validate.Result + }{ + { + name: "valid pod", + }, + { + name: "invalid pod: pod name is not set", + pod: func(pod *corev1.Pod) { + pod.Name = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "pod.Name is empty", + }, + }, + }, + { + name: "invalid pod: pod namespace is empty", + pod: func(pod *corev1.Pod) { + pod.Namespace = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "pod.Namespace is empty", + }, + }, + }, + { + name: "invalid pod: spec containers is empty", + pod: func(pod *corev1.Pod) { + pod.Spec.Containers = nil + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "spec.Containers is empty", + }, + }, + }, + { + name: "invalid pod: pod status is pending", + pod: func(pod *corev1.Pod) { + pod.Status.Phase = corev1.PodPending + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "pod 'sourcegraph-frontend-' has a status 'pending'", + }, + }, + }, + { + name: "invalid pod: pod status failed", + pod: func(pod *corev1.Pod) { + pod.Status.Phase = corev1.PodFailed + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "pod 'sourcegraph-frontend-' has a status 'failed'", + }, + }, + }, + { + name: "invalid pod: container name is empty", + pod: func(pod *corev1.Pod) { + pod.Spec.Containers[0].Name = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "container.Name is empty, pod 'sourcegraph-frontend-'", + }, + }, + }, + { + name: "invalid pod: container image is empty", + pod: func(pod *corev1.Pod) { + pod.Spec.Containers[0].Image = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "container.Image is empty, pod 'sourcegraph-frontend-'", + }, + }, + }, + { + name: "invalid pod: image is not set", + pod: func(pod *corev1.Pod) { + pod.Spec.Containers[0].Image = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "container.Image is empty, pod 'sourcegraph-frontend-'", + }, + }, + }, + { + name: "invalid pod: container status not ready", + pod: func(pod *corev1.Pod) { + pod.Status.ContainerStatuses[0].Ready = false + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "container 'sourcegraph-test-id' is not ready, pod 'sourcegraph-frontend-'", + }, + }, + }, + { + name: "invalid pod: container restart count is high", + pod: func(pod *corev1.Pod) { + pod.Status.ContainerStatuses[0].RestartCount = 100 + }, + result: []validate.Result{ + { + Status: validate.Warning, + Message: "container 'sourcegraph-test-id' has high restart count: 100 restarts", + }, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pod := testPod() + if tc.pod != nil { + tc.pod(pod) + } + result := validatePod(pod) + + // test should error + if len(tc.result) > 0 { + if result == nil { + t.Fatal("validate should return result") + return + } + if result[0].Status != tc.result[0].Status { + t.Errorf("result status\nwant: %v\n got: %v", tc.result[0].Status, result[0].Status) + } + if result[0].Message != tc.result[0].Message { + t.Errorf("result msg\nwant: %s\n got: %s", tc.result[0].Message, result[0].Message) + } + return + } + + // test should not error + if result != nil { + t.Fatalf("ValidatePod error: %v", result) + } + }) + } +} + +func TestValidateService(t *testing.T) { + cases := []struct { + name string + service func(service *corev1.Service) + result []validate.Result + }{ + { + name: "valid service", + }, + { + name: "invalid service: service name is not set", + service: func(service *corev1.Service) { + service.Name = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "service.Name is empty", + }, + }, + }, + { + name: "invalid service: service namespace is not set", + service: func(service *corev1.Service) { + service.Namespace = "" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "service.Namespace is empty", + }, + }, + }, + { + name: "invalid service: service ports is empty", + service: func(service *corev1.Service) { + service.Spec.Ports = nil + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "service.Ports is empty", + }, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + service := testService() + if tc.service != nil { + tc.service(service) + } + result := validateService(service) + + // test should error + if len(tc.result) > 0 { + if result == nil { + t.Fatal("validate should return result") + return + } + if result[0].Status != tc.result[0].Status { + t.Errorf("result status\nwant: %v\n got: %v", tc.result[0].Status, result[0].Status) + } + if result[0].Message != tc.result[0].Message { + t.Errorf("result msg\nwant: %s\n got: %s", tc.result[0].Message, result[0].Message) + } + return + } + + // test should not error + if result != nil { + t.Fatalf("ValidateService error: %v", result) + } + }) + } +} + +func TestValidatePVC(t *testing.T) { + cases := []struct { + name string + pvc func(pvc *corev1.PersistentVolumeClaim) + result []validate.Result + }{ + { + name: "valid pvc", + }, + { + name: "invalid pvc: status not bound", + pvc: func(pvc *corev1.PersistentVolumeClaim) { + pvc.Status.Phase = "Waiting" + }, + result: []validate.Result{ + { + Status: validate.Failure, + Message: "pvc.Status is not bound", + }, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pvc := testPVC() + if tc.pvc != nil { + tc.pvc(pvc) + } + result := validatePVC(pvc) + + // test should error + if len(tc.result) > 0 { + if result == nil { + t.Fatal("validate should return result") + return + } + if result[0].Status != tc.result[0].Status { + t.Errorf("result status\nwant: %v\n got: %v", tc.result[0].Status, result[0].Status) + } + if result[0].Message != tc.result[0].Message { + t.Errorf("result msg\nwant: %s\n got: %s", tc.result[0].Message, result[0].Message) + } + return + } + + // test should not error + if result != nil { + t.Fatalf("ValidateService error: %v", result) + } + }) + } +} + +// helper test function to return a valid pod +func testPod() *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "sourcegraph-frontend-", + Labels: map[string]string{ + "deploy": "sourcegraph", + }, + Annotations: map[string]string{ + "kubectl.kubernetes.io/default-container": "frontend", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "sourcegraph-frontend", + Image: "sourcegraph/foo:test", + Ports: []corev1.ContainerPort{ + { + ContainerPort: 8800, + Protocol: corev1.ProtocolTCP, + }, + { + ContainerPort: 3090, + Protocol: corev1.ProtocolTCP, + }, + { + ContainerPort: 6060, + Protocol: corev1.ProtocolTCP, + }, + }, + Args: []string{"serve"}, + }, + }, + }, + Status: corev1.PodStatus{ + ContainerStatuses: []corev1.ContainerStatus{ + { + ContainerID: "sourcegraph-test-id", + Ready: true, + RestartCount: 0, + }, + }, + }, + } +} + +// helper test function to return a valid service +func testService() *corev1.Service { + return &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "symbols", + Labels: map[string]string{ + "deploy": "sourcegraph", + }, + }, + Spec: corev1.ServiceSpec{ + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: 3184, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + Status: corev1.ServiceStatus{}, + } +} + +// helper test function to return a valid PVC +func testPVC() *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "pgsql", + Labels: map[string]string{ + "deploy": "sourcegraph", + }, + }, + Status: corev1.PersistentVolumeClaimStatus{ + Phase: "Bound", + }, + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go new file mode 100644 index 0000000000..402d2686ea --- /dev/null +++ b/internal/validate/validate.go @@ -0,0 +1,23 @@ +package validate + +var ( + EmojiFingerPointRight = "👉" + FailureEmoji = "🛑" + FlashingLightEmoji = "🚨" + HourglassEmoji = "⌛" + SuccessEmoji = "✅" + WarningSign = "⚠️ " // why does this need an extra space to align?!?! +) + +type Status string + +const ( + Failure Status = "Failure" + Warning Status = "Warning" + Success Status = "Success" +) + +type Result struct { + Status Status + Message string +}