diff --git a/.github/workflows/build-test-release-tagged.yaml b/.github/workflows/build-test-release-tagged.yaml new file mode 100644 index 00000000..a89aeb93 --- /dev/null +++ b/.github/workflows/build-test-release-tagged.yaml @@ -0,0 +1,32 @@ +name: build-test-release-tagged + +on: + push: + tags: + - v* + +jobs: + build-test-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build all targets. + run: | + make build-all + - name: Run unit tests across all targets. + run: | + make test-all + - name: Prepare scenarios to be released. + run: | + sudo apt install zip + zip -r scenarios.zip scenarios + - name: Release Innovation Engine + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + generate_release_notes: true + files: | + ./bin/ie + ./scenarios.zip diff --git a/.github/workflows/build-test-release.yaml b/.github/workflows/build-test-release.yaml new file mode 100644 index 00000000..118b7270 --- /dev/null +++ b/.github/workflows/build-test-release.yaml @@ -0,0 +1,32 @@ +name: build-test-release +on: + push: + branches: + - main +jobs: + build-test-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build all targets. + run: | + make build-all + - name: Run unit tests across all targets. + run: | + make test-all + - name: Prepare scenarios to be released. + run: | + sudo apt install zip + zip -r scenarios.zip scenarios + - name: Release Innovation Engine + uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + title: "IE" + automatic_release_tag: "latest" + prerelease: true + files: | + ./bin/ie + ./scenarios.zip diff --git a/.github/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml~ similarity index 100% rename from .github/github-actions-demo.yml rename to .github/workflows/github-actions-demo.yml~ diff --git a/.github/workflows/helloWorld.yml b/.github/workflows/helloWorld.yml new file mode 100644 index 00000000..6e737461 --- /dev/null +++ b/.github/workflows/helloWorld.yml @@ -0,0 +1,36 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [ main ] + pull_request: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v3 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! + + # Runs a set of commands using the runners shell + - name: Run a multi-line script + run: | + echo Add other actions to build, + echo test, and deploy your project. diff --git a/.github/workflows/scenario-testing.yaml b/.github/workflows/scenario-testing.yaml new file mode 100644 index 00000000..e0aa5ed0 --- /dev/null +++ b/.github/workflows/scenario-testing.yaml @@ -0,0 +1,70 @@ +name: scenario-testing +on: + schedule: + - cron: "0 */2 * * *" + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: +permissions: + id-token: write + contents: read +jobs: + test-ie-installation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Check ie installation + run: | + set -e + cat scripts/install_from_release.sh | /bin/bash + if ! command -v ie; then + echo "ie not found" + exit 1 + fi + + bash scripts/install_docs_from_release.sh en-us v1.0.1 + if [ ! -d "${HOME}/scenarios" ] + then + echo "scenarios not found" + exit 1 + fi + test-ocd-scenarios: + runs-on: ubuntu-latest + # This is needed in order to obtain OIDC tokens to sign this pipeline into + # the testing subscription for any branch in this repository. + environment: ScenarioTesting + steps: + - uses: actions/checkout@v2 + - name: Build all targets. + run: | + make build-all + make test-all WITH_COVERAGE=true + - name: Upload test coverage + uses: actions/upload-artifact@v2 + if: github.event_name == 'pull_request' + with: + name: coverage + path: coverage.html + - name: Sign into Azure + uses: azure/login@v1 + if: github.event_name != 'pull_request' + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION }} + - name: Run all one click deployment scenarios. + uses: azure/CLI@v1 + if: github.event_name != 'pull_request' + with: + azcliversion: 2.53.0 + inlineScript: | + apk add --no-cache make git openssh openssl helm curl jq + make test-upstream-scenarios SUBSCRIPTION=${{ secrets.AZURE_SUBSCRIPTION }} + - name: Display ie.log file + if: (success() || failure()) && github.event_name != 'pull_request' + run: | + cat ie.log diff --git a/.gitignore b/.gitignore index f383d4e8..d8c21bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,15 @@ -*/*~ \ No newline at end of file +# Python +__pycache__ + +#VS Code +.vscode + +# Ignore all binaries. +bin/ + +# Ignore ie logs +ie.log + +# Ignore test coverage +coverage.html +coverage.out diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..955d6436 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "upstream-scenarios"] + path = upstream-scenarios + url = https://github.com/MicrosoftDocs/executable-docs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..16811904 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,180 @@ + +# Contributing to InnovationEngine + +First off, thanks for taking the time to contribute! ❤️ + +All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles contributions. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 + +> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: +> - Star the project +> - Tweet about it +> - Refer this project in your project's readme +> - Mention the project at local meetups and tell your friends/colleagues + + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Microsoft Open Source Contribution Guide](#microsoft-open-source-contribution-guide) +- [I Have a Question](#i-have-a-question) +- [I Want To Contribute](#i-want-to-contribute) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Enhancements](#suggesting-enhancements) +- [Your First Code Contribution](#your-first-code-contribution) + +- [Styleguides](#styleguides) + + +## Code of Conduct + +This project and everyone participating in it is governed by the +[InnovationEngine Code of Conduct](https://github.com/Azure/InnovationEngine/blob/main/CODE_OF_CONDUCT.md). +By participating, you are expected to uphold this code. Please report unacceptable behavior +to mbifeld@microsoft.com. + +## Microsoft Open Source Contribution Guide + +This is a Microsoft Open Source project. Please reference to the [Microsoft Open Source Contribtution Guide](https://docs.opensource.microsoft.com/contributing/) for FAQs and general information on contributing to Microsoft Open Source. + +## I Have a Question + + +Before you ask a question, it is best to search for existing [Issues](https://github.com/Azure/InnovationEngine/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. + +If you then still feel the need to ask a question and need clarification, we recommend the following: + +- Open an [Issue](https://github.com/Azure/InnovationEngine/issues/new). +- Provide as much context as you can about what you're running into. +- Provide project and platform versions (golang version, operating system, etc), depending on what seems relevant. + +We will then address the issue as soon as possible. + +## I Want To Contribute + +> ### Legal Notice +> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. + +### Reporting Bugs + + +#### Before Submitting a Bug Report + +A good bug report shouldn't leave others needing to chase you down for more information. Therefore, we ask you to investigate carefully, collect information, and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. + +- Make sure that you are using the latest version. +- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](./README.md). If you are looking for support, you might want to check [I Have A Question](#i-have-a-question)). +- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in [Issues](https://github.com/Azure/InnovationEngine/issues). +- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. +- Collect information about the bug: +- Stack trace (Traceback) +- OS, Platform and Version (Windows, Linux, macOS, x86, ARM, etc) +- Version of the golang, make, etc depending on what seems relevant. +- Possibly your input and the output +- Can you reliably reproduce the issue? And can you also reproduce it with older versions? + + +#### How Do I Submit a Good Bug Report? + +> You must never report security related issues, vulnerabilities, or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead, sensitive bugs must be sent by email to mbifeld@microsoft.com. + + +We use GitHub issues to track bugs and errors. If you run into an issue with the project: + +- Open an [Issue](https://github.com/Azure/InnovationEngine/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) +- Explain the behavior you would expect and the actual behavior. +- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. +- Provide the information you collected in the previous section. + +Once it's filed: + +- The project team will label the issue accordingly. +- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. +- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for InnovationEngine, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. + + +#### Before Submitting an Enhancement + +- Make sure that you are using the latest version. +- Read the [documentation](./README.md) carefully and find out if the functionality is already covered, maybe by an individual configuration. +- Perform a [search](https://github.com/Azure/InnovationEngine/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. +- Find out whether your idea fits within the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. + + +#### How Do I Submit a Good Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub Issues](https://github.com/Azure/InnovationEngine/issues). + +- Use a **clear and descriptive title** for the issue to identify the suggestion. +- Provide a **step-by-step description of the suggested enhancement** in as many details as possible. +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. +- You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. +- **Explain why this enhancement would be useful** to most InnovationEngine users. You may also want to point out the other projects that solved it better and which could serve as inspiration. + +### Your First Code Contribution +#### Innovation Engine +To get started with developing features for the Innovation Engine itself, you +will need `make` & `go`. Once you have those installed and the project cloned +to a local repository, you can attempt to build the project using: + +```bash +make build-all +``` + +If the build completes, you should be able to start adding features/fixes +to the Innovation Engine codebase. Once you've added new changes, you can test +for regressions using: + +```bash +make test-all +``` + +If implementing a new feature, it is expected to add & update any necessary +tests for the changes introduced by the feature. + +If you're still looking for more information about how to build & run Innovation Engine, +[README](./README.md) has a more comprehensive guide for how to get started with project +development. + +#### Innovation Engine markdown scenarios + +If you are contributing to one of the markdown scenarios (executable documents) +for Innovation Engine, you are expected to follow the installation steps before +updating/adding your document. This is needed because once you've made changes +or have added a new scenario, you should test your executable document by +using the Innovation Engine: + +```bash +ie execute +``` + +This will attempt to parse your document into an executable scenario, make sure +that the commands extracted from codeblocks execute successfully, and that +their corresponding result blocks (if any) also line up with what the command +returned. Once you get your scenario to execute successfully, you should go ahead +and make a PR for it! + +#### Creating a PR + + +When creating a PR, please include as much context as possible. At minimum, this should include what the PR does and the testing strategies for it. + +If your PR is a work in progress, please label it as a draft and include 'WIP' at the beginning of the PR title. + + + +## Styleguides +For working on the Innovation Engine, `go fmt` is what is used to format the +code for the project. + +The commit style for individual commits doesn't necessarily matter as +all commits from a PR branch will be squashed and merged into the main +branch when PRs are completed. + + +## Attribution +This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..82e12250 --- /dev/null +++ b/Makefile @@ -0,0 +1,75 @@ +.PHONY: build-ie build-all run-ie clean test-all test all + +BINARY_DIR := bin +IE_BINARY := $(BINARY_DIR)/ie +API_BINARY := $(BINARY_DIR)/api + +# -------------------------- Native build targets ------------------------------ + +build-ie: + @echo "Building the Innovation Engine CLI..." + @CGO_ENABLED=0 go build -o "$(IE_BINARY)" cmd/ie/ie.go + + +build-all: build-ie + +# ------------------------------ Install targets ------------------------------- + +install-ie: + @echo "Installing the Innovation Engine CLI..." + @CGO_ENABLED=0 go install cmd/ie/ie.go + +# ------------------------------ Test targets ---------------------------------- + +WITH_COVERAGE := false + +test-all: + @go clean -testcache +ifeq ($(WITH_COVERAGE), true) + @echo "Running all tests with coverage..." + @go test -v -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out -o coverage.html +else + @echo "Running all tests..." + @go test -v ./... +endif + + +SUBSCRIPTION ?= 00000000-0000-0000-0000-000000000000 +SCENARIO ?= ./README.md +WORKING_DIRECTORY ?= $(PWD) +test-scenario: + @echo "Running scenario $(SCENARIO)" + $(IE_BINARY) test $(SCENARIO) --subscription $(SUBSCRIPTION) --working-directory $(WORKING_DIRECTORY) + +test-scenarios: + @echo "Testing out the scenarios" + for dir in ./scenarios/ocd/*/; do \ + ($(MAKE) test-scenario SCENARIO="$${dir}README.md" SUBCRIPTION="$(SUBSCRIPTION)") || exit $$?; \ + done + +test-upstream-scenarios: + @echo "Pulling the upstream scenarios" + @git config --global --add safe.directory /home/runner/work/InnovationEngine/InnovationEngine + @git submodule update --init --recursive + @echo "Testing out the upstream scenarios" + for dir in ./upstream-scenarios/scenarios/*/; do \ + if ! [ -f $${dir}README.md ]; then \ + continue; \ + fi; \ + if echo "$${dir}" | grep -q "CreateContainerAppDeploymentFromSource"; then \ + continue; \ + fi; \ + ($(MAKE) test-scenario SCENARIO="$${dir}README.md" SUBCRIPTION="$(SUBSCRIPTION)" WORKING_DIRECTORY="$${dir}") || exit $$?; \ + done + +# ------------------------------- Run targets ---------------------------------- + +run-ie: build-ie + @echo "Running the Innovation Engine CLI" + @"$(IE_BINARY)" + +clean: + @echo "Cleaning up" + @rm -rf "$(BINARY_DIR)" + diff --git a/README.md b/README.md index 5cd7cecf..b9a5d9a0 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,217 @@ -# Project +# Overview -> This repo has been populated by an initial template to help get you started. Please -> make sure to update the content to build a great experience for community-building. +Innovation Engine is a tool for rapid innovation and simplification. -As the maintainer of this project, please make a few updates: +# Executable Documentation +Executable documentation takes standard markdown language and amplifies it by +allowing it to be executed step by step in an educational manner, and tested +via automated CI/CD pipelines. -- Improving this README.MD file to provide a great experience -- Updating SUPPORT.MD with content about this project's support experience -- Understanding the security reporting process in SECURITY.MD -- Remove this section from the README +# Try Out Executable Documentation +Azure Cloud Shell provides an environment with all of the prerequisites +installed to run Executable Documentation. This is the recommended method for +new users to try and develop tutorials for Innovation Engine. + +Open [Azure Cloud Shell](https://ms.portal.azure.com/#cloudshell/) and select +Bash as the environment. Paste the following commands into the shell, this will +clone the Innovation Engine repo, install the requirements, and build out the +innovation engine executable. + +```bash +git clone https://github.com/Azure/InnovationEngine; +cd InnovationEngine; +make build-ie; +``` + +Now you can run the Innovation Engine tutorial with the following +command: + +```bash +./bin/ie execute tutorial.md +``` + +The general format to run an executable document is: +`ie ` + +### Modes of Operation +Today, executable documentation can be run in 3 modes of operation: + +Interactive: Displays the descriptive text of the tutorial and pauses at code +blocks and headings to allow user interaction +`ie interactive tutorial.md` + +Test: Runs the commands and then verifies that the output is sufficiently +similar to the expected results (recorded in the markdown file) to be +considered correct. `ie test tutorial.md` + +Execute: Reads the document and executes all of the code blocks not pausing for +input or testing output. Essentially executes a markdown file as a script. +`ie execute tutorial.md` + +## Use Executable documentation for Automated Testing +One of the core benefits of executable documentation is the ability to run +automated testing on markdown file. This can be used to ensure freshness of +content. + +In order to do this one will need to combine innovation engine executable +documentation syntax with GitHub actions. + +In order to test if a command or action ran correctly executable documentation +needs something to compare the results against. This requirement is met with +result blocks. + +### Result Blocks +Result blocks are distinguished in Executable documentation by a custom +expected_similarity comment tag followed by a code block. For example + + + +```text +Hello world +``` +This example purposely breaks the comment syntax so that it shows up in +markdown. Otherwise, the tag of expected_similarity is completely invisible. + +The expected similarity value is a floating point number between 0 and 1 which +specifies how closely the output needs to match the results block. 0 being no +similarity, 1 being an exact match. + +>**Note** It may take a little bit of trial and error to find the exact value for expected_similarity. + +### Environment Variables + +Another barrier to automated testing is setting default values for test cases +to use in running. This problem can be solved with command line variables in +Executable documentation Syntax. + +Default environment variables can be set for executable documentation in a few +different ways. + +1. A matching .ini file to the markdown + - Upon running any document executable documentation will look for a + corresponding .ini file. For example if my markdown file is named tutorial.md + the corresponding ini file would be tutorial.ini. + - This file is a simple key value match for environment variable and value. + For example: + ```ini + MY_RESOURCE_GROUP_NAME = myResourceGroup + MY_LOCATION = eastus + MY_VM_NAME = myVM + MY_VM_IMAGE = debian + MY_ADMIN_USERNAME = azureuser + ``` +2. A comment at the beginning of the document containing a code blog with the +tag 'variables'. This will be invisible to users unless they look at the raw +markdown. For example: + >**Note** The below example intentionally has broken comment syntax w/ two !'s. + + + +Variables set in comments will override variables set in a .ini file. +Consequently, locally declared variables in code samples will override +variables set in comments. + +### Setting Up GitHub Actions to use Innovation Engine + +After documentation is set up to take advantage of automated testing a github +action will need to be created to run testing on a recurring basis. The action +will simply create a basic Linux container, install Innovation Engine +Executable Documentation and run Executable documentation in the Test mode on +whatever markdown files are specified. + +It is important to note that if you require any specific access or cli tools +not included in standard bash that will need to be installed in the container. +The following example is how this may be done for a document which runs Azure +commands. + +```yml +name: 00-testing + +on: + push: + branches: + - main + + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - name: Deploy + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} + GITHUB_SHA: ${{ github.sha }} + run: | + cd $GITHUB_WORKSPACE/ + git clone https://github.com/Azure/InnovationEngine/tree/ParserAndExecutor + cd innovationEngine + pip3 install -r requirements.txt + cp ../../articles/quick-create-cli.md README.md + python3 main.py test README.md +``` + + +## Use Executable Documentation for Interactive Documentation + +Innovation Engine can also be used for interactive tutorials via a local or +remote shell environment. After cloning the project and running +`make build-ie`, Innovation Engine can be used for +interactive tutorials by simply using the interactive flag when executing the +program. For example, `./bin/ie interactive tutorial.md` + +As it is written the code will pause and wait for input on any header or code +block. Any document written in standard markdown can be run as an interactive +document. ## Contributing -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. +This is an open source project. Don't keep your code improvements, +features and cool ideas to yourself. Please issue pull requests +against our [GitHub repo](https://github.com/Azure/innovationengine). + +Be sure to use our Git pre-commit script to test your contributions +before committing, simply run the following command: `python3 main.py test test` + +This project welcomes contributions and suggestions. Most +contributions require you to agree to a Contributor License Agreement +(CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit +https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine +whether you need to provide a CLA and decorate the PR appropriately +(e.g., label, comment). Simply follow the instructions provided by the +bot. You will only need to do this once across all repos using our +CLA. -When you submit a pull request, a CLA bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. +This project has adopted +the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see +the +[Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with +any additional questions or comments. -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ## Trademarks -This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft -trademarks or logos is subject to and must follow -[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). -Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. -Any use of third-party trademarks or logos are subject to those third-party's policies. +This project may contain trademarks or logos for projects, products, or +services. Authorized use of Microsoft trademarks or logos is subject to and +must follow [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must +not cause confusion or imply Microsoft sponsorship. Any use of third-party +trademarks or logos are subject to those third-party's policies. diff --git a/cmd/ie/commands/execute.go b/cmd/ie/commands/execute.go new file mode 100644 index 00000000..d9890c9b --- /dev/null +++ b/cmd/ie/commands/execute.go @@ -0,0 +1,130 @@ +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(executeCommand) + + // Bool flags + executeCommand.PersistentFlags(). + Bool("verbose", false, "Enable verbose logging & standard output.") + executeCommand.PersistentFlags(). + Bool("do-not-delete", false, "Do not delete the Azure resources created by the Azure CLI commands executed.") + + // String flags + executeCommand.PersistentFlags(). + String("correlation-id", "", "Adds a correlation ID to the user agent used by a scenarios azure-cli commands.") + executeCommand.PersistentFlags(). + String("subscription", "", "Sets the subscription ID used by a scenarios azure-cli commands. Will rely on the default subscription if not set.") + executeCommand.PersistentFlags(). + String("working-directory", ".", "Sets the working directory for innovation engine to operate out of. Restores the current working directory when finished.") + + // StringArray flags + executeCommand.PersistentFlags(). + StringArray("var", []string{}, "Sets an environment variable for the scenario. Format: --var =") +} + +var executeCommand = &cobra.Command{ + Use: "execute [markdown file]", + Args: cobra.MinimumNArgs(1), + Short: "Execute the commands for an Azure deployment scenario.", + 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) + } + + 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] + } + + for _, feature := range features { + switch feature { + case "render-values": + renderValues = true + default: + logging.GlobalLogger.Errorf( + "Error: Invalid feature: %s", + feature, + ) + fmt.Printf("Error: Invalid feature: %s\n", feature) + cmd.Help() + os.Exit(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.ExecuteScenario(scenario) + if err != nil { + logging.GlobalLogger.Errorf("Error executing scenario: %s", err) + fmt.Printf("Error executing scenario: %s\n", err) + os.Exit(1) + } + }, +} 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 new file mode 100644 index 00000000..8d6c8904 --- /dev/null +++ b/cmd/ie/commands/interactive.go @@ -0,0 +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) + } + + 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/ie/commands/root.go b/cmd/ie/commands/root.go new file mode 100644 index 00000000..9c4dcc4a --- /dev/null +++ b/cmd/ie/commands/root.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/Azure/InnovationEngine/internal/engine/environments" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/spf13/cobra" +) + +// The root command for the CLI. Currently initializes the logging for all other +// commands. +var rootCommand = &cobra.Command{ + Use: "ie", + Short: "The innovation engine.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + logLevel, err := cmd.Flags().GetString("log-level") + if err != nil { + fmt.Printf("Error getting log level: %s", err) + os.Exit(1) + } + logging.Init(logging.LevelFromString(logLevel)) + + // Check environment + environment, err := cmd.Flags().GetString("environment") + if err != nil { + fmt.Printf("Error getting environment: %s", err) + logging.GlobalLogger.Errorf("Error getting environment: %s", err) + os.Exit(1) + } + + if !environments.IsValidEnvironment(environment) { + fmt.Printf("Invalid environment: %s", environment) + logging.GlobalLogger.Errorf("Invalid environment: %s", err) + os.Exit(1) + } + }, +} + +// Entrypoint into the Innovation Engine CLI. +func ExecuteCLI() { + rootCommand.PersistentFlags(). + String("log-level", string(logging.Debug), "Configure the log level") + rootCommand.PersistentFlags(). + String("environment", environments.EnvironmentsLocal, "The environment that the CLI is running in. (local, ci, ocd)") + + rootCommand.PersistentFlags(). + StringArray("feature", []string{}, "Enables the specified feature. Format: --feature ") + + if err := rootCommand.Execute(); err != nil { + fmt.Println(err) + logging.GlobalLogger.Errorf("Error executing command: %s", err) + os.Exit(1) + } +} diff --git a/cmd/ie/commands/test.go b/cmd/ie/commands/test.go new file mode 100644 index 00000000..35259bb4 --- /dev/null +++ b/cmd/ie/commands/test.go @@ -0,0 +1,71 @@ +package commands + +import ( + "fmt" + "os" + + "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(testCommand) + testCommand.PersistentFlags(). + Bool("verbose", false, "Enable verbose logging & standard output.") + testCommand.PersistentFlags(). + String("subscription", "", "Sets the subscription ID used by a scenarios azure-cli commands. Will rely on the default subscription if not set.") + testCommand.PersistentFlags(). + String("working-directory", ".", "Sets the working directory for innovation engine to operate out of. Restores the current working directory when finished.") +} + +var testCommand = &cobra.Command{ + Use: "test", + Args: cobra.MinimumNArgs(1), + Short: "Test document commands against it's expected outputs.", + Run: func(cmd *cobra.Command, args []string) { + + markdownFile := args[0] + if markdownFile == "" { + cmd.Help() + return + } + + verbose, _ := cmd.Flags().GetBool("verbose") + subscription, _ := cmd.Flags().GetString("subscription") + workingDirectory, _ := cmd.Flags().GetString("working-directory") + + innovationEngine, err := engine.NewEngine(engine.EngineConfiguration{ + Verbose: verbose, + DoNotDelete: false, + Subscription: subscription, + CorrelationId: "", + WorkingDirectory: workingDirectory, + }) + + if err != nil { + logging.GlobalLogger.Errorf("Error creating engine %s", err) + fmt.Printf("Error creating engine %s", err) + os.Exit(1) + } + + scenario, err := engine.CreateScenarioFromMarkdown( + markdownFile, + []string{"bash", "azurecli", "azurecli-interactive", "terraform"}, + nil, + ) + if err != nil { + logging.GlobalLogger.Errorf("Error creating scenario %s", err) + fmt.Printf("Error creating engine %s", err) + os.Exit(1) + } + + err = innovationEngine.TestScenario(scenario) + if err != nil { + logging.GlobalLogger.Errorf("Error testing scenario: %s", err) + fmt.Printf("Error testing scenario: %s\n", err) + os.Exit(1) + } + }, +} diff --git a/cmd/ie/commands/to-bash.go b/cmd/ie/commands/to-bash.go new file mode 100644 index 00000000..248e1427 --- /dev/null +++ b/cmd/ie/commands/to-bash.go @@ -0,0 +1,90 @@ +package commands + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Azure/InnovationEngine/internal/engine" + "github.com/Azure/InnovationEngine/internal/engine/environments" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/spf13/cobra" +) + +type AzureScript struct { + Script string `json:"script"` +} + +var toBashCommand = &cobra.Command{ + Use: "to-bash", + Short: "Convert a markdown scenario into a bash script.", + RunE: func(cmd *cobra.Command, args []string) error { + markdownFile := args[0] + if markdownFile == "" { + logging.GlobalLogger.Errorf("Error: No markdown file specified.") + return errors.New("error: No markdown file specified") + } + + environment, _ := cmd.Flags().GetString("environment") + environmentVariables, _ := cmd.Flags().GetStringArray("var") + + // Parse the environment variables + 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() + return fmt.Errorf( + "error: Invalid environment variable format, %s", + environmentVariable, + ) + } + + 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) + return err + } + + // If within cloudshell, we need to wrap the script in a json object to + // communicate it to the portal. + if environments.IsAzureEnvironment(environment) { + script := AzureScript{Script: scenario.ToShellScript()} + scriptJson, err := json.Marshal(script) + + if err != nil { + logging.GlobalLogger.Errorf("Error converting to json: %s", err) + fmt.Printf("Error converting to json: %s", err) + return err + } + + fmt.Printf("ie_us%sie_ue\n", scriptJson) + } else { + fmt.Printf("%s", scenario.ToShellScript()) + } + + return nil + + }, +} + +func init() { + rootCommand.AddCommand(toBashCommand) + toBashCommand.PersistentFlags(). + StringArray("var", []string{}, "Sets an environment variable for the scenario. Format: --var =") +} diff --git a/cmd/ie/ie.go b/cmd/ie/ie.go new file mode 100644 index 00000000..ea115e84 --- /dev/null +++ b/cmd/ie/ie.go @@ -0,0 +1,7 @@ +package main + +import "github.com/Azure/InnovationEngine/cmd/ie/commands" + +func main() { + commands.ExecuteCLI() +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..a6a5165b --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module github.com/Azure/InnovationEngine + +go 1.20 + +require ( + github.com/charmbracelet/bubbles v0.17.1 + github.com/charmbracelet/bubbletea v0.25.0 + github.com/charmbracelet/glamour v0.6.0 + github.com/charmbracelet/lipgloss v0.9.1 + github.com/sergi/go-diff v1.3.1 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.7.0 + github.com/stretchr/testify v1.8.2 + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 + github.com/yuin/goldmark v1.5.4 + github.com/yuin/goldmark-meta v1.1.0 + golang.org/x/sys v0.18.0 + gopkg.in/ini.v1 v1.67.0 +) + +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/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/gorilla/css v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // 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/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.2 // 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/rogpeppe/go-internal v1.10.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/yuin/goldmark-emoji v1.0.1 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/term v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..d70e99b0 --- /dev/null +++ b/go.sum @@ -0,0 +1,132 @@ +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/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/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/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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/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/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.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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +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= +github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= +github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= +golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +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-20210615035016-665e8c7367d1/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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.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.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infra/api/Dockerfile b/infra/api/Dockerfile new file mode 100644 index 00000000..38bd2c7f --- /dev/null +++ b/infra/api/Dockerfile @@ -0,0 +1,17 @@ +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 + +ARG HOST=0.0.0.0 +ARG PORT=8080 + +WORKDIR /api + +RUN tdnf update && \ + tdnf install golang make ca-certificates -y + +COPY . . + +RUN make build-api + +EXPOSE 8080 + +CMD ["./bin/api"] \ No newline at end of file diff --git a/infra/api/deployment.yaml b/infra/api/deployment.yaml new file mode 100644 index 00000000..46151e87 --- /dev/null +++ b/infra/api/deployment.yaml @@ -0,0 +1,20 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: innovation-engine-api +spec: + replicas: 2 + selector: + matchLabels: + app: innovation-engine-api + template: + metadata: + labels: + app: innovation-engine-api + spec: + containers: + - name: innovation-engine-api + image: innovation-engine-api:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 \ No newline at end of file diff --git a/infra/api/ingress.yaml b/infra/api/ingress.yaml new file mode 100644 index 00000000..ec544529 --- /dev/null +++ b/infra/api/ingress.yaml @@ -0,0 +1,16 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: innovation-engine-api-ingress +spec: + rules: + - host: innovation-engine.localhost + http: + paths: + - path: "/" + pathType: Prefix + backend: + service: + name: innovation-engine-api-service + port: + number: 80 \ No newline at end of file diff --git a/infra/api/service.yaml b/infra/api/service.yaml new file mode 100644 index 00000000..938112f0 --- /dev/null +++ b/infra/api/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: innovation-engine-api-service +spec: + selector: + app: innovation-engine-api + ports: + - protocol: TCP + port: 80 + targetPort: 8080 \ No newline at end of file diff --git a/infra/runner/Dockerfile b/infra/runner/Dockerfile new file mode 100644 index 00000000..3ccd4c1a --- /dev/null +++ b/infra/runner/Dockerfile @@ -0,0 +1,14 @@ +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 + +ARG HOST=0.0.0.0 +ARG PORT=8080 + +WORKDIR /api + +RUN tdnf install golang make -y + +COPY . . + +RUN make build-runner + +CMD ["./bin/runner"] \ No newline at end of file diff --git a/internal/az/account.go b/internal/az/account.go new file mode 100644 index 00000000..25b6d443 --- /dev/null +++ b/internal/az/account.go @@ -0,0 +1,32 @@ +package az + +import ( + "fmt" + + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/shells" +) + +func SetSubscription(subscription string) error { + if subscription != "" { + command := fmt.Sprintf("az account set --subscription %s", subscription) + _, err := shells.ExecuteBashCommand( + command, + shells.BashCommandConfiguration{ + EnvironmentVariables: map[string]string{}, + InteractiveCommand: false, + WriteToHistory: false, + InheritEnvironment: false, + }, + ) + + if err != nil { + logging.GlobalLogger.Errorf("Failed to set subscription: %s", err) + return err + } + + logging.GlobalLogger.Infof("Set subscription to %s", subscription) + } + + return nil +} diff --git a/internal/az/env.go b/internal/az/env.go new file mode 100644 index 00000000..e66c3b30 --- /dev/null +++ b/internal/az/env.go @@ -0,0 +1,17 @@ +package az + +import ( + "fmt" + + "github.com/Azure/InnovationEngine/internal/logging" +) + +// If the correlation ID is set, we need to set the AZURE_HTTP_USER_AGENT +// environment variable so that the Azure CLI will send the correlation ID +// with Azure Resource Manager requests. +func SetCorrelationId(correlationId string, env map[string]string) { + if correlationId != "" { + env["AZURE_HTTP_USER_AGENT"] = fmt.Sprintf("innovation-engine-%s", correlationId) + logging.GlobalLogger.Info("Resource tracking enabled. Tracking ID: " + env["AZURE_HTTP_USER_AGENT"]) + } +} diff --git a/internal/az/env_test.go b/internal/az/env_test.go new file mode 100644 index 00000000..2ff973a6 --- /dev/null +++ b/internal/az/env_test.go @@ -0,0 +1,16 @@ +package az + +import ( + "testing" +) + +func TestSetCorrelationId(t *testing.T) { + t.Run("Test setting a custom correlation ID", func(t *testing.T) { + correlationId := "test-correlation-id" + env := map[string]string{} + SetCorrelationId(correlationId, env) + if env["AZURE_HTTP_USER_AGENT"] != "innovation-engine-test-correlation-id" { + t.Errorf("Expected AZURE_HTTP_USER_AGENT to be set to innovation-engine-test-correlation-id, got %s", env["AZURE_HTTP_USER_AGENT"]) + } + }) +} diff --git a/internal/az/group.go b/internal/az/group.go new file mode 100644 index 00000000..82cb9b6e --- /dev/null +++ b/internal/az/group.go @@ -0,0 +1,40 @@ +package az + +import ( + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/patterns" + "github.com/Azure/InnovationEngine/internal/shells" +) + +// Find all the deployed resources in a resource group. +func FindAllDeployedResourceURIs(resourceGroup string) []string { + output, err := shells.ExecuteBashCommand( + "az resource list -g "+resourceGroup, + shells.BashCommandConfiguration{ + EnvironmentVariables: map[string]string{}, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: true, + }, + ) + + if err != nil { + logging.GlobalLogger.Error("Failed to list deployments", err) + } + + matches := patterns.AzResourceURI.FindAllStringSubmatch(output.StdOut, -1) + results := []string{} + for _, match := range matches { + results = append(results, match[1]) + } + return results +} + +// Find the resource group name from the output of an az command. +func FindResourceGroupName(commandOutput string) string { + matches := patterns.AzResourceGroupName.FindStringSubmatch(commandOutput) + if len(matches) > 1 { + return matches[1] + } + return "" +} 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 new file mode 100644 index 00000000..b072c44b --- /dev/null +++ b/internal/engine/common.go @@ -0,0 +1,70 @@ +package engine + +import ( + "fmt" + "regexp" + "strings" + + "github.com/Azure/InnovationEngine/internal/lib" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/ui" + "github.com/xrash/smetrics" +) + +// Compares the actual output of a command to the expected output of a command. +func compareCommandOutputs( + actualOutput string, + expectedOutput string, + expectedSimilarity float64, + expectedRegex *regexp.Regexp, + expectedOutputLanguage string, +) error { + if expectedRegex != nil { + if !expectedRegex.MatchString(actualOutput) { + return fmt.Errorf( + ui.ErrorMessageStyle.Render( + fmt.Sprintf("Expected output does not match: %q.", expectedRegex), + ), + ) + } + + return nil + } + + if strings.ToLower(expectedOutputLanguage) == "json" { + logging.GlobalLogger.Debugf( + "Comparing JSON strings:\nExpected: %s\nActual%s", + expectedOutput, + actualOutput, + ) + results, err := lib.CompareJsonStrings(actualOutput, expectedOutput, expectedSimilarity) + if err != nil { + return err + } + + if !results.AboveThreshold { + return fmt.Errorf( + ui.ErrorMessageStyle.Render("Expected output does not match actual output."), + ) + } + + logging.GlobalLogger.Debugf( + "Expected Similarity: %f, Actual Similarity: %f", + expectedSimilarity, + results.Score, + ) + + return nil + } + + // Default case, using similarity on non JSON block. + 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 nil +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 00000000..caf71753 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,120 @@ +package engine + +import ( + "fmt" + "strings" + + "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. +type EngineConfiguration struct { + Verbose bool + DoNotDelete bool + CorrelationId string + Subscription string + Environment string + WorkingDirectory string + RenderValues bool +} + +type Engine struct { + Configuration EngineConfiguration +} + +// / Create a new engine instance. +func NewEngine(configuration EngineConfiguration) (*Engine, error) { + return &Engine{ + Configuration: configuration, + }, nil +} + +// Executes a deployment scenario. +func (e *Engine) ExecuteScenario(scenario *Scenario) error { + return fs.UsingDirectory(e.Configuration.WorkingDirectory, func() error { + az.SetCorrelationId(e.Configuration.CorrelationId, scenario.Environment) + + // Execute the steps + fmt.Println(ui.ScenarioTitleStyle.Render(scenario.Name)) + err := e.ExecuteAndRenderSteps(scenario.Steps, lib.CopyMap(scenario.Environment)) + return err + }) +} + +// Validates a deployment scenario. +func (e *Engine) TestScenario(scenario *Scenario) error { + return fs.UsingDirectory(e.Configuration.WorkingDirectory, func() error { + az.SetCorrelationId(e.Configuration.CorrelationId, scenario.Environment) + + // Test the steps + fmt.Println(ui.ScenarioTitleStyle.Render(scenario.Name)) + err := e.TestSteps(scenario.Steps, lib.CopyMap(scenario.Environment)) + 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()) + + var finalModel tea.Model + var ok bool + finalModel, err = program.Run() + + model, ok = finalModel.(InteractiveModeModel) + + if environments.EnvironmentsAzure == e.Configuration.Environment { + if !ok { + return fmt.Errorf("failed to cast tea.Model to InteractiveModeModel") + } + + logging.GlobalLogger.Info("Writing session output to stdout") + fmt.Println(strings.Join(model.commandLines, "\n")) + } + + 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 new file mode 100644 index 00000000..8155c5fb --- /dev/null +++ b/internal/engine/environments/azure.go @@ -0,0 +1,133 @@ +package environments + +import ( + "encoding/json" + "fmt" + + "github.com/Azure/InnovationEngine/internal/az" + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/Azure/InnovationEngine/internal/ui" +) + +// 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 []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: []AzureStep{}, + CurrentStep: 0, + Status: "Executing", + ResourceURIs: []string{}, + Error: "", + } +} + +// Get the status as a JSON string. +func (status *AzureDeploymentStatus) AsJsonString() (string, error) { + json, err := json.Marshal(status) + if err != nil { + logging.GlobalLogger.Error("Failed to marshal status", err) + return "", err + } + + return string(json), nil +} + +func (status *AzureDeploymentStatus) AddStep(step string, codeBlocks []AzureCodeBlock) { + status.Steps = append(status.Steps, AzureStep{ + Name: step, + CodeBlocks: codeBlocks, + }) +} + +func (status *AzureDeploymentStatus) AddResourceURI(uri string) { + status.ResourceURIs = append(status.ResourceURIs, uri) +} + +func (status *AzureDeploymentStatus) SetError(err error) { + status.Status = "Failed" + status.Error = err.Error() +} + +// Print out the status JSON for azure/cloudshell if in the correct environment. +func ReportAzureStatus(status AzureDeploymentStatus, environment string) { + if !IsAzureEnvironment(environment) { + return + } + + statusJson, err := status.AsJsonString() + if err != nil { + logging.GlobalLogger.Error("Failed to marshal status", err) + } 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) + fmt.Println(ui.OcdStatusUpdateStyle.Render(ocdStatus)) + } +} + +// 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( + status *AzureDeploymentStatus, + resourceGroupName string, + environment string, +) { + + if !IsAzureEnvironment(environment) { + logging.GlobalLogger.Info( + "Not fetching resource URIs because we're not in the OCD environment.", + ) + } + + if resourceGroupName == "" { + logging.GlobalLogger.Warn("No resource group name found.") + return + } + + resourceURIs := az.FindAllDeployedResourceURIs(resourceGroupName) + + if len(resourceURIs) > 0 { + logging.GlobalLogger.WithField("resourceURIs", resourceURIs). + Info("Found deployed resources.") + status.ResourceURIs = resourceURIs + } else { + logging.GlobalLogger.Warn("No deployed resources found.") + } +} diff --git a/internal/engine/environments/environments.go b/internal/engine/environments/environments.go new file mode 100644 index 00000000..c6c9dc45 --- /dev/null +++ b/internal/engine/environments/environments.go @@ -0,0 +1,22 @@ +package environments + +const ( + EnvironmentsLocal = "local" + EnvironmentsCI = "ci" + EnvironmentsOCD = "ocd" + EnvironmentsAzure = "azure" +) + +// Check if the environment is valid. +func IsValidEnvironment(environment string) bool { + switch environment { + case EnvironmentsLocal, EnvironmentsCI, EnvironmentsOCD, EnvironmentsAzure: + return true + default: + return false + } +} + +func IsAzureEnvironment(environment string) bool { + return environment == EnvironmentsAzure || environment == EnvironmentsOCD +} diff --git a/internal/engine/execution.go b/internal/engine/execution.go new file mode 100644 index 00000000..f7382eba --- /dev/null +++ b/internal/engine/execution.go @@ -0,0 +1,324 @@ +package engine + +import ( + "fmt" + "strings" + "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/shells" + "github.com/Azure/InnovationEngine/internal/terminal" + "github.com/Azure/InnovationEngine/internal/ui" +) + +const ( + // TODO - Make this configurable for terminals that support it. + // spinnerFrames = `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` + spinnerFrames = `-\|/` + spinnerRefresh = 100 * time.Millisecond +) + +// If a scenario has an `az group delete` command and the `--do-not-delete` +// flag is set, we remove it from the steps. +func filterDeletionCommands(steps []Step, preserveResources bool) []Step { + filteredSteps := []Step{} + if preserveResources { + for _, step := range steps { + newBlocks := []parsers.CodeBlock{} + for _, block := range step.CodeBlocks { + if patterns.AzGroupDelete.MatchString(block.Content) { + continue + } else { + newBlocks = append(newBlocks, block) + } + } + if len(newBlocks) > -1 { + filteredSteps = append(filteredSteps, Step{ + Name: step.Name, + CodeBlocks: newBlocks, + }) + } + } + } else { + filteredSteps = steps + } + return filteredSteps +} + +func renderCommand(blockContent string) (shells.CommandOutput, error) { + escapedCommand := blockContent + if !patterns.MultilineQuotedStringCommand.MatchString(blockContent) { + escapedCommand = strings.ReplaceAll(blockContent, "\\\n", "\\\\\n") + } + renderedCommand, err := shells.ExecuteBashCommand( + "echo -e \""+escapedCommand+"\"", + shells.BashCommandConfiguration{ + EnvironmentVariables: map[string]string{}, + InteractiveCommand: false, + WriteToHistory: false, + InheritEnvironment: true, + }, + ) + return renderedCommand, err +} + +// Executes the steps from a scenario and renders the output to the terminal. +func (e *Engine) ExecuteAndRenderSteps(steps []Step, env map[string]string) error { + + var resourceGroupName string = "" + var azureStatus = environments.NewAzureDeploymentStatus() + + err := az.SetSubscription(e.Configuration.Subscription) + if err != nil { + logging.GlobalLogger.Errorf("Invalid Config: Failed to set subscription: %s", err) + azureStatus.SetError(err) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + return err + } + + stepsToExecute := filterDeletionCommands(steps, e.Configuration.DoNotDelete) + + for stepNumber, step := range stepsToExecute { + + 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) + + for stepNumber, step := range stepsToExecute { + stepTitle := fmt.Sprintf("%d. %s\n", stepNumber+1, step.Name) + fmt.Println(ui.StepTitleStyle.Render(stepTitle)) + azureStatus.CurrentStep = stepNumber + 1 + + for _, block := range step.CodeBlocks { + var finalCommandOutput string + if e.Configuration.RenderValues { + // Render the codeblock. + renderedCommand, err := renderCommand(block.Content) + if err != nil { + logging.GlobalLogger.Errorf("Failed to render command: %s", err.Error()) + azureStatus.SetError(err) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + return err + } + finalCommandOutput = ui.IndentMultiLineCommand(renderedCommand.StdOut, 4) + } else { + finalCommandOutput = ui.IndentMultiLineCommand(block.Content, 4) + } + + fmt.Print(" " + finalCommandOutput) + + // execute the command as a goroutine to allow for the spinner to be + // rendered while the command is executing. + done := make(chan error) + var commandOutput shells.CommandOutput + + // If the command is an SSH command, we need to forward the input and + // output + interactiveCommand := false + if patterns.SshCommand.MatchString(block.Content) { + interactiveCommand = true + } + + logging.GlobalLogger.WithField("isInteractive", interactiveCommand). + Infof("Executing command: %s", block.Content) + + var commandErr error + var frame int = 0 + + // If forwarding input/output, don't render the spinner. + if !interactiveCommand { + // Grab the number of lines it contains & set the cursor to the + // beginning of the block. + + lines := strings.Count(finalCommandOutput, "\n") + terminal.MoveCursorPositionUp(lines) + + // Render the spinner and hide the cursor. + fmt.Print(ui.SpinnerStyle.Render(" "+string(spinnerFrames[0])) + " ") + terminal.HideCursor() + + go func(block parsers.CodeBlock) { + output, err := shells.ExecuteBashCommand( + block.Content, + shells.BashCommandConfiguration{ + EnvironmentVariables: lib.CopyMap(env), + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: true, + }, + ) + logging.GlobalLogger.Infof("Command output to stdout:\n %s", output.StdOut) + logging.GlobalLogger.Infof("Command output to stderr:\n %s", output.StdErr) + commandOutput = output + done <- err + }(block) + renderingLoop: + // While the command is executing, render the spinner. + for { + select { + case commandErr = <-done: + // Show the cursor, check the result of the command, and display the + // final status. + terminal.ShowCursor() + + if commandErr == nil { + + actualOutput := commandOutput.StdOut + expectedOutput := block.ExpectedOutput.Content + expectedSimilarity := block.ExpectedOutput.ExpectedSimilarity + expectedRegex := block.ExpectedOutput.ExpectedRegex + expectedOutputLanguage := block.ExpectedOutput.Language + + outputComparisonError := compareCommandOutputs(actualOutput, expectedOutput, expectedSimilarity, expectedRegex, expectedOutputLanguage) + + if outputComparisonError != nil { + logging.GlobalLogger.Errorf("Error comparing command outputs: %s", outputComparisonError.Error()) + fmt.Printf("\r %s \n", ui.ErrorStyle.Render("✗")) + terminal.MoveCursorPositionDown(lines) + fmt.Printf(" %s\n", ui.ErrorMessageStyle.Render(outputComparisonError.Error())) + fmt.Printf(" %s\n", lib.GetDifferenceBetweenStrings(block.ExpectedOutput.Content, commandOutput.StdOut)) + + azureStatus.SetError(outputComparisonError) + environments.AttachResourceURIsToAzureStatus( + &azureStatus, + resourceGroupName, + e.Configuration.Environment, + ) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + + return outputComparisonError + } + + fmt.Printf("\r %s \n", ui.CheckStyle.Render("✔")) + terminal.MoveCursorPositionDown(lines) + + fmt.Printf("%s\n", ui.RemoveHorizontalAlign(ui.VerboseStyle.Render(commandOutput.StdOut))) + + // Extract the resource group name from the command output if + // it's not already set. + if resourceGroupName == "" && patterns.AzCommand.MatchString(block.Content) { + logging.GlobalLogger.Info("Attempting to extract resource group name from command output") + tmpResourceGroup := az.FindResourceGroupName(commandOutput.StdOut) + if tmpResourceGroup != "" { + logging.GlobalLogger.WithField("resourceGroup", tmpResourceGroup).Info("Found resource group") + resourceGroupName = tmpResourceGroup + } + } + + if stepNumber != len(stepsToExecute)-1 { + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + } + + } else { + terminal.ShowCursor() + fmt.Printf("\r %s \n", ui.ErrorStyle.Render("✗")) + terminal.MoveCursorPositionDown(lines) + fmt.Printf(" %s\n", ui.ErrorMessageStyle.Render(commandErr.Error())) + + logging.GlobalLogger.Errorf("Error executing command: %s", commandErr.Error()) + + azureStatus.SetError(commandErr) + environments.AttachResourceURIsToAzureStatus( + &azureStatus, + resourceGroupName, + e.Configuration.Environment, + ) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + + return commandErr + } + + break renderingLoop + default: + frame = (frame + 1) % len(spinnerFrames) + fmt.Printf("\r %s", ui.SpinnerStyle.Render(string(spinnerFrames[frame]))) + time.Sleep(spinnerRefresh) + } + } + } else { + lines := strings.Count(block.Content, "\n") + + // 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 stepNumber == len(stepsToExecute)-1 && patterns.SshCommand.MatchString(block.Content) { + azureStatus.Status = "Succeeded" + environments.AttachResourceURIsToAzureStatus(&azureStatus, resourceGroupName, e.Configuration.Environment) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + } + + output, commandExecutionError := shells.ExecuteBashCommand( + block.Content, + shells.BashCommandConfiguration{ + EnvironmentVariables: lib.CopyMap(env), + InheritEnvironment: true, + InteractiveCommand: true, + WriteToHistory: false, + }, + ) + + terminal.ShowCursor() + + if commandExecutionError == nil { + fmt.Printf("\r %s \n", ui.CheckStyle.Render("✔")) + terminal.MoveCursorPositionDown(lines) + + fmt.Printf(" %s\n", ui.VerboseStyle.Render(output.StdOut)) + + if stepNumber != len(stepsToExecute)-1 { + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + } + } else { + fmt.Printf("\r %s \n", ui.ErrorStyle.Render("✗")) + terminal.MoveCursorPositionDown(lines) + fmt.Printf(" %s\n", ui.ErrorMessageStyle.Render(commandExecutionError.Error())) + + azureStatus.SetError(commandExecutionError) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + return commandExecutionError + } + } + } + } + + // Report the final status of the deployment (Only applies to one click deployments). + azureStatus.Status = "Succeeded" + environments.AttachResourceURIsToAzureStatus( + &azureStatus, + resourceGroupName, + e.Configuration.Environment, + ) + environments.ReportAzureStatus(azureStatus, e.Configuration.Environment) + + 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() + } + + return nil +} diff --git a/internal/engine/execution_test.go b/internal/engine/execution_test.go new file mode 100644 index 00000000..875229cd --- /dev/null +++ b/internal/engine/execution_test.go @@ -0,0 +1,24 @@ +package engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecuteBlock(t *testing.T) { + blocks := []string{ + "echo \"hello \\\nworld\"", // tutorial.md + "echo hello \\\nworld", + "echo \"hello world\"", + "echo hello world", + "ls \\\n-a", + } + for _, blockCommand := range blocks { + t.Run("render command", func(t *testing.T) { + _, err := renderCommand(blockCommand) + assert.Equal(t, nil, err) + }) + } + +} diff --git a/internal/engine/interactive.go b/internal/engine/interactive.go new file mode 100644 index 00000000..156615d1 --- /dev/null +++ b/internal/engine/interactive.go @@ -0,0 +1,582 @@ +package engine + +import ( + "fmt" + "strings" + "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 + azureCLIViewport 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 + commandLines []string +} + +// 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) + azureCLIViewport := viewport.New(width, height) + + components := interactiveModeComponents{ + paginator: p, + stepViewport: stepViewport, + outputViewport: outputViewport, + azureCLIViewport: azureCLIViewport, + } + + 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 + components.azureCLIViewport.Height = terminalHeight - 1 +} + +// 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.azureCLIViewport.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 + } + } + model.commandLines = append(model.commandLines, codeBlockState.StdOut) + + // Increment the codeblock and update the viewport content. + model.currentCodeBlock++ + + if model.currentCodeBlock < len(model.codeBlockState) { + nextCommand := model.codeBlockState[model.currentCodeBlock].CodeBlock.Content + nextLanguage := model.codeBlockState[model.currentCodeBlock].CodeBlock.Language + + model.commandLines = append(model.commandLines, ui.CommandPrompt(nextLanguage)+nextCommand) + } + + // 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 + model.commandLines = append(model.commandLines, codeBlockState.StdErr) + + // 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) + } + + model.components.azureCLIViewport.SetContent(strings.Join(model.commandLines, "\n")) + + // 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) + + model.components.azureCLIViewport, command = model.components.azureCLIViewport.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 { + // When running in the portal, we only want to show the Azure CLI viewport + // which mimics a command line interface during execution. + if model.environment == "azure" { + return model.components.azureCLIViewport.View() + } + + scenarioTitle := ui.ScenarioTitleStyle.Width(model.width). + Align(lipgloss.Center). + Render(model.scenarioTitle) + + border := lipgloss.NewStyle(). + Width(model.components.stepViewport.Width - 2). + Border(lipgloss.NormalBorder()) + + stepTitle := ui.StepTitleStyle.Render( + fmt.Sprintf( + "Step %d - %s", + model.currentCodeBlock+1, + model.codeBlockState[model.currentCodeBlock].StepName, + ), + ) + stepView := border.Render(model.components.stepViewport.View()) + stepSection := fmt.Sprintf("%s\n%s\n\n", stepTitle, stepView) + + outputTitle := ui.StepTitleStyle.Render("Output") + outputView := border.Render(model.components.outputViewport.View()) + outputSection := fmt.Sprintf("%s\n%s\n\n", outputTitle, outputView) + + 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) + } + + language := codeBlockState[0].CodeBlock.Language + commandLines := []string{ + ui.CommandPrompt(language) + codeBlockState[0].CodeBlock.Content, + } + + 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, + commandLines: commandLines, + }, nil +} diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go new file mode 100644 index 00000000..cd05e49f --- /dev/null +++ b/internal/engine/scenario.go @@ -0,0 +1,217 @@ +package engine + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "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/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 + Environment map[string]string +} + +// 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) + 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 { + logging.GlobalLogger.Debugf("Attempting to override %s with %s", key, value) + exportRegex := regexp.MustCompile(fmt.Sprintf(`export %s=["']?([a-z-A-Z0-9_]+)["']?`, 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) + title, err := parsers.ExtractScenarioTitleFromAst(markdown, source) + if err != nil { + return nil, err + } + + logging.GlobalLogger.Infof("Successfully built out the scenario: %s", title) + + return &Scenario{ + Name: title, + Environment: environmentVariables, + Steps: steps, + MarkdownAst: markdown, + }, 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() +} diff --git a/internal/engine/scenario_test.go b/internal/engine/scenario_test.go new file mode 100644 index 00000000..510b240a --- /dev/null +++ b/internal/engine/scenario_test.go @@ -0,0 +1,78 @@ +package engine + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// Mock HTTP server for testing downloading markdown from URL +func mockHTTPServer(content string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, content) + })) +} + +func TestResolveMarkdownSource(t *testing.T) { + // Test downloading from URL + t.Run("Download markdown from URL", func(t *testing.T) { + content := "Test content from URL" + mockServer := mockHTTPServer(content) + defer mockServer.Close() + + url := mockServer.URL + result, err := resolveMarkdownSource(url) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := []byte(content) + if string(result) != string(expected) { + t.Errorf("Expected content to be %q, got %q", expected, result) + } + }) + + // Test reading from local file + t.Run("Read from a local file", func(t *testing.T) { + content := "Test content from local file" + temporaryFile, err := os.CreateTemp("", "example") + if err != nil { + t.Fatalf("Error creating temporary file: %v", err) + } + defer os.Remove(temporaryFile.Name()) + + if _, err := temporaryFile.Write([]byte(content)); err != nil { + t.Fatalf("Error writing to temporary file: %v", err) + } + if err := temporaryFile.Close(); err != nil { + t.Fatalf("Error closing temporary file: %v", err) + } + + path := temporaryFile.Name() + result, err := resolveMarkdownSource(path) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + expected := []byte(content) + if string(result) != string(expected) { + t.Errorf("Expected content to be %q, got %q", expected, result) + } + }) + + // Test non-existing file + t.Run("Non-existing file", func(t *testing.T) { + nonExistingPath := "non_existing_file.md" + _, err := resolveMarkdownSource(nonExistingPath) + if err == nil { + t.Error("Expected error for non-existing file, but got nil") + } + expectedErrorMsg := fmt.Sprintf("markdown file '%s' does not exist", nonExistingPath) + if !strings.Contains(err.Error(), expectedErrorMsg) { + t.Errorf("Expected error message to contain %q, got %q", expectedErrorMsg, err.Error()) + } + }) +} diff --git a/internal/engine/testing.go b/internal/engine/testing.go new file mode 100644 index 00000000..1b4b928f --- /dev/null +++ b/internal/engine/testing.go @@ -0,0 +1,131 @@ +package engine + +import ( + "errors" + "fmt" + "time" + + "github.com/Azure/InnovationEngine/internal/az" + "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/shells" + "github.com/Azure/InnovationEngine/internal/terminal" + "github.com/Azure/InnovationEngine/internal/ui" +) + +func (e *Engine) TestSteps(steps []Step, env map[string]string) error { + var resourceGroupName string + stepsToExecute := filterDeletionCommands(steps, true) + err := az.SetSubscription(e.Configuration.Subscription) + if err != nil { + logging.GlobalLogger.Errorf("Invalid Config: Failed to set subscription: %s", err) + return err + } + + var testRunnerError error = nil +testRunner: + for stepNumber, step := range stepsToExecute { + stepTitle := fmt.Sprintf(" %d. %s\n", stepNumber+1, step.Name) + fmt.Println(ui.StepTitleStyle.Render(stepTitle)) + terminal.MoveCursorPositionUp(1) + terminal.HideCursor() + + for _, block := range step.CodeBlocks { + // execute the command as a goroutine to allow for the spinner to be + // rendered while the command is executing. + done := make(chan error) + var commandOutput shells.CommandOutput + go func(block parsers.CodeBlock) { + logging.GlobalLogger.Infof("Executing command: %s", block.Content) + output, err := shells.ExecuteBashCommand(block.Content, shells.BashCommandConfiguration{EnvironmentVariables: lib.CopyMap(env), InheritEnvironment: true, InteractiveCommand: false, WriteToHistory: true}) + logging.GlobalLogger.Infof("Command stdout: %s", output.StdOut) + logging.GlobalLogger.Infof("Command stderr: %s", output.StdErr) + commandOutput = output + done <- err + }(block) + + frame := 0 + var err error + + loop: + // While the command is executing, render the spinner. + for { + select { + case err = <-done: + terminal.ShowCursor() + + if err == nil { + actualOutput := commandOutput.StdOut + expectedOutput := block.ExpectedOutput.Content + expectedSimilarity := block.ExpectedOutput.ExpectedSimilarity + expectedRegex := block.ExpectedOutput.ExpectedRegex + expectedOutputLanguage := block.ExpectedOutput.Language + + err := compareCommandOutputs(actualOutput, expectedOutput, expectedSimilarity, expectedRegex, expectedOutputLanguage) + if err != nil { + logging.GlobalLogger.Errorf("Error comparing command outputs: %s", err.Error()) + fmt.Print(ui.ErrorStyle.Render("Error when comparing the command outputs: %s\n", err.Error())) + } + + // Extract the resource group name from the command output if + // it's not already set. + if resourceGroupName == "" && patterns.AzCommand.MatchString(block.Content) { + tmpResourceGroup := az.FindResourceGroupName(commandOutput.StdOut) + if tmpResourceGroup != "" { + logging.GlobalLogger.Infof("Found resource group: %s", tmpResourceGroup) + resourceGroupName = tmpResourceGroup + } + } + + fmt.Printf("\r %s \n", ui.CheckStyle.Render("✔")) + terminal.MoveCursorPositionDown(1) + } else { + + fmt.Printf("\r %s \n", ui.ErrorStyle.Render("✗")) + terminal.MoveCursorPositionDown(1) + fmt.Printf(" %s\n", ui.ErrorStyle.Render("Error executing command: %s\n", err.Error())) + + logging.GlobalLogger.Errorf("Error executing command: %s", err.Error()) + + testRunnerError = err + break testRunner + } + + break loop + default: + frame = (frame + 1) % len(spinnerFrames) + fmt.Printf("\r %s", ui.SpinnerStyle.Render(string(spinnerFrames[frame]))) + time.Sleep(spinnerRefresh) + } + } + } + } + + // If the resource group name was set, delete it. + if resourceGroupName != "" { + fmt.Printf("\n") + fmt.Printf("Deleting resource group: %s\n", resourceGroupName) + command := fmt.Sprintf("az group delete --name %s --yes --no-wait", resourceGroupName) + output, err := shells.ExecuteBashCommand( + command, + shells.BashCommandConfiguration{ + EnvironmentVariables: lib.CopyMap(env), + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: true, + }, + ) + if err != nil { + fmt.Print(ui.ErrorStyle.Render("Error deleting resource group: %s\n", err.Error())) + logging.GlobalLogger.Errorf("Error deleting resource group: %s", err.Error()) + testRunnerError = errors.Join(testRunnerError, err) + } + + fmt.Print(output.StdOut) + } + + shells.ResetStoredEnvironmentVariables() + return testRunnerError +} diff --git a/internal/lib/diff.go b/internal/lib/diff.go new file mode 100644 index 00000000..d595b1bd --- /dev/null +++ b/internal/lib/diff.go @@ -0,0 +1,12 @@ +package lib + +import ( + "github.com/sergi/go-diff/diffmatchpatch" +) + +func GetDifferenceBetweenStrings(a, b string) string { + dmp := diffmatchpatch.New() + + diffs := dmp.DiffMain(a, b, false) + return dmp.DiffPrettyText(diffs) +} diff --git a/internal/lib/fs/directories.go b/internal/lib/fs/directories.go new file mode 100644 index 00000000..f6211761 --- /dev/null +++ b/internal/lib/fs/directories.go @@ -0,0 +1,41 @@ +package fs + +import ( + "errors" + "os" + + "github.com/Azure/InnovationEngine/internal/logging" +) + +func SetWorkingDirectory(directory string) error { + // Change working directory if specified + if directory != "" { + err := os.Chdir(directory) + if err != nil { + logging.GlobalLogger.Error("Failed to change working directory", err) + return err + } + + logging.GlobalLogger.Infof("Changed directory to %s", directory) + } + return nil +} + +// Executes a function within a given working directory and restores +// the original working directory when the function completes. +func UsingDirectory(directory string, executor func() error) error { + originalDirectory, err := os.Getwd() + if err != nil { + return err + } + + err = SetWorkingDirectory(directory) + if err != nil { + return err + } + + executionError := executor() + err = SetWorkingDirectory(originalDirectory) + + return errors.Join(executionError, err) +} diff --git a/internal/lib/fs/file.go b/internal/lib/fs/file.go new file mode 100644 index 00000000..36f44860 --- /dev/null +++ b/internal/lib/fs/file.go @@ -0,0 +1,9 @@ +package fs + +import "os" + +// Checks if a given file exists. +func FileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} diff --git a/internal/lib/ints.go b/internal/lib/ints.go new file mode 100644 index 00000000..c6f79e1d --- /dev/null +++ b/internal/lib/ints.go @@ -0,0 +1,9 @@ +package lib + +// 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/lib/json.go b/internal/lib/json.go new file mode 100644 index 00000000..a5c1f875 --- /dev/null +++ b/internal/lib/json.go @@ -0,0 +1,52 @@ +package lib + +import ( + "encoding/json" + + "github.com/xrash/smetrics" +) + +func OrderJsonFields(jsonStr string) (string, error) { + expectedMap := make(map[string]interface{}) + err := json.Unmarshal([]byte(jsonStr), &expectedMap) + if err != nil { + return "", err + } + + orderedJson, err := json.Marshal(expectedMap) + if err != nil { + return "", err + } + return string(orderedJson), nil +} + +type ComparisonResult struct { + AboveThreshold bool + Score float64 +} + +// Compare two JSON strings by ordering the fields alphabetically and then +// comparing the strings using the Jaro-Winkler algorithm to compute a score. +// If the score is greater than the threshold, return true. +func CompareJsonStrings( + actualJson string, + expectedJson string, + threshold float64, +) (ComparisonResult, error) { + actualOutput, err := OrderJsonFields(actualJson) + if err != nil { + return ComparisonResult{}, err + } + + expectedOutput, err := OrderJsonFields(expectedJson) + if err != nil { + return ComparisonResult{}, err + } + + score := smetrics.Jaro(actualOutput, expectedOutput) + + return ComparisonResult{ + AboveThreshold: score >= threshold, + Score: score, + }, nil +} diff --git a/internal/lib/json_test.go b/internal/lib/json_test.go new file mode 100644 index 00000000..55c21f80 --- /dev/null +++ b/internal/lib/json_test.go @@ -0,0 +1 @@ +package lib diff --git a/internal/lib/maps.go b/internal/lib/maps.go new file mode 100644 index 00000000..7e4388a5 --- /dev/null +++ b/internal/lib/maps.go @@ -0,0 +1,20 @@ +package lib + +// Makes a copy of a map +func CopyMap(m map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range m { + result[k] = v + } + return result +} + +// Merge two maps together. +func MergeMaps(a, b map[string]string) map[string]string { + merged := CopyMap(a) + for k, v := range b { + merged[k] = v + } + + return merged +} diff --git a/internal/lib/maps_test.go b/internal/lib/maps_test.go new file mode 100644 index 00000000..fe68eec5 --- /dev/null +++ b/internal/lib/maps_test.go @@ -0,0 +1,46 @@ +package lib + +import ( + "testing" +) + +func TestMapUtilities(t *testing.T) { + + t.Run("Copying maps", func(t *testing.T) { + original := make(map[string]string) + original["key"] = "value" + + copy := CopyMap(original) + + if len(copy) != 1 { + t.Errorf("Copy length is wrong: %d", len(copy)) + } + + if copy["key"] != "value" { + t.Errorf("Copy is wrong: %s", copy["key"]) + } + }) + + t.Run("Merging maps", func(t *testing.T) { + original := make(map[string]string) + original["key"] = "value" + + merge := make(map[string]string) + merge["key2"] = "value2" + + merged := MergeMaps(original, merge) + + if len(merged) != 2 { + t.Errorf("Merged length is wrong: %d", len(merged)) + } + + if merged["key"] != "value" { + t.Errorf("Merged is wrong: %s", merged["key"]) + } + + if merged["key2"] != "value2" { + t.Errorf("Merged is wrong: %s", merged["key2"]) + } + }) + +} diff --git a/internal/lib/user.go b/internal/lib/user.go new file mode 100644 index 00000000..6066aaa9 --- /dev/null +++ b/internal/lib/user.go @@ -0,0 +1,30 @@ +package lib + +import ( + "fmt" + "os" + "os/user" +) + +func GetHomeDirectory() (string, error) { + // Try to get home directory from user.Current() + usr, err := user.Current() + if err == nil { + return usr.HomeDir, nil + } + + // Fallback to environment variable + home, exists := os.LookupEnv("HOME") + if exists && home != "" { + return home, nil + } + + // Fallback for Windows + homeDrive, driveExists := os.LookupEnv("HOMEDRIVE") + homePath, pathExists := os.LookupEnv("HOMEPATH") + if driveExists && pathExists { + return homeDrive + homePath, nil + } + + return "", fmt.Errorf("home directory cannot be determined") +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 00000000..e8fd9c45 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,80 @@ +package logging + +import ( + "os" + + "github.com/sirupsen/logrus" +) + +type Level string + +const ( + Trace Level = "trace" + Debug Level = "debug" + Info Level = "info" + Warn Level = "warn" + Error Level = "error" + Fatal Level = "fatal" +) + +// / Convert a logging level to a logrus level (uint32). +func (l Level) Integer() logrus.Level { + switch l { + case Trace: + return logrus.TraceLevel + case Debug: + return logrus.DebugLevel + case Info: + return logrus.InfoLevel + case Warn: + return logrus.WarnLevel + case Error: + return logrus.ErrorLevel + case Fatal: + return logrus.FatalLevel + default: + return logrus.InfoLevel + } +} + +// / Convert a string to a logging level. +func LevelFromString(level string) Level { + switch level { + case string(Trace): + return Trace + case string(Debug): + return Debug + case string(Info): + return Info + case string(Warn): + return Warn + case string(Error): + return Error + case string(Fatal): + return Fatal + default: + return Info + } +} + +var GlobalLogger = logrus.New() + +func Init(level Level) { + GlobalLogger.SetFormatter(&logrus.TextFormatter{ + DisableColors: false, + FullTimestamp: true, + DisableQuote: true, + }) + + GlobalLogger.SetReportCaller(false) + GlobalLogger.SetLevel(level.Integer()) + + file, err := os.OpenFile("ie.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + + if err == nil { + GlobalLogger.SetOutput(file) + } else { + GlobalLogger.SetOutput(os.Stdout) + GlobalLogger.Warn("Failed to log to file, using default stderr") + } +} diff --git a/internal/parsers/ini.go b/internal/parsers/ini.go new file mode 100644 index 00000000..f4cb58ff --- /dev/null +++ b/internal/parsers/ini.go @@ -0,0 +1,27 @@ +package parsers + +import ( + "fmt" + + "gopkg.in/ini.v1" +) + +// Parses an INI file into a flat map of keys mapped to values. This reduces +// the complexity of the INI file to a simple key/value store and ignores the +// sections. +func ParseINIFile(filePath string) (map[string]string, error) { + + iniFile, err := ini.Load(filePath) + + if err != nil { + return nil, fmt.Errorf("failed to read the INI file %s because %v", filePath, err) + } + + data := make(map[string]string) + for _, section := range iniFile.Sections() { + for key, value := range section.KeysHash() { + data[key] = value + } + } + return data, nil +} diff --git a/internal/parsers/ini_test.go b/internal/parsers/ini_test.go new file mode 100644 index 00000000..9cdcaa38 --- /dev/null +++ b/internal/parsers/ini_test.go @@ -0,0 +1,41 @@ +package parsers + +import ( + "os" + "testing" +) + +func TestParsingINIFiles(t *testing.T) { + + t.Run("INI with valid contents", func(t *testing.T) { + tempFile, err := os.CreateTemp("", "test") + + if err != nil { + t.Errorf("Error creating temp file: %s", err) + } + + defer os.Remove(tempFile.Name()) + + contents := []byte(`[section] + key=value`) + + if _, err := tempFile.Write(contents); err != nil { + t.Errorf("Error writing to temp file: %s", err) + } + + data, err := ParseINIFile(tempFile.Name()) + + if err != nil { + t.Errorf("Error parsing INI file: %s", err) + } + + if len(data) != 1 { + t.Errorf("Data length is wrong: %d", len(data)) + } + + if data["key"] != "value" { + t.Errorf("Data is wrong: %s", data["key"]) + } + }) + +} diff --git a/internal/parsers/markdown.go b/internal/parsers/markdown.go new file mode 100644 index 00000000..f777a29b --- /dev/null +++ b/internal/parsers/markdown.go @@ -0,0 +1,261 @@ +package parsers + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/Azure/InnovationEngine/internal/logging" + "github.com/yuin/goldmark" + meta "github.com/yuin/goldmark-meta" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" +) + +var markdownParser = goldmark.New( + goldmark.WithExtensions(extension.GFM, meta.New(meta.WithStoresInDocument())), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + parser.WithBlockParsers(), + ), + goldmark.WithRendererOptions( + html.WithXHTML(), + ), +) + +// Parses a markdown file into an AST representing the markdown document. +func ParseMarkdownIntoAst(source []byte) ast.Node { + document := markdownParser.Parser().Parse(text.NewReader(source)) + return document +} + +func ExtractYamlMetadataFromAst(node ast.Node) map[string]interface{} { + return node.OwnerDocument().Meta() +} + +// The representation of an expected output block in a markdown file. This is +// for scenarios that have expected output that should be validated against the +// actual output. +type ExpectedOutputBlock struct { + Language string + Content string + ExpectedSimilarity float64 + ExpectedRegex *regexp.Regexp +} + +// The representation of a code block in a markdown file. +type CodeBlock struct { + Language string + Content string + Header string + Description string + ExpectedOutput ExpectedOutputBlock +} + +// Assumes the title of the scenario is the first h1 header in the +// markdown file. +func ExtractScenarioTitleFromAst(node ast.Node, source []byte) (string, error) { + header := "" + ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + switch n := node.(type) { + case *ast.Heading: + if n.Level == 1 { + header = string(extractTextFromMarkdown(&n.BaseBlock, source)) + return ast.WalkStop, nil + } + } + } + return ast.WalkContinue, nil + }) + + if header == "" { + return "", fmt.Errorf("no header found") + } + + return header, nil +} + +var expectedSimilarityRegex = regexp.MustCompile( + ``, +) + +// Extracts the code blocks from a provided markdown AST that match the +// languagesToExtract. +func ExtractCodeBlocksFromAst( + node ast.Node, + source []byte, + languagesToExtract []string, +) []CodeBlock { + var lastHeader string + var commands []CodeBlock + 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 { + switch n := node.(type) { + // 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) + matches := expectedSimilarityRegex.FindStringSubmatch(content) + + if len(matches) < 3 { + break + } + + match := matches[1] + if match != "" { + score, err := strconv.ParseFloat(match, 64) + logging.GlobalLogger.Debugf("Simalrity score of %f found", score) + if err != nil { + return ast.WalkStop, err + } + lastExpectedSimilarityScore = score + } else { + match = matches[2] + logging.GlobalLogger.Debugf("Regex %q found", match) + + if match == "" { + 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) + } + + lastExpectedRegex = re + } + + 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: content, + Header: lastHeader, + Description: description, + } + commands = append(commands, command) + break + } else if nextBlockIsExpectedOutput { + // Map the expected output to the last command. If there + // are no commands, then we ignore the expected output. + if len(commands) > 0 { + expectedOutputBlock := ExpectedOutputBlock{ + Language: language, + Content: extractTextFromMarkdown(&n.BaseBlock, source), + ExpectedSimilarity: lastExpectedSimilarityScore, + ExpectedRegex: lastExpectedRegex, + } + commands[len(commands)-1].ExpectedOutput = expectedOutputBlock + + // Reset the expected output state. + nextBlockIsExpectedOutput = false + lastExpectedSimilarityScore = 0 + lastExpectedRegex = nil + } + break + } + } + } + } + return ast.WalkContinue, nil + }) + + return commands +} + +// This regex matches HTML comments within markdown blocks that contain +// variables to use within the scenario. +var variableCommentBlockRegex = regexp.MustCompile("(?s)\n```\nHello\n```\n", + "echo Hello", + ), + ) + + document := ParseMarkdownIntoAst(markdown) + codeBlocks := ExtractCodeBlocksFromAst(document, markdown, []string{"bash"}) + + if len(codeBlocks) != 1 { + t.Errorf("Code block count is wrong: %d", len(codeBlocks)) + } + + block := codeBlocks[0].ExpectedOutput + expectedFloat := .8 + if block.ExpectedSimilarity != expectedFloat { + t.Errorf( + "ExpectedSimilarity is wrong, got %f, expected %f", + block.ExpectedSimilarity, + expectedFloat, + ) + } + }) +} + +func TestParsingMarkdownExpectedRegex(t *testing.T) { + t.Run("Markdown with a expected_similarty tag using regex", func(t *testing.T) { + markdown := []byte( + fmt.Sprintf( + "```bash\n%s\n```\n\n```\nFoo Bar\n```\n", + "echo 'Foo Bar'", + ), + ) + + document := ParseMarkdownIntoAst(markdown) + codeBlocks := ExtractCodeBlocksFromAst(document, markdown, []string{"bash"}) + + if len(codeBlocks) != 1 { + t.Errorf("Code block count is wrong: %d", len(codeBlocks)) + } + + block := codeBlocks[0].ExpectedOutput + if block.ExpectedRegex == nil { + t.Errorf("ExpectedRegex is nil") + } + + stringRegex := block.ExpectedRegex.String() + expectedRegex := `Foo \w+` + if stringRegex != expectedRegex { + t.Errorf("ExpectedRegex is wrong, got %q, expected %q", stringRegex, expectedRegex) + } + }) +} diff --git a/internal/patterns/regex.go b/internal/patterns/regex.go new file mode 100644 index 00000000..67e5db8c --- /dev/null +++ b/internal/patterns/regex.go @@ -0,0 +1,21 @@ +package patterns + +import "regexp" + +var ( + // An SSH command regex where there must be a username@host somewhere present in the command. + SshCommand = regexp.MustCompile( + `(^|\s)\bssh\b\s+([^\s]+(\s+|$))+((?P[a-zA-Z0-9_-]+|\$[A-Z_0-9]+)@(?P[a-zA-Z0-9.-]+|\$[A-Z_0-9]+))`, + ) + + // Multiline quoted string + MultilineQuotedStringCommand = regexp.MustCompile(`\"(.*\\\n.*)+\"`) + + // Az cli command regex + AzCommand = regexp.MustCompile(`az\s+([a-z]+)\s+([a-z]+)`) + AzGroupDelete = regexp.MustCompile(`az group delete`) + + // ARM regex + AzResourceURI = regexp.MustCompile(`\"id\": \"(/subscriptions/[^\"]+)\"`) + AzResourceGroupName = regexp.MustCompile(`resourceGroups/([^\"]+)`) +) diff --git a/internal/patterns/regex_test.go b/internal/patterns/regex_test.go new file mode 100644 index 00000000..2ce64dcf --- /dev/null +++ b/internal/patterns/regex_test.go @@ -0,0 +1,42 @@ +package patterns + +import ( + "testing" +) + +func TestRegex(t *testing.T) { + + t.Run("Test ssh command regex", func(t *testing.T) { + testCases := []string{ + "Run ssh -i key.pem username@host to connect", + "ssh -p 22 -L 8080:localhost:8080 username@host", + "ssh -Y username@host", + "Use ssh to connect", + "sshusername@host is not correct", + " ssh username@domain.com", + "Invalid ssh username@@domain.com", + "ssh -o StrictHostKeyChecking=no $MY_USERNAME@$IP_ADDRESS", + } + + testResults := []bool{ + true, + true, + true, + false, + false, + false, + false, + true, + } + + for index, testCase := range testCases { + match := SshCommand.FindString(testCase) + if match == "" && testResults[index] { + t.Errorf("Expected match not found: %s\n", testCase) + } else if match != "" && !testResults[index] { + t.Errorf("Unexpected match found: %s\n", testCase) + } + } + }) + +} diff --git a/internal/shells/bash.go b/internal/shells/bash.go new file mode 100644 index 00000000..9dd9d9af --- /dev/null +++ b/internal/shells/bash.go @@ -0,0 +1,215 @@ +package shells + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "regexp" + "strings" + + "golang.org/x/sys/unix" + + "github.com/Azure/InnovationEngine/internal/lib" + "github.com/Azure/InnovationEngine/internal/lib/fs" +) + +// Location where the environment state from commands is captured and sent to +// for being able to share state across commands. +var environmentStateFile = "/tmp/env-vars" + +func loadEnvFile(path string) (map[string]string, error) { + if !fs.FileExists(path) { + return nil, fmt.Errorf("env file '%s' does not exist", path) + } + + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open env file '%s': %w", path, err) + } + + defer file.Close() + + scanner := bufio.NewScanner(file) + env := make(map[string]string) + + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) // Split at the first "=" only + value := parts[1] + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + // Remove leading and trailing quotes + value = value[1 : len(value)-1] + } + env[parts[0]] = value + } + } + return env, nil +} + +func appendToBashHistory(command string, filePath string) error { + file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Lock the file to prevent other processes from writing to it concurrently + // and then unlock after we're done writing to it. + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("failed to lock file: %w", err) + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + + // Append the command and a newline to the file + _, err = file.WriteString(command + "\n") + if err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + + return nil + +} + +// Resets the stored environment variables file. +func ResetStoredEnvironmentVariables() error { + return os.Remove(environmentStateFile) +} + +var environmentVariableName = regexp.MustCompile("^[a-zA-Z_][a-zA-Z0-9_]*$") + +func filterInvalidKeys(envMap map[string]string) map[string]string { + validEnvMap := make(map[string]string) + for key, value := range envMap { + if environmentVariableName.MatchString(key) { + validEnvMap[key] = value + } + } + return validEnvMap +} + +func CleanEnvironmentStateFile() error { + env, err := loadEnvFile(environmentStateFile) + + if err != nil { + return err + } + + env = filterInvalidKeys(env) + + file, err := os.Create(environmentStateFile) + if err != nil { + return err + } + + writer := bufio.NewWriter(file) + for k, v := range env { + _, err := fmt.Fprintf(writer, "%s=\"%s\"\n", k, v) + if err != nil { + return err + } + } + return writer.Flush() +} + +type CommandOutput struct { + StdOut string + StdErr string +} + +type BashCommandConfiguration struct { + EnvironmentVariables map[string]string + InheritEnvironment bool + InteractiveCommand bool + WriteToHistory bool +} + +// Executes a bash command and returns the output or error. +func ExecuteBashCommand(command string, config BashCommandConfiguration) (CommandOutput, error) { + var commandWithStateSaved = []string{ + "set -e", + command, + "IE_LAST_COMMAND_EXIT_CODE=\"$?\"", + "env > " + environmentStateFile, + "exit $IE_LAST_COMMAND_EXIT_CODE", + } + + commandToExecute := exec.Command("bash", "-c", strings.Join(commandWithStateSaved, "\n")) + + var stdoutBuffer, stderrBuffer bytes.Buffer + + // If the command requires interaction, we provide the user with the ability + // to interact with the command. However, we cannot capture the buffer this + // way. + if config.InteractiveCommand { + commandToExecute.Stdout = os.Stdout + commandToExecute.Stderr = os.Stderr + commandToExecute.Stdin = os.Stdin + } else { + commandToExecute.Stdout = &stdoutBuffer + commandToExecute.Stderr = &stderrBuffer + } + + if config.InheritEnvironment { + commandToExecute.Env = os.Environ() + } + + // Sharing environment variable state between isolated shell executions is a + // bit tough, but how we handle it is by storing the environment variables + // after a command is executed within a file and then loading that file + // before executing the next command. This allows us to share state between + // isolated command calls. + envFromPreviousStep, err := loadEnvFile(environmentStateFile) + if err == nil { + merged := lib.MergeMaps(config.EnvironmentVariables, envFromPreviousStep) + for k, v := range merged { + commandToExecute.Env = append(commandToExecute.Env, fmt.Sprintf("%s=%s", k, v)) + } + } else { + for k, v := range config.EnvironmentVariables { + commandToExecute.Env = append(commandToExecute.Env, fmt.Sprintf("%s=%s", k, v)) + } + } + + if config.WriteToHistory { + + homeDir, err := lib.GetHomeDirectory() + + if err != nil { + return CommandOutput{}, fmt.Errorf("failed to get home directory: %w", err) + } + + err = appendToBashHistory(command, homeDir+"/.bash_history") + + if err != nil { + return CommandOutput{}, fmt.Errorf("failed to write command to history: %w", err) + } + } + + err = commandToExecute.Run() + + // TODO(vmarcella): Find a better way to handle this. + if config.InteractiveCommand { + return CommandOutput{}, err + } + + standardOutput, standardError := stdoutBuffer.String(), stderrBuffer.String() + + if err != nil { + return CommandOutput{ + StdOut: standardOutput, + StdErr: standardError, + }, fmt.Errorf( + "command exited with '%w' and the message '%s'", + err, + standardError, + ) + } + + return CommandOutput{ + StdOut: standardOutput, + StdErr: standardError, + }, nil +} diff --git a/internal/shells/bash_test.go b/internal/shells/bash_test.go new file mode 100644 index 00000000..2cc88734 --- /dev/null +++ b/internal/shells/bash_test.go @@ -0,0 +1,177 @@ +package shells + +import ( + "testing" +) + +func TestEnvironmentVariableValidationAndFiltering(t *testing.T) { + // Test key validation + t.Run("Key Validation", func(t *testing.T) { + validCases := []struct { + key string + expected bool + }{ + {"ValidKey", true}, + {"VALID_VARIABLE", true}, + {"_AnotherValidKey", true}, + {"123Key", false}, // Starts with a digit + {"key-with-hyphen", false}, // Contains a hyphen + {"key.with.dot", false}, // Contains a period + {"Fabric_NET-0-[Delegated]", false}, // From cloud shell environment. + } + + for _, tc := range validCases { + t.Run(tc.key, func(t *testing.T) { + result := environmentVariableName.MatchString(tc.key) + if result != tc.expected { + t.Errorf( + "Expected isValidKey(%s) to be %v, got %v", + tc.key, + tc.expected, + result, + ) + } + }) + } + }) + + // Test key filtering + t.Run("Key Filtering", func(t *testing.T) { + envMap := map[string]string{ + "ValidKey": "value1", + "_AnotherValidKey": "value2", + "123Key": "value3", + "key-with-hyphen": "value4", + "key.with.dot": "value5", + "Fabric_NET-0-[Delegated]": "false", // From cloud shell environment. + } + + validEnvMap := filterInvalidKeys(envMap) + + expectedValidEnvMap := map[string]string{ + "ValidKey": "value1", + "_AnotherValidKey": "value2", + } + + if len(validEnvMap) != len(expectedValidEnvMap) { + t.Errorf( + "Expected validEnvMap to have %d keys, got %d", + len(expectedValidEnvMap), + len(validEnvMap), + ) + } + + for key, value := range validEnvMap { + if expectedValue, ok := expectedValidEnvMap[key]; !ok || value != expectedValue { + t.Errorf("Expected validEnvMap[%s] to be %s, got %s", key, expectedValue, value) + } + } + }) +} + +func TestBashCommandExecution(t *testing.T) { + // Ensures that if a command succeeds, the output is returned. + t.Run("Valid command execution", func(t *testing.T) { + cmd := "printf hello" + result, err := ExecuteBashCommand( + cmd, + BashCommandConfiguration{ + EnvironmentVariables: nil, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: false, + }, + ) + if err != nil { + t.Errorf("Expected err to be nil, got %v", err) + } + if result.StdOut != "hello" { + t.Errorf("Expected result to be non-empty, got '%s'", result.StdOut) + } + }) + + // Ensures that if a command fails, an error is returned. + t.Run("Invalid command execution", func(t *testing.T) { + cmd := "not_real_command" + _, err := ExecuteBashCommand( + cmd, + BashCommandConfiguration{ + EnvironmentVariables: nil, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: false, + }, + ) + + if err == nil { + t.Errorf("Expected an error to occur, but the command succeeded.") + } + + }) + + // Test the execution of commands with multiple subcommands. + t.Run("Command with multiple subcommands", func(t *testing.T) { + cmd := "printf hello; printf world" + result, err := ExecuteBashCommand( + cmd, + BashCommandConfiguration{ + EnvironmentVariables: nil, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: false, + }, + ) + if err != nil { + t.Errorf("Expected err to be nil, got %v", err) + } + if result.StdOut != "helloworld" { + t.Errorf("Expected result to be non-empty, got '%s'", result.StdOut) + } + }) + + // Ensures that if one of the subcommands fail, the other commands do + // as well. + t.Run("Command with multiple subcommands exits on first error", func(t *testing.T) { + cmd := "printf hello; not_real_command; printf world" + _, err := ExecuteBashCommand( + cmd, + BashCommandConfiguration{ + EnvironmentVariables: nil, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: false, + }, + ) + + if err == nil { + t.Errorf("Expected an error to occur, but the command succeeded.") + } + + }) + + // Ensures that commands can access environment variables passed into + // the configuration. + t.Run("Command with environment variables", func(t *testing.T) { + cmd := "printf $TEST_ENV_VAR" + result, err := ExecuteBashCommand( + cmd, + BashCommandConfiguration{ + EnvironmentVariables: map[string]string{ + "TEST_ENV_VAR": "hello", + }, + InheritEnvironment: true, + InteractiveCommand: false, + WriteToHistory: false, + }, + ) + + if err != nil { + t.Errorf("Expected err to be nil, got %v", err) + } + + if result.StdOut != "hello" { + t.Errorf("Expected result to be non-empty, got '%s'", result.StdOut) + } + }) + +} diff --git a/internal/terminal/cursor.go b/internal/terminal/cursor.go new file mode 100644 index 00000000..6bbbe51e --- /dev/null +++ b/internal/terminal/cursor.go @@ -0,0 +1,32 @@ +package terminal + +import "fmt" + +// Hides the cursor from the CLI using ANSI escape codes. +func HideCursor() string { + hide := "\033[?25l" + fmt.Print("\033[?25l") + return hide +} + +// Displays the cursor in the CLI using ANSI escape codes. +func ShowCursor() string { + show := "\033[?25h" + fmt.Print("\033[?25h") + return show +} + +// Moves the cursor up a specified number of lines. +func MoveCursorPositionUp(lines int) string { + position := fmt.Sprintf("\033[%dA", lines) + fmt.Print(position) + return position + +} + +// Moves the cursor down a specified number of lines. +func MoveCursorPositionDown(lines int) string { + position := fmt.Sprintf("\033[%dB\n", lines) + fmt.Print(position) + return position +} diff --git a/internal/terminal/cursor_test.go b/internal/terminal/cursor_test.go new file mode 100644 index 00000000..3b71f1ed --- /dev/null +++ b/internal/terminal/cursor_test.go @@ -0,0 +1,30 @@ +package terminal + +import "testing" + +func TestCursorManipulation(t *testing.T) { + + t.Run("Test moving cursor up", func(t *testing.T) { + position := MoveCursorPositionUp(1) + if position != "\033[1A" { + t.Errorf("Expected cursor to move up 1 line, got %s", position) + } + + position = MoveCursorPositionUp(2) + if position != "\033[2A" { + t.Errorf("Expected cursor to move up 2 lines, got %s", position) + } + }) + + t.Run("Test moving cursor down", func(t *testing.T) { + position := MoveCursorPositionDown(1) + if position != "\033[1B\n" { + t.Errorf("Expected cursor to move up 1 line, got %s", position) + } + + position = MoveCursorPositionDown(2) + if position != "\033[2B\n" { + t.Errorf("Expected cursor to move up 2 lines, got %s", position) + } + }) +} diff --git a/internal/ui/text.go b/internal/ui/text.go new file mode 100644 index 00000000..3ce1274e --- /dev/null +++ b/internal/ui/text.go @@ -0,0 +1,89 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Styles used for rendering output to the terminal. +var ( + ScenarioTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6CB6FF")). + Align(lipgloss.Center). + Bold(true). + Underline(true) + StepTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#518BAD")). + Align(lipgloss.Left). + Bold(true) + SpinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#518BAD")) + VerboseStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#437684")). + Align(lipgloss.Left) + CheckStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#32CD32")) + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000")) + ErrorMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5733")) + 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")) + + promptTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) + promptDollarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#32CD32")) +) + +// Command prompt for interactive environments +func CommandPrompt(language string) string { + promptText := promptTextStyle.Render(language) + promptDollar := promptDollarStyle.Render("$") + return promptText + ":" + promptDollar + " " +} + +// 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( + strings.Split(s, "\n"), + func(s string) string { return strings.TrimRight(s, " ") }, + ), + "\n", + ) +} + +func mapSliceString(slice []string, apply func(string) string) []string { + var result []string + for _, s := range slice { + result = append(result, apply(s)) + } + return result +} diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go new file mode 100644 index 00000000..b8fe2578 --- /dev/null +++ b/internal/ui/text_test.go @@ -0,0 +1,17 @@ +package ui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestVerboseStyle(t *testing.T) { + text := `aaaa + b` + styledText := VerboseStyle.Render(text) + expectedStyledText := `aaaa + b ` + assert.Equal(t, expectedStyledText, styledText) + assert.Equal(t, text, RemoveHorizontalAlign(styledText)) +} diff --git a/scenarios/demos/aksQuickstartEventGrid.md b/scenarios/demos/aksQuickstartEventGrid.md new file mode 100644 index 00000000..3a111b93 --- /dev/null +++ b/scenarios/demos/aksQuickstartEventGrid.md @@ -0,0 +1,556 @@ +--- +title: Subscribe to Azure Kubernetes Service events with Azure Event Grid +description: Use Azure Event Grid to subscribe to Azure Kubernetes Service events +services: container-service +author: zr-msft +ms.topic: article +ms.date: 07/12/2021 +ms.author: zarhoads +--- + +# Quickstart: Subscribe to Azure Kubernetes Service (AKS) events with Azure Event Grid + +Azure Event Grid is a fully managed event routing service that provides uniform event consumption using a publish-subscribe model. + +In this quickstart, you'll create an AKS cluster and subscribe to AKS events. + +## Prerequisites + +* An Azure subscription. If you don't have an Azure subscription, you can create a [free account](https://azure.microsoft.com/free). +* [Azure CLI][azure-cli-install] or [Azure PowerShell][azure-powershell-install] installed. + +## Create an AKS cluster + +### [Azure CLI](#tab/azure-cli) + +## Define Environment Variables + +This document uses environment variables for all parameters to facilitate reuse. The default values provided here should work in most test environments. For production work you will obviously need to modify these values. + +```azurecli-interactive +export RESOURCE_GROUP_NAME=aksQuickstartResourceGroup +export RESOURCE_LOCATION=eastus +export AKS_CLUSTER_NAME=aksQuickstartCluster +export NAMESPACE_NAME="aksQuickstartNamespace$(printf "%08d" $((RANDOM%100000000)))" +export EVENT_GRID_HUB_NAME=aksQuickstartEventGridHub +export EVENT_GRID_SUBSCRIPTION_NAME=aksQuickstartEventGridSubscription +``` + +## Create an AKS Cluster + +Create an AKS cluster using the [az aks create][az-aks-create] command. The following example creates a resource group and a cluster with one node. They will be named according to the environment variables set above: + +```azurecli-interactive +az group create --name $RESOURCE_GROUP_NAME --location $RESOURCE_LOCATION +``` + + +```output +{ + "id": "/subscriptions/325e7c34-99fb-4190-aa87-1df746c67705/resourceGroups/aksQuickstartResourceGroup", + "location": "eastus", + "managedBy": null, + "name": "aksQuickstartResourceGroup", + "properties": { + "provisioningState": "Succeeded" + }, + "tags": null, + "type": "Microsoft.Resources/resourceGroups" +} +``` + +Now we can create an AKS cluster within that resource group. + +```azurecli-interactive +az aks create --resource-group $RESOURCE_GROUP_NAME --name $AKS_CLUSTER_NAME --location $RESOURCE_LOCATION --node-count 1 --generate-ssh-keys +``` + +This will take a little while to run, when it completes you should see an output that looks something like this: + + +```output +{ + "aadProfile": null, + "addonProfiles": null, + "agentPoolProfiles": [ + { + "availabilityZones": null, + "count": 1, + "creationData": null, + "currentOrchestratorVersion": "1.24.9", + "enableAutoScaling": false, + "enableEncryptionAtHost": false, + "enableFips": false, + "enableNodePublicIp": false, + "enableUltraSsd": false, + "gpuInstanceProfile": null, + "hostGroupId": null, + "kubeletConfig": null, + "kubeletDiskType": "OS", + "linuxOsConfig": null, + "maxCount": null, + "maxPods": 110, + "minCount": null, + "mode": "System", + "name": "nodepool1", + "nodeImageVersion": "AKSUbuntu-1804gen2containerd-2023.01.20", + "nodeLabels": null, + "nodePublicIpPrefixId": null, + "nodeTaints": null, + "orchestratorVersion": "1.24.9", + "osDiskSizeGb": 128, + "osDiskType": "Managed", + "osSku": "Ubuntu", + "osType": "Linux", + "podSubnetId": null, + "powerState": { + "code": "Running" + }, + "provisioningState": "Succeeded", + "proximityPlacementGroupId": null, + "scaleDownMode": null, + "scaleSetEvictionPolicy": null, + "scaleSetPriority": null, + "spotMaxPrice": null, + "tags": null, + "type": "VirtualMachineScaleSets", + "upgradeSettings": { + "maxSurge": null + }, + "vmSize": "Standard_DS2_v2", + "vnetSubnetId": null, + "workloadRuntime": null + } + ], + "apiServerAccessProfile": null, + "autoScalerProfile": null, + "autoUpgradeProfile": null, + "azurePortalFqdn": "aksquickst-aksquickstartres-325e7c-784c55cf.portal.hcp.eastus.azmk8s.io", + "currentKubernetesVersion": "1.24.9", + "disableLocalAccounts": false, + "diskEncryptionSetId": null, + "dnsPrefix": "aksQuickst-aksQuickstartRes-325e7c", + "enablePodSecurityPolicy": null, + "enableRbac": true, + "extendedLocation": null, + "fqdn": "aksquickst-aksquickstartres-325e7c-784c55cf.hcp.eastus.azmk8s.io", + "fqdnSubdomain": null, + "httpProxyConfig": null, + "id": "/subscriptions/325e7c34-99fb-4190-aa87-1df746c67705/resourcegroups/aksQuickstartResourceGroup/providers/Microsoft.ContainerService/managedClusters/aksQuickstartCluster", + "identity": { + "principalId": "REDACTED", + "tenantId": "REDACTED", + "type": "SystemAssigned", + "userAssignedIdentities": null + }, + "identityProfile": { + "kubeletidentity": { + "clientId": "REDACTED", + "objectId": "REDACTED", + "resourceId": "/subscriptions/REDACTED/resourcegroups/MC_aksQuickstartResourceGroup_aksQuickstartCluster_eastus/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aksQuickstartCluster-agentpool" + } + }, + "kubernetesVersion": "1.24.9", + "linuxProfile": { + "adminUsername": "azureuser", + "ssh": { + "publicKeys": [ + { + "keyData": "ssh-rsa REDACTED" + } + ] + } + }, + "location": "eastus", + "maxAgentPools": 100, + "name": "aksQuickstartCluster", + "networkProfile": { + "dnsServiceIp": "10.0.0.10", + "dockerBridgeCidr": "172.17.0.1/16", + "ipFamilies": [ + "IPv4" + ], + "loadBalancerProfile": { + "allocatedOutboundPorts": null, + "effectiveOutboundIPs": [ + { + "id": "/subscriptions/REDACTED/resourceGroups/MC_aksQuickstartResourceGroup_aksQuickstartCluster_eastus/providers/Microsoft.Network/publicIPAddresses/e19ddc6c-0842-45d5-814d-702cc95945ce", + "resourceGroup": "MC_aksQuickstartResourceGroup_aksQuickstartCluster_eastus" + } + ], + "enableMultipleStandardLoadBalancers": null, + "idleTimeoutInMinutes": null, + "managedOutboundIPs": { + "count": 1, + "countIpv6": null + }, + "outboundIPs": null, + "outboundIpPrefixes": null + }, + "loadBalancerSku": "Standard", + "natGatewayProfile": null, + "networkMode": null, + "networkPlugin": "kubenet", + "networkPolicy": null, + "outboundType": "loadBalancer", + "podCidr": "10.244.0.0/16", + "podCidrs": [ + "10.244.0.0/16" + ], + "serviceCidr": "10.0.0.0/16", + "serviceCidrs": [ + "10.0.0.0/16" + ] + }, + "nodeResourceGroup": "MC_aksQuickstartResourceGroup_aksQuickstartCluster_eastus", + "oidcIssuerProfile": { + "enabled": false, + "issuerUrl": null + }, + "podIdentityProfile": null, + "powerState": { + "code": "Running" + }, + "privateFqdn": null, + "privateLinkResources": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": null, + "resourceGroup": "aksQuickstartResourceGroup", + "securityProfile": { + "azureKeyVaultKms": null, + "defender": null + }, + "servicePrincipalProfile": { + "clientId": "msi", + "secret": null + }, + "sku": { + "name": "Basic", + "tier": "Free" + }, + "storageProfile": { + "blobCsiDriver": null, + "diskCsiDriver": { + "enabled": true + }, + "fileCsiDriver": { + "enabled": true + }, + "snapshotController": { + "enabled": true + } + }, + "systemData": null, + "tags": null, + "type": "Microsoft.ContainerService/ManagedClusters", + "windowsProfile": null +} +``` + +### [Azure PowerShell](#tab/azure-powershell) + +Create an AKS cluster using the [New-AzAksCluster][new-azakscluster] command. The following example creates a resource group *MyResourceGroup* and a cluster named *MyAKS* with one node in the *MyResourceGroup* resource group: + +```azurepowershell-interactive +New-AzResourceGroup -Name MyResourceGroup -Location eastus +New-AzAksCluster -ResourceGroupName MyResourceGroup -Name MyAKS -Location eastus -NodeCount 1 -GenerateSshKey +``` + +--- + +## Subscribe to AKS events + +### [Azure CLI](#tab/azure-cli) + +Create a namespace and event hub using [az eventhubs namespace create][az-eventhubs-namespace-create] and [az eventhubs eventhub create][az-eventhubs-eventhub-create]. The following example creates a namespace *MyNamespace* and an event hub *MyEventGridHub* in *MyNamespace*, both in the *MyResourceGroup* resource group. + +```azurecli-interactive +az eventhubs namespace create --location $RESOURCE_LOCATION --name $NAMESPACE_NAME --resource-group $RESOURCE_GROUP_NAME +``` + + +```output +{ + "alternateName": null, + "clusterArmId": null, + "createdAt": "2023-02-11T00:27:48.977000+00:00", + "disableLocalAuth": false, + "encryption": null, + "id": "/subscriptions/325e7c34-99fb-4190-aa87-1df746c67705/resourceGroups/aksQuickstartResourceGroup/providers/Microsoft.EventHub/namespaces/aksQuickstartNamespace00021677", + "identity": null, + "isAutoInflateEnabled": false, + "kafkaEnabled": true, + "location": "East US", + "maximumThroughputUnits": 0, + "metricId": "325e7c34-99fb-4190-aa87-1df746c67705:aksquickstartnamespace00021677", + "minimumTlsVersion": "1.2", + "name": "aksQuickstartNamespace00021677", + "privateEndpointConnections": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": "Enabled", + "resourceGroup": "aksQuickstartResourceGroup", + "serviceBusEndpoint": "https://aksQuickstartNamespace00021677.servicebus.windows.net:443/", + "sku": { + "capacity": 1, + "name": "Standard", + "tier": "Standard" + }, + "status": "Active", + "systemData": null, + "tags": {}, + "type": "Microsoft.EventHub/Namespaces", + "updatedAt": "2023-02-11T00:28:40.050000+00:00", + "zoneRedundant": false +} +``` + +```azurecli-interactive +az eventhubs eventhub create --name $EVENT_GRID_HUB_NAME --namespace-name $NAMESPACE_NAME --resource-group $RESOURCE_GROUP_NAME +``` + + +```output +{ + "alternateName": null, + "clusterArmId": null, + "createdAt": "2023-02-11T00:27:48.977000+00:00", + "disableLocalAuth": false, + "encryption": null, + "id": "/subscriptions/325e7c34-99fb-4190-aa87-1df746c67705/resourceGroups/aksQuickstartResourceGroup/providers/Microsoft.EventHub/namespaces/aksQuickstartNamespace00021677", + "identity": null, + "isAutoInflateEnabled": false, + "kafkaEnabled": true, + "location": "East US", + "maximumThroughputUnits": 0, + "metricId": "325e7c34-99fb-4190-aa87-1df746c67705:aksquickstartnamespace00021677", + "minimumTlsVersion": "1.2", + "name": "aksQuickstartNamespace00021677", + "privateEndpointConnections": null, + "provisioningState": "Succeeded", + "publicNetworkAccess": "Enabled", + "resourceGroup": "aksQuickstartResourceGroup", + "serviceBusEndpoint": "https://aksQuickstartNamespace00021677.servicebus.windows.net:443/", + "sku": { + "capacity": 1, + "name": "Standard", + "tier": "Standard" + }, + "status": "Active", + "systemData": null, + "tags": {}, + "type": "Microsoft.EventHub/Namespaces", + "updatedAt": "2023-02-11T00:29:54.450000+00:00", + "zoneRedundant": false +} +``` + +> [!NOTE] +> The *name* of your namespace must be unique. In the defaults above we set a random postfix to try to ensure it is unique, but this is not guaranteed. + +Subscribe to the AKS events using [az eventgrid event-subscription create][az-eventgrid-event-subscription-create]: + +First we need the resource ID and endpoint, which we will store in an environment variables for later use: + +```azurecli-interactive +SOURCE_RESOURCE_ID=$(az aks show -g $RESOURCE_GROUP_NAME -n $AKS_CLUSTER_NAME --query id --output tsv) +ENDPOINT=$(az eventhubs eventhub show -g $RESOURCE_GROUP_NAME -n $EVENT_GRID_HUB_NAME --namespace-name $NAMESPACE_NAME --query id --output tsv) +``` + +Now we can actually subscribe to the events: + +```azurecli-interactive +az eventgrid event-subscription create --name $EVENT_GRID_SUBSCRIPTION_NAME \ + --source-resource-id $SOURCE_RESOURCE_ID \ + --endpoint-type eventhub \ + --endpoint $ENDPOINT +``` + + +```output +{ + "deadLetterDestination": null, + "deadLetterWithResourceIdentity": null, + "deliveryWithResourceIdentity": null, + "destination": { + "deliveryAttributeMappings": null, + "endpointType": "EventHub", + "resourceId": "/subscriptions/REDACTED/resourceGroups/aksQuickstartResourceGroup/providers/Microsoft.EventHub/namespaces/aksQuickstartNamespace00006800/eventhubs/aksQuickstartEventGridHub" + }, + "eventDeliverySchema": "EventGridSchema", + "expirationTimeUtc": null, + "filter": { + "advancedFilters": null, + "enableAdvancedFilteringOnArrays": null, + "includedEventTypes": [ + "Microsoft.ContainerService.NewKubernetesVersionAvailable" + ], + "isSubjectCaseSensitive": null, + "subjectBeginsWith": "", + "subjectEndsWith": "" + }, + "id": "/subscriptions/REDACTED/resourceGroups/aksQuickstartResourceGroup/providers/Microsoft.ContainerService/managedClusters/aksQuickstartCluster/providers/Microsoft.EventGrid/eventSubscriptions/aksQuickstartEventGridSubscription", + "labels": null, + "name": "aksQuickstartEventGridSubscription", + "provisioningState": "Succeeded", + "resourceGroup": "aksQuickstartResourceGroup", + "retryPolicy": { + "eventTimeToLiveInMinutes": 1440, + "maxDeliveryAttempts": 30 + }, + "systemData": null, + "topic": "/subscriptions/REDACTED/resourceGroups/aksquickstartresourcegroup/providers/microsoft.containerservice/managedclusters/aksquickstartcluster", + "type": "Microsoft.EventGrid/eventSubscriptions" +} +``` + +Verify your subscription to AKS events using `az eventgrid event-subscription list`: + +```azurecli-interactive +az eventgrid event-subscription list --source-resource-id $SOURCE_RESOURCE_ID +``` + +The following example output shows you're subscribed to events from the *MyAKS* cluster and those events are delivered to the *MyEventGridHub* event hub: + +```output +[ + { + "deadLetterDestination": null, + "deadLetterWithResourceIdentity": null, + "deliveryWithResourceIdentity": null, + "destination": { + "deliveryAttributeMappings": null, + "endpointType": "EventHub", + "resourceId": "/subscriptions/SUBSCRIPTION_ID/resourceGroups/MyResourceGroup/providers/Microsoft.EventHub/namespaces/MyNamespace/eventhubs/MyEventGridHub" + }, + "eventDeliverySchema": "EventGridSchema", + "expirationTimeUtc": null, + "filter": { + "advancedFilters": null, + "enableAdvancedFilteringOnArrays": null, + "includedEventTypes": [ + "Microsoft.ContainerService.NewKubernetesVersionAvailable" + ], + "isSubjectCaseSensitive": null, + "subjectBeginsWith": "", + "subjectEndsWith": "" + }, + "id": "/subscriptions/SUBSCRIPTION_ID/resourceGroups/MyResourceGroup/providers/Microsoft.ContainerService/managedClusters/MyAKS/providers/Microsoft.EventGrid/eventSubscriptions/MyEventGridSubscription", + "labels": null, + "name": "MyEventGridSubscription", + "provisioningState": "Succeeded", + "resourceGroup": "MyResourceGroup", + "retryPolicy": { + "eventTimeToLiveInMinutes": 1440, + "maxDeliveryAttempts": 30 + }, + "systemData": null, + "topic": "/subscriptions/SUBSCRIPTION_ID/resourceGroups/MyResourceGroup/providers/microsoft.containerservice/managedclusters/MyAKS", + "type": "Microsoft.EventGrid/eventSubscriptions" + } +] +``` + +### [Azure PowerShell](#tab/azure-powershell) + +Create a namespace and event hub using [New-AzEventHubNamespace][new-azeventhubnamespace] and [New-AzEventHub][new-azeventhub]. The following example creates a namespace *MyNamespace* and an event hub *MyEventGridHub* in *MyNamespace*, both in the *MyResourceGroup* resource group. + +```azurepowershell-interactive +New-AzEventHubNamespace -Location eastus -Name MyNamespace -ResourceGroupName $RESOURCE_GROUP_NAME +New-AzEventHub -Name MyEventGridHub -Namespace MyNamespace -ResourceGroupName $RESOURCE_GROUP_NAME +``` + +> [!NOTE] +> The *name* of your namespace must be unique. + +Subscribe to the AKS events using [New-AzEventGridSubscription][new-azeventgridsubscription]: + +```azurepowershell-interactive +$SOURCE_RESOURCE_ID = (Get-AzAksCluster -ResourceGroupName MyResourceGroup -Name MyAKS).Id +$ENDPOINT = (Get-AzEventHub -ResourceGroupName MyResourceGroup -EventHubName MyEventGridHub -Namespace MyNamespace).Id +$params = @{ + EventSubscriptionName = 'MyEventGridSubscription' + ResourceId = $SOURCE_RESOURCE_ID + EndpointType = 'eventhub' + Endpoint = $ENDPOINT +} +New-AzEventGridSubscription @params +``` + +Verify your subscription to AKS events using `Get-AzEventGridSubscription`: + +```azurepowershell-interactive +Get-AzEventGridSubscription -ResourceId $SOURCE_RESOURCE_ID | Select-Object -ExpandProperty PSEventSubscriptionsList +``` + +The following example output shows you're subscribed to events from the *MyAKS* cluster and those events are delivered to the *MyEventGridHub* event hub: + +```Output +EventSubscriptionName : MyEventGridSubscription +Id : /subscriptions/SUBSCRIPTION_ID/resourceGroups/MyResourceGroup/providers/Microsoft.ContainerService/managedClusters/MyAKS/providers/Microsoft.EventGrid/eventSubscriptions/MyEventGridSubscription +Type : Microsoft.EventGrid/eventSubscriptions +Topic : /subscriptions/SUBSCRIPTION_ID/resourceGroups/myresourcegroup/providers/microsoft.containerservice/managedclusters/myaks +Filter : Microsoft.Azure.Management.EventGrid.Models.EventSubscriptionFilter +Destination : Microsoft.Azure.Management.EventGrid.Models.EventHubEventSubscriptionDestination +ProvisioningState : Succeeded +Labels : +EventTtl : 1440 +MaxDeliveryAttempt : 30 +EventDeliverySchema : EventGridSchema +ExpirationDate : +DeadLetterEndpoint : +Endpoint : /subscriptions/SUBSCRIPTION_ID/resourceGroups/MyResourceGroup/providers/Microsoft.EventHub/namespaces/MyNamespace/eventhubs/MyEventGridHub +``` + +--- + +When AKS events occur, you'll see those events appear in your event hub. For example, when the list of available Kubernetes versions for your clusters changes, you'll see a `Microsoft.ContainerService.NewKubernetesVersionAvailable` event. For more information on the events AKS emits, see [Azure Kubernetes Service (AKS) as an Event Grid source][aks-events]. + +## Delete the cluster and subscriptions + +### [Azure CLI](#tab/azure-cli) + +Use the [az group delete][az-group-delete] command to remove the resource group, the AKS cluster, namespace, and event hub, and all related resources. + +```azurecli-interactive +az group delete --name $RESOURCE_GROUP_NAME --yes --no-wait +``` + +### [Azure PowerShell](#tab/azure-powershell) + +Use the [Remove-AzResourceGroup][remove-azresourcegroup] cmdlet to remove the resource group, the AKS cluster, namespace, and event hub, and all related resources. + +```azurepowershell-interactive +Remove-AzResourceGroup -Name MyResourceGroup +``` + +--- + +> [!NOTE] +> When you delete the cluster, the Azure Active Directory service principal used by the AKS cluster is not removed. For steps on how to remove the service principal, see [AKS service principal considerations and deletion][sp-delete]. +> +> If you used a managed identity, the identity is managed by the platform and does not require removal. + +## Next steps + +In this quickstart, you deployed a Kubernetes cluster and then subscribed to AKS events in Azure Event Hubs. + +To learn more about AKS, and walk through a complete code to deployment example, continue to the Kubernetes cluster tutorial. + +> [!div class="nextstepaction"] +> [AKS tutorial][aks-tutorial] + +[azure-cli-install]: /cli/azure/install-azure-cli +[azure-powershell-install]: /powershell/azure/install-az-ps +[aks-events]: ../event-grid/event-schema-aks.md +[aks-tutorial]: ./tutorial-kubernetes-prepare-app.md +[az-aks-create]: /cli/azure/aks#az_aks_create +[new-azakscluster]: /powershell/module/az.aks/new-azakscluster +[az-eventhubs-namespace-create]: /cli/azure/eventhubs/namespace#az-eventhubs-namespace-create +[new-azeventhubnamespace]: /powershell/module/az.eventhub/new-azeventhubnamespace +[az-eventhubs-eventhub-create]: /cli/azure/eventhubs/eventhub#az-eventhubs-eventhub-create +[new-azeventhub]: /powershell/module/az.eventhub/new-azeventhub +[az-eventgrid-event-subscription-create]: /cli/azure/eventgrid/event-subscription#az-eventgrid-event-subscription-create +[new-azeventgridsubscription]: /powershell/module/az.eventgrid/new-azeventgridsubscription +[az-group-delete]: /cli/azure/group#az_group_delete +[sp-delete]: kubernetes-service-principal.md#other-considerations +[remove-azresourcegroup]: /powershell/module/az.resources/remove-azresourcegroup \ No newline at end of file diff --git a/scenarios/demos/createVMCommentVars.md b/scenarios/demos/createVMCommentVars.md new file mode 100644 index 00000000..016edbfa --- /dev/null +++ b/scenarios/demos/createVMCommentVars.md @@ -0,0 +1,150 @@ + + + +The following example uses a comment block to set environment variables which are used throughout the file as one of the acceptable patterns for an executable document. + + +# Quickstart: Create a Linux virtual machine with the Azure CLI + +**Applies to:** :heavy_check_mark: Linux VMs + +This quickstart shows you how to use the Azure CLI to deploy a Linux virtual machine (VM) in Azure. The Azure CLI is used to create and manage Azure resources via either the command line or scripts. + +In this tutorial, we will be installing the latest Debian image. To show the VM in action, you'll connect to it using SSH and install the NGINX web server. + +If you don't have an Azure subscription, create a [free account](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) before you begin. + +## Launch Azure Cloud Shell + +The Azure Cloud Shell is a free interactive shell that you can use to run the steps in this article. It has common Azure tools preinstalled and configured to use with your account. + +To open the Cloud Shell, just select **Try it** from the upper right corner of a code block. You can also open Cloud Shell in a separate browser tab by going to [https://shell.azure.com/bash](https://shell.azure.com/bash). Select **Copy** to copy the blocks of code, paste it into the Cloud Shell, and select **Enter** to run it. + +If you prefer to install and use the CLI locally, this quickstart requires Azure CLI version 2.0.30 or later. Run `az --version` to find the version. If you need to install or upgrade, see [Install Azure CLI]( /cli/azure/install-azure-cli). + + + +## Create a resource group + +Create a resource group with the [az group create](/cli/azure/group) command. An Azure resource group is a logical container into which Azure resources are deployed and managed. The following example creates a resource group named *myResourceGroup* in the *eastus* location: + +```bash +az group create --name $MY_RESOURCE_GROUP_NAME --location $MY_LOCATION +``` + +## Create virtual machine + +Create a VM with the [az vm create](/cli/azure/vm) command. + +The following example creates a VM named *myVM* and adds a user account named *azureuser*. The `--generate-ssh-keys` parameter is used to automatically generate an SSH key, and put it in the default key location (*~/.ssh*). To use a specific set of keys instead, use the `--ssh-key-values` option. + +```bash +az vm create \ + --resource-group $MY_RESOURCE_GROUP_NAME \ + --name $MY_VM_NAME \ + --image $MY_VM_IMAGE \ + --admin-username $MY_ADMIN_USERNAME \ + --generate-ssh-keys +``` + +It takes a few minutes to create the VM and supporting resources. The following example output shows the VM create operation was successful. + +```Output +{ + "fqdns": "", + "id": "/subscriptions//resourceGroups/myResourceGroup/providers/Microsoft.Compute/virtualMachines/myVM", + "location": "eastus", + "macAddress": "00-0D-3A-23-9A-49", + "powerState": "VM running", + "privateIpAddress": "10.0.0.4", + "publicIpAddress": "40.68.254.142", + "resourceGroup": "myResourceGroup" +} +``` + +Make a note of the `publicIpAddress` to use later. + +## Install web server + +To see your VM in action, install the NGINX web server. Update your package sources and then install the latest NGINX package. + +```bash +az vm run-command invoke \ + -g $MY_RESOURCE_GROUP_NAME \ + -n $MY_VM_NAME \ + --command-id RunShellScript \ + --scripts "sudo apt-get update && sudo apt-get install -y nginx" +``` + +## Open port 80 for web traffic + +By default, only SSH connections are opened when you create a Linux VM in Azure. Use [az vm open-port](/cli/azure/vm) to open TCP port 80 for use with the NGINX web server: + +```bash +az vm open-port --port 80 --resource-group $MY_RESOURCE_GROUP_NAME --name $MY_VM_NAME +``` + +## View the web server in action + +Use a web browser of your choice to view the default NGINX welcome page. Use the public IP address of your VM as the web address. The following example shows the default NGINX web site: + +![Screenshot showing the N G I N X default web page.](./media/quick-create-cli/nginix-welcome-page-debian.png) + +Or Run the following command to see the NGINX welcome page in terminal + +```bash + curl $(az vm show -d -g $MY_RESOURCE_GROUP_NAME -n $MY_VM_NAME --query "publicIps" -o tsv) +``` + + +```HTML + + + +Welcome to nginx! + + + +

Welcome to nginx!

+

If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.

+ +

For online documentation and support please refer to +nginx.org.
+Commercial support is available at +nginx.com.

+ +

Thank you for using nginx.

+ + +``` + +## Clean up resources + +When no longer needed, you can use the [az group delete](/cli/azure/group) command to remove the resource group, VM, and all related resources. + +```bash +az group delete --name $MY_RESOURCE_GROUP_NAME --no-wait --yes --verbose +``` + +## Next steps + +In this quickstart, you deployed a simple virtual machine, opened a network port for web traffic, and installed a basic web server. To learn more about Azure virtual machines, continue to the tutorial for Linux VMs. + + +> [!div class="nextstepaction"] +> [Azure Linux virtual machine tutorials](./tutorial-manage-vm.md) diff --git a/scenarios/demos/createVMEnvVars.ini b/scenarios/demos/createVMEnvVars.ini new file mode 100644 index 00000000..ad6cd15a --- /dev/null +++ b/scenarios/demos/createVMEnvVars.ini @@ -0,0 +1,5 @@ +MY_RESOURCE_GROUP_NAME = myResourceGroup +MY_LOCATION = eastus +MY_VM_NAME = myVM +MY_VM_IMAGE = debian +MY_ADMIN_USERNAME = azureuser \ No newline at end of file diff --git a/scenarios/demos/createVMEnvVars.md b/scenarios/demos/createVMEnvVars.md new file mode 100644 index 00000000..876a554d --- /dev/null +++ b/scenarios/demos/createVMEnvVars.md @@ -0,0 +1,140 @@ + +The following example uses a .ini file which is named azureVmCreateEnvVariables.ini to set environment variables which are used throughout the file as one of the acceptable patterns for an executable document. + +# Quickstart: Create a Linux virtual machine with the Azure CLI + +**Applies to:** :heavy_check_mark: Linux VMs + +This quickstart shows you how to use the Azure CLI to deploy a Linux virtual machine (VM) in Azure. The Azure CLI is used to create and manage Azure resources via either the command line or scripts. + +In this tutorial, we will be installing the latest Debian image. To show the VM in action, you'll connect to it using SSH and install the NGINX web server. + +If you don't have an Azure subscription, create a [free account](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) before you begin. + +## Launch Azure Cloud Shell + +The Azure Cloud Shell is a free interactive shell that you can use to run the steps in this article. It has common Azure tools preinstalled and configured to use with your account. + +To open the Cloud Shell, just select **Try it** from the upper right corner of a code block. You can also open Cloud Shell in a separate browser tab by going to [https://shell.azure.com/bash](https://shell.azure.com/bash). Select **Copy** to copy the blocks of code, paste it into the Cloud Shell, and select **Enter** to run it. + +If you prefer to install and use the CLI locally, this quickstart requires Azure CLI version 2.0.30 or later. Run `az --version` to find the version. If you need to install or upgrade, see [Install Azure CLI]( /cli/azure/install-azure-cli). + + + +## Create a resource group + +Create a resource group with the [az group create](/cli/azure/group) command. An Azure resource group is a logical container into which Azure resources are deployed and managed. The following example creates a resource group named *myResourceGroup* in the *eastus* location: + +```bash +az group create --name $MY_RESOURCE_GROUP_NAME --location $MY_LOCATION +``` + +## Create virtual machine + +Create a VM with the [az vm create](/cli/azure/vm) command. + +The following example creates a VM named *myVM* and adds a user account named *azureuser*. The `--generate-ssh-keys` parameter is used to automatically generate an SSH key, and put it in the default key location (*~/.ssh*). To use a specific set of keys instead, use the `--ssh-key-values` option. + +```bash +az vm create \ + --resource-group $MY_RESOURCE_GROUP_NAME \ + --name $MY_VM_NAME \ + --image $MY_VM_IMAGE \ + --admin-username $MY_ADMIN_USERNAME \ + --public-ip-sku Standard \ + --generate-ssh-keys +``` + +It takes a few minutes to create the VM and supporting resources. The following example output shows the VM create operation was successful. + +```json +{ + "fqdns": "", + "id": "/subscriptions//resourceGroups/myResourceGroup/providers/Microsoft.Compute/virtualMachines/myVM", + "location": "eastus", + "macAddress": "00-0D-3A-23-9A-49", + "powerState": "VM running", + "privateIpAddress": "10.0.0.4", + "publicIpAddress": "40.68.254.142", + "resourceGroup": "myResourceGroup" +} +``` + +Make a note of the `publicIpAddress` to use later. + +## Install web server + +To see your VM in action, install the NGINX web server. Update your package sources and then install the latest NGINX package. + +```bash +az vm run-command invoke \ + -g $MY_RESOURCE_GROUP_NAME \ + -n $MY_VM_NAME \ + --command-id RunShellScript \ + --scripts "sudo apt-get update && sudo apt-get install -y nginx" +``` + +## Open port 80 for web traffic + +By default, only SSH connections are opened when you create a Linux VM in Azure. Use [az vm open-port](/cli/azure/vm) to open TCP port 80 for use with the NGINX web server: + +```bash +az vm open-port --port 80 --resource-group $MY_RESOURCE_GROUP_NAME --name $MY_VM_NAME +``` + +## View the web server in action + +Use a web browser of your choice to view the default NGINX welcome page. Use the public IP address of your VM as the web address. The following example shows the default NGINX web site: + +![Screenshot showing the N G I N X default web page.](./media/quick-create-cli/nginix-welcome-page-debian.png) + +Or Run the following command to see the NGINX welcome page in terminal + +```bash + curl $(az vm show -d -g myResourceGroup -n myVM --query "publicIps" -o tsv) +``` + + +```HTML + + + +Welcome to nginx! + + + +

Welcome to nginx!

+

If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.

+ +

For online documentation and support please refer to +nginx.org.
+Commercial support is available at +nginx.com.

+ +

Thank you for using nginx.

+ + +``` + +## Clean up resources + +When no longer needed, you can use the [az group delete](/cli/azure/group) command to remove the resource group, VM, and all related resources. + +```bash +az group delete --name $MY_RESOURCE_GROUP_NAME --no-wait --yes --verbose +``` + +## Next steps + +In this quickstart, you deployed a simple virtual machine, opened a network port for web traffic, and installed a basic web server. To learn more about Azure virtual machines, continue to the tutorial for Linux VMs. + + +> [!div class="nextstepaction"] +> [Azure Linux virtual machine tutorials](./tutorial-manage-vm.md) diff --git a/scenarios/demos/vmssQuickstart.md b/scenarios/demos/vmssQuickstart.md new file mode 100644 index 00000000..c95b7156 --- /dev/null +++ b/scenarios/demos/vmssQuickstart.md @@ -0,0 +1,258 @@ +--- +title: Quickstart - Create a Virtual Machine Scale Set with Azure CLI +description: Get started with your deployments by learning how to quickly create a Virtual Machine Scale Set with Azure CLI. +author: ju-shim +ms.author: jushiman +ms.topic: quickstart +ms.service: virtual-machine-scale-sets +ms.date: 11/22/2022 +ms.reviewer: mimckitt +ms.custom: mimckitt, devx-track-azurecli, mode-api +--- + +# Quickstart: Create a Virtual Machine Scale Set with the Azure CLI + +**Applies to:** :heavy_check_mark: Linux VMs :heavy_check_mark: Windows VMs :heavy_check_mark: Uniform scale sets + +> [!NOTE] +> The following article is for Uniform Virtual Machine Scale Sets. We recommend using Flexible Virtual Machine Scale Sets for new workloads. Learn more about this new orchestration mode in our [Flexible Virtual Machine Scale Sets overview](flexible-virtual-machine-scale-sets.md). + +A Virtual Machine Scale Set allows you to deploy and manage a set of auto-scaling virtual machines. You can scale the number of VMs in the scale set manually, or define rules to autoscale based on resource usage like CPU, memory demand, or network traffic. An Azure load balancer then distributes traffic to the VM instances in the scale set. In this quickstart, you create a Virtual Machine Scale Set and deploy a sample application with the Azure CLI. + +[!INCLUDE [quickstarts-free-trial-note](../../includes/quickstarts-free-trial-note.md)] + +[!INCLUDE [azure-cli-prepare-your-environment.md](~/articles/reusable-content/azure-cli/azure-cli-prepare-your-environment.md)] + +- This article requires version 2.0.29 or later of the Azure CLI. If using Azure Cloud Shell, the latest version is already installed. + + +## Define Environment Variables + +Throughout this document we use environment variables to facilitate cut and paste reuse. +The default values below will enable you to work through this document in most cases. The meaning of each +environment variable will be addressed as they are used in the steps below. + +```azurecli-interactive +export RESOURCE_GROUP_NAME=vmssQuickstartRG +export RESOURCE_LOCATION=eastus +export SCALE_SET_NAME=vmssQuickstart +export BASE_VM_IMAGE=UbuntuLTS +export ADMIN_USERNAME=azureuser +export LOAD_BALANCER_NAME=vmssQuickstartLB +export BACKEND_POOL_NAME=vmssQuickstartPool +export LOAD_BALANCER_RULE_NAME=vmssQuickstartRule +export FRONT_END_IP_NAME=vmssQuickstartLoadBalancerFrontEnd +export CUSTOM_SCRIPT_NAME=vmssQuickstartCustomScript +export SCALE_SET_PUBLIC_IP=vmssQuickstartPublicIP +``` + +## Create a scale set +Before you can create a scale set, create a resource group with [az group create](/cli/azure/group). The following example creates a resource group named *myResourceGroup* in the *eastus* location: + +```azurecli-interactive +az group create --name $RESOURCE_GROUP_NAME --location $RESOURCE_LOCATION +``` + + +```Output +{ + "id": "/subscriptions//resourceGroups/myResourceGroup", + "location": "eastus", + "managedBy": null, + "name": "myResourceGroup", + "properties": { + "provisioningState": "Succeeded" + }, + "tags": null, + "type": "Microsoft.Resources/resourceGroups" +} +``` + +Now create a Virtual Machine Scale Set with [az vmss create](/cli/azure/vmss). The following example creates a scale set named *myScaleSet* that is set to automatically update as changes are applied, and generates SSH keys if they do not exist in *~/.ssh/id_rsa*. These SSH keys are used if you need to log in to the VM instances. To use an existing set of SSH keys, instead use the `--ssh-key-value` parameter and specify the location of your keys. + +```azurecli-interactive +az vmss create \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $SCALE_SET_NAME \ + --image $BASE_VM_IMAGE \ + --upgrade-policy-mode automatic \ + --admin-username $ADMIN_USERNAME \ + --generate-ssh-keys +``` + +It takes a few minutes to create and configure all the scale set resources and VMs. + + +## Deploy sample application +To test your scale set, install a basic web application. The Azure Custom Script Extension is used to download and run a script that installs an application on the VM instances. This extension is useful for post deployment configuration, software installation, or any other configuration / management task. For more information, see the [Custom Script Extension overview](../virtual-machines/extensions/custom-script-linux.md). + +Use the Custom Script Extension to install a basic NGINX web server. Apply the Custom Script Extension that installs NGINX with [az vmss extension set](/cli/azure/vmss/extension) as follows: + +```azurecli-interactive +az vmss extension set \ + --publisher Microsoft.Azure.Extensions \ + --version 2.0 \ + --name $CUSTOM_SCRIPT_NAME \ + --resource-group $RESOURCE_GROUP_NAME \ + --vmss-name $SCALE_SET_NAME \ + --settings '{"fileUris":["https://raw.githubusercontent.com/Azure-Samples/compute-automation-configurations/master/automate_nginx.sh"],"commandToExecute":"./automate_nginx.sh"}' +``` + +```Output +{ + "vmss": { + "doNotRunExtensionsOnOverprovisionedVMs": false, + "orchestrationMode": "Uniform", + "overprovision": true, + "provisioningState": "Succeeded", + "singlePlacementGroup": true, + "timeCreated": "2023-02-01T22:17:20.1117742+00:00", + "uniqueId": "38328143-69e8-4a9b-9d55-8a404cdb6d8b", + "upgradePolicy": { + "mode": "Automatic", + "rollingUpgradePolicy": { + "maxBatchInstancePercent": 20, + "maxSurge": false, + "maxUnhealthyInstancePercent": 20, + "maxUnhealthyUpgradedInstancePercent": 20, + "pauseTimeBetweenBatches": "PT0S", + "rollbackFailedInstancesOnPolicyBreach": false + } + }, + "virtualMachineProfile": { + "networkProfile": { + "networkInterfaceConfigurations": [ + { + "name": "mysca2132Nic", + "properties": { + "disableTcpStateTracking": false, + "dnsSettings": { + "dnsServers": [] + }, + "enableAcceleratedNetworking": false, + "enableIPForwarding": false, + "ipConfigurations": [ + { + "name": "mysca2132IPConfig", + "properties": { + "loadBalancerBackendAddressPools": [ + { + "id": "/subscriptions/f7a60fca-9977-4899-b907-005a076adbb6/resourceGroups/myResourceGroup/providers/Microsoft.Network/loadBalancers/myScaleSetLB/backendAddressPools/myScaleSetLBBEPool", + "resourceGroup": "myResourceGroup" + } + ], + "loadBalancerInboundNatPools": [ + { + "id": "/subscriptions/f7a60fca-9977-4899-b907-005a076adbb6/resourceGroups/myResourceGroup/providers/Microsoft.Network/loadBalancers/myScaleSetLB/inboundNatPools/myScaleSetLBNatPool", + "resourceGroup": "myResourceGroup" + } + ], + "privateIPAddressVersion": "IPv4", + "subnet": { + "id": "/subscriptions/f7a60fca-9977-4899-b907-005a076adbb6/resourceGroups/myResourceGroup/providers/Microsoft.Network/virtualNetworks/myScaleSetVNET/subnets/myScaleSetSubnet", + "resourceGroup": "myResourceGroup" + } + } + } + ], + "primary": true + } + } + ] + }, + "osProfile": { + "adminUsername": "azureuser", + "allowExtensionOperations": true, + "computerNamePrefix": "mysca2132", + "linuxConfiguration": { + "disablePasswordAuthentication": true, + "enableVMAgentPlatformUpdates": false, + "provisionVMAgent": true, + "ssh": { + "publicKeys": [ + { + "keyData": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvR1+fGFuVMWS2bAY0SgW4E9QzLZ77ETdbCBUVF46eAyL8JWsLynX214hNSK16l4UYZyC3E6jea5qw2rGHPP4eMp7iif50xqd6qGICS428mqc9Gz29J0LFanM7XpHwLnBiJ6hmKvqvHB5tsGKh44MddW0wv+KiiEHIV1ZdSSvBRJ5MMQhqZoUiqlChHourOhaZxvw2dpJhRCvAEKw1s5RoeoLJAdZ6Qr53ERSkJr3BF7uAoNlGx6gatBVkjV+w9CZXN/YN62b1QQiGnk5/BIXNqEIsyxsa84+GbyieRIN/wYjSEV7ASRxSj60qV7RPexvAI+4JGa9UELYMQDrBElgL", + "path": "/home/azureuser/.ssh/authorized_keys" + } + ] + } + }, + "requireGuestProvisionSignal": true, + "secrets": [] + }, + "storageProfile": { + "imageReference": { + "offer": "UbuntuServer", + "publisher": "Canonical", + "sku": "18.04-LTS", + "version": "latest" + }, + "osDisk": { + "caching": "ReadWrite", + "createOption": "FromImage", + "diskSizeGB": 30, + "managedDisk": { + "storageAccountType": "Premium_LRS" + }, + "osType": "Linux" + } + } + } + } +} +``` + +## Allow traffic to application +When the scale set was created, an Azure load balancer was automatically deployed. The load balancer distributes traffic to the VM instances in the scale set. To allow traffic to reach the sample web application, create a load balancer rule with [az network lb rule create](/cli/azure/network/lb/rule). The following example creates a rule named *myLoadBalancerRuleWeb*: + +```azurecli-interactive +az network lb rule create \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $LOAD_BALANCER_RULE_NAME \ + --lb-name $LOAD_BALANCER_NAME \ + --backend-pool-name $BACKEND_POOL_NAME \ + --backend-port 80 \ + --frontend-ip-name $FRONT_END_IP_NAME \ + --frontend-port 80 \ + --protocol tcp +``` + +## Test your scale set +To see your scale set in action, access the sample web application in a web browser. Obtain the public IP address of your load balancer with [az network public-ip show](/cli/azure/network/public-ip). The following example obtains the IP address for *myScaleSetLBPublicIP* created as part of the scale set: + +```azurecli-interactive +az network public-ip show \ + --resource-group $RESOURCE_GROUP_NAME \ + --name $SCALE_SET_PUBLIC_IP \ + --query '[ipAddress]' \ + --output tsv +``` + +Enter the public IP address of the load balancer in to a web browser. The load balancer distributes traffic to one of your VM instances, as shown in the following example: + +![Default web page in NGINX](media/virtual-machine-scale-sets-create-cli/running-nginx-site.png) + +Or run the following command in a local shell to validate the scale set is set up properly + +```bash + curl $(az network public-ip show --resource-group $RESOURCE_GROUP_NAME --name $SCALE_SET_PUBLIC_IP --query '[ipAddress]' --output tsv) +``` + + +```HTML +Hello World from host myscabd00000000 ! +``` + +## Clean up resources +When no longer needed, you can use [az group delete](/cli/azure/group) to remove the resource group, scale set, and all related resources as follows. The `--no-wait` parameter returns control to the prompt without waiting for the operation to complete. The `--yes` parameter confirms that you wish to delete the resources without an additional prompt to do so. + +```azurecli-interactive +az group delete --name $RESOURCE_GROUP_NAME --yes --no-wait +``` + + +## Next steps +In this quickstart, you created a basic scale set and used the Custom Script Extension to install a basic NGINX web server on the VM instances. To learn more, continue to the tutorial for how to create and manage Azure Virtual Machine Scale Sets. + +> [!div class="nextstepaction"] +> [Create and manage Azure Virtual Machine Scale Sets](tutorial-create-and-manage-cli.md) \ No newline at end of file diff --git a/scenarios/testing/CommentTest.md b/scenarios/testing/CommentTest.md new file mode 100644 index 00000000..05627f9b --- /dev/null +++ b/scenarios/testing/CommentTest.md @@ -0,0 +1,22 @@ + + + + + + +# Testing multi Line code block + +```azurecli-interactive +echo "Hello \ +world" +``` + +# This is what the output should be + +```text +hello world +``` \ No newline at end of file diff --git a/scenarios/testing/brokenMarkdown.md b/scenarios/testing/brokenMarkdown.md new file mode 100644 index 00000000..2754fdb4 --- /dev/null +++ b/scenarios/testing/brokenMarkdown.md @@ -0,0 +1,7 @@ +This is a markdown file which does not pass the requirements... It has a code block which never ends. + +Innovation Engine should be able to exit the program automatically instead of hanging + +```bash +echo "hello World" +`` diff --git a/scenarios/testing/createRG.md b/scenarios/testing/createRG.md new file mode 100644 index 00000000..f53b7693 --- /dev/null +++ b/scenarios/testing/createRG.md @@ -0,0 +1,29 @@ + + + +## Create a resource group + +Create a resource group with the [az group create](/cli/azure/group) command. An Azure resource group is a logical container into which Azure resources are deployed and managed. The following example creates a resource group named *myResourceGroup* in the *eastus* location: + +```bash +az group create --name $MY_RESOURCE_GROUP_NAME --location $MY_LOCATION +``` + + +```Output +{ + "fqdns": "", + "id": "/subscriptions//resourceGroups/myResourceGroup/providers/Microsoft.Compute/virtualMachines/myVM", + "location": "eastus", + "macAddress": "00-0D-3A-23-9A-49", + "powerState": "VM running", + "privateIpAddress": "10.0.0.4", + "publicIpAddress": "40.68.254.142", + "resourceGroup": "myResourceGroup" +} +``` \ No newline at end of file diff --git a/scenarios/testing/e2eAzureTestCommentVariables.md b/scenarios/testing/e2eAzureTestCommentVariables.md new file mode 100644 index 00000000..2106218f --- /dev/null +++ b/scenarios/testing/e2eAzureTestCommentVariables.md @@ -0,0 +1,159 @@ +--- +title: 'Quickstart: Use the Azure CLI to create a Linux VM' +description: In this quickstart, you learn how to use the Azure CLI to create a Linux virtual machine +author: cynthn +ms.service: virtual-machines +ms.collection: linux +ms.topic: quickstart +ms.workload: infrastructure +ms.date: 06/01/2022 +ms.author: cynthn +ms.custom: mvc, seo-javascript-september2019, seo-javascript-october2019, seo-python-october2019, devx-track-azurecli, mode-api +--- + + + +# Quickstart: Create a Linux virtual machine with the Azure CLI + +**Applies to:** :heavy_check_mark: Linux VMs + +This quickstart shows you how to use the Azure CLI to deploy a Linux virtual machine (VM) in Azure. The Azure CLI is used to create and manage Azure resources via either the command line or scripts. + +In this tutorial, we will be installing the latest Debian image. To show the VM in action, you'll connect to it using SSH and install the NGINX web server. + +If you don't have an Azure subscription, create a [free account](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) before you begin. + +## Launch Azure Cloud Shell + +The Azure Cloud Shell is a free interactive shell that you can use to run the steps in this article. It has common Azure tools preinstalled and configured to use with your account. + +To open the Cloud Shell, just select **Try it** from the upper right corner of a code block. You can also open Cloud Shell in a separate browser tab by going to [https://shell.azure.com/bash](https://shell.azure.com/bash). Select **Copy** to copy the blocks of code, paste it into the Cloud Shell, and select **Enter** to run it. + +If you prefer to install and use the CLI locally, this quickstart requires Azure CLI version 2.0.30 or later. Run `az --version` to find the version. If you need to install or upgrade, see [Install Azure CLI]( /cli/azure/install-azure-cli). + + + +## Create a resource group + +Create a resource group with the [az group create](/cli/azure/group) command. An Azure resource group is a logical container into which Azure resources are deployed and managed. The following example creates a resource group named *myResourceGroup* in the *eastus* location: + +```bash +az group create --name $MY_RESOURCE_GROUP_NAME --location $MY_LOCATION +``` + +## Create virtual machine + +Create a VM with the [az vm create](/cli/azure/vm) command. + +The following example creates a VM named *myVM* and adds a user account named *azureuser*. The `--generate-ssh-keys` parameter is used to automatically generate an SSH key, and put it in the default key location (*~/.ssh*). To use a specific set of keys instead, use the `--ssh-key-values` option. + +```bash +az vm create \ + --resource-group $MY_RESOURCE_GROUP_NAME \ + --name $MY_VM_NAME \ + --image $MY_VM_IMAGE \ + --admin-username $MY_ADMIN_USERNAME \ + --generate-ssh-keys +``` + +It takes a few minutes to create the VM and supporting resources. The following example output shows the VM create operation was successful. + +```Output +{ + "fqdns": "", + "id": "/subscriptions//resourceGroups/myResourceGroup/providers/Microsoft.Compute/virtualMachines/myVM", + "location": "eastus", + "macAddress": "00-0D-3A-23-9A-49", + "powerState": "VM running", + "privateIpAddress": "10.0.0.4", + "publicIpAddress": "40.68.254.142", + "resourceGroup": "myResourceGroup" +} +``` + +Make a note of the `publicIpAddress` to use later. + +## Install web server + +To see your VM in action, install the NGINX web server. Update your package sources and then install the latest NGINX package. + +```bash +az vm run-command invoke \ + -g $MY_RESOURCE_GROUP_NAME \ + -n $MY_VM_NAME \ + --command-id RunShellScript \ + --scripts "sudo apt-get update && sudo apt-get install -y nginx" +``` + +## Open port 80 for web traffic + +By default, only SSH connections are opened when you create a Linux VM in Azure. Use [az vm open-port](/cli/azure/vm) to open TCP port 80 for use with the NGINX web server: + +```bash +az vm open-port --port 80 --resource-group $MY_RESOURCE_GROUP_NAME --name $MY_VM_NAME +``` + +## View the web server in action + +Use a web browser of your choice to view the default NGINX welcome page. Use the public IP address of your VM as the web address. The following example shows the default NGINX web site: + +![Screenshot showing the N G I N X default web page.](./media/quick-create-cli/nginix-welcome-page-debian.png) + +Or Run the following command to see the NGINX welcome page in terminal + +```bash + curl $(az vm show -d -g $MY_RESOURCE_GROUP_NAME -n $MY_VM_NAME --query "publicIps" -o tsv) +``` + + +```HTML + + + +Welcome to nginx! + + + +

Welcome to nginx!

+

If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.

+ +

For online documentation and support please refer to +nginx.org.
+Commercial support is available at +nginx.com.

+ +

Thank you for using nginx.

+ + +``` + +## Clean up resources + +When no longer needed, you can use the [az group delete](/cli/azure/group) command to remove the resource group, VM, and all related resources. + +```bash +az group delete --name $MY_RESOURCE_GROUP_NAME --no-wait --yes --verbose +``` + +## Next steps + +In this quickstart, you deployed a simple virtual machine, opened a network port for web traffic, and installed a basic web server. To learn more about Azure virtual machines, continue to the tutorial for Linux VMs. + + +> [!div class="nextstepaction"] +> [Azure Linux virtual machine tutorials](./tutorial-manage-vm.md) diff --git a/scenarios/testing/fuzzyMatchTest.md b/scenarios/testing/fuzzyMatchTest.md new file mode 100644 index 00000000..f58e37c3 --- /dev/null +++ b/scenarios/testing/fuzzyMatchTest.md @@ -0,0 +1,51 @@ +# Testing multi Line code block + +```azurecli-interactive +echo "Hello World" +``` +This is what the expected output should be + +```text +Hello world +``` + + +# Testing multi Line code block + +```azurecli-interactive +echo "Hello \ +world" +``` + +# Output Should Fail + +```text +world Hello +``` + +# Code block + +```azurecli-interactive +echo "Hello \ +world" +``` + +# Output Should Pass + +```text +Hello world +``` + +# Code block + +```azurecli-interactive +echo "Hello \ +world" +``` + +# Bad similarity - should fail + +```text +Hello world +``` + diff --git a/scenarios/testing/nonCLI.md b/scenarios/testing/nonCLI.md new file mode 100644 index 00000000..9d16b6fb --- /dev/null +++ b/scenarios/testing/nonCLI.md @@ -0,0 +1,114 @@ +--- +title: Quickstart - Create a Linux VM in the Azure portal +description: In this quickstart, you learn how to use the Azure portal to create a Linux virtual machine. +author: cynthn +ms.service: virtual-machines +ms.collection: linux +ms.topic: quickstart +ms.workload: infrastructure +ms.date: 08/01/2022 +ms.author: cynthn +ms.custom: mvc, mode-ui +--- +This document will not be a CLI document. I am curious what innovation engine will do in this case. + +# Quickstart: Create a Linux virtual machine in the Azure portal + +**Applies to:** :heavy_check_mark: Linux VMs + +Azure virtual machines (VMs) can be created through the Azure portal. The Azure portal is a browser-based user interface to create Azure resources. This quickstart shows you how to use the Azure portal to deploy a Linux virtual machine (VM) running Ubuntu 18.04 LTS. To see your VM in action, you also SSH to the VM and install the NGINX web server. + +If you don't have an Azure subscription, create a [free account](https://azure.microsoft.com/free/?WT.mc_id=A261C142F) before you begin. + +## Sign in to Azure + +Sign in to the [Azure portal](https://portal.azure.com). + +## Create virtual machine + +1. Enter *virtual machines* in the search. +1. Under **Services**, select **Virtual machines**. +1. In the **Virtual machines** page, select **Create** and then **Virtual machine**. The **Create a virtual machine** page opens. + +1. In the **Basics** tab, under **Project details**, make sure the correct subscription is selected and then choose to **Create new** resource group. Enter *myResourceGroup* for the name.*. + + ![Screenshot of the Project details section showing where you select the Azure subscription and the resource group for the virtual machine](./media/quick-create-portal/project-details.png) + +1. Under **Instance details**, enter *myVM* for the **Virtual machine name**, and choose *Ubuntu 18.04 LTS - Gen2* for your **Image**. Leave the other defaults. The default size and pricing is only shown as an example. Size availability and pricing are dependent on your region and subscription. + + :::image type="content" source="media/quick-create-portal/instance-details.png" alt-text="Screenshot of the Instance details section where you provide a name for the virtual machine and select its region, image, and size."::: + + > [!NOTE] + > Some users will now see the option to create VMs in multiple zones. To learn more about this new capability, see [Create virtual machines in an availability zone](../create-portal-availability-zone.md). + > :::image type="content" source="../media/create-portal-availability-zone/preview.png" alt-text="Screenshot showing that you have the option to create virtual machines in multiple availability zones."::: + + +1. Under **Administrator account**, select **SSH public key**. + +1. In **Username** enter *azureuser*. + +1. For **SSH public key source**, leave the default of **Generate new key pair**, and then enter *myKey* for the **Key pair name**. + + ![Screenshot of the Administrator account section where you select an authentication type and provide the administrator credentials](./media/quick-create-portal/administrator-account.png) + +1. Under **Inbound port rules** > **Public inbound ports**, choose **Allow selected ports** and then select **SSH (22)** and **HTTP (80)** from the drop-down. + + ![Screenshot of the inbound port rules section where you select what ports inbound connections are allowed on](./media/quick-create-portal/inbound-port-rules.png) + +1. Leave the remaining defaults and then select the **Review + create** button at the bottom of the page. + +1. On the **Create a virtual machine** page, you can see the details about the VM you are about to create. When you are ready, select **Create**. + +1. When the **Generate new key pair** window opens, select **Download private key and create resource**. Your key file will be download as **myKey.pem**. Make sure you know where the `.pem` file was downloaded; you will need the path to it in the next step. + +1. When the deployment is finished, select **Go to resource**. + +1. On the page for your new VM, select the public IP address and copy it to your clipboard. + + + ![Screenshot showing how to copy the IP address for the virtual machine](./media/quick-create-portal/ip-address.png) + + +## Connect to virtual machine + +Create an SSH connection with the VM. + +1. If you are on a Mac or Linux machine, open a Bash prompt and set read-only permission on the .pem file using `chmod 400 ~/Downloads/myKey.pem`. If you are on a Windows machine, open a PowerShell prompt. + +1. At your prompt, open an SSH connection to your virtual machine. Replace the IP address with the one from your VM, and replace the path to the `.pem` with the path to where the key file was downloaded. + +```console +ssh -i ~/Downloads/myKey.pem azureuser@10.111.12.123 +``` + +> [!TIP] +> The SSH key you created can be used the next time your create a VM in Azure. Just select the **Use a key stored in Azure** for **SSH public key source** the next time you create a VM. You already have the private key on your computer, so you won't need to download anything. + +## Install web server + +To see your VM in action, install the NGINX web server. From your SSH session, update your package sources and then install the latest NGINX package. + +```bash +sudo apt-get -y update +sudo apt-get -y install nginx +``` + +When done, type `exit` to leave the SSH session. + + +## View the web server in action + +Use a web browser of your choice to view the default NGINX welcome page. Type the public IP address of the VM as the web address. The public IP address can be found on the VM overview page or as part of the SSH connection string you used earlier. + +![Screenshot showing the NGINX default site in a browser](./media/quick-create-portal/nginx.png) + +## Clean up resources + +When no longer needed, you can delete the resource group, virtual machine, and all related resources. To do so, select the resource group for the virtual machine, select **Delete**, then confirm the name of the resource group to delete. + +## Next steps + +In this quickstart, you deployed a simple virtual machine, created a Network Security Group and rule, and installed a basic web server. To learn more about Azure virtual machines, continue to the tutorial for Linux VMs. + +> [!div class="nextstepaction"] +> [Azure Linux virtual machine tutorials](./tutorial-manage-vm.md) diff --git a/scenarios/testing/test.md b/scenarios/testing/test.md new file mode 100644 index 00000000..164f31b3 --- /dev/null +++ b/scenarios/testing/test.md @@ -0,0 +1,172 @@ +--- +title: 'Quickstart: Use the Azure CLI to create a Linux VM' +--- + +# Prerequisites + +Innovation Engine can process prerequisites for documents. This code section tests that the pre requisites functionality works in Innovation Engine. +It will run the following real prerequisites along with a look for and fail to run a fake prerequisite. + +You must have completed [Fuzzy Matching Test](testScripts/fuzzyMatchTest.md) and you must have completed [Comment Test](testScripts/CommentTest.md) + +You also need to have completed [This is a fake file](testScripts/fakefile.md) + +And there are going to be additional \ and ( to throw off the algorithm... + +# Running simple bash commands + +Innovation engine can execute bash commands. For example + + +```bash +echo "Hello World" +``` + +# Test Code block with expected output + +```azurecli-interactive +echo "Hello \ +world" +``` + +It also can test the output to make sure everything ran as planned. + +``` +Hello world +``` + +# Test non-executable code blocks +If a code block does not have an executable tag it will simply render the codeblock as text + +For example: + +```YAML +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-back +spec: + replicas: 1 + selector: + matchLabels: + app: azure-vote-back + template: + metadata: + labels: + app: azure-vote-back + spec: + nodeSelector: + "kubernetes.io/os": linux + containers: + - name: azure-vote-back + image: mcr.microsoft.com/oss/bitnami/redis:6.0.8 + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + ports: + - containerPort: 6379 + name: redis +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-back +spec: + ports: + - port: 6379 + selector: + app: azure-vote-back +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-front +spec: + replicas: 1 + selector: + matchLabels: + app: azure-vote-front + template: + metadata: + labels: + app: azure-vote-front + spec: + nodeSelector: + "kubernetes.io/os": linux + containers: + - name: azure-vote-front + image: mcr.microsoft.com/azuredocs/azure-vote-front:v1 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + ports: + - containerPort: 80 + env: + - name: REDIS + value: "azure-vote-back" +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-front +spec: + type: LoadBalancer + ports: + - port: 80 + selector: + app: azure-vote-front + +``` + +# Testing regular comments + +Innovation engine is able to handle comments and actual do fancy things with special comments. + +There are comments you can't see here. + + + + + +# Testing Declaring Environment Variables from Comments +Innovation Engine can declare environment variables via hidden inline comments. This feature is useful for running documents E2E as part of CI/CD + + + + +```azurecli-interactive +echo $MY_VARIABLE +``` + + +# Test Running an Azure Command +```azurecli-interactive +az group exists --name MyResourceGroup +``` + +# Next Steps + +These are the next steps... at some point we need to do something here \ No newline at end of file diff --git a/scenarios/testing/variableHierarchy.ini b/scenarios/testing/variableHierarchy.ini new file mode 100644 index 00000000..84c5cad7 --- /dev/null +++ b/scenarios/testing/variableHierarchy.ini @@ -0,0 +1,2 @@ +MY_RESOURCE_GROUP = SetInINI +MY_VARIABLE_NAME = VariableFromIni \ No newline at end of file diff --git a/scenarios/testing/variableHierarchy.md b/scenarios/testing/variableHierarchy.md new file mode 100644 index 00000000..003d0e34 --- /dev/null +++ b/scenarios/testing/variableHierarchy.md @@ -0,0 +1,26 @@ +This document is to show the hierarchy of environment variables + + + + +```bash +echo $MY_RESOURCE_GROUP +echo $MY_VARIABLE_NAME +``` + +# The following will now declare variables locally which will overwrite comment variables + +```bash +export MY_RESOURCE_GROUP=RGSetLocally +export MY_VARIABLE_NAME=LocallySetVariable +``` + +```bash +echo $MY_RESOURCE_GROUP +echo $MY_VARIABLE_NAME +``` diff --git a/scripts/install_docs_from_release.sh b/scripts/install_docs_from_release.sh new file mode 100644 index 00000000..13ce8cfd --- /dev/null +++ b/scripts/install_docs_from_release.sh @@ -0,0 +1,67 @@ +# Script to install documentation from our upstream repository. + +set -e + +# TODO: make parameters mandatory +LANG="$1" +RELEASE="$2" + +# If no release is specified, download the latest release +if [ "$RELEASE" == "" ]; then + RELEASE="latest" +fi + +# Set a default scenarios file +SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/scenarios.zip" + +# If the LANG parameter was set, download appropriate script +if [ "$LANG" != "" ]; then + # Map the language parameter to the corresponding scenarios file + # If no parameter, download the scenarios from IE + MAIN_LANG_PREFIX="$(echo "$LANG" | head -c2 | tr '[:upper:]' '[:lower:]')" + LANG_ARRAY=("de" "es" "fr" "it" "nl" "pt" "zh" "cs" "hu" "id" "ja" "ko" "pl" "pt" "ru" "sv" "tr") + + if [[ "${LANG_ARRAY[*]}" =~ "$MAIN_LANG_PREFIX" ]]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/$MAIN_LANG_PREFIX-$MAIN_LANG_PREFIX-scenarios.zip" + if [ "$MAIN_LANG_PREFIX" = "pt" ]; then + if [ "$LANG" = "pt-pt" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/pt-pt-scenarios.zip" + elif [ "$LANG" = "pt-br" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/pt-br-scenarios.zip" + else + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/pt-pt-scenarios.zip" + fi + fi + if [ "$MAIN_LANG_PREFIX" = "zh" ]; then + if [ "$LANG" = "zh-cn" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/zh-cn-scenarios.zip" + elif [ "$LANG" = "zh-tw" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/zh-tw-scenarios.zip" + else + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/zh-cn-scenarios.zip" + fi + fi + if [ "$MAIN_LANG_PREFIX" = "cs" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/$MAIN_LANG_PREFIX-cz-scenarios.zip" + fi + if [ "$MAIN_LANG_PREFIX" = "ja" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/$MAIN_LANG_PREFIX-jp-scenarios.zip" + fi + if [ "$MAIN_LANG_PREFIX" = "ko" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/$MAIN_LANG_PREFIX-kr-scenarios.zip" + fi + if [ "$MAIN_LANG_PREFIX" = "sv" ]; then + SCENARIOS="https://github.com/MicrosoftDocs/executable-docs/releases/download/$RELEASE/$MAIN_LANG_PREFIX-se-scenarios.zip" + fi + fi +fi + +# Download the scenarios. +echo "Installing scenarios from the $RELEASE release..." +wget -q -O scenarios.zip "$SCENARIOS" >/dev/null + +# Unzip the scenarios, overwrite if they already exist. +unzip -o scenarios.zip -d ~ >/dev/null +rm scenarios.zip >/dev/null + +echo "Done." diff --git a/scripts/install_from_release.sh b/scripts/install_from_release.sh new file mode 100644 index 00000000..234a5e72 --- /dev/null +++ b/scripts/install_from_release.sh @@ -0,0 +1,25 @@ +# Script to install scenarios file. Pass in language code parameter for a particular language, such as it-it for Italian. +set -e + +RELEASE="$1" + +# If no release is specified, download the latest release +if [ "$RELEASE" == "" ]; then + RELEASE="latest" +fi + +# Download the binary +echo "Installing IE from the $RELEASE release..." +wget -q -O ie https://github.com/Azure/InnovationEngine/releases/download/"$RELEASE"/ie >/dev/null + +# Setup permissions & move to the local bin +chmod +x ie >/dev/null +mkdir -p ~/.local/bin >/dev/null +mv ie ~/.local/bin >/dev/null + +# Export the path to IE if it's not already available +if [[ !"$PATH" =~ "~/.local/bin" || !"$PATH" =~ "$HOME/.local/bin" ]]; then + export PATH="$PATH:~/.local/bin" +fi + +echo "Done." diff --git a/tutorial.md b/tutorial.md new file mode 100644 index 00000000..8db3d843 --- /dev/null +++ b/tutorial.md @@ -0,0 +1,140 @@ +# Welcome to the innovation Engine Tutorial +## *TODO ADD MORE DETAIL TO IMPROVE TUTORIAL* + +# Running simple bash commands + +Innovation engine can execute bash commands. For example + +```bash +export VAR="Hello World" +``` + +```bash +echo $VAR +``` + +# Test Code block with expected output + +```azurecli-interactive +echo "Hello \ +world" +``` + +It also can test the output to make sure everything ran as planned. + +``` +Hello world +``` + +# Test Code block matches expected regex + +```bash +echo "Foo Bar" +``` + +It also can test the output to make sure everything ran as planned. + +``` +Foo Bar +``` + +# Executable vs non-executable code blocks +Innovation engine supports code blocks which are both executable and non-executable. A code block is executable if the label/tag after the bash scripts is one of the supported executable tags. Those tags are: bash, terraform, azurecli-interactive, and azurecli. + +If a code block has a non supported tag like YAML or HTML it will simply render the code block as text and continue parsing the document. + +For example: + +```YAML +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-back +spec: + replicas: 1 + selector: + matchLabels: + app: azure-vote-back + template: + metadata: + labels: + app: azure-vote-back + spec: + nodeSelector: + "kubernetes.io/os": linux + containers: + - name: azure-vote-back + image: mcr.microsoft.com/oss/bitnami/redis:6.0.8 + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + ports: + - containerPort: 6379 + name: redis +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-back +spec: + ports: + - port: 6379 + selector: + app: azure-vote-back +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: azure-vote-front +spec: + replicas: 1 + selector: + matchLabels: + app: azure-vote-front + template: + metadata: + labels: + app: azure-vote-front + spec: + nodeSelector: + "kubernetes.io/os": linux + containers: + - name: azure-vote-front + image: mcr.microsoft.com/azuredocs/azure-vote-front:v1 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 250m + memory: 256Mi + ports: + - containerPort: 80 + env: + - name: REDIS + value: "azure-vote-back" +--- +apiVersion: v1 +kind: Service +metadata: + name: azure-vote-front +spec: + type: LoadBalancer + ports: + - port: 80 + selector: + app: azure-vote-front + +``` + + +# Next Steps + +These are the next steps... at some point we need to do something here \ No newline at end of file diff --git a/upstream-scenarios b/upstream-scenarios new file mode 160000 index 00000000..8c715328 --- /dev/null +++ b/upstream-scenarios @@ -0,0 +1 @@ +Subproject commit 8c7153283e160569577589d923449fd940ea0ef1