internal/engine/common/scenario.go (173 lines of code) (raw):
package common
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/Azure/InnovationEngine/internal/lib"
"github.com/Azure/InnovationEngine/internal/lib/fs"
"github.com/Azure/InnovationEngine/internal/logging"
"github.com/Azure/InnovationEngine/internal/parsers"
"github.com/Azure/InnovationEngine/internal/patterns"
"github.com/yuin/goldmark/ast"
)
// Individual steps within a scenario.
type Step struct {
Name string
CodeBlocks []parsers.CodeBlock
}
// Scenarios are the top-level object that represents a scenario to be executed.
type Scenario struct {
Name string
MarkdownAst ast.Node
Steps []Step
Properties map[string]interface{}
Environment map[string]string
Source []byte
}
// Get the markdown source for the scenario as a string.
func (s *Scenario) GetSourceAsString() string {
return string(s.Source)
}
// Groups the codeblocks into steps based on the header of the codeblock.
// This organizes the codeblocks into steps that can be executed linearly.
func groupCodeBlocksIntoSteps(blocks []parsers.CodeBlock) []Step {
var groupedSteps []Step
headerIndex := make(map[string]int)
for _, block := range blocks {
if index, ok := headerIndex[block.Header]; ok {
groupedSteps[index].CodeBlocks = append(groupedSteps[index].CodeBlocks, block)
} else {
headerIndex[block.Header] = len(groupedSteps)
groupedSteps = append(groupedSteps, Step{
Name: block.Header,
CodeBlocks: []parsers.CodeBlock{block},
})
}
}
return groupedSteps
}
// Download the scenario markdown over http
func downloadScenarioMarkdown(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
// Given either a local or remote path to a markdown file, resolve the path to
// the markdown file and return the contents of the file.
func resolveMarkdownSource(path string) ([]byte, error) {
if strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") {
return downloadScenarioMarkdown(path)
}
if !fs.FileExists(path) {
return nil, fmt.Errorf("markdown file '%s' does not exist", path)
}
return os.ReadFile(path)
}
// Creates a scenario object from a given markdown file. languagesToExecute is
// used to filter out code blocks that should not be parsed out of the markdown
// file.
func CreateScenarioFromMarkdown(
path string,
languagesToExecute []string,
environmentVariableOverrides map[string]string,
) (*Scenario, error) {
source, err := resolveMarkdownSource(path)
if err != nil {
return nil, err
}
// Load environment variables
markdownINI := strings.TrimSuffix(path, filepath.Ext(path)) + ".ini"
environmentVariables := make(map[string]string)
// Check if the INI file exists & load it.
if !fs.FileExists(markdownINI) {
logging.GlobalLogger.Infof("INI file '%s' does not exist, skipping...", markdownINI)
} else {
logging.GlobalLogger.Infof("INI file '%s' exists, loading...", markdownINI)
environmentVariables, err = parsers.ParseINIFile(markdownINI)
if err != nil {
return nil, err
}
for key, value := range environmentVariables {
logging.GlobalLogger.Debugf("Setting %s=%s\n", key, value)
}
}
// Convert the markdonw into an AST and extract the scenario variables.
markdown := parsers.ParseMarkdownIntoAst(source)
properties := parsers.ExtractYamlMetadataFromAst(markdown)
scenarioVariables := parsers.ExtractScenarioVariablesFromAst(markdown, source)
for key, value := range scenarioVariables {
environmentVariables[key] = value
}
// Extract the code blocks from the markdown file.
codeBlocks := parsers.ExtractCodeBlocksFromAst(markdown, source, languagesToExecute)
logging.GlobalLogger.WithField("CodeBlocks", codeBlocks).
Debugf("Found %d code blocks", len(codeBlocks))
varsToExport := lib.CopyMap(environmentVariableOverrides)
for key, value := range environmentVariableOverrides {
environmentVariables[key] = value
logging.GlobalLogger.Debugf("Attempting to override %s with %s", key, value)
exportRegex := patterns.ExportVariableRegex(key)
for index, codeBlock := range codeBlocks {
matches := exportRegex.FindAllStringSubmatch(codeBlock.Content, -1)
if len(matches) != 0 {
logging.GlobalLogger.Debugf(
"Found %d matches for %s, deleting from varsToExport",
len(matches),
key,
)
delete(varsToExport, key)
} else {
logging.GlobalLogger.Debugf("Found no matches for %s inside of %s", key, codeBlock.Content)
}
for _, match := range matches {
oldLine := match[0]
oldValue := match[1]
// Replace the old export with the new export statement
newLine := strings.Replace(oldLine, oldValue, value+" ", 1)
logging.GlobalLogger.Debugf("Replacing '%s' with '%s'", oldLine, newLine)
// Update the code block with the new export statement
codeBlocks[index].Content = strings.Replace(codeBlock.Content, oldLine, newLine, 1)
}
}
}
// If there are some variables left after going through each of the codeblocks,
// do not update the scenario
// steps.
if len(varsToExport) != 0 {
logging.GlobalLogger.Debugf(
"Found %d variables to add to the scenario as a step.",
len(varsToExport),
)
exportCodeBlock := parsers.CodeBlock{
Language: "bash",
Content: "",
Header: "Exporting variables defined via the CLI and not in the markdown file.",
ExpectedOutput: parsers.ExpectedOutputBlock{},
}
for key, value := range varsToExport {
exportCodeBlock.Content += fmt.Sprintf("export %s=\"%s\"\n", key, value)
}
codeBlocks = append([]parsers.CodeBlock{exportCodeBlock}, codeBlocks...)
}
// Group the code blocks into steps.
steps := groupCodeBlocksIntoSteps(codeBlocks)
// If no title is found, we simply use the name of the markdown file as
// the title of the scenario.
title, err := parsers.ExtractScenarioTitleFromAst(markdown, source)
if err != nil {
logging.GlobalLogger.Warnf(
"Failed to extract scenario title: '%s'. Using the name of the markdown as the scenario title",
err,
)
title = filepath.Base(path)
}
logging.GlobalLogger.Infof("Successfully built out the scenario: %s", title)
return &Scenario{
Name: title,
Environment: environmentVariables,
Steps: steps,
Properties: properties,
MarkdownAst: markdown,
Source: source,
}, nil
}
// Convert a scenario into a shell script
func (s *Scenario) ToShellScript() string {
var script strings.Builder
for key, value := range s.Environment {
script.WriteString(fmt.Sprintf("export %s=\"%s\"\n", key, value))
}
for _, step := range s.Steps {
script.WriteString(fmt.Sprintf("# %s\n", step.Name))
for _, block := range step.CodeBlocks {
script.WriteString(fmt.Sprintf("%s\n", block.Content))
}
}
return script.String()
}