From 49e5695660197f1a04de70b333d06f0f2a196adc Mon Sep 17 00:00:00 2001 From: Dan Miller Date: Fri, 10 Jan 2025 16:41:24 -0500 Subject: [PATCH] DEV-2617: Improved AtmosWorkflows (#703) --- docs/layers/gitops/setup.mdx | 2 +- src/components/AtmosWorkflow/constants.ts | 4 + src/components/AtmosWorkflow/index.tsx | 118 +++++++--------- src/components/AtmosWorkflow/styles.css | 6 + src/components/AtmosWorkflow/types.ts | 11 ++ src/components/AtmosWorkflow/utils.ts | 157 ++++++++++++++++++++++ 6 files changed, 229 insertions(+), 69 deletions(-) create mode 100644 src/components/AtmosWorkflow/constants.ts create mode 100644 src/components/AtmosWorkflow/styles.css create mode 100644 src/components/AtmosWorkflow/types.ts create mode 100644 src/components/AtmosWorkflow/utils.ts diff --git a/docs/layers/gitops/setup.mdx b/docs/layers/gitops/setup.mdx index 9a609c324..8bbb722bf 100644 --- a/docs/layers/gitops/setup.mdx +++ b/docs/layers/gitops/setup.mdx @@ -123,7 +123,7 @@ import AtmosWorkflow from '@site/src/components/AtmosWorkflow'; Deploy three components, `gitops/s3-bucket`, `gitops/dynamodb`, and `gitops` with the following workflow: - + And that's it! diff --git a/src/components/AtmosWorkflow/constants.ts b/src/components/AtmosWorkflow/constants.ts new file mode 100644 index 000000000..c03ba20dc --- /dev/null +++ b/src/components/AtmosWorkflow/constants.ts @@ -0,0 +1,4 @@ +// constants.ts + +export const CLOUDPOSSE_DOCS_URL = 'https://raw.githubusercontent.com/cloudposse/docs/master/'; +export const WORKFLOWS_DIRECTORY_PATH = 'examples/snippets/stacks/workflows/'; diff --git a/src/components/AtmosWorkflow/index.tsx b/src/components/AtmosWorkflow/index.tsx index f51335260..3ba35f013 100644 --- a/src/components/AtmosWorkflow/index.tsx +++ b/src/components/AtmosWorkflow/index.tsx @@ -1,71 +1,32 @@ +// index.tsx + import React, { useEffect, useState } from 'react'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; import CodeBlock from '@theme/CodeBlock'; +import Note from '@site/src/components/Note'; import Steps from '@site/src/components/Steps'; +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; -import * as yaml from 'js-yaml'; - -// Define constants for the base URL and workflows directory path -const CLOUDPOSSE_DOCS_URL = 'https://raw.githubusercontent.com/cloudposse/docs/master/'; -const WORKFLOWS_DIRECTORY_PATH = 'examples/snippets/stacks/workflows/'; - -async function GetAtmosTerraformCommands(workflow: string, fileName: string, stack?: string): Promise { - try { - // Construct the full URL to the workflow YAML file - const url = `${CLOUDPOSSE_DOCS_URL}${WORKFLOWS_DIRECTORY_PATH}${fileName}.yaml`; - - // Fetch the workflow file from the constructed URL - const response = await fetch(url); - if (!response.ok) { - console.error('Failed to fetch the file:', response.statusText); - console.error('Workflow URL:', url); - return undefined; - } - const fileContent = await response.text(); - - // Parse the YAML content - const workflows = yaml.load(fileContent) as any; - - // Find the specified workflow in the parsed YAML - if (workflows && workflows.workflows && workflows.workflows[workflow]) { - const workflowDetails = workflows.workflows[workflow]; - - // Extract the commands under that workflow - const commands = workflowDetails.steps.map((step: any) => { - let command = step.command; - // TODO handle nested Atmos Workflows - // For example: https://raw.githubusercontent.com/cloudposse/docs/master/examples/snippets/stacks/workflows/identity.yaml - if (!step.type) { - command = `atmos ${command}`; - if (stack) { - command += ` -s ${stack}`; - } - } - return command; - }); - - return commands; - } +import { GetAtmosTerraformCommands } from './utils'; +import { WorkflowStep, WorkflowData } from './types'; +import { WORKFLOWS_DIRECTORY_PATH } from './constants'; - // Return undefined if the workflow is not found - return undefined; - } catch (error) { - console.error('Error fetching or parsing the file:', error); - return undefined; - } +interface AtmosWorkflowProps { + workflow: string; + stack?: string; + fileName: string; } -export default function AtmosWorkflow({ workflow, stack = "", fileName }) { - const [commands, setCommands] = useState([]); +export default function AtmosWorkflow({ workflow, stack = '', fileName }: AtmosWorkflowProps) { + const [workflowData, setWorkflowData] = useState(null); const fullFilePath = `${WORKFLOWS_DIRECTORY_PATH}${fileName}.yaml`; useEffect(() => { - GetAtmosTerraformCommands(workflow, fileName, stack).then((cmds) => { - if (Array.isArray(cmds)) { - setCommands(cmds); + GetAtmosTerraformCommands(workflow, fileName, stack).then((data) => { + if (data) { + setWorkflowData(data); } else { - setCommands([]); // Default to an empty array if cmds is undefined or not an array + setWorkflowData(null); } }); }, [workflow, fileName, stack]); @@ -73,24 +34,45 @@ export default function AtmosWorkflow({ workflow, stack = "", fileName }) { return ( - These are the commands included in the {workflow} workflow in the {fullFilePath} file: + + These are the commands included in the {workflow} workflow in the{' '} + {fullFilePath} file: + + {workflowData?.description && ( +

+ {workflowData.description} +

+ )}
    - {commands.length > 0 ? commands.map((cmd, index) => ( -
  • - - {cmd} - -
  • - )) : 'No commands found'} + {workflowData?.steps.length ? ( + workflowData.steps.map((step, index) => ( +
  • + {step.type === 'title' ? ( + <> +

    + {step.content.split('\n\n')[0]} +

    + + {step.content.split('\n\n')[1]} + + + ) : ( + {step.content} + )} +
  • + )) + ) : ( + 'No commands found' + )}
- Too many commands? Consider using the Atmos workflow! 🚀 +

Too many commands? Consider using the Atmos workflow! 🚀

- Run the following from your Geodesic shell using the Atmos workflow: +

Run the following from your Geodesic shell using the Atmos workflow:

- atmos workflow {workflow} -f {fileName} {stack && `-s ${stack}`} + {`atmos workflow ${workflow} -f ${fileName} ${stack ? `-s ${stack}` : ''}`}
diff --git a/src/components/AtmosWorkflow/styles.css b/src/components/AtmosWorkflow/styles.css new file mode 100644 index 000000000..77f65303a --- /dev/null +++ b/src/components/AtmosWorkflow/styles.css @@ -0,0 +1,6 @@ +/* styles.css */ + +.workflow-title { + font-size: 1.25em; + color: #2c3e50; +} diff --git a/src/components/AtmosWorkflow/types.ts b/src/components/AtmosWorkflow/types.ts new file mode 100644 index 000000000..63ebf25ac --- /dev/null +++ b/src/components/AtmosWorkflow/types.ts @@ -0,0 +1,11 @@ +// types.ts + +export interface WorkflowStep { + type: 'command' | 'title'; + content: string; +} + +export interface WorkflowData { + description?: string; + steps: WorkflowStep[]; +} diff --git a/src/components/AtmosWorkflow/utils.ts b/src/components/AtmosWorkflow/utils.ts new file mode 100644 index 000000000..8a1aefd14 --- /dev/null +++ b/src/components/AtmosWorkflow/utils.ts @@ -0,0 +1,157 @@ +// utils.ts + +import * as yaml from 'js-yaml'; +import { WorkflowStep, WorkflowData } from './types'; +import { CLOUDPOSSE_DOCS_URL, WORKFLOWS_DIRECTORY_PATH } from './constants'; + +export async function GetAtmosTerraformCommands( + workflow: string, + fileName: string, + stack?: string, + visitedWorkflows = new Set() +): Promise { + try { + const url = `${CLOUDPOSSE_DOCS_URL}${WORKFLOWS_DIRECTORY_PATH}${fileName}.yaml`; + + const response = await fetch(url); + if (!response.ok) { + console.error('Failed to fetch the file:', response.statusText); + console.error('Workflow URL:', url); + return undefined; + } + const fileContent = await response.text(); + + const workflows = yaml.load(fileContent) as any; + + if (workflows && workflows.workflows && workflows.workflows[workflow]) { + const workflowDetails = workflows.workflows[workflow]; + + const workflowKey = `${fileName}:${workflow}`; + if (visitedWorkflows.has(workflowKey)) { + console.warn( + `Already visited workflow ${workflow} in file ${fileName}, skipping to prevent infinite loop.` + ); + return { description: workflowDetails.description, steps: [] }; + } + visitedWorkflows.add(workflowKey); + + let steps: WorkflowStep[] = []; + let currentGroupCommands: string[] = []; + let currentTitle: string | null = null; + + const addGroupToSteps = () => { + if (currentGroupCommands.length > 0) { + if (currentTitle) { + steps.push({ + type: 'title', + content: `${currentTitle}\n\n${currentGroupCommands.join('\n')}` + }); + } else { + steps.push({ + type: 'command', + content: currentGroupCommands.join('\n') + }); + } + currentGroupCommands = []; + currentTitle = null; + } + }; + + // Group all vendor pull commands together + const isVendorWorkflow = workflowDetails.steps.every(step => + step.command.startsWith('vendor pull') + ); + + for (const step of workflowDetails.steps) { + let command = step.command; + + if (isVendorWorkflow) { + // Add all vendor commands to a single group + let atmosCommand = `atmos ${command}`; + if (stack) { + atmosCommand += ` -s ${stack}`; + } + currentGroupCommands.push(atmosCommand); + } else if (command.trim().startsWith('echo') && step.type === 'shell') { + // When we find an echo, add previous group and start new group + addGroupToSteps(); + currentTitle = command.replace(/^echo\s+['"](.+)['"]$/, '$1'); + } else if (command.startsWith('workflow')) { + // For nested workflows, add current group first + addGroupToSteps(); + + const commandParts = command.split(' '); + const nestedWorkflowIndex = commandParts.findIndex((part) => part === 'workflow') + 1; + const nestedWorkflow = commandParts[nestedWorkflowIndex]; + + let nestedFileName = fileName; + const fileFlagIndex = commandParts.findIndex((part) => part === '-f' || part === '--file'); + if (fileFlagIndex !== -1) { + nestedFileName = commandParts[fileFlagIndex + 1]; + } + + let nestedStack = stack; + const stackFlagIndex = commandParts.findIndex((part) => part === '-s' || part === '--stack'); + if (stackFlagIndex !== -1) { + nestedStack = commandParts[stackFlagIndex + 1]; + } + + const nestedData = await GetAtmosTerraformCommands( + nestedWorkflow, + nestedFileName, + nestedStack, + visitedWorkflows + ); + + if (nestedData && nestedData.steps) { + steps = steps.concat(nestedData.steps); + } + } else { + if (currentTitle) { + // We're in an echo group + if (step.type === 'shell') { + const shebang = `#!/bin/bash\n`; + const titleComment = `# Run the ${step.name || 'script'} Script\n`; + currentGroupCommands.push(`${shebang}${titleComment}${command}`); + } else { + let atmosCommand = `atmos ${command}`; + if (stack) { + atmosCommand += ` -s ${stack}`; + } + currentGroupCommands.push(atmosCommand); + } + } else { + // Individual step + if (step.type === 'shell') { + const shebang = `#!/bin/bash\n`; + const titleComment = `# Run the ${step.name || 'script'} Script\n`; + steps.push({ + type: 'command', + content: `${shebang}${titleComment}${command}`, + }); + } else { + let atmosCommand = `atmos ${command}`; + if (stack) { + atmosCommand += ` -s ${stack}`; + } + steps.push({ + type: 'command', + content: atmosCommand, + }); + } + } + } + } + + // Add any remaining grouped commands + addGroupToSteps(); + + return { description: workflowDetails.description, steps }; + } + + return undefined; + } catch (error) { + console.error('Error fetching or parsing the file:', error); + return undefined; + } +}