diff --git a/Makefile b/Makefile index f0beeac8..a65ba58c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build-ie build-api build-all run-ie run-api clean test-all test all +.PHONY: build-ie build-all run-ie clean test-all test all BINARY_DIR := bin IE_BINARY := $(BINARY_DIR)/ie @@ -10,15 +10,8 @@ build-ie: @echo "Building the Innovation Engine CLI..." @CGO_ENABLED=0 go build -o "$(IE_BINARY)" cmd/ie/ie.go -build-api: - @echo "Building the Innovation Engine API..." - @CGO_ENABLED=0 go build -o "$(API_BINARY)" cmd/api/main.go -build-runner: build-ie build-api - @echo "Building the Innovation Engine Runner..." - @CGO_ENABLED=0 go build -o "$(BINARY_DIR)/runner" cmd/runner/main.go - -build-all: build-ie build-api build-runner +build-all: build-ie # ------------------------------ Install targets ------------------------------- @@ -67,58 +60,7 @@ run-ie: build-ie @echo "Running the Innovation Engine CLI" @"$(IE_BINARY)" -run-api: build-api - @echo "Running the Innovation Engine API" - @"$(API_BINARY)" - clean: @echo "Cleaning up" @rm -rf "$(BINARY_DIR)" -# ----------------------------- Docker targets --------------------------------- - -API_IMAGE_TAG ?= latest - -# Builds the API container. -build-api-container: - @echo "Building the Innovation Engine API container" - @docker build -t innovation-engine-api:$(API_IMAGE_TAG) -f infra/api/Dockerfile . - - -# ----------------------------- Kubernetes targets ----------------------------- - -# Applies the ingress controller to the cluster and waits for it to be ready. -k8s-deploy-ingress-controller: - @echo "Deploying the ingress controller to your local cluster..." - @kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.7.1/deploy/static/provider/cloud/deploy.yaml - @kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=120s - -# Deploys the API deployment, service, and ingress specifications to the -# cluster, allowing the API to be accessed via the ingress controller. -k8s-deploy-api: build-api-container - @echo "Deploying the Innovation Engine API container to your local cluster..." - @kubectl apply -f infra/api/deployment.yaml - @kubectl apply -f infra/api/service.yaml - @kubectl apply -f infra/api/ingress.yaml - -k8s-initialize-cluster: k8s-deploy-ingress-controller k8s-deploy-api - @echo "Set up Kubernetes cluster for local development." - -k8s-delete-ingress-controller: - @echo "Deleting the ingress controller from your local cluster..." - @kubectl delete -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.7.1/deploy/static/provider/cloud/deploy.yaml - -k8s-delete-api: - @echo "Deleting the Innovation Engine API container from your local cluster..." - @kubectl delete -f infra/api/deployment.yaml - @kubectl delete -f infra/api/service.yaml - @kubectl delete -f infra/api/ingress.yaml - -k8s-refresh-api: k8s-delete-api k8s-deploy-api - @echo "Refreshed the Innovation Engine API container in your local cluster..." - -k8s-delete-cluster: k8s-delete-api k8s-delete-ingress-controller - @echo "Deleted Kubernetes cluster for local development." - -k8s-refresh-cluster: k8s-delete-cluster k8s-initialize-cluster - @echo "Refreshed Kubernetes cluster for local development." diff --git a/cmd/api/main.go b/cmd/api/main.go deleted file mode 100644 index dffdaadb..00000000 --- a/cmd/api/main.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "net/http" - "path" - - "github.com/Azure/InnovationEngine/internal/kube" - "github.com/google/uuid" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" -) - -var ( - BASE_ROUTE = "/api" - HEALTH_ROUTE = path.Join(BASE_ROUTE, "health") - EXECUTION_ROUTE = path.Join(BASE_ROUTE, "execute") - DEPLOYMENTS_ROUTE = path.Join(BASE_ROUTE, "deployments") -) - -func main() { - server := echo.New() - - // Setup middleware. - server.Use(middleware.Logger()) - server.Use(middleware.Recover()) - - server.GET(HEALTH_ROUTE, func(c echo.Context) error { - return c.JSON(http.StatusOK, map[string]string{"message": "OK"}) - }) - - server.POST(EXECUTION_ROUTE, func(c echo.Context) error { - clientset, err := kube.GetKubernetesClient() - - id := uuid.New().String() - - // Create deployment - deployment := kube.GetAgentDeployment(id) - _, err = kube.CreateAgentDeployment(clientset, deployment) - - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"message": err.Error()}) - } - - // Create service - service := kube.GetAgentService(id) - _, err = kube.CreateAgentService(clientset, service) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"message": err.Error()}) - } - - return c.JSON(http.StatusOK, map[string]string{"deployment": deployment.Name, "service": service.Name}) - }) - - server.Logger.Fatal(server.Start(":8080")) -} diff --git a/cmd/api/types.go b/cmd/api/types.go deleted file mode 100644 index 454727e7..00000000 --- a/cmd/api/types.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -type DeploymentStep struct { - Name string `json:"name"` - Command string `json:"command"` -} - -type DeploymentResponse struct { - AgentWebsocketUrl string `json:"agentWebsocketUrl"` - Steps []DeploymentStep `json:"steps"` -} diff --git a/cmd/ie/commands/inspect.go b/cmd/ie/commands/inspect.go new file mode 100644 index 00000000..05467ca9 --- /dev/null +++ b/cmd/ie/commands/inspect.go @@ -0,0 +1,109 @@ +package commands + +import ( + "fmt" + "os" + "strings" + + "github.com/Azure/InnovationEngine/internal/engine" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/ui" + "github.com/spf13/cobra" +) + +// Register the command with our command runner. +func init() { + rootCommand.AddCommand(inspectCommand) + + // String flags + inspectCommand.PersistentFlags(). + String("correlation-id", "", "Adds a correlation ID to the user agent used by a scenarios azure-cli commands.") + inspectCommand.PersistentFlags(). + String("subscription", "", "Sets the subscription ID used by a scenarios azure-cli commands. Will rely on the default subscription if not set.") + inspectCommand.PersistentFlags(). + String("working-directory", ".", "Sets the working directory for innovation engine to operate out of. Restores the current working directory when finished.") + + // StringArray flags + inspectCommand.PersistentFlags(). + StringArray("var", []string{}, "Sets an environment variable for the scenario. Format: --var =") +} + +var inspectCommand = &cobra.Command{ + Use: "inspect", + Short: "Execute a document in inspect mode.", + Run: func(cmd *cobra.Command, args []string) { + markdownFile := args[0] + if markdownFile == "" { + logging.GlobalLogger.Errorf("Error: No markdown file specified.") + cmd.Help() + os.Exit(1) + } + + environmentVariables, _ := cmd.Flags().GetStringArray("var") + // features, _ := cmd.Flags().GetStringArray("feature") + + // Parse the environment variables from the command line into a map + cliEnvironmentVariables := make(map[string]string) + for _, environmentVariable := range environmentVariables { + keyValuePair := strings.SplitN(environmentVariable, "=", 2) + if len(keyValuePair) != 2 { + logging.GlobalLogger.Errorf( + "Error: Invalid environment variable format: %s", + environmentVariable, + ) + fmt.Printf("Error: Invalid environment variable format: %s", environmentVariable) + cmd.Help() + os.Exit(1) + } + + cliEnvironmentVariables[keyValuePair[0]] = keyValuePair[1] + } + // Parse the markdown file and create a scenario + scenario, err := engine.CreateScenarioFromMarkdown( + markdownFile, + []string{"bash", "azurecli", "azurecli-inspect", "terraform"}, + cliEnvironmentVariables, + ) + if err != nil { + logging.GlobalLogger.Errorf("Error creating scenario: %s", err) + fmt.Printf("Error creating scenario: %s", err) + os.Exit(1) + } + + if err != nil { + logging.GlobalLogger.Errorf("Error creating engine: %s", err) + fmt.Printf("Error creating engine: %s", err) + os.Exit(1) + } + + fmt.Println(ui.ScenarioTitleStyle.Render(scenario.Name)) + for stepNumber, step := range scenario.Steps { + stepTitle := fmt.Sprintf(" %d. %s\n", stepNumber+1, step.Name) + fmt.Println(ui.StepTitleStyle.Render(stepTitle)) + for codeBlockNumber, codeBlock := range step.CodeBlocks { + fmt.Println( + ui.InteractiveModeCodeBlockDescriptionStyle.Render( + fmt.Sprintf( + " %d.%d %s", + stepNumber+1, + codeBlockNumber+1, + codeBlock.Description, + ), + ), + ) + fmt.Print( + ui.IndentMultiLineCommand( + fmt.Sprintf( + " %s", + ui.InteractiveModeCodeBlockStyle.Render( + codeBlock.Content, + ), + ), + 6), + ) + fmt.Println() + } + } + + }, +} diff --git a/cmd/ie/commands/interactive.go b/cmd/ie/commands/interactive.go index 064a3581..8d6c8904 100644 --- a/cmd/ie/commands/interactive.go +++ b/cmd/ie/commands/interactive.go @@ -1,15 +1,107 @@ package commands import ( + "fmt" + "os" + "strings" + + "github.com/Azure/InnovationEngine/internal/engine" + "github.com/Azure/InnovationEngine/internal/logging" "github.com/spf13/cobra" ) +// Register the command with our command runner. +func init() { + rootCommand.AddCommand(interactiveCommand) + + // String flags + interactiveCommand.PersistentFlags(). + String("correlation-id", "", "Adds a correlation ID to the user agent used by a scenarios azure-cli commands.") + interactiveCommand.PersistentFlags(). + String("subscription", "", "Sets the subscription ID used by a scenarios azure-cli commands. Will rely on the default subscription if not set.") + interactiveCommand.PersistentFlags(). + String("working-directory", ".", "Sets the working directory for innovation engine to operate out of. Restores the current working directory when finished.") + + // StringArray flags + interactiveCommand.PersistentFlags(). + StringArray("var", []string{}, "Sets an environment variable for the scenario. Format: --var =") +} + var interactiveCommand = &cobra.Command{ Use: "interactive", Short: "Execute a document in interactive mode.", -} + Run: func(cmd *cobra.Command, args []string) { + markdownFile := args[0] + if markdownFile == "" { + logging.GlobalLogger.Errorf("Error: No markdown file specified.") + cmd.Help() + os.Exit(1) + } -// / Register the command with our command runner. -func init() { - rootCommand.AddCommand(interactiveCommand) + verbose, _ := cmd.Flags().GetBool("verbose") + doNotDelete, _ := cmd.Flags().GetBool("do-not-delete") + + subscription, _ := cmd.Flags().GetString("subscription") + correlationId, _ := cmd.Flags().GetString("correlation-id") + environment, _ := cmd.Flags().GetString("environment") + workingDirectory, _ := cmd.Flags().GetString("working-directory") + + environmentVariables, _ := cmd.Flags().GetStringArray("var") + // features, _ := cmd.Flags().GetStringArray("feature") + + // Known features + renderValues := false + + // Parse the environment variables from the command line into a map + cliEnvironmentVariables := make(map[string]string) + for _, environmentVariable := range environmentVariables { + keyValuePair := strings.SplitN(environmentVariable, "=", 2) + if len(keyValuePair) != 2 { + logging.GlobalLogger.Errorf( + "Error: Invalid environment variable format: %s", + environmentVariable, + ) + fmt.Printf("Error: Invalid environment variable format: %s", environmentVariable) + cmd.Help() + os.Exit(1) + } + + cliEnvironmentVariables[keyValuePair[0]] = keyValuePair[1] + } + // Parse the markdown file and create a scenario + scenario, err := engine.CreateScenarioFromMarkdown( + markdownFile, + []string{"bash", "azurecli", "azurecli-interactive", "terraform"}, + cliEnvironmentVariables, + ) + if err != nil { + logging.GlobalLogger.Errorf("Error creating scenario: %s", err) + fmt.Printf("Error creating scenario: %s", err) + os.Exit(1) + } + + innovationEngine, err := engine.NewEngine(engine.EngineConfiguration{ + Verbose: verbose, + DoNotDelete: doNotDelete, + Subscription: subscription, + CorrelationId: correlationId, + Environment: environment, + WorkingDirectory: workingDirectory, + RenderValues: renderValues, + }) + + if err != nil { + logging.GlobalLogger.Errorf("Error creating engine: %s", err) + fmt.Printf("Error creating engine: %s", err) + os.Exit(1) + } + + // Execute the scenario + err = innovationEngine.InteractWithScenario(scenario) + if err != nil { + logging.GlobalLogger.Errorf("Error executing scenario: %s", err) + fmt.Printf("Error executing scenario: %s", err) + os.Exit(1) + } + }, } diff --git a/cmd/runner/main.go b/cmd/runner/main.go deleted file mode 100644 index e5a802c0..00000000 --- a/cmd/runner/main.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "fmt" - "net/http" -) - -func main() { - fmt.Println("Hello, world!") - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello, world!") - }) -} diff --git a/go.mod b/go.mod index 06e39089..ae5d0929 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/Azure/InnovationEngine go 1.20 require ( - github.com/charmbracelet/lipgloss v0.7.1 + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/lipgloss v0.9.1 github.com/google/uuid v1.3.0 github.com/labstack/echo/v4 v4.10.2 github.com/sergi/go-diff v1.3.1 @@ -12,7 +14,7 @@ require ( github.com/stretchr/testify v1.8.2 github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 github.com/yuin/goldmark v1.5.4 - golang.org/x/sys v0.13.0 + golang.org/x/sys v0.16.0 gopkg.in/ini.v1 v1.67.0 k8s.io/api v0.27.1 k8s.io/apimachinery v0.27.1 @@ -20,8 +22,13 @@ require ( ) require ( + github.com/alecthomas/chroma v0.10.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/charmbracelet/glamour v0.6.0 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // 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/go-logr/logr v1.2.3 // indirect github.com/go-openapi/jsonpointer v0.19.6 // indirect @@ -33,6 +40,7 @@ require ( github.com/google/gnostic v0.5.7-v3refs // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.1.0 // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -42,21 +50,28 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/microcosm-cc/bluemonday v1.0.21 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.1 // indirect + github.com/muesli/termenv v0.15.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/crypto v0.14.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect - golang.org/x/term v0.13.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/term v0.16.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 30e33ca1..0bd46e00 100644 --- a/go.sum +++ b/go.sum @@ -33,21 +33,36 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= -github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/charmbracelet/bubbles v0.17.1 h1:0SIyjOnkrsfDo88YvPgAWvZMwXe26TP6drRvmkjyUu4= +github.com/charmbracelet/bubbles v0.17.1/go.mod h1:9HxZWlkCqz2PRwsCbYl7a3KXvGzFaDHpYbSYMJ+nE3o= +github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= +github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= +github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= +github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= +github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= +github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE= github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= @@ -132,6 +147,8 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +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/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= @@ -170,20 +187,33 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg= +github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= -github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.9.1 h1:zie5Ly042PD3bsCvsSOPvRnFwyo3rKe64TJlD6nu0mk= github.com/onsi/gomega v1.27.4 h1:Z2AnStgsdSayCMDiCU42qIz+HLqEPcgiOCXjAU/w+8E= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -228,8 +258,11 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= +github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -300,6 +333,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -318,6 +352,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -350,13 +386,15 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= +golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/engine/commands.go b/internal/engine/commands.go new file mode 100644 index 00000000..52db6ab9 --- /dev/null +++ b/internal/engine/commands.go @@ -0,0 +1,140 @@ +package engine + +import ( + "fmt" + + "github.com/Azure/InnovationEngine/internal/engine/environments" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/parsers" + "github.com/Azure/InnovationEngine/internal/shells" + tea "github.com/charmbracelet/bubbletea" +) + +// Emitted when a command has been executed successfully. +type SuccessfulCommandMessage struct { + StdOut string + StdErr string +} + +// Emitted when a command has failed to execute. +type FailedCommandMessage struct { + StdOut string + StdErr string + Error error +} + +// Executes a bash command and returns a tea message with the output. This function +// will be executed asycnhronously. +func ExecuteCodeBlockAsync(codeBlock parsers.CodeBlock, env map[string]string) tea.Cmd { + return func() tea.Msg { + logging.GlobalLogger.Infof( + "Executing command asynchronously:\n %s", codeBlock.Content) + + output, err := shells.ExecuteBashCommand(codeBlock.Content, shells.BashCommandConfiguration{ + EnvironmentVariables: env, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: true, + }) + if err != nil { + logging.GlobalLogger.Errorf("Error executing command:\n %s", err.Error()) + return FailedCommandMessage{ + StdOut: output.StdOut, + StdErr: output.StdErr, + Error: err, + } + } + + // Check command output against the expected output. + actualOutput := output.StdOut + expectedOutput := codeBlock.ExpectedOutput.Content + expectedSimilarity := codeBlock.ExpectedOutput.ExpectedSimilarity + expectedRegex := codeBlock.ExpectedOutput.ExpectedRegex + expectedOutputLanguage := codeBlock.ExpectedOutput.Language + + outputComparisonError := compareCommandOutputs( + actualOutput, + expectedOutput, + expectedSimilarity, + expectedRegex, + expectedOutputLanguage, + ) + + if outputComparisonError != nil { + logging.GlobalLogger.Errorf( + "Error comparing command outputs: %s", + outputComparisonError.Error(), + ) + + return FailedCommandMessage{ + StdOut: output.StdOut, + StdErr: output.StdErr, + Error: outputComparisonError, + } + + } + + logging.GlobalLogger.Infof("Command output to stdout:\n %s", output.StdOut) + return SuccessfulCommandMessage{ + StdOut: output.StdOut, + StdErr: output.StdErr, + } + } +} + +// Executes a bash command syncrhonously. This function will block until the command +// finishes executing. +func ExecuteCodeBlockSync(codeBlock parsers.CodeBlock, env map[string]string) tea.Msg { + logging.GlobalLogger.Info("Executing command synchronously: ", codeBlock.Content) + program.ReleaseTerminal() + + output, err := shells.ExecuteBashCommand(codeBlock.Content, shells.BashCommandConfiguration{ + EnvironmentVariables: env, + InheritEnvironment: true, + InteractiveCommand: true, + WriteToHistory: true, + }) + + program.RestoreTerminal() + + if err != nil { + return FailedCommandMessage{ + StdOut: output.StdOut, + StdErr: output.StdErr, + Error: err, + } + } + + logging.GlobalLogger.Infof("Command output to stdout:\n %s", output.StdOut) + return SuccessfulCommandMessage{ + StdOut: output.StdOut, + StdErr: output.StdErr, + } +} + +// clearScreen returns a command that clears the terminal screen and positions the cursor at the top-left corner +func clearScreen() tea.Cmd { + return func() tea.Msg { + fmt.Print( + "\033[H\033[2J", + ) // ANSI escape codes for clearing the screen and repositioning the cursor + return nil + } +} + +// Updates the azure status with the current state of the interactive mode +// model. +func updateAzureStatus(model InteractiveModeModel) tea.Cmd { + return func() tea.Msg { + logging.GlobalLogger.Tracef( + "Attempting to update the azure status: %+v", + model.azureStatus, + ) + environments.ReportAzureStatus(model.azureStatus, model.environment) + return AzureStatusUpdatedMessage{} + } +} + +// Empty struct used to indicate that the azure status has been updated so +// that we can respond to it within the Update() function. +type AzureStatusUpdatedMessage struct{} diff --git a/internal/engine/common.go b/internal/engine/common.go index 60fb9e7e..e5d14080 100644 --- a/internal/engine/common.go +++ b/internal/engine/common.go @@ -11,21 +11,6 @@ import ( "github.com/xrash/smetrics" ) -// Indents a multi-line command to be nested under the first line of the -// command. -func indentMultiLineCommand(content string, indentation int) string { - lines := strings.Split(content, "\n") - for i := 1; i < len(lines); i++ { - if strings.HasSuffix(strings.TrimSpace(lines[i-1]), "\\") { - lines[i] = strings.Repeat(" ", indentation) + lines[i] - } else if strings.TrimSpace(lines[i]) != "" { - lines[i] = strings.Repeat(" ", indentation) + lines[i] - } - - } - return strings.Join(lines, "\n") -} - // Compares the actual output of a command to the expected output of a command. func compareCommandOutputs( actualOutput string, @@ -36,7 +21,11 @@ func compareCommandOutputs( ) error { if expectedRegex != nil { if !expectedRegex.MatchString(actualOutput) { - return fmt.Errorf(ui.ErrorMessageStyle.Render(fmt.Sprintf("Expected output does not match: %q.", expectedRegex))) + return fmt.Errorf( + ui.ErrorMessageStyle.Render( + fmt.Sprintf("Expected output does not match: %q.", expectedRegex), + ), + ) } return nil @@ -73,7 +62,9 @@ func compareCommandOutputs( score := smetrics.JaroWinkler(expectedOutput, actualOutput, 0.7, 4) if expectedSimilarity > score { - return fmt.Errorf(ui.ErrorMessageStyle.Render("Expected output does not match actual output.")) + return fmt.Errorf( + ui.ErrorMessageStyle.Render("Expected output does not match actual output."), + ) } return nil diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 843cdef6..fee9fd36 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -4,9 +4,13 @@ import ( "fmt" "github.com/Azure/InnovationEngine/internal/az" + "github.com/Azure/InnovationEngine/internal/engine/environments" "github.com/Azure/InnovationEngine/internal/lib" "github.com/Azure/InnovationEngine/internal/lib/fs" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/shells" "github.com/Azure/InnovationEngine/internal/ui" + tea "github.com/charmbracelet/bubbletea" ) // Configuration for the engine. @@ -54,3 +58,48 @@ func (e *Engine) TestScenario(scenario *Scenario) error { return err }) } + +// Executes a Scenario in interactive mode. This mode goes over each codeblock +// step by step and allows the user to interact with the codeblock. +func (e *Engine) InteractWithScenario(scenario *Scenario) error { + return fs.UsingDirectory(e.Configuration.WorkingDirectory, func() error { + az.SetCorrelationId(e.Configuration.CorrelationId, scenario.Environment) + + stepsToExecute := filterDeletionCommands(scenario.Steps, e.Configuration.DoNotDelete) + + model, err := NewInteractiveModeModel( + scenario.Name, + e, + stepsToExecute, + lib.CopyMap(scenario.Environment), + ) + if err != nil { + return err + } + + program = tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + _, err = program.Run() + + switch e.Configuration.Environment { + case environments.EnvironmentsAzure, environments.EnvironmentsOCD: + logging.GlobalLogger.Info( + "Cleaning environment variable file located at /tmp/env-vars", + ) + err := shells.CleanEnvironmentStateFile() + if err != nil { + logging.GlobalLogger.Errorf("Error cleaning environment variables: %s", err.Error()) + return err + } + + default: + shells.ResetStoredEnvironmentVariables() + } + + if err != nil { + logging.GlobalLogger.Errorf("Failed to run program %s", err) + return err + } + + return nil + }) +} diff --git a/internal/engine/environments/azure.go b/internal/engine/environments/azure.go index 0526abdd..c1e5f39d 100644 --- a/internal/engine/environments/azure.go +++ b/internal/engine/environments/azure.go @@ -9,18 +9,30 @@ import ( "github.com/Azure/InnovationEngine/internal/ui" ) -// / The status of a one-click deployment. +// codeblock metadata needed for learn mode deployments. +type AzureCodeBlock struct { + Description string `json:"description"` + Command string `json:"command"` +} + +// Step metadata needed for learn mode deployments. +type AzureStep struct { + Name string `json:"name"` + CodeBlocks []AzureCodeBlock `json:"codeblocks"` +} + +// The status of a one-click deployment or learn mode deployment. type AzureDeploymentStatus struct { - Steps []string `json:"steps"` - CurrentStep int `json:"currentStep"` - Status string `json:"status"` - ResourceURIs []string `json:"resourceURIs"` - Error string `json:"error"` + Steps []AzureStep `json:"steps"` + CurrentStep int `json:"currentStep"` + Status string `json:"status"` + ResourceURIs []string `json:"resourceURIs"` + Error string `json:"error"` } func NewAzureDeploymentStatus() AzureDeploymentStatus { return AzureDeploymentStatus{ - Steps: []string{}, + Steps: []AzureStep{}, CurrentStep: 0, Status: "Executing", ResourceURIs: []string{}, @@ -39,8 +51,11 @@ func (status *AzureDeploymentStatus) AsJsonString() (string, error) { return string(json), nil } -func (status *AzureDeploymentStatus) AddStep(step string) { - status.Steps = append(status.Steps, step) +func (status *AzureDeploymentStatus) AddStep(step string, codeBlocks []AzureCodeBlock) { + status.Steps = append(status.Steps, AzureStep{ + Name: step, + CodeBlocks: codeBlocks, + }) } func (status *AzureDeploymentStatus) AddResourceURI(uri string) { @@ -69,6 +84,24 @@ func ReportAzureStatus(status AzureDeploymentStatus, environment string) { } } +// Same as ReportAzureStatus, but returns the status string instead of printing it. +func GetAzureStatus(status AzureDeploymentStatus, environment string) string { + if !IsAzureEnvironment(environment) { + return "" + } + + statusJson, err := status.AsJsonString() + if err != nil { + logging.GlobalLogger.Error("Failed to marshal status", err) + return "" + } else { + // We add these strings to the output so that the portal can find and parse + // the JSON status. + ocdStatus := fmt.Sprintf("ie_us%sie_ue", statusJson) + return ocdStatus + } +} + // Attach deployed resource URIs to the one click deployment status if we're in // the correct environment & we have a resource group name. func AttachResourceURIsToAzureStatus( diff --git a/internal/engine/execution.go b/internal/engine/execution.go index e48504b5..f7382eba 100644 --- a/internal/engine/execution.go +++ b/internal/engine/execution.go @@ -84,7 +84,16 @@ func (e *Engine) ExecuteAndRenderSteps(steps []Step, env map[string]string) erro stepsToExecute := filterDeletionCommands(steps, e.Configuration.DoNotDelete) for stepNumber, step := range stepsToExecute { - azureStatus.AddStep(fmt.Sprintf("%d. %s", stepNumber+1, step.Name)) + + azureCodeBlocks := []environments.AzureCodeBlock{} + for _, block := range step.CodeBlocks { + azureCodeBlocks = append(azureCodeBlocks, environments.AzureCodeBlock{ + Command: block.Content, + Description: block.Description, + }) + } + + azureStatus.AddStep(fmt.Sprintf("%d. %s", stepNumber+1, step.Name), azureCodeBlocks) } environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) @@ -105,9 +114,9 @@ func (e *Engine) ExecuteAndRenderSteps(steps []Step, env map[string]string) erro environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) return err } - finalCommandOutput = indentMultiLineCommand(renderedCommand.StdOut, 4) + finalCommandOutput = ui.IndentMultiLineCommand(renderedCommand.StdOut, 4) } else { - finalCommandOutput = indentMultiLineCommand(block.Content, 4) + finalCommandOutput = ui.IndentMultiLineCommand(block.Content, 4) } fmt.Print(" " + finalCommandOutput) @@ -223,11 +232,11 @@ func (e *Engine) ExecuteAndRenderSteps(steps []Step, env map[string]string) erro logging.GlobalLogger.Errorf("Error executing command: %s", commandErr.Error()) azureStatus.SetError(commandErr) - environments.AttachResourceURIsToAzureStatus( - &azureStatus, - resourceGroupName, - e.Configuration.Environment, - ) + environments.AttachResourceURIsToAzureStatus( + &azureStatus, + resourceGroupName, + e.Configuration.Environment, + ) environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) return commandErr diff --git a/internal/engine/interactive.go b/internal/engine/interactive.go new file mode 100644 index 00000000..bf02070b --- /dev/null +++ b/internal/engine/interactive.go @@ -0,0 +1,566 @@ +package engine + +import ( + "fmt" + "time" + + "github.com/Azure/InnovationEngine/internal/az" + "github.com/Azure/InnovationEngine/internal/engine/environments" + "github.com/Azure/InnovationEngine/internal/lib" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/parsers" + "github.com/Azure/InnovationEngine/internal/patterns" + "github.com/Azure/InnovationEngine/internal/ui" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/paginator" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" +) + +type InteractiveModeCommands struct { + execute key.Binding + quit key.Binding + previous key.Binding + next key.Binding +} + +// State for the codeblock in interactive mode. Used to keep track of the +// state of each codeblock. +type CodeBlockState struct { + CodeBlock parsers.CodeBlock + CodeBlockNumber int + Error error + StdErr string + StdOut string + StepName string + StepNumber int + Success bool +} + +type interactiveModeComponents struct { + paginator paginator.Model + stepViewport viewport.Model + outputViewport viewport.Model +} + +type InteractiveModeModel struct { + azureStatus environments.AzureDeploymentStatus + codeBlockState map[int]CodeBlockState + commands InteractiveModeCommands + currentCodeBlock int + env map[string]string + environment string + executingCommand bool + height int + help help.Model + resourceGroupName string + scenarioTitle string + width int + scenarioCompleted bool + components interactiveModeComponents + ready bool +} + +// Initialize the intractive mode model +func (model InteractiveModeModel) Init() tea.Cmd { + environments.ReportAzureStatus(model.azureStatus, model.environment) + return tea.Batch(clearScreen(), tea.Tick(time.Millisecond*10, func(t time.Time) tea.Msg { + return tea.KeyMsg{Type: tea.KeyCtrlL} // This is to force a repaint + })) +} + +// Initializes the viewports for the interactive mode model. +func initializeComponents(model InteractiveModeModel, width, height int) interactiveModeComponents { + // paginator setup + p := paginator.New() + p.TotalPages = len(model.codeBlockState) + p.Type = paginator.Dots + // Dots + p.ActiveDot = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}). + Render("•") + p.InactiveDot = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}). + Render("•") + + p.KeyMap.PrevPage = model.commands.previous + p.KeyMap.NextPage = model.commands.next + + stepViewport := viewport.New(width, 4) + outputViewport := viewport.New(width, 2) + + components := interactiveModeComponents{ + paginator: p, + stepViewport: stepViewport, + outputViewport: outputViewport, + } + + components.updateViewportHeight(height) + return components +} + +// Handle user input for interactive mode. +func handleUserInput( + model InteractiveModeModel, + message tea.KeyMsg, +) (InteractiveModeModel, []tea.Cmd) { + var commands []tea.Cmd + switch { + case key.Matches(message, model.commands.execute): + if model.executingCommand { + logging.GlobalLogger.Info("Command is already executing, ignoring execute command") + break + } + + // Prevent the user from executing a command if the previous command has + // not been executed successfully or executed at all. + previousCodeBlock := model.currentCodeBlock - 1 + if previousCodeBlock >= 0 { + previousCodeBlockState := model.codeBlockState[previousCodeBlock] + if !previousCodeBlockState.Success { + logging.GlobalLogger.Info( + "Previous command has not been executed successfully, ignoring execute command", + ) + break + } + } + + // Prevent the user from executing a command if the current command has + // already been executed successfully. + codeBlockState := model.codeBlockState[model.currentCodeBlock] + if codeBlockState.Success { + logging.GlobalLogger.Info( + "Command has already been executed successfully, ignoring execute command", + ) + break + } + + codeBlock := codeBlockState.CodeBlock + + model.executingCommand = true + + // If we're on the last step and the command is an SSH command, we need + // to report the status before executing the command. This is needed for + // one click deployments and does not affect the normal execution flow. + if model.currentCodeBlock == len(model.codeBlockState)-1 && + patterns.SshCommand.MatchString(codeBlock.Content) { + model.azureStatus.Status = "Succeeded" + environments.AttachResourceURIsToAzureStatus( + &model.azureStatus, + model.resourceGroupName, + model.environment, + ) + + commands = append(commands, tea.Sequence( + updateAzureStatus(model), + func() tea.Msg { + return ExecuteCodeBlockSync(codeBlock, lib.CopyMap(model.env)) + })) + + } else { + commands = append(commands, ExecuteCodeBlockAsync( + codeBlock, + lib.CopyMap(model.env), + )) + } + + case key.Matches(message, model.commands.previous): + if model.executingCommand { + logging.GlobalLogger.Info("Command is already executing, ignoring execute command") + break + } + if model.currentCodeBlock > 0 { + model.currentCodeBlock-- + } + case key.Matches(message, model.commands.next): + if model.executingCommand { + logging.GlobalLogger.Info("Command is already executing, ignoring execute command") + break + } + if model.currentCodeBlock < len(model.codeBlockState)-1 { + model.currentCodeBlock++ + } + + case key.Matches(message, model.commands.quit): + commands = append(commands, tea.Quit) + } + + return model, commands +} + +func (components *interactiveModeComponents) updateViewportHeight(terminalHeight int) { + stepViewportPercent := 0.4 + outputViewportPercent := 0.2 + stepViewportHeight := int(float64(terminalHeight) * stepViewportPercent) + outputViewportHeight := int(float64(terminalHeight) * outputViewportPercent) + + if stepViewportHeight < 4 { + stepViewportHeight = 4 + } + + if outputViewportHeight < 2 { + outputViewportHeight = 2 + } + + components.stepViewport.Height = stepViewportHeight + components.outputViewport.Height = outputViewportHeight +} + +// Updates the intractive mode model +func (model InteractiveModeModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { + var commands []tea.Cmd + + switch message := message.(type) { + + case tea.WindowSizeMsg: + model.width = message.Width + model.height = message.Height + logging.GlobalLogger.Debugf("Window size changed to: %d x %d", message.Width, message.Height) + if !model.ready { + model.components = initializeComponents(model, message.Width, message.Height) + model.ready = true + } else { + model.components.stepViewport.Width = message.Width + model.components.outputViewport.Width = message.Width + model.components.updateViewportHeight(message.Height) + } + + case tea.KeyMsg: + model, commands = handleUserInput(model, message) + + case SuccessfulCommandMessage: + // Handle successful command executions + model.executingCommand = false + step := model.currentCodeBlock + + // Update the state of the codeblock which finished executing. + codeBlockState := model.codeBlockState[step] + codeBlockState.StdOut = message.StdOut + codeBlockState.StdErr = message.StdErr + codeBlockState.Success = true + model.codeBlockState[step] = codeBlockState + + logging.GlobalLogger.Infof("Finished executing:\n %s", codeBlockState.CodeBlock.Content) + + // Extract the resource group name from the command output if + // it's not already set. + if model.resourceGroupName == "" && patterns.AzCommand.MatchString(codeBlockState.CodeBlock.Content) { + logging.GlobalLogger.Debugf("Attempting to extract resource group name from command output") + tmpResourceGroup := az.FindResourceGroupName(codeBlockState.StdOut) + if tmpResourceGroup != "" { + logging.GlobalLogger.Infof("Found resource group named: %s", tmpResourceGroup) + model.resourceGroupName = tmpResourceGroup + } + } + + // Increment the codeblock and update the viewport content. + model.currentCodeBlock++ + + // Only increment the step for azure if the step name has changed. + nextCodeBlockState := model.codeBlockState[model.currentCodeBlock] + + if codeBlockState.StepName != nextCodeBlockState.StepName { + logging.GlobalLogger.Debugf("Step name has changed, incrementing step for Azure") + model.azureStatus.CurrentStep++ + } else { + logging.GlobalLogger.Debugf("Step name has not changed, not incrementing step for Azure") + } + + // If the scenario has been completed, we need to update the azure + // status and quit the program. + if model.currentCodeBlock == len(model.codeBlockState) { + model.scenarioCompleted = true + model.azureStatus.Status = "Succeeded" + environments.AttachResourceURIsToAzureStatus( + &model.azureStatus, + model.resourceGroupName, + model.environment, + ) + commands = append( + commands, + tea.Sequence( + updateAzureStatus(model), + tea.Quit, + ), + ) + } else { + commands = append(commands, updateAzureStatus(model)) + } + + case FailedCommandMessage: + // Handle failed command executions + + // Update the state of the codeblock which finished executing. + step := model.currentCodeBlock + codeBlockState := model.codeBlockState[step] + codeBlockState.StdOut = message.StdOut + codeBlockState.StdErr = message.StdErr + codeBlockState.Success = false + + model.codeBlockState[step] = codeBlockState + + // Report the error + model.executingCommand = false + model.azureStatus.SetError(message.Error) + environments.AttachResourceURIsToAzureStatus( + &model.azureStatus, + model.resourceGroupName, + model.environment, + ) + commands = append(commands, tea.Sequence(updateAzureStatus(model), tea.Quit)) + + case AzureStatusUpdatedMessage: + // After the status has been updated, we force a window resize to + // render over the status update. For some reason, clearing the screen + // manually seems to cause the text produced by View() to not render + // properly if we don't trigger a window size event. + commands = append(commands, + tea.Sequence( + tea.ClearScreen, + func() tea.Msg { + return tea.WindowSizeMsg{ + Width: model.width, + Height: model.height, + } + }, + ), + ) + } + + // Update viewport content + block := model.codeBlockState[model.currentCodeBlock] + + renderedStepSection := fmt.Sprintf( + "%s\n\n%s", + block.CodeBlock.Description, + block.CodeBlock.Content, + ) + + // TODO(vmarcella): We shoulkd figure out a way to not have to recreate + // the renderer every time we update the view. + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(model.width-4), + ) + + if err == nil { + var glamourizedSection string + glamourizedSection, err = renderer.Render( + fmt.Sprintf( + "%s\n```%s\n%s```", + block.CodeBlock.Description, + block.CodeBlock.Language, + block.CodeBlock.Content, + ), + ) + if err != nil { + logging.GlobalLogger.Errorf( + "Error rendering codeblock: %s, using non rendered codeblock instead", + err, + ) + } else { + renderedStepSection = glamourizedSection + } + } else { + logging.GlobalLogger.Errorf( + "Error creating glamour renderer: %s, using non rendered codeblock instead", + err, + ) + } + + model.components.stepViewport.SetContent( + renderedStepSection, + ) + + if block.Success { + model.components.outputViewport.SetContent(block.StdOut) + } else { + model.components.outputViewport.SetContent(block.StdErr) + } + + // Update all the viewports and append resulting commands. + var command tea.Cmd + + model.components.paginator.Page = model.currentCodeBlock + + model.components.stepViewport, command = model.components.stepViewport.Update(message) + commands = append(commands, command) + + model.components.outputViewport, command = model.components.outputViewport.Update(message) + commands = append(commands, command) + + return model, tea.Batch(commands...) +} + +// Shows the commands that the user can use to interact with the interactive +// mode model. +func (model InteractiveModeModel) helpView() string { + if model.environment == "azure" { + return "" + } + keyBindingGroups := [][]key.Binding{ + // Command related bindings + { + model.commands.execute, + model.commands.previous, + model.commands.next, + }, + // Scenario related bindings + { + model.commands.quit, + }, + } + + return model.help.FullHelpView(keyBindingGroups) +} + +// Renders the interactive mode model. +func (model InteractiveModeModel) View() string { + scenarioTitle := ui.ScenarioTitleStyle.Width(model.width). + Align(lipgloss.Center). + Render(model.scenarioTitle) + var stepTitle string + var stepView string + var stepSection string + stepTitle = ui.StepTitleStyle.Render( + fmt.Sprintf( + "Step %d - %s", + model.currentCodeBlock+1, + model.codeBlockState[model.currentCodeBlock].StepName, + ), + ) + + border := lipgloss.NewStyle(). + Width(model.components.stepViewport.Width - 2) + // Border(lipgloss.NormalBorder()) + + stepView = border.Render(model.components.stepViewport.View()) + + if model.environment != "azure" { + stepSection = fmt.Sprintf("%s\n%s\n\n", stepTitle, stepView) + } else { + stepSection = fmt.Sprintf("%s\n%s\n", stepTitle, stepView) + } + + var outputTitle string + var outputView string + var outputSection string + if model.environment != "azure" { + outputTitle = ui.StepTitleStyle.Render("Output") + outputView = border.Render(model.components.outputViewport.View()) + outputSection = fmt.Sprintf("%s\n%s\n\n", outputTitle, outputView) + } else { + outputTitle = "" + outputView = "" + outputSection = "" + } + + paginator := lipgloss.NewStyle(). + Width(model.width). + Align(lipgloss.Center). + Render(model.components.paginator.View()) + + var executing string + + if model.executingCommand { + executing = "Executing command..." + } else { + executing = "" + } + + // TODO(vmarcella): Format this to be more readable. + return ((scenarioTitle + "\n") + + (paginator + "\n\n") + + (stepSection) + + (outputSection) + + (model.helpView())) + + ("\n" + executing) +} + +// TODO: Ideally we won't need a global program variable. We should +// refactor this in the future such that each tea program is localized to the +// function that creates it and ExecuteCodeBlockSync doesn't mutate the global +// program variable. +var program *tea.Program = nil + +// Create a new interactive mode model. +func NewInteractiveModeModel( + title string, + engine *Engine, + steps []Step, + env map[string]string, +) (InteractiveModeModel, error) { + // TODO: In the future we should just set the current step for the azure status + // to one as the default. + azureStatus := environments.NewAzureDeploymentStatus() + azureStatus.CurrentStep = 1 + totalCodeBlocks := 0 + codeBlockState := make(map[int]CodeBlockState) + + err := az.SetSubscription(engine.Configuration.Subscription) + if err != nil { + logging.GlobalLogger.Errorf("Invalid Config: Failed to set subscription: %s", err) + azureStatus.SetError(err) + environments.ReportAzureStatus(azureStatus, engine.Configuration.Environment) + return InteractiveModeModel{}, err + } + + for stepNumber, step := range steps { + azureCodeBlocks := []environments.AzureCodeBlock{} + for blockNumber, block := range step.CodeBlocks { + azureCodeBlocks = append(azureCodeBlocks, environments.AzureCodeBlock{ + Command: block.Content, + Description: block.Description, + }) + + codeBlockState[totalCodeBlocks] = CodeBlockState{ + StepName: step.Name, + CodeBlock: block, + StepNumber: stepNumber, + CodeBlockNumber: blockNumber, + StdOut: "", + StdErr: "", + Error: nil, + Success: false, + } + + totalCodeBlocks += 1 + } + azureStatus.AddStep(fmt.Sprintf("%d. %s", stepNumber+1, step.Name), azureCodeBlocks) + } + + return InteractiveModeModel{ + scenarioTitle: title, + commands: InteractiveModeCommands{ + execute: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "Execute the current command."), + ), + quit: key.NewBinding( + key.WithKeys("q"), + key.WithHelp("q", "Quit the scenario."), + ), + previous: key.NewBinding( + key.WithKeys("left"), + key.WithHelp("←", "Go to the previous command."), + ), + next: key.NewBinding( + key.WithKeys("right"), + key.WithHelp("→", "Go to the next command."), + ), + }, + env: env, + resourceGroupName: "", + azureStatus: azureStatus, + codeBlockState: codeBlockState, + executingCommand: false, + currentCodeBlock: 0, + help: help.New(), + environment: engine.Configuration.Environment, + scenarioCompleted: false, + ready: false, + }, nil +} diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index b847b51c..ba51708d 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -170,11 +170,6 @@ func CreateScenarioFromMarkdown( }, nil } -func (s *Scenario) OverwriteEnvironmentVariables(environmentVariables map[string]string) { - for key, value := range environmentVariables { - s.Environment[key] = value - } -} // Convert a scenario into a shell script func (s *Scenario) ToShellScript() string { diff --git a/internal/kube/client.go b/internal/kube/client.go deleted file mode 100644 index f26601da..00000000 --- a/internal/kube/client.go +++ /dev/null @@ -1,37 +0,0 @@ -package kube - -import ( - "os" - "path/filepath" - - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" -) - -// Obtains the Kubernetes clientset based on the environment -// this function is executing in. -func GetKubernetesClient() (*kubernetes.Clientset, error) { - var config *rest.Config - var err error - - if _, err := rest.InClusterConfig(); err != nil { - kubeConfig := filepath.Join(os.Getenv("HOME"), ".kube", "config") - config, err = clientcmd.BuildConfigFromFlags("", kubeConfig) - if err != nil { - return nil, err - } - } else { - config, err = rest.InClusterConfig() - if err != nil { - return nil, err - } - } - - clientset, err := kubernetes.NewForConfig(config) - if err != nil { - return nil, err - } - - return clientset, nil -} diff --git a/internal/kube/deployments.go b/internal/kube/deployments.go deleted file mode 100644 index 89ccc3cf..00000000 --- a/internal/kube/deployments.go +++ /dev/null @@ -1,56 +0,0 @@ -package kube - -import ( - "context" - - "github.com/Azure/InnovationEngine/internal/lib" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" -) - -func GetAgentDeployment(id string) *appsv1.Deployment { - - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "runner-" + id, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: lib.Int32Ptr(1), - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": "runner", - "id": id, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": "runner", - "id": id, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "runner", - Image: "innovation-engine-runner", - Ports: []corev1.ContainerPort{ - { - Name: "http", - ContainerPort: 8080, - Protocol: corev1.ProtocolTCP, - }, - }, - }, - }, - }, - }, - }, - } -} - -func CreateAgentDeployment(clientset *kubernetes.Clientset, deployment *appsv1.Deployment) (*appsv1.Deployment, error) { - return clientset.AppsV1().Deployments("default").Create(context.TODO(), deployment, metav1.CreateOptions{}) -} diff --git a/internal/kube/services.go b/internal/kube/services.go deleted file mode 100644 index 39c6cbfa..00000000 --- a/internal/kube/services.go +++ /dev/null @@ -1,34 +0,0 @@ -package kube - -import ( - "context" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" -) - -func GetAgentService(id string) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: "runner - " + id, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "app": "runner", - "id": id, - }, - Ports: []corev1.ServicePort{ - { - Name: "http", - Port: 8080, - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } -} - -func CreateAgentService(clientset *kubernetes.Clientset, service *corev1.Service) (*corev1.Service, error) { - return clientset.CoreV1().Services("default").Create(context.TODO(), service, metav1.CreateOptions{}) -} diff --git a/internal/lib/ints.go b/internal/lib/ints.go index 5b0d834c..c6f79e1d 100644 --- a/internal/lib/ints.go +++ b/internal/lib/ints.go @@ -1,3 +1,9 @@ package lib -func Int32Ptr(i int32) *int32 { return &i } +// Max returns the larger of x or y. +func Max(x, y int) int { + if x > y { + return x + } + return y +} diff --git a/internal/lib/ints_test.go b/internal/lib/ints_test.go new file mode 100644 index 00000000..ada3e64a --- /dev/null +++ b/internal/lib/ints_test.go @@ -0,0 +1,13 @@ +package lib + +import ( + "testing" +) + +// Simple test to ensure that Max() returns the correct value. +func TestMax(t *testing.T) { + got := Max(1, 2) + if got != 2 { + t.Errorf("Max(1, 2) = %d; want 2", got) + } +} diff --git a/internal/parsers/markdown.go b/internal/parsers/markdown.go index a9858a0c..ecc576cc 100644 --- a/internal/parsers/markdown.go +++ b/internal/parsers/markdown.go @@ -48,6 +48,7 @@ type CodeBlock struct { Language string Content string Header string + Description string ExpectedOutput ExpectedOutputBlock } @@ -75,7 +76,9 @@ func ExtractScenarioTitleFromAst(node ast.Node, source []byte) (string, error) { return header, nil } -var expectedSimilarityRegex = regexp.MustCompile(``) +var expectedSimilarityRegex = regexp.MustCompile( + ``, +) // Extracts the code blocks from a provided markdown AST that match the // languagesToExtract. @@ -89,6 +92,7 @@ func ExtractCodeBlocksFromAst( var nextBlockIsExpectedOutput bool var lastExpectedSimilarityScore float64 var lastExpectedRegex *regexp.Regexp + var lastNode ast.Node ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { if entering { @@ -96,6 +100,9 @@ func ExtractCodeBlocksFromAst( // Set the last header when we encounter a heading. case *ast.Heading: lastHeader = string(extractTextFromMarkdown(&n.BaseBlock, source)) + lastNode = node + case *ast.Paragraph: + lastNode = node // Extract the code block if it matches the language. case *ast.HTMLBlock: content := extractTextFromMarkdown(&n.BaseBlock, source) @@ -118,12 +125,12 @@ func ExtractCodeBlocksFromAst( logging.GlobalLogger.Debugf("Regex %q found", match) if match == "" { - return ast.WalkStop, errors.New("No regex found!") + return ast.WalkStop, errors.New("No regex found") } re, err := regexp.Compile(match) if err != nil { - return ast.WalkStop, fmt.Errorf("Cannot compile the following regex: %q.", match) + return ast.WalkStop, fmt.Errorf("Cannot compile the following regex: %q", match) } lastExpectedRegex = re @@ -132,12 +139,28 @@ func ExtractCodeBlocksFromAst( nextBlockIsExpectedOutput = true case *ast.FencedCodeBlock: language := string(n.Language((source))) + content := extractTextFromMarkdown(&n.BaseBlock, source) + description := "" + + if lastNode != nil { + switch n := lastNode.(type) { + case *ast.Paragraph: + description = string(extractTextFromMarkdown(&n.BaseBlock, source)) + default: + logging.GlobalLogger.Warnf("The node before the codeblock `%s` is not a paragraph, it is a %s", content, n.Kind()) + } + } else { + logging.GlobalLogger.Warnf("There are no markdown elements before the last codeblock `%s`", content) + } + + lastNode = node for _, desiredLanguage := range languagesToExtract { if language == desiredLanguage { command := CodeBlock{ - Language: language, - Content: extractTextFromMarkdown(&n.BaseBlock, source), - Header: lastHeader, + Language: language, + Content: content, + Header: lastHeader, + Description: description, } commands = append(commands, command) break diff --git a/internal/shells/bash.go b/internal/shells/bash.go index 8ca35e2c..1b4c930f 100644 --- a/internal/shells/bash.go +++ b/internal/shells/bash.go @@ -197,11 +197,14 @@ func ExecuteBashCommand(command string, config BashCommandConfiguration) (Comman standardOutput, standardError := stdoutBuffer.String(), stderrBuffer.String() if err != nil { - return CommandOutput{}, fmt.Errorf( - "command exited with '%w' and the message '%s'", - err, - standardError, - ) + return CommandOutput{ + StdOut: standardOutput, + StdErr: standardError, + }, fmt.Errorf( + "command exited with '%w' and the message '%s'", + err, + standardError, + ) } return CommandOutput{ diff --git a/internal/ui/text.go b/internal/ui/text.go index 3e5094c5..c04cb4f4 100644 --- a/internal/ui/text.go +++ b/internal/ui/text.go @@ -27,6 +27,40 @@ var ( OcdStatusUpdateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#000000")) ) +var ( + InteractiveModeCodeBlockDescriptionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ffffff")) + InteractiveModeCodeBlockStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#fff")) + + InteractiveModeStepTitleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }().Foreground(lipgloss.Color("#518BAD")).Bold(true) + + InteractiveModeStepFooterStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return InteractiveModeStepTitleStyle.Copy().BorderStyle(b) + }().Foreground(lipgloss.Color("#fff")) +) + +// Indents a multi-line command to be nested under the first line of the +// command. +func IndentMultiLineCommand(content string, indentation int) string { + lines := strings.Split(content, "\n") + for i := 1; i < len(lines); i++ { + if strings.HasSuffix(strings.TrimSpace(lines[i-1]), "\\") { + lines[i] = strings.Repeat(" ", indentation) + lines[i] + } else if strings.TrimSpace(lines[i]) != "" { + lines[i] = strings.Repeat(" ", indentation) + lines[i] + } + + } + return strings.Join(lines, "\n") +} + func RemoveHorizontalAlign(s string) string { return strings.Join( mapSliceString( diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go index 885b5e6d..b8fe2578 100644 --- a/internal/ui/text_test.go +++ b/internal/ui/text_test.go @@ -8,10 +8,10 @@ import ( func TestVerboseStyle(t *testing.T) { text := `aaaa - b` + b` styledText := VerboseStyle.Render(text) expectedStyledText := `aaaa - b ` + b ` assert.Equal(t, expectedStyledText, styledText) assert.Equal(t, text, RemoveHorizontalAlign(styledText)) } diff --git a/scenarios/ocd/CreateLinuxVMAndSSH/README.md b/scenarios/ocd/CreateLinuxVMAndSSH/README.md index f7387399..d483959d 100644 --- a/scenarios/ocd/CreateLinuxVMAndSSH/README.md +++ b/scenarios/ocd/CreateLinuxVMAndSSH/README.md @@ -15,11 +15,15 @@ export MY_VM_IMAGE="Canonical:0001-com-ubuntu-minimal-jammy:minimal-22_04-lts-ge # Login to Azure using the CLI -In order to run commands against Azure using the CLI you need to login. This is done, very simply, though the `az login` command: +In order to run commands against Azure using the CLI you need to login. This is +done, very simply, though the `az login` command: # Create a resource group -A resource group is a container for related resources. All resources must be placed in a resource group. We will create one for this tutorial. The following command creates a resource group with the previously defined $MY_RESOURCE_GROUP_NAME and $REGION parameters. +A resource group is a container for related resources. All resources must be +placed in a resource group. We will create one for this tutorial. The following +command creates a resource group with the previously defined +$MY_RESOURCE_GROUP_NAME and $REGION parameters. ```bash az group create --name $MY_RESOURCE_GROUP_NAME --location $REGION @@ -45,9 +49,13 @@ Results: ## Create the Virtual Machine -To create a VM in this resource group we need to run a simple command, here we have provided the `--generate-ssh-keys` flag, this will cause the CLI to look for an avialable ssh key in `~/.ssh`, if one is found it will be used, otherwise one will be generated and stored in `~/.ssh`. We also provide the `--public-ip-sku Standard` flag to ensure that the machine is accessible via a public IP. Finally, we are deploying the latest `Ubuntu 22.04` image. - -All other values are configured using environment variables. +To create a VM in this resource group we need to run a simple command, here we +have provided the `--generate-ssh-keys` flag, this will cause the CLI to look +for an avialable ssh key in `~/.ssh`, if one is found it will be used, otherwise +one will be generated and stored in `~/.ssh`. We also provide the +`--public-ip-sku Standard` flag to ensure that the machine is accessible via a +public IP. Finally, we are deploying the latest `Ubuntu 22.04` image. All other +values are configured using environment variables. ```bash az vm create \ @@ -80,7 +88,10 @@ Results: ### Enable Azure AD login for a Linux Virtual Machine in Azure -The following example has deploys a Linux VM and then installs the extension to enable Azure AD login for a Linux VM. VM extensions are small applications that provide post-deployment configuration and automation tasks on Azure virtual machines. +The following example has deploys a Linux VM and then installs the extension to +enable Azure AD login for a Linux VM. VM extensions are small applications that +provide post-deployment configuration and automation tasks on Azure virtual +machines. ```bash az vm extension set \ @@ -92,7 +103,9 @@ az vm extension set \ # Store IP Address of VM in order to SSH -run the following command to get the IP Address of the VM and store it as an environment variable +Run the following command to get the IP Address of the VM and store it as an +environment variable + ```bash export IP_ADDRESS=$(az vm show --show-details --resource-group $MY_RESOURCE_GROUP_NAME --name $MY_VM_NAME --query publicIps --output tsv) @@ -109,7 +122,7 @@ yes | az ssh config --file ~/.ssh/config --name $MY_VM_NAME --resource-group $MY ``` --> -You can now SSH into the VM by running the output of the following command in your ssh client of choice +You can now SSH into the VM by running the following command. ```bash ssh -o StrictHostKeyChecking=no $MY_USERNAME@$IP_ADDRESS