Skip to content

Commit

Permalink
feat: add kurtosis cloud load to CLI (#882)
Browse files Browse the repository at this point in the history
## Description:
In this PR we are adding the `kurtosis cloud load <instance_id>` option
that enables the user to load the config from the cloud and
automatically switch the context.

To use the command the user must export an API key to the env var:
`KURTOSIS_CLOUD_API_KEY`

## Is this change user facing?
YES

## References (if applicable):
<!-- Add relevant Github Issues, Discord threads, or other helpful
information. -->
  • Loading branch information
adschwartz committed Jul 27, 2023
1 parent 0f33b5b commit b2db8c9
Show file tree
Hide file tree
Showing 38 changed files with 1,375 additions and 27 deletions.
55 changes: 55 additions & 0 deletions api/golang/engine/lib/cloud/cloud.go
@@ -0,0 +1,55 @@
package cloud

import (
"crypto/tls"
"crypto/x509"
api "github.com/kurtosis-tech/kurtosis/cloud/api/golang/kurtosis_backend_server_rpc_api_bindings"
"github.com/kurtosis-tech/stacktrace"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)

func CreateCloudClient(connectionStr string, caCertChain string) (api.KurtosisCloudBackendServerClient, error) {
caCertChainBytes := []byte(caCertChain)
p := x509.NewCertPool()
p.AppendCertsFromPEM(caCertChainBytes)

tlsConfig := &tls.Config{
Rand: nil,
Time: nil,
Certificates: nil,
NameToCertificate: nil,
GetCertificate: nil,
GetClientCertificate: nil,
GetConfigForClient: nil,
VerifyPeerCertificate: nil,
VerifyConnection: nil,
RootCAs: p,
NextProtos: nil,
ServerName: "",
ClientAuth: 0,
ClientCAs: nil,
InsecureSkipVerify: false,
CipherSuites: nil,
PreferServerCipherSuites: false,
SessionTicketsDisabled: false,
SessionTicketKey: [32]byte{},
ClientSessionCache: nil,
MinVersion: 0,
MaxVersion: 0,
CurvePreferences: nil,
DynamicRecordSizingDisabled: false,
Renegotiation: 0,
KeyLogWriter: nil,
}
conn, err := grpc.Dial(connectionStr, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
if err != nil {
return nil, stacktrace.Propagate(
err,
"An error occurred creating a connection to the Kurtosis Cloud server at '%v'",
connectionStr,
)
}
client := api.NewKurtosisCloudBackendServerClient(conn)
return client, nil
}
2 changes: 1 addition & 1 deletion api/golang/engine/lib/kurtosis_context/kurtosis_context.go
Expand Up @@ -74,7 +74,7 @@ func NewKurtosisContextFromLocalEngine() (*KurtosisContext, error) {
return nil, stacktrace.Propagate(err, "An error occurred validating the Kurtosis engine API version")
}

// portal is still optional as it is incubating. For local context, everything will run fine if poral is not
// portal is still optional as it is incubating. For local context, everything will run fine if portal is not
// present. For remote context, is it expected that the caller checks that the portal is present before or after
// the Kurtosis Context is built, to avoid unexpected failures downstream
portalClient, err := CreatePortalDaemonClient(portalIsRequired)
Expand Down
@@ -0,0 +1,67 @@
package instance_id_arg

import (
"context"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/args"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/flags"
"github.com/kurtosis-tech/stacktrace"
)

const (
defaultIsRequired = true
defaultValueEmpty = ""
validInstanceIdLength = 32
)

// InstanceIdentifierArg pre-builds instance identifier arg which has tab-completion and validation ready out-of-the-box
func InstanceIdentifierArg(
// The arg key where this context identifier argument will be stored
argKey string,
isGreedy bool,
) *args.ArgConfig {

validate := getValidationFunc(argKey, isGreedy)

return &args.ArgConfig{
Key: argKey,
IsOptional: defaultIsRequired,
DefaultValue: defaultValueEmpty,
IsGreedy: isGreedy,
ValidationFunc: validate,
ArgCompletionProvider: args.NewManualCompletionsProvider(getCompletionsFunc()),
}
}

func getCompletionsFunc() func(ctx context.Context, flags *flags.ParsedFlags, previousArgs *args.ParsedArgs) ([]string, error) {
return func(ctx context.Context, flags *flags.ParsedFlags, previousArgs *args.ParsedArgs) ([]string, error) {
// TODO: Given the instance id and the API Key, we could potentially query the API for instance ids to
// auto complete the typing but those endpoints don't exist (yet).
return []string{}, nil
}
}

func getValidationFunc(argKey string, isGreedy bool) func(context.Context, *flags.ParsedFlags, *args.ParsedArgs) error {
return func(ctx context.Context, flags *flags.ParsedFlags, args *args.ParsedArgs) error {
var instanceIdsToValidate []string
if isGreedy {
instanceID, err := args.GetGreedyArg(argKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for greedy arg '%v' but didn't find one", argKey)
}
instanceIdsToValidate = instanceID
} else {
instanceID, err := args.GetNonGreedyArg(argKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for non-greedy arg '%v' but didn't find one", argKey)
}
instanceIdsToValidate = []string{instanceID}
}

for _, instanceIdToValidate := range instanceIdsToValidate {
if len(instanceIdToValidate) < validInstanceIdLength {
return stacktrace.NewError("Instance Id is not valid: %s", instanceIdsToValidate)
}
}
return nil
}
}
7 changes: 4 additions & 3 deletions cli/cli/command_str_consts/command_str_consts.go
Expand Up @@ -6,14 +6,15 @@ import (
"path"
)

// We put all the command strings here so that when we need to give users remediation instructions, we can give them the
//
// commands they need to run
// We put all the command strings here so that when we need to give users remediation instructions,
// we can give them the commands they need to run
var KurtosisCmdStr = path.Base(os.Args[0])

const (
Analytics = "analytics"
CleanCmdStr = "clean"
CloudCmdStr = "cloud"
CloudLoadCmdStr = "load"
ClusterCmdStr = "cluster"
ClusterSetCmdStr = "set"
ClusterGetCmdStr = "get"
Expand Down
19 changes: 19 additions & 0 deletions cli/cli/commands/cloud/cloud.go
@@ -0,0 +1,19 @@
package cloud

import (
"github.com/kurtosis-tech/kurtosis/cli/cli/command_str_consts"
"github.com/kurtosis-tech/kurtosis/cli/cli/commands/cloud/load"
"github.com/spf13/cobra"
)

// CloudCmd Suppressing exhaustruct requirement because this struct has ~40 properties
// nolint: exhaustruct
var CloudCmd = &cobra.Command{
Use: command_str_consts.CloudCmdStr,
Short: "Manage Kurtosis cloud instances",
RunE: nil,
}

func init() {
CloudCmd.AddCommand(load.LoadCmd.MustGetCobraCommand())
}
133 changes: 133 additions & 0 deletions cli/cli/commands/cloud/load/load.go
@@ -0,0 +1,133 @@
package load

import (
"context"
"encoding/base64"
"fmt"
"github.com/kurtosis-tech/kurtosis/api/golang/engine/lib/cloud"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/highlevel/instance_id_arg"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/args"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_framework/lowlevel/flags"
"github.com/kurtosis-tech/kurtosis/cli/cli/command_str_consts"
"github.com/kurtosis-tech/kurtosis/cli/cli/commands/kurtosis_context/add"
"github.com/kurtosis-tech/kurtosis/cli/cli/commands/kurtosis_context/context_switch"
"github.com/kurtosis-tech/kurtosis/cli/cli/kurtosis_config"
"github.com/kurtosis-tech/kurtosis/cli/cli/kurtosis_config/resolved_config"
api "github.com/kurtosis-tech/kurtosis/cloud/api/golang/kurtosis_backend_server_rpc_api_bindings"
"github.com/kurtosis-tech/kurtosis/contexts-config-store/store"
"github.com/kurtosis-tech/stacktrace"
"github.com/sirupsen/logrus"
"os"
)

const (
instanceIdentifierArgKey = "instance-id"
instanceIdentifierArgIsGreedy = false
kurtosisCloudApiKeyEnvVarArg = "KURTOSIS_CLOUD_API_KEY"
)

var LoadCmd = &lowlevel.LowlevelKurtosisCommand{
CommandStr: command_str_consts.CloudLoadCmdStr,
ShortDescription: "Load a Kurtosis Cloud instance",
LongDescription: "Load a remote Kurtosis Cloud instance by providing the instance id." +
"Note, the remote instance must be in a running state for this operation to complete successfully",
Flags: []*flags.FlagConfig{},
Args: []*args.ArgConfig{
instance_id_arg.InstanceIdentifierArg(instanceIdentifierArgKey, instanceIdentifierArgIsGreedy),
},
PreValidationAndRunFunc: nil,
RunFunc: run,
PostValidationAndRunFunc: nil,
}

func run(ctx context.Context, _ *flags.ParsedFlags, args *args.ParsedArgs) error {
instanceID, err := args.GetNonGreedyArg(instanceIdentifierArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for instance id arg '%v' but none was found; "+
"this is a bug in the Kurtosis CLI!", instanceIdentifierArgKey)
}
logrus.Infof("Loading cloud instance %s", instanceID)

apiKey, err := loadApiKey()
if err != nil {
return stacktrace.Propagate(err, "Could not load an API Key. Check that it's defined using the "+
"%s env var and it's a valid (active) key", kurtosisCloudApiKeyEnvVarArg)
}

cloudConfig, err := getCloudConfig()
if err != nil {
return stacktrace.Propagate(err, "An error occured while loading the Cloud Config")
}
// Create the connection
connectionStr := fmt.Sprintf("%s:%d", cloudConfig.ApiUrl, cloudConfig.Port)
client, err := cloud.CreateCloudClient(connectionStr, cloudConfig.CertificateChain)
if err != nil {
return stacktrace.Propagate(err, "Error building client for Kurtosis Cloud")
}

getConfigArgs := &api.GetCloudInstanceConfigArgs{
ApiKey: *apiKey,
InstanceId: instanceID,
}
result, err := client.GetCloudInstanceConfig(ctx, getConfigArgs)
if err != nil {
return stacktrace.Propagate(err, "An error occurred while calling the Kurtosis Cloud API")
}
decodedConfigBytes, err := base64.StdEncoding.DecodeString(result.ContextConfig)
if err != nil {
return stacktrace.Propagate(err, "Failed to base64 decode context config")
}

parsedContext, err := add.ParseContextData(decodedConfigBytes)
if err != nil {
return stacktrace.Propagate(err, "Unable to decode context config")
}

contextsConfigStore := store.GetContextsConfigStore()
// We first have to remove the context incase it's already loaded
err = contextsConfigStore.RemoveContext(parsedContext.Uuid)
if err != nil {
return stacktrace.Propagate(err, "While attempting to reload the context with uuid %s an error occurred while removing it from the context store", parsedContext.Uuid)
}
if add.AddContext(parsedContext) != nil {
return stacktrace.Propagate(err, "Unable to add context to context store")
}
contextIdentifier := parsedContext.GetName()
return context_switch.SwitchContext(ctx, contextIdentifier)
}

func loadApiKey() (*string, error) {
apiKey := os.Getenv(kurtosisCloudApiKeyEnvVarArg)
if len(apiKey) < 1 {
return nil, stacktrace.NewError("No API Key was found. An API Key must be provided as env var %s", kurtosisCloudApiKeyEnvVarArg)
}
logrus.Info("Successfully Loaded API Key...")
return &apiKey, nil
}

func getCloudConfig() (*resolved_config.KurtosisCloudConfig, error) {
// Get the configuration
kurtosisConfigStore := kurtosis_config.GetKurtosisConfigStore()
configProvider := kurtosis_config.NewKurtosisConfigProvider(kurtosisConfigStore)
kurtosisConfig, err := configProvider.GetOrInitializeConfig()
if err != nil {
return nil, stacktrace.Propagate(err, "Failed to get or initialize Kurtosis configuration")
}
if kurtosisConfig.GetCloudConfig() == nil {
return nil, stacktrace.Propagate(err, "No cloud config was found. This is an internal Kurtosis error.")
}
cloudConfig := kurtosisConfig.GetCloudConfig()

if cloudConfig.Port == 0 {
cloudConfig.Port = resolved_config.DefaultCloudConfigPort
}
if len(cloudConfig.ApiUrl) < 1 {
cloudConfig.ApiUrl = resolved_config.DefaultCloudConfigApiUrl
}
if len(cloudConfig.CertificateChain) < 1 {
cloudConfig.CertificateChain = resolved_config.DefaultCertificateChain
}

return cloudConfig, nil
}
2 changes: 1 addition & 1 deletion cli/cli/commands/enclave/inspect/inspect.go
Expand Up @@ -90,7 +90,7 @@ func run(
) error {
enclaveIdentifier, err := args.GetNonGreedyArg(enclaveIdentifierArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for non-greedy enclave identifier arg '%v' but none was found; this is a bug with Kurtosis!", enclaveIdentifierArgKey)
return stacktrace.Propagate(err, "Expected a value for non-greedy enclave identifier arg '%v' but none was found; this is a bug in the Kurtosis CLI!", enclaveIdentifierArgKey)
}

showFullUuids, err := flags.GetBool(fullUuidsFlagKey)
Expand Down
2 changes: 1 addition & 1 deletion cli/cli/commands/enclave/rm/rm.go
Expand Up @@ -67,7 +67,7 @@ func run(
) error {
enclaveIdentifiers, err := args.GetGreedyArg(enclaveIdentifiersArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for greedy enclave identifier arg '%v' but none was found; this is a bug with Kurtosis!", enclaveIdentifiersArgKey)
return stacktrace.Propagate(err, "Expected a value for greedy enclave identifier arg '%v' but none was found; this is a bug in the Kurtosis CLI!", enclaveIdentifiersArgKey)
}

shouldForceRemove, err := flags.GetBool(shouldForceRemoveFlagKey)
Expand Down
16 changes: 11 additions & 5 deletions cli/cli/commands/kurtosis_context/add/add.go
Expand Up @@ -44,17 +44,20 @@ func run(_ context.Context, _ *flags.ParsedFlags, args *args.ParsedArgs) error {
contextFilePath, err := args.GetNonGreedyArg(contextFilePathArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for context file arg '%v' but none was found; "+
"this is a bug with Kurtosis!", contextFilePathArgKey)
"this is a bug in the Kurtosis CLI!", contextFilePathArgKey)
}

contextsConfigStore := store.GetContextsConfigStore()
newContextToAdd, err := parseContextFile(contextFilePath)
if err != nil {
return stacktrace.Propagate(err, "Unable to read content of context file at '%s'", contextFilePath)
}
return AddContext(newContextToAdd)
}

func AddContext(newContextToAdd *generated.KurtosisContext) error {
logrus.Infof("Adding new context '%s'", newContextToAdd.GetName())
if err = contextsConfigStore.AddNewContext(newContextToAdd); err != nil {
contextsConfigStore := store.GetContextsConfigStore()
if err := contextsConfigStore.AddNewContext(newContextToAdd); err != nil {
return stacktrace.Propagate(err, "New context '%s' with UUID '%s' could not be added to the list of "+
"contexts already configured", newContextToAdd.GetName(), newContextToAdd.GetUuid().GetValue())
}
Expand All @@ -67,10 +70,13 @@ func parseContextFile(contextFilePath string) (*generated.KurtosisContext, error
if err != nil {
return nil, stacktrace.Propagate(err, "Unable to read context of context file")
}
return ParseContextData(contextFileContent)
}

func ParseContextData(contextContent []byte) (*generated.KurtosisContext, error) {
newContext := new(generated.KurtosisContext)
if err = protojson.Unmarshal(contextFileContent, newContext); err != nil {
return nil, stacktrace.Propagate(err, "Content of context file at does not seem to be valid. It couldn't be parsed.")
if err := protojson.Unmarshal(contextContent, newContext); err != nil {
return nil, stacktrace.Propagate(err, "Content of context file could not be parsed.")
}
return newContext, nil
}
9 changes: 8 additions & 1 deletion cli/cli/commands/kurtosis_context/context_switch/switch.go
Expand Up @@ -45,9 +45,16 @@ var ContextSwitchCmd = &lowlevel.LowlevelKurtosisCommand{
func run(ctx context.Context, _ *flags.ParsedFlags, args *args.ParsedArgs) error {
contextIdentifier, err := args.GetNonGreedyArg(contextIdentifierArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for context identifier arg '%v' but none was found; this is a bug with Kurtosis!", contextIdentifierArgKey)
return stacktrace.Propagate(err, "Expected a value for context identifier arg '%v' but none was found; this is a bug in the Kurtosis CLI!", contextIdentifierArgKey)
}

return SwitchContext(ctx, contextIdentifier)
}

func SwitchContext(
ctx context.Context,
contextIdentifier string,
) error {
isContextSwitchSuccessful := false
logrus.Info("Switching context...")

Expand Down
2 changes: 1 addition & 1 deletion cli/cli/commands/kurtosis_context/rm/rm.go
Expand Up @@ -33,7 +33,7 @@ var ContextRmCmd = &lowlevel.LowlevelKurtosisCommand{
func run(_ context.Context, _ *flags.ParsedFlags, args *args.ParsedArgs) error {
contextIdentifiers, err := args.GetGreedyArg(contextIdentifiersArgKey)
if err != nil {
return stacktrace.Propagate(err, "Expected a value for greedy context identifiers arg '%v' but none was found; this is a bug with Kurtosis!", contextIdentifiersArgKey)
return stacktrace.Propagate(err, "Expected a value for greedy context identifiers arg '%v' but none was found; this is a bug in the Kurtosis CLI!", contextIdentifiersArgKey)
}

contextsConfigStore := store.GetContextsConfigStore()
Expand Down

0 comments on commit b2db8c9

Please sign in to comment.