diff --git a/.github/workflows/e2e_openssl.yml b/.github/workflows/e2e_openssl.yml index 4514870a1..1f8c99610 100644 --- a/.github/workflows/e2e_openssl.yml +++ b/.github/workflows/e2e_openssl.yml @@ -14,6 +14,7 @@ on: env: container_registry: ghcr.io/edgelesssys azure_resource_group: contrast-ci + DO_NOT_TRACK: 1 jobs: test: diff --git a/.github/workflows/e2e_servicemesh.yml b/.github/workflows/e2e_servicemesh.yml index d3fd7171f..551694178 100644 --- a/.github/workflows/e2e_servicemesh.yml +++ b/.github/workflows/e2e_servicemesh.yml @@ -14,6 +14,7 @@ on: env: container_registry: ghcr.io/edgelesssys azure_resource_group: contrast-ci + DO_NOT_TRACK: 1 jobs: test: diff --git a/.github/workflows/e2e_simple.yml b/.github/workflows/e2e_simple.yml index 8cc9348e6..cad26cb0a 100644 --- a/.github/workflows/e2e_simple.yml +++ b/.github/workflows/e2e_simple.yml @@ -14,6 +14,7 @@ on: env: container_registry: ghcr.io/edgelesssys azure_resource_group: contrast-ci + DO_NOT_TRACK: 1 jobs: test: diff --git a/cli/cmd/common.go b/cli/cmd/common.go index f89cb3c6c..2d0ff8e9f 100644 --- a/cli/cmd/common.go +++ b/cli/cmd/common.go @@ -1,9 +1,14 @@ package cmd import ( + "context" _ "embed" "os" "path/filepath" + "time" + + "github.com/edgelesssys/contrast/cli/telemetry" + "github.com/spf13/cobra" ) const ( @@ -44,3 +49,19 @@ func must(err error) { panic(err) } } + +func withTelemetry(runFunc func(*cobra.Command, []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cmdErr := runFunc(cmd, args) + + if os.Getenv("DO_NOT_TRACK") != "1" { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + cl := telemetry.NewClient() + _ = cl.SendTelemetry(ctx, cmd, cmdErr) + } + + return cmdErr + } +} diff --git a/cli/cmd/generate.go b/cli/cmd/generate.go index 66c6236e3..851c69132 100644 --- a/cli/cmd/generate.go +++ b/cli/cmd/generate.go @@ -48,7 +48,7 @@ If the Kubernetes YAML contains a Contrast Coordinator pod whose policy differs the embedded default, the generated policy will be printed to stdout, alongside a warning message on stderr. This hash needs to be passed to the set and verify subcommands.`, - RunE: runGenerate, + RunE: withTelemetry(runGenerate), } cmd.Flags().StringP("policy", "p", rulesFilename, "path to policy (.rego) file") diff --git a/cli/cmd/set.go b/cli/cmd/set.go index e8affc9c1..c2f4a2e8f 100644 --- a/cli/cmd/set.go +++ b/cli/cmd/set.go @@ -45,7 +45,7 @@ reference values embedded into the CLI. After the connection is established, the manifest is set. The Coordinator will re-generate the mesh root certificate and accept new workloads to issuer certificates.`, - RunE: runSet, + RunE: withTelemetry(runSet), } cmd.Flags().StringP("manifest", "m", manifestFilename, "path to manifest (.json) file") diff --git a/cli/cmd/verify.go b/cli/cmd/verify.go index ae9a5a453..9521bef38 100644 --- a/cli/cmd/verify.go +++ b/cli/cmd/verify.go @@ -33,7 +33,7 @@ reference values embedded into the CLI. After the connection is established, the CLI will request the manifest history, all policies, and the certificates of the Coordinator certificate authority.`, - RunE: runVerify, + RunE: withTelemetry(runVerify), } // Override persistent workspace-dir flag with a default value. diff --git a/cli/telemetry/errors.go b/cli/telemetry/errors.go new file mode 100644 index 000000000..392574d17 --- /dev/null +++ b/cli/telemetry/errors.go @@ -0,0 +1,12 @@ +package telemetry + +// classifyCmdErr maps errors to fixed strings to avoid leaking sensitive data inside error messages. +func classifyCmdErr(e error) string { + if e == nil { + return "" + } + switch { + default: + return "unknown error" + } +} diff --git a/cli/telemetry/telemetry.go b/cli/telemetry/telemetry.go new file mode 100644 index 000000000..ecfec616f --- /dev/null +++ b/cli/telemetry/telemetry.go @@ -0,0 +1,93 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "runtime" + + "github.com/spf13/cobra" +) + +const ( + apiHost = "telemetry.confidential.cloud" + telemetryPath = "api/contrast/v1" +) + +// RequestV1 holds the information to be sent to the telemetry server. +type RequestV1 struct { + Version string `json:"version"` + GOOS string `json:"goos"` + GOARCH string `json:"goarch"` + Cmd string `json:"cmd"` + CmdErrClass string `json:"cmderrclass"` + Test bool `json:"test" gorm:"-"` +} + +// IsTest checks if the request is used for testing. +func (r *RequestV1) IsTest() bool { + return r.Test +} + +// Client sends the telemetry. +type Client struct { + httpClient *http.Client +} + +// NewClient creates a new Client. +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{}, + } +} + +// SendTelemetry sends telemetry data to the telemetry server. +func (c *Client) SendTelemetry(ctx context.Context, cmd *cobra.Command, cmdErr error) error { + cmdErrClass := classifyCmdErr(cmdErr) + + if cmd.Root().Version == "" { + return fmt.Errorf("no cli version found") + } + + telemetryRequest := RequestV1{ + Version: cmd.Root().Version, + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + Cmd: cmd.Name(), + CmdErrClass: cmdErrClass, + Test: false, + } + + reqBody, err := json.Marshal(telemetryRequest) + if err != nil { + return fmt.Errorf("marshalling input: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, telemetryURL().String(), bytes.NewReader(reqBody)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("doing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http error %d", resp.StatusCode) + } + + return nil +} + +func telemetryURL() *url.URL { + return &url.URL{ + Scheme: "https", + Host: apiHost, + Path: telemetryPath, + } +} diff --git a/cli/telemetry/telemetry_test.go b/cli/telemetry/telemetry_test.go new file mode 100644 index 000000000..cc98ca665 --- /dev/null +++ b/cli/telemetry/telemetry_test.go @@ -0,0 +1,85 @@ +package telemetry + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type roundTripFunc func(req *http.Request) *http.Response + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req), nil +} + +func newTestClient(fn roundTripFunc) *http.Client { + return &http.Client{ + Transport: fn, + } +} + +func TestSendTelemetry(t *testing.T) { + rootTestCmd := &cobra.Command{Version: "0.0.0-dev"} + goodTestCmd := &cobra.Command{} + rootTestCmd.AddCommand(goodTestCmd) + + badTestCmd := &cobra.Command{} + + testCases := map[string]struct { + cmd *cobra.Command + cmdErr error + serverResponseCode int + wantError bool + }{ + "success no cmdError": { + cmd: goodTestCmd, + cmdErr: nil, + serverResponseCode: http.StatusOK, + wantError: false, + }, + "success with cmdError": { + cmd: goodTestCmd, + cmdErr: fmt.Errorf("test error"), + serverResponseCode: http.StatusOK, + wantError: false, + }, + "bad command": { + cmd: badTestCmd, + cmdErr: nil, + serverResponseCode: http.StatusOK, + wantError: true, + }, + "bad http response": { + cmd: goodTestCmd, + cmdErr: nil, + serverResponseCode: http.StatusInternalServerError, + wantError: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + client := &Client{ + httpClient: newTestClient(func(_ *http.Request) *http.Response { + return &http.Response{ + StatusCode: tc.serverResponseCode, + } + }), + } + + err := client.SendTelemetry(context.Background(), tc.cmd, tc.cmdErr) + + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +} diff --git a/flake.nix b/flake.nix index 2f51ffa46..a374249bd 100644 --- a/flake.nix +++ b/flake.nix @@ -38,7 +38,10 @@ gotools just ]; - shellHook = ''alias make=just''; + shellHook = '' + alias make=just + export DO_NOT_TRACK=1 + ''; }; docs = pkgs.mkShell { packages = with pkgs; [