Skip to content
This repository has been archived by the owner on Jul 10, 2024. It is now read-only.

Generate Secrets with Kubernetes API #204

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cmd/internal/cmderr/checks.go
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 29 additions & 17 deletions cmd/internal/ecs/ecs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.")
Expand All @@ -84,29 +91,34 @@ 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
if projectFlag == "" {
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,
),
}
}
}

Expand Down
19 changes: 19 additions & 0 deletions cmd/internal/kube/kube.go
Original file line number Diff line number Diff line change
@@ -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")}
},
}
186 changes: 186 additions & 0 deletions cmd/internal/kube/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package kube

import (
"bytes"
"encoding/json"
"github.com/akitasoftware/akita-cli/printer"
"github.com/akitasoftware/akita-cli/telemetry"
"github.com/ghodss/yaml"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
k8_json "k8s.io/apimachinery/pkg/runtime/serializer/json"
"os"
"path/filepath"

"github.com/akitasoftware/akita-cli/cmd/internal/cmderr"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)

var (
outputFlag string
namespaceFlag string
)

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, outputFlag)
if err != nil {
return err
}

// Output the generated secret to the console
printer.RawOutput(output)

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)
},
}

/*
XXX: Kuberenetes Go API package currently has issues with valid serialization.
The ObjectMeta field's CreationTimestamp field is improperly serialized as null when it should be omitted entirely if it is a zero value.
This shouldn't cause any issues applying the secret, but it does cause issues for any tools that depend on valid yaml objects (such as linting tools)
See: https://github.com/kubernetes/kubernetes/issues/109427

Here, I've manually filtered out the CreationTimestamp field from the serialized object to work around this issue.
*/
func buildSecretConfiguration(namespace, apiKey, apiSecret string) ([]byte, error) {
secret := &v1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: "akita-secrets",
Namespace: namespace,
},
Type: v1.SecretTypeOpaque,
Data: map[string][]byte{
"akita-api-key": []byte(apiKey),
"akita-api-secret": []byte(apiSecret),
},
}

unstructuredSecret, err := runtime.DefaultUnstructuredConverter.ToUnstructured(secret)
if err != nil {
return nil, err
}

unstructuredObj := &unstructured.Unstructured{Object: unstructuredSecret}
serializer := k8_json.NewSerializerWithOptions(
k8_json.DefaultMetaFactory,
nil,
nil,
k8_json.SerializerOptions{Yaml: false, Pretty: false, Strict: true},
)

buf := bytes.NewBuffer([]byte{})
err = serializer.Encode(unstructuredObj, buf)
if err != nil {
return nil, err
}

// HACK: Manually filter out the CreationTimestamp field from the serialized object
objMap := make(map[string]interface{})
err = json.Unmarshal(buf.Bytes(), &objMap)
if err != nil {
return nil, err
}

if _, ok := objMap["metadata"]; ok {
metadataMap := objMap["metadata"].(map[string]interface{})
delete(metadataMap, "creationTimestamp")
}

// Re-serialize the object
fixedJSON, err := json.Marshal(objMap)
if err != nil {
return nil, err
}

return yaml.JSONToYAML(fixedJSON)
}

// Generates a Kubernetes secret config file for Akita
func handleSecretGeneration(namespace, apiKey, apiSecret, output string) (string, error) {

secret, err := buildSecretConfiguration(namespace, apiKey, apiSecret)
if err != nil {
return "", cmderr.AkitaErr{Err: errors.Wrap(err, "failed to generate Kubernetes secret")}
}

// Serialize the secret to YAML
secretFile, err := createSecretFile(output)
if err != nil {
return "", cmderr.AkitaErr{Err: errors.Wrap(err, "failed to create output file")}
}
defer secretFile.Close()

_, err = secretFile.Write(secret)
if err != nil {
return "", cmderr.AkitaErr{Err: errors.Wrap(err, "failed to generate template")}
}

return string(secret), nil
}

// Creates a file at the give path to be used for storing of the generated Secret config
// If any child dicrectories do not exist, it will be created.
func createSecretFile(path string) (*os.File, error) {
// Split the outut 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(&outputFlag, "output", "o", "akita-secret.yml", "File to output the generated secret.")

Cmd.AddCommand(secretCmd)
}
76 changes: 76 additions & 0 deletions cmd/internal/kube/secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package kube

import (
_ "embed"
"encoding/json"
"github.com/stretchr/testify/assert"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"os"
"path/filepath"
"sigs.k8s.io/yaml"
"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"
)

dir := t.TempDir()
actualOutput := filepath.Join(dir, "akita-secret.yml")

// WHEN
result, err := handleSecretGeneration(namespace, key, secret, actualOutput)
if err != nil {
t.Errorf("Unexpected error: %s", err)
}

// THEN
data, err := os.ReadFile(actualOutput)
if err != nil {
t.Errorf("Failed to read generated data: %v", err)
}

convert := func(yamlBytes []byte) (v1.Secret, error) {
var result v1.Secret

jsonData, err := yaml.YAMLToJSONStrict(yamlBytes)
if err != nil {
return result, err
}

var parsedSecret v1.Secret
err = json.Unmarshal(jsonData, &parsedSecret)

return parsedSecret, err
}

file, err := convert(data)
output, err := convert([]byte(result))

expected := v1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: "akita-secrets",
Namespace: namespace,
},
Type: v1.SecretTypeOpaque,
Data: map[string][]byte{
"akita-api-key": []byte(key),
"akita-api-secret": []byte(secret),
},
}

assert.Equal(t, expected, file)
assert.Equal(t, expected, output)
}
6 changes: 6 additions & 0 deletions cmd/internal/kube/template.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kube

import "embed"

//go:embed template
var templateFS embed.FS
Loading