diff --git a/cmd/internal/cmderr/checks.go b/cmd/internal/cmderr/checks.go new file mode 100644 index 00000000..0e718234 --- /dev/null +++ b/cmd/internal/cmderr/checks.go @@ -0,0 +1,26 @@ +package cmderr + +import ( + "errors" + "github.com/akitasoftware/akita-cli/cfg" + "github.com/akitasoftware/akita-cli/env" + "github.com/akitasoftware/akita-cli/printer" +) + +// Checks that a user has configured their API key and secret and returned them. +// If the user has not configured their API key, a user-friendly error message is printed and an error is returned. +func RequireAPICredentials(explanation string) (string, string, error) { + key, secret := cfg.GetAPIKeyAndSecret() + if key == "" || secret == "" { + printer.Errorf("No Akita API key configured. %s\n", explanation) + if env.InDocker() { + printer.Infof("Please set the AKITA_API_KEY_ID and AKITA_API_KEY_SECRET environment variables on the Docker command line.\n") + } else { + printer.Infof("Use the AKITA_API_KEY_ID and AKITA_API_KEY_SECRET environment variables, or run 'akita login'.\n") + } + + return "", "", AkitaErr{Err: errors.New("could not find an Akita API key to use")} + } + + return key, secret, nil +} diff --git a/cmd/internal/ecs/ecs.go b/cmd/internal/ecs/ecs.go index 86f9c771..6683b997 100644 --- a/cmd/internal/ecs/ecs.go +++ b/cmd/internal/ecs/ecs.go @@ -4,10 +4,7 @@ import ( "fmt" "strings" - "github.com/akitasoftware/akita-cli/cfg" "github.com/akitasoftware/akita-cli/cmd/internal/cmderr" - "github.com/akitasoftware/akita-cli/env" - "github.com/akitasoftware/akita-cli/printer" "github.com/akitasoftware/akita-cli/rest" "github.com/akitasoftware/akita-cli/telemetry" "github.com/akitasoftware/akita-cli/util" @@ -71,8 +68,18 @@ func init() { Cmd.PersistentFlags().StringVar(&awsRegionFlag, "region", "", "The AWS region in which your ECS cluster resides.") Cmd.PersistentFlags().StringVar(&ecsClusterFlag, "cluster", "", "The name or ARN of your ECS cluster.") Cmd.PersistentFlags().StringVar(&ecsServiceFlag, "service", "", "The name or ARN of your ECS service.") - Cmd.PersistentFlags().StringVar(&ecsTaskDefinitionFlag, "task", "", "The name of your ECS task definition to modify.") - Cmd.PersistentFlags().BoolVar(&dryRunFlag, "dry-run", false, "Perform a dry run: show what will be done, but do not modify ECS.") + Cmd.PersistentFlags().StringVar( + &ecsTaskDefinitionFlag, + "task", + "", + "The name of your ECS task definition to modify.", + ) + Cmd.PersistentFlags().BoolVar( + &dryRunFlag, + "dry-run", + false, + "Perform a dry run: show what will be done, but do not modify ECS.", + ) // Support for credentials in a nonstandard location Cmd.PersistentFlags().StringVar(&awsCredentialsFlag, "aws-credentials", "", "Location of AWS credentials file.") @@ -84,15 +91,9 @@ func init() { func addAgentToECS(cmd *cobra.Command, args []string) error { // Check for API key - key, secret := cfg.GetAPIKeyAndSecret() - if key == "" || secret == "" { - printer.Errorf("No Akita API key configured. The Akita agent must have an API key in order to capture traces.\n") - if env.InDocker() { - printer.Infof("Please set the AKITA_API_KEY_ID and AKITA_API_KEY_SECRET environment variables on the Docker command line.\n") - } else { - printer.Infof("Use the AKITA_API_KEY_ID and AKITA_API_KEY_SECRET environment variables, or run 'akita login'.\n") - } - return cmderr.AkitaErr{Err: errors.New("Could not find an Akita API key to use.")} + _, _, err := cmderr.RequireAPICredentials("The Akita agent must have an API key in order to capture traces.") + if err != nil { + return err } // Check project's existence @@ -100,13 +101,24 @@ func addAgentToECS(cmd *cobra.Command, args []string) error { return errors.New("Must specify the name of your Akita project with the --project flag.") } frontClient := rest.NewFrontClient(rest.Domain, telemetry.GetClientID()) - _, err := util.GetServiceIDByName(frontClient, projectFlag) + _, err = util.GetServiceIDByName(frontClient, projectFlag) if err != nil { // TODO: we _could_ offer to create it, instead. if strings.Contains(err.Error(), "cannot determine project ID") { - return cmderr.AkitaErr{Err: fmt.Errorf("Could not find the project %q in the Akita cloud. Please create it from the Akita web console before proceeding.", projectFlag)} + return cmderr.AkitaErr{ + Err: fmt.Errorf( + "Could not find the project %q in the Akita cloud. Please create it from the Akita web console before proceeding.", + projectFlag, + ), + } } else { - return cmderr.AkitaErr{Err: errors.Wrapf(err, "Could not look up the project %q in the Akita cloud", projectFlag)} + return cmderr.AkitaErr{ + Err: errors.Wrapf( + err, + "Could not look up the project %q in the Akita cloud", + projectFlag, + ), + } } } diff --git a/cmd/internal/kube/kube.go b/cmd/internal/kube/kube.go new file mode 100644 index 00000000..9b5a623d --- /dev/null +++ b/cmd/internal/kube/kube.go @@ -0,0 +1,19 @@ +package kube + +import ( + "github.com/akitasoftware/akita-cli/cmd/internal/cmderr" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "kube", + Short: "Install Akita in your Kubernetes cluster", + Aliases: []string{ + "k8s", + "kubernetes", + }, + RunE: func(_ *cobra.Command, _ []string) error { + return cmderr.AkitaErr{Err: errors.New("no subcommand specified")} + }, +} diff --git a/cmd/internal/kube/secret.go b/cmd/internal/kube/secret.go new file mode 100644 index 00000000..5d0161db --- /dev/null +++ b/cmd/internal/kube/secret.go @@ -0,0 +1,179 @@ +package kube + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "text/template" + + "github.com/akitasoftware/akita-cli/telemetry" + + "github.com/akitasoftware/akita-cli/cmd/internal/cmderr" + "github.com/akitasoftware/akita-cli/printer" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +var ( + secretFilePathFlag string + namespaceFlag string + // Store a parsed representation of /template/akita-secret.tmpl + secretTemplate *template.Template +) + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "Generate a Kubernetes secret containing the Akita credentials", + RunE: func(cmd *cobra.Command, args []string) error { + key, secret, err := cmderr.RequireAPICredentials("Akita API key is required for Kubernetes Secret generation") + if err != nil { + return err + } + + output, err := handleSecretGeneration(namespaceFlag, key, secret) + if err != nil { + return err + } + + // If the secret file path flag hasn't been set, print the generated secret to stdout + if secretFilePathFlag == "" { + printer.RawOutput(string(output)) + return nil + } + + // Otherwise, write the generated secret to the given file path + err = writeSecretFile(output, secretFilePathFlag) + if err != nil { + return cmderr.AkitaErr{Err: errors.Wrapf(err, "Failed to write generated secret to %s", output)} + } + + printer.Infof("Successfully generated a Kubernetes Secret file for Akita at %s\n", secretFilePathFlag) + printer.Infof("To apply, run: kubectl apply -f %s\n", secretFilePathFlag) + return nil + }, + // Override the parent command's PersistentPreRun to prevent any logs from being printed. + // This is necessary because the secret command is intended to be used in a pipeline + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // Initialize the telemetry client, but do not allow any logs to be printed + telemetry.Init(false) + }, +} + +// Represents the input used by secretTemplate +type secretTemplateInput struct { + Namespace string + APIKey string + APISecret string +} + +func initSecretTemplate() error { + var err error + + secretTemplate, err = template.ParseFS(templateFS, "template/akita-secret.tmpl") + if err != nil { + return cmderr.AkitaErr{Err: errors.Wrap(err, "failed to parse secret template")} + } + + return nil +} + +// Generates a Kubernetes secret config file for Akita +// On success, the generated output is returned as a string. +func handleSecretGeneration(namespace, key, secret string) ([]byte, error) { + err := initSecretTemplate() + if err != nil { + return nil, cmderr.AkitaErr{Err: errors.Wrap(err, "failed to initialize secret template")} + } + + input := secretTemplateInput{ + Namespace: namespace, + APIKey: base64.StdEncoding.EncodeToString([]byte(key)), + APISecret: base64.StdEncoding.EncodeToString([]byte(secret)), + } + + buf := bytes.NewBuffer([]byte{}) + + err = secretTemplate.Execute(buf, input) + if err != nil { + return nil, cmderr.AkitaErr{Err: errors.Wrap(err, "failed to generate template")} + } + + return buf.Bytes(), nil +} + +// Writes the generated secret to the given file path +func writeSecretFile(data []byte, filePath string) error { + secretFile, err := createSecretFile(filePath) + if err != nil { + return cmderr.AkitaErr{ + Err: cmderr.AkitaErr{ + Err: errors.Wrapf( + err, + "failed to create secret file %s", + filePath, + ), + }, + } + } + defer secretFile.Close() + + _, err = secretFile.Write(data) + if err != nil { + return cmderr.AkitaErr{Err: errors.Wrap(err, "failed to write generated secret file")} + } + + return nil +} + +// Creates a file at the given path to be used for storing of the generated Secret configuration +// If the directory provided does not exist, an error will be returned and the file will not be created +func createSecretFile(path string) (*os.File, error) { + // Split the output flag value into directory and filename + outputDir, outputName := filepath.Split(path) + + // Get the absolute path of the output directory + absOutputDir, err := filepath.Abs(outputDir) + if err != nil { + return nil, errors.Wrapf(err, "failed to resolve the absolute path of the output directory") + } + + // Check that the output directory exists + if _, statErr := os.Stat(absOutputDir); os.IsNotExist(statErr) { + return nil, errors.Errorf("output directory %s does not exist", absOutputDir) + } + + // Check if the output file already exists + outputFilePath := filepath.Join(absOutputDir, outputName) + if _, statErr := os.Stat(outputFilePath); statErr == nil { + return nil, errors.Errorf("output file %s already exists", outputFilePath) + } + + // Create the output file in the output directory + outputFile, err := os.Create(outputFilePath) + if err != nil { + return nil, errors.Wrap(err, "failed to create the output file") + } + + return outputFile, nil +} + +func init() { + secretCmd.Flags().StringVarP( + &namespaceFlag, + "namespace", + "n", + "default", + "The Kubernetes namespace the secret should be applied to", + ) + + secretCmd.Flags().StringVarP( + &secretFilePathFlag, + "file", + "f", + "", + "File to output the generated secret. If not set, the secret will be printed to stdout.", + ) + + Cmd.AddCommand(secretCmd) +} diff --git a/cmd/internal/kube/secret_test.go b/cmd/internal/kube/secret_test.go new file mode 100644 index 00000000..710719eb --- /dev/null +++ b/cmd/internal/kube/secret_test.go @@ -0,0 +1,28 @@ +package kube + +import ( + _ "embed" + "github.com/stretchr/testify/assert" + "testing" +) + +//go:embed test_resource/akita-secret.yml +var testAkitaSecretYAML []byte + +func Test_secretGeneration(t *testing.T) { + // GIVEN + const ( + namespace = "default" + key = "api-key" + secret = "api-secret" + ) + + // WHEN + output, err := handleSecretGeneration(namespace, key, secret) + if err != nil { + t.Errorf("Unexpected error: %s", err) + } + + // THEN + assert.Equal(t, testAkitaSecretYAML, output) +} diff --git a/cmd/internal/kube/template.go b/cmd/internal/kube/template.go new file mode 100644 index 00000000..c024a4fd --- /dev/null +++ b/cmd/internal/kube/template.go @@ -0,0 +1,6 @@ +package kube + +import "embed" + +//go:embed template +var templateFS embed.FS diff --git a/cmd/internal/kube/template/akita-secret.tmpl b/cmd/internal/kube/template/akita-secret.tmpl new file mode 100644 index 00000000..25cc44ea --- /dev/null +++ b/cmd/internal/kube/template/akita-secret.tmpl @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: akita-secrets + namespace: {{.Namespace}} +type: Opaque +data: + akita-api-key: {{.APIKey}} + akita-api-secret: {{.APISecret}} diff --git a/cmd/internal/kube/test_resource/akita-secret.yml b/cmd/internal/kube/test_resource/akita-secret.yml new file mode 100644 index 00000000..f2627273 --- /dev/null +++ b/cmd/internal/kube/test_resource/akita-secret.yml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: akita-secrets + namespace: default +type: Opaque +data: + akita-api-key: YXBpLWtleQ== + akita-api-secret: YXBpLXNlY3JldA== diff --git a/cmd/root.go b/cmd/root.go index 97a6fe67..ddda260e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "github.com/akitasoftware/akita-cli/cmd/internal/daemon" "github.com/akitasoftware/akita-cli/cmd/internal/ecs" "github.com/akitasoftware/akita-cli/cmd/internal/get" + "github.com/akitasoftware/akita-cli/cmd/internal/kube" "github.com/akitasoftware/akita-cli/cmd/internal/learn" "github.com/akitasoftware/akita-cli/cmd/internal/legacy" "github.com/akitasoftware/akita-cli/cmd/internal/login" @@ -73,6 +74,8 @@ var ( ) func preRun(cmd *cobra.Command, args []string) { + telemetry.Init(true) + switch logFormatFlag { case "json": printer.SwitchToJSON() @@ -279,6 +282,7 @@ func init() { rootCmd.AddCommand(ci_guard.GuardCommand(get.Cmd)) rootCmd.AddCommand(ecs.Cmd) rootCmd.AddCommand(nginx.Cmd) + rootCmd.AddCommand(kube.Cmd) // Legacy commands, included for backward compatibility but are hidden. legacy.SessionsCmd.Hidden = true diff --git a/learn/parse_http_test.go b/learn/parse_http_test.go index 046d945f..fea16ea3 100644 --- a/learn/parse_http_test.go +++ b/learn/parse_http_test.go @@ -3,6 +3,7 @@ package learn import ( "bytes" "compress/flate" + "github.com/akitasoftware/akita-cli/telemetry" "net/http" "strings" "testing" @@ -127,6 +128,7 @@ type parseTest struct { } func TestParseHTTPRequest(t *testing.T) { + telemetry.Init(false) standardMethodMeta := &as.MethodMeta{ Meta: &as.MethodMeta_Http{ Http: &as.HTTPMethodMeta{ diff --git a/telemetry/telemetry.go b/telemetry/telemetry.go index 99034c77..c9779691 100644 --- a/telemetry/telemetry.go +++ b/telemetry/telemetry.go @@ -18,7 +18,7 @@ import ( var ( // Shared client object - analyticsClient analytics.Client + analyticsClient analytics.Client = nullClient{} // Is analytics enabled? analyticsEnabled bool @@ -50,7 +50,9 @@ func (_ nullClient) Close() error { return nil } -func init() { +// Initialize the telemetry client. +// This should be called once at startup either from the root command or from a subcommand that overrides the default PersistentPreRun. +func Init(isLoggingEnabled bool) { // Opt-out mechanism disableTelemetry := os.Getenv("AKITA_DISABLE_TELEMETRY") if disableTelemetry != "" { @@ -70,31 +72,37 @@ func init() { segmentKey = defaultSegmentKey } if segmentKey == "" { - printer.Infof("Telemetry unavailable; no Segment key configured.\n") - printer.Infof("This is caused by building from source rather than using an official build.\n") + if isLoggingEnabled { + printer.Infof("Telemetry unavailable; no Segment key configured.\n") + printer.Infof("This is caused by building from source rather than using an official build.\n") + } analyticsClient = nullClient{} return } var err error - analyticsClient, err = analytics.NewClient(analytics.Config{ - WriteKey: segmentKey, - SegmentEndpoint: segmentEndpoint, - App: analytics.AppInfo{ - Name: "akita-cli", - Version: version.ReleaseVersion().String(), - Build: version.GitVersion(), - Namespace: "", + analyticsClient, err = analytics.NewClient( + analytics.Config{ + WriteKey: segmentKey, + SegmentEndpoint: segmentEndpoint, + App: analytics.AppInfo{ + Name: "akita-cli", + Version: version.ReleaseVersion().String(), + Build: version.GitVersion(), + Namespace: "", + }, + // No output from the Segment library + IsLoggingEnabled: false, + // IsMixpanelEnabled: false, -- irrelevant for us, leaving at default value + BatchSize: 1, // disable batching }, - // No output from the Segment library - IsLoggingEnabled: false, - // IsMixpanelEnabled: false, -- irrelevant for us, leaving at default value - BatchSize: 1, // disable batching - }) + ) if err != nil { - printer.Infof("Telemetry unavailable; error setting up Segment client: %v\n", err) - printer.Infof("Akita support will not be able to see any errors you encounter.\n") - printer.Infof("Please send this log message to support@akitasoftware.com.\n") + if isLoggingEnabled { + printer.Infof("Telemetry unavailable; error setting up Segment client: %v\n", err) + printer.Infof("Akita support will not be able to see any errors you encounter.\n") + printer.Infof("Please send this log message to support@akitasoftware.com.\n") + } analyticsClient = nullClient{} } else { analyticsEnabled = true