From 8bb5cdca8c3a538aebe6be9fa0bb38b9023e76d1 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Nov 2026 17:34:43 -0500 Subject: [PATCH 1/4] Fix lab CLI lines and Python/Terraform snippets found by replaying every lab Lab commands: - Organizations create-policy lines pass the required --description. - Shorthand values with {a,b} are quoted, so the shell doesn't brace-expand them (Lambda environment, Firehose buffering hints, CodeBuild environment and report export, RI budget, put-metric-data dimensions, ECS network configuration, SageMaker input channels). - apigatewayv2 create-stage quotes '$default', which the shell would empty. - The guided EC2 send-command uses a JSON list the CLI accepts. - SQS batch entry files and the EventBridge targets file hold the list the CLI option expects; the SQS create-queue attributes file is an attributes map. Snippets: - Python catches the errors a lab marks as expected (refused, denied, ...) so the rest of the script still runs. - CLI doubles render as plain numbers instead of an unimported Decimal. - Abbreviated or placeholder shorthand keeps placeholders and drops fragments that only half-parse. - Terraform flow logs use log_destination (AWS provider 6 removed log_group_name). Tests: every complete CLI line parses with the AWS CLI, every lab command survives the shell, every artifact fits its CLI parameter, and every boto3 snippet's literal values fit the API shapes and use only defined names. Co-Authored-By: Claude Opus 5.5 --- dashboard/labs/apigateway_labs.py | 4 +- dashboard/labs/cloudwatch_logging_labs.py | 4 +- .../labs/cloudwatch_observability_labs.py | 6 +- dashboard/labs/codepipeline_gates_labs.py | 2 +- dashboard/labs/codepipeline_labs.py | 4 +- dashboard/labs/costexplorer_labs.py | 4 +- dashboard/labs/ec2_guided.py | 2 +- dashboard/labs/ec2_performance_labs.py | 6 +- dashboard/labs/ecs_bluegreen_labs.py | 2 +- dashboard/labs/eventbridge_application.py | 6 +- dashboard/labs/iam_organizations_labs.py | 6 +- dashboard/labs/lambda_labs.py | 4 +- dashboard/labs/sagemaker_labs.py | 4 +- dashboard/labs/snippets.py | 109 ++++++++-- dashboard/labs/sqs_labs.py | 18 +- dashboard/tests_labs.py | 187 +++++++++++++++++- 16 files changed, 316 insertions(+), 52 deletions(-) diff --git a/dashboard/labs/apigateway_labs.py b/dashboard/labs/apigateway_labs.py index a9dba7b..e39d610 100644 --- a/dashboard/labs/apigateway_labs.py +++ b/dashboard/labs/apigateway_labs.py @@ -112,7 +112,7 @@ { 'key': 'create-stage', 'title': 'Create the default stage', - 'command': 'aws apigatewayv2 create-stage --api-id --stage-name $default --auto-deploy', + 'command': 'aws apigatewayv2 create-stage --api-id --stage-name \'$default\' --auto-deploy', 'explanation': 'Publishes route changes automatically through the default HTTP API stage.', }, { @@ -436,7 +436,7 @@ def _run_apigw_create_route() -> dict[str, Any]: def _run_apigw_create_stage() -> dict[str, Any]: - command = 'aws apigatewayv2 create-stage --api-id --stage-name $default --auto-deploy' + command = "aws apigatewayv2 create-stage --api-id --stage-name '$default' --auto-deploy" started = time.perf_counter() api_id = _apigw_lambda_api_id() stage_verification = _verify_apigw_lambda_stage() diff --git a/dashboard/labs/cloudwatch_logging_labs.py b/dashboard/labs/cloudwatch_logging_labs.py index 8da8a2b..c62361c 100644 --- a/dashboard/labs/cloudwatch_logging_labs.py +++ b/dashboard/labs/cloudwatch_logging_labs.py @@ -181,8 +181,8 @@ def destination_policy(destination_arn: str) -> dict[str, Any]: f'aws s3api put-bucket-lifecycle-configuration --bucket {BUCKET} --lifecycle-configuration file://lifecycle.json\n' f'aws iam create-role --role-name {FIREHOSE_ROLE} --assume-role-policy-document file://firehose-trust.json\n' f'aws firehose create-delivery-stream --delivery-stream-name {ARCHIVE_STREAM} ' - f'--extended-s3-destination-configuration RoleARN={FIREHOSE_ROLE_ARN},BucketARN=arn:aws:s3:::{BUCKET},' - f'Prefix={ARCHIVE_PREFIX},BufferingHints={{IntervalInSeconds={ARCHIVE_BUFFER_SECONDS},SizeInMBs=1}}\n' + f'--extended-s3-destination-configuration "RoleARN={FIREHOSE_ROLE_ARN},BucketARN=arn:aws:s3:::{BUCKET},' + f'Prefix={ARCHIVE_PREFIX},BufferingHints={{IntervalInSeconds={ARCHIVE_BUFFER_SECONDS},SizeInMBs=1}}"\n' + ''.join( f'aws logs put-subscription-filter --log-group-name {group} --filter-name {ARCHIVE_SUBSCRIPTION} ' f'--filter-pattern "" --destination-arn {ARCHIVE_ARN} --role-arn {LOGS_ROLE_ARN}\n' diff --git a/dashboard/labs/cloudwatch_observability_labs.py b/dashboard/labs/cloudwatch_observability_labs.py index c85eee3..c8bace5 100644 --- a/dashboard/labs/cloudwatch_observability_labs.py +++ b/dashboard/labs/cloudwatch_observability_labs.py @@ -193,7 +193,7 @@ def handler(event, context): ) ALARM_COMMAND = ( f'aws sns create-topic --name {TOPIC}\n' - f'aws sqs create-queue --queue-name {INBOX} --attributes file://inbox-policy.json\n' + f'aws sqs create-queue --queue-name {INBOX} --attributes file://inbox-attributes.json\n' f'aws sns subscribe --topic-arn {TOPIC_ARN} --protocol sqs --notification-endpoint {INBOX_ARN}\n' f'aws cloudwatch put-metric-alarm --alarm-name {ALARM} --namespace {NAMESPACE} --metric-name {ERRORS_METRIC} ' f'--statistic Sum --period 60 --evaluation-periods 1 --threshold {ALARM_THRESHOLD} ' @@ -304,8 +304,8 @@ def handler(event, context): 'notification to the topic (Floci 2.3.0 ran no alarm actions, so nothing reached the queue). The step ' 'checks the state and the configured actions, and reports whether the ALARM message arrived.' ), - 'artifact_label': 'inbox-policy.json', - 'artifact': json.dumps(INBOX_POLICY, indent=2), + 'artifact_label': 'inbox-attributes.json', + 'artifact': json.dumps({'Policy': json.dumps(INBOX_POLICY)}, indent=2), }, { 'key': 'dashboard', diff --git a/dashboard/labs/codepipeline_gates_labs.py b/dashboard/labs/codepipeline_gates_labs.py index 8847e04..fa72fd8 100644 --- a/dashboard/labs/codepipeline_gates_labs.py +++ b/dashboard/labs/codepipeline_gates_labs.py @@ -504,7 +504,7 @@ def queue_policy(queue_arn: str, rule_arn: str) -> dict[str, Any]: ) REPORT_COMMAND = ( f'aws codebuild create-report-group --name {REPORT_GROUP} --type TEST ' - f'--export-config exportConfigType=S3,s3Destination={{bucket={ARTIFACT_BUCKET},path=reports,packaging=NONE}}\n' + f'--export-config "exportConfigType=S3,s3Destination={{bucket={ARTIFACT_BUCKET},path=reports,packaging=NONE}}"\n' f'aws codebuild list-reports-for-report-group --report-group-arn \n' 'aws codebuild describe-test-cases --report-arn \n' f'aws s3 cp s3://{ARTIFACT_BUCKET}/{UNIT_PREFIX}/junit.xml -' diff --git a/dashboard/labs/codepipeline_labs.py b/dashboard/labs/codepipeline_labs.py index c59d8d9..275887c 100644 --- a/dashboard/labs/codepipeline_labs.py +++ b/dashboard/labs/codepipeline_labs.py @@ -154,8 +154,8 @@ def _trust(service: str) -> dict[str, Any]: f'aws codebuild create-project --name {PROJECT} --service-role {BUILD_ROLE} \\\n' f' --source type=S3,location={BUCKET}/{SOURCE_KEY} \\\n' f' --artifacts type=S3,location={BUCKET},path={ARTIFACT_PATH},namespaceType=BUILD_ID,name={ARTIFACT_NAME},packaging=ZIP \\\n' - f' --environment type=LINUX_CONTAINER,computeType=BUILD_GENERAL1_SMALL,image={BUILD_IMAGE},' - f'environmentVariables=[{{name=STAGE,value={BUILD_STAGE}}}] \\\n' + f' --environment "type=LINUX_CONTAINER,computeType=BUILD_GENERAL1_SMALL,image={BUILD_IMAGE},' + f'environmentVariables=[{{name=STAGE,value={BUILD_STAGE}}}]" \\\n' f' --timeout-in-minutes {BUILD_TIMEOUT_MINUTES}' ) FIRST_BUILD_COMMAND = ( diff --git a/dashboard/labs/costexplorer_labs.py b/dashboard/labs/costexplorer_labs.py index 320c240..800b521 100644 --- a/dashboard/labs/costexplorer_labs.py +++ b/dashboard/labs/costexplorer_labs.py @@ -101,8 +101,8 @@ f'aws sns create-topic --name {TOPIC}\n' f'aws budgets create-budget --account-id {ACCOUNT} --budget file://platform-budget.json ' '--notifications-with-subscribers file://alerts.json # ACTUAL > 80%, FORECASTED > 100%\n' - f'aws budgets create-budget --account-id {ACCOUNT} --budget BudgetName={RI_BUDGET},BudgetType=RI_UTILIZATION,' - 'TimeUnit=MONTHLY,BudgetLimit={Amount=80,Unit=PERCENTAGE}' + f'aws budgets create-budget --account-id {ACCOUNT} --budget "BudgetName={RI_BUDGET},BudgetType=RI_UTILIZATION,' + 'TimeUnit=MONTHLY,BudgetLimit={Amount=80,Unit=PERCENTAGE}"' ) EXPLORER_COMMAND = ( 'aws ce get-cost-and-usage --time-period Start=,End= ' diff --git a/dashboard/labs/ec2_guided.py b/dashboard/labs/ec2_guided.py index 51e085c..267c9d8 100644 --- a/dashboard/labs/ec2_guided.py +++ b/dashboard/labs/ec2_guided.py @@ -76,7 +76,7 @@ { 'key': 'verify-script-execution', 'title': 'Confirm initialization status', - 'command': "aws ssm send-command --instance-ids --document-name AWS-RunShellScript --parameters commands='test -f /tmp/floci-userdata.txt && echo \"artifact-confirmed\"'", + 'command': "aws ssm send-command --instance-ids --document-name AWS-RunShellScript --parameters 'commands=[\"test -f /tmp/floci-userdata.txt && echo artifact-confirmed\"]'", 'explanation': 'Validates file existence and execution completion.', }, ], diff --git a/dashboard/labs/ec2_performance_labs.py b/dashboard/labs/ec2_performance_labs.py index 4c96fce..c53927b 100644 --- a/dashboard/labs/ec2_performance_labs.py +++ b/dashboard/labs/ec2_performance_labs.py @@ -250,15 +250,15 @@ def proxy_role_policy(secret_arn: str) -> dict[str, Any]: f'aws ssm send-command --document-name AWS-RunShellScript --instance-ids ' f'--parameters commands=[""]\n' f'aws cloudwatch put-metric-data --namespace {NAMESPACE} --metric-data ' - f'MetricName={CPU_METRIC},Value=,Dimensions=[{{Name=InstanceId,Value=}}] ' - f'MetricName={THROTTLE_METRIC},Value=,Dimensions=[{{Name=InstanceId,Value=}}]\n' + f'"MetricName={CPU_METRIC},Value=,Dimensions=[{{Name=InstanceId,Value=}}]" ' + f'"MetricName={THROTTLE_METRIC},Value=,Dimensions=[{{Name=InstanceId,Value=}}]"\n' f'aws cloudwatch describe-alarms --alarm-names {CPU_ALARM}' ) MEMORY_COMMAND = ( f'aws ssm send-command --document-name AWS-RunShellScript --instance-ids ' f'--parameters commands=[""]\n' f'aws cloudwatch put-metric-data --namespace {NAMESPACE} --metric-data ' - f'MetricName={OOM_METRIC},Value=1,Dimensions=[{{Name=InstanceId,Value=}}]\n' + f'"MetricName={OOM_METRIC},Value=1,Dimensions=[{{Name=InstanceId,Value=}}]"\n' f'aws cloudwatch describe-alarms --alarm-names {OOM_ALARM}' ) RIGHT_SIZE_COMMAND = ( diff --git a/dashboard/labs/ecs_bluegreen_labs.py b/dashboard/labs/ecs_bluegreen_labs.py index b5fa0a7..544c103 100644 --- a/dashboard/labs/ecs_bluegreen_labs.py +++ b/dashboard/labs/ecs_bluegreen_labs.py @@ -298,7 +298,7 @@ def manifest(layer: bytes, config: bytes) -> str: f'aws ecs create-service --cluster {CLUSTER} --service-name {SERVICE_NAME} --task-definition {FAMILY}:1 ' f'--desired-count {DESIRED_COUNT} --launch-type FARGATE --deployment-controller type=CODE_DEPLOY ' f'--load-balancers targetGroupArn=<{BLUE_TG}>,containerName={CONTAINER},containerPort={CONTAINER_PORT} ' - '--network-configuration awsvpcConfiguration={subnets=[,]}' + '--network-configuration "awsvpcConfiguration={subnets=[,]}"' ) DEPLOYMENT_GROUP_COMMAND = ( f'aws cloudwatch put-metric-alarm --alarm-name {ALARM} --namespace AWS/ApplicationELB ' diff --git a/dashboard/labs/eventbridge_application.py b/dashboard/labs/eventbridge_application.py index dfaaa81..f8c9b50 100644 --- a/dashboard/labs/eventbridge_application.py +++ b/dashboard/labs/eventbridge_application.py @@ -172,20 +172,20 @@ def lambda_handler(event, context): 'explanation': 'Transforms the processing payload, preserves the full audit envelope, and invokes the notification Lambda. Retry/DLQ fields show the AWS shape; current Floci executes transformation and delivery but does not enforce target retry/DLQ behavior.', 'artifact_label': 'processing-target.json', 'artifact': json.dumps( - { + [{ 'Id': 'processing', 'Arn': QUEUE_ARNS[PROCESSING_QUEUE], 'InputTransformer': PROCESSING_TRANSFORMER, 'RetryPolicy': RETRY_POLICY, 'DeadLetterConfig': DEAD_LETTER_CONFIG, - }, + }], indent=2, ), }, { 'key': 'create-api', 'title': 'Expose the producer through API Gateway', - 'command': f'aws apigatewayv2 create-api --name {API_NAME} --protocol-type HTTP\naws apigatewayv2 create-integration --api-id --integration-type AWS_PROXY --integration-uri {PRODUCER_FUNCTION_ARN} --payload-format-version 2.0\naws apigatewayv2 create-route --api-id --route-key "{ROUTE_KEY}" --target integrations/\naws apigatewayv2 create-stage --api-id --stage-name $default --auto-deploy', + 'command': f'aws apigatewayv2 create-api --name {API_NAME} --protocol-type HTTP\naws apigatewayv2 create-integration --api-id --integration-type AWS_PROXY --integration-uri {PRODUCER_FUNCTION_ARN} --payload-format-version 2.0\naws apigatewayv2 create-route --api-id --route-key "{ROUTE_KEY}" --target integrations/\naws apigatewayv2 create-stage --api-id --stage-name \'$default\' --auto-deploy', 'explanation': 'Builds the HTTP entry point and Lambda proxy route.', }, { diff --git a/dashboard/labs/iam_organizations_labs.py b/dashboard/labs/iam_organizations_labs.py index 1f15b67..53c428f 100644 --- a/dashboard/labs/iam_organizations_labs.py +++ b/dashboard/labs/iam_organizations_labs.py @@ -119,7 +119,8 @@ f'aws organizations create-account --account-name {name} --email {info["email"]} # then move-account to {info["ou"]}' for name, info in ACCOUNTS.items()) SCP_COMMAND = '\n'.join( - f'aws organizations create-policy --type SERVICE_CONTROL_POLICY --name {name} --content file://{name}.json\n' + f'aws organizations create-policy --type SERVICE_CONTROL_POLICY --name {name} ' + f'--description "{info["description"]}" --content file://{name}.json\n' f'aws organizations attach-policy --policy-id <{name}-id> --target-id <{info["target"]}-id>' for name, info in SCPS.items()) EVAL_COMMAND = ( @@ -129,7 +130,8 @@ ) TAG_COMMAND = ( 'aws organizations enable-policy-type --root-id --policy-type TAG_POLICY\n' - f'aws organizations create-policy --type TAG_POLICY --name {TAG_POLICY} --content file://cost-center.json\n' + f'aws organizations create-policy --type TAG_POLICY --name {TAG_POLICY} --description "CostCenter tag values" ' + '--content file://cost-center.json\n' f'aws organizations attach-policy --policy-id <{TAG_POLICY}-id> --target-id \n' f'aws organizations describe-effective-policy --policy-type TAG_POLICY --target-id <{PROD_ACCOUNT}-id>' ) diff --git a/dashboard/labs/lambda_labs.py b/dashboard/labs/lambda_labs.py index 874a7b3..41de9b9 100644 --- a/dashboard/labs/lambda_labs.py +++ b/dashboard/labs/lambda_labs.py @@ -327,7 +327,7 @@ def lambda_handler(event, context): { 'key': 'create-function', 'title': 'Create the config reader function', - 'command': f'aws lambda create-function --function-name {LAMBDA_CONFIG_FUNCTION_NAME} --runtime {LAMBDA_RUNTIME} --role {LAMBDA_CONFIG_ROLE_ARN} --handler {LAMBDA_HANDLER} --zip-file fileb://function.zip --environment Variables={{PARAMETER_NAME={LAMBDA_CONFIG_PARAMETER_NAME},SECRET_ID={LAMBDA_CONFIG_SECRET_NAME}}}', + 'command': f'aws lambda create-function --function-name {LAMBDA_CONFIG_FUNCTION_NAME} --runtime {LAMBDA_RUNTIME} --role {LAMBDA_CONFIG_ROLE_ARN} --handler {LAMBDA_HANDLER} --zip-file fileb://function.zip --environment "Variables={{PARAMETER_NAME={LAMBDA_CONFIG_PARAMETER_NAME},SECRET_ID={LAMBDA_CONFIG_SECRET_NAME}}}"', 'explanation': 'Packages a handler that reads SSM and Secrets Manager at invocation time.', 'artifact_label': 'handler.py', 'artifact': LAMBDA_CONFIG_HANDLER_SOURCE, @@ -1151,7 +1151,7 @@ def _run_lambda_config_create_function() -> dict[str, Any]: f'aws lambda create-function --function-name {LAMBDA_CONFIG_FUNCTION_NAME} ' f'--runtime {LAMBDA_RUNTIME} --role {LAMBDA_CONFIG_ROLE_ARN} ' f'--handler {LAMBDA_HANDLER} --zip-file fileb://function.zip ' - f'--environment Variables={{PARAMETER_NAME={LAMBDA_CONFIG_PARAMETER_NAME},SECRET_ID={LAMBDA_CONFIG_SECRET_NAME}}}' + f'--environment "Variables={{PARAMETER_NAME={LAMBDA_CONFIG_PARAMETER_NAME},SECRET_ID={LAMBDA_CONFIG_SECRET_NAME}}}"' ) started = time.perf_counter() environment = { diff --git a/dashboard/labs/sagemaker_labs.py b/dashboard/labs/sagemaker_labs.py index a3fe21b..8669999 100644 --- a/dashboard/labs/sagemaker_labs.py +++ b/dashboard/labs/sagemaker_labs.py @@ -122,8 +122,8 @@ f' --algorithm-specification TrainingImage={TRAIN_IMAGE},TrainingInputMode=File,' 'ContainerEntrypoint=python,/opt/ml/input/data/code/train.py \\\n' ' --hyper-parameters epochs=5,learning_rate=0.1,model_version=1 \\\n' - f' --input-data-config ChannelName=train,DataSource={{S3DataSource={{S3DataType=S3Prefix,S3Uri=s3://{BUCKET}/train/}}}} ' - f'ChannelName=code,DataSource={{S3DataSource={{S3DataType=S3Prefix,S3Uri=s3://{BUCKET}/code/}}}} \\\n' + f' --input-data-config "ChannelName=train,DataSource={{S3DataSource={{S3DataType=S3Prefix,S3Uri=s3://{BUCKET}/train/}}}}" ' + f'"ChannelName=code,DataSource={{S3DataSource={{S3DataType=S3Prefix,S3Uri=s3://{BUCKET}/code/}}}}" \\\n' f' --output-data-config S3OutputPath=s3://{BUCKET}/models/ \\\n' f' --resource-config InstanceType={INSTANCE_TYPE},InstanceCount=1,VolumeSizeInGB=5 ' '--stopping-condition MaxRuntimeInSeconds=600\n' diff --git a/dashboard/labs/snippets.py b/dashboard/labs/snippets.py index dc9b409..0f3e24f 100644 --- a/dashboard/labs/snippets.py +++ b/dashboard/labs/snippets.py @@ -23,6 +23,7 @@ import contextlib import copy +import decimal import io import json import keyword @@ -287,17 +288,27 @@ def convert(raw: str, shape=shape): if _FILE_TOKEN_RE.match(raw) or _PLACEHOLDER_RE.match(raw) or raw.startswith('$'): return raw if shape.type_name in ('structure', 'map', 'list'): - if raw.lstrip()[:1] in '{[': + text, marks = _mark_placeholders(_strip_ellipses(raw)) + parsed = _NOT_PARSED + if text.lstrip()[:1] in '{[': try: - return json.loads(raw) + parsed = json.loads(text) except ValueError: pass - try: - parsed = ShorthandParser().parse(raw) - BackCompatVisitor().visit(parsed, shape) - return parsed - except Exception: - return raw + if parsed is _NOT_PARSED: + try: + parsed = ShorthandParser().parse(text) + except Exception: + parsed = _NOT_PARSED + else: + try: + BackCompatVisitor().visit(parsed, shape) + except Exception: # a marker stands where a number goes + pass + if parsed is _NOT_PARSED: + return _NOT_PARSED # an unparseable fragment would only mislead; leave it out + parsed = _unmark_placeholders(parsed, marks) + return parsed if _fits_shape(parsed, shape) else _NOT_PARSED if shape.type_name in ('integer', 'long'): return int(raw) if re.fullmatch(r'-?\d+', raw) else raw if shape.type_name in ('float', 'double'): @@ -311,14 +322,65 @@ def convert(raw: str, shape=shape): if shape.type_name == 'list': if len(values) == 1 and (values[0].lstrip()[:1] == '[' or _FILE_TOKEN_RE.match(values[0])): - params[name] = convert(values[0]) + value = convert(values[0]) else: - params[name] = [convert(v, shape.member) for v in values] + value = [v for v in (convert(v, shape.member) for v in values if v != '...') if v is not _NOT_PARSED] + value = value or _NOT_PARSED else: - params[name] = convert(values[0]) + value = convert(values[0]) + if value is not _NOT_PARSED: + params[name] = value return params +_NOT_PARSED = object() +_MARK_RE = re.compile(r'__FLOCI_PH(\d+)__') + + +def _strip_ellipses(raw: str) -> str: + """Drop the "..." a lab uses to shorten a list or document, so the rest still parses.""" + raw = re.sub(r',\s*\.\.\.(?=\s*[\]}]|\s*$)', '', raw) + return re.sub(r'\[\s*\.\.\.\s*\]', '[]', raw) + + +def _mark_placeholders(raw: str) -> tuple[str, list[str]]: + """Swap for parseable markers (quoted when they stand where a JSON value goes).""" + marks: list[str] = [] + + def swap(m: re.Match) -> str: + marks.append(m.group(0)) + token = f'__FLOCI_PH{len(marks) - 1}__' + before = raw[:m.start()].rstrip()[-1:] + return f'"{token}"' if raw.lstrip()[:1] in '{[' and before in (':', ',', '[') else token + + return _ANGLE_PLACEHOLDER_RE.sub(swap, raw), marks + + +def _unmark_placeholders(value: Any, marks: list[str]) -> Any: + if isinstance(value, dict): + return {k: _unmark_placeholders(v, marks) for k, v in value.items()} + if isinstance(value, list): + return [_unmark_placeholders(v, marks) for v in value] + if isinstance(value, str) and marks: + return _MARK_RE.sub(lambda m: marks[int(m.group(1))], value) + return value + + +def _fits_shape(value: Any, shape) -> bool: + """False when a parsed value has the wrong container type or fields the API does not have.""" + kind = shape.type_name + if getattr(shape, 'is_document_type', False): + return True + if kind == 'structure': + return isinstance(value, dict) and all( + k in shape.members and _fits_shape(v, shape.members[k]) for k, v in value.items()) + if kind == 'list': + return isinstance(value, list) and all(_fits_shape(v, shape.member) for v in value) + if kind == 'map': + return isinstance(value, dict) + return not isinstance(value, (dict, list)) + + @lru_cache(maxsize=None) def _service_model(service_name: str): import botocore.session @@ -702,6 +764,8 @@ def _py(value: Any, level: int = 0) -> str: return f'f{quote}{value.template}{quote}' if isinstance(value, _JsonDoc): return f'json.dumps({_py(value.value, level)})' + if isinstance(value, decimal.Decimal): # the CLI parses doubles as Decimal; boto3 takes plain numbers + return repr(int(value)) if value == value.to_integral_value() else repr(float(value)) if isinstance(value, dict): items = [f'{k!r}: {_py(v, level + 1)}' for k, v in value.items()] flat = '{' + ', '.join(items) + '}' @@ -808,6 +872,9 @@ def _aws_index(tokens: list[str]) -> int: return -1 +# Comments that say the lab expects this call to fail. +_EXPECTED_ERROR_RE = re.compile(r'\b(refused|denied|denies|AccessDenied)\b|fails with|\w+Exception\b') + _FOR_RE = re.compile(r'^for\s+(\w+)\s+in\s+(.+?)\s*;\s*do\b\s*(.*)$') @@ -876,7 +943,17 @@ def _generate_boto3(command: str, service_hint: str) -> str: script.body.append(f'{indent}# No boto3 equivalent ({reason}); run with the CLI:') script.body.append(f'{indent}# {shlex.join(seg)}') continue - script.body.extend(_render_call(call, files, scope, script, indent)) + rendered = _render_call(call, files, scope, script, indent) + if comment and _EXPECTED_ERROR_RE.search(comment) and not call.raw: + # The lab shows this call failing; catch the error so the rest of the script still runs. + script.imports.add('botocore.exceptions') + notes = [line for line in rendered if line.strip().startswith('# ')] + body = [line for line in rendered if line not in notes] + rendered = notes + [f'{indent}try:'] + [f' {line}' if line else line for line in body] + [ + f'{indent}except botocore.exceptions.ClientError as err:', + f"{indent} print(err.response['Error']['Code'], err.response['Error'].get('Message', ''))", + ] + script.body.extend(rendered) calls += 1 if any(isinstance(v, _FileRef) and v.as_json for v in _walk(call.params, files)): script.imports.add('json') @@ -1980,8 +2057,14 @@ def _tf_flow_log(p, tf): kind = {'VPC': 'vpc_id', 'Subnet': 'subnet_id', 'NetworkInterface': 'eni_id'}.get(p.get('ResourceType'), 'vpc_id') body = [(kind, tf.value(ids[0] if ids else None)), ('traffic_type', tf.value(p.get('TrafficType', 'ALL')))] body += _pick(p, tf, {'LogDestinationType': 'log_destination_type', 'LogDestination': 'log_destination', - 'LogGroupName': 'log_group_name', 'DeliverLogsPermissionArn': 'iam_role_arn', + 'DeliverLogsPermissionArn': 'iam_role_arn', 'MaxAggregationInterval': 'max_aggregation_interval', 'LogFormat': 'log_format'}) + group = p.get('LogGroupName') + if group and not p.get('LogDestination'): + # AWS provider 6 dropped log_group_name: point log_destination at the group's ARN. + role = str(p.get('DeliverLogsPermissionArn') or '') + account = role.split(':')[4] if role.count(':') >= 5 else '000000000000' + body.append(('log_destination', tf.value(f'arn:aws:logs:us-east-1:{account}:log-group:{group}'))) return [_Res('aws_flow_log', ids[0] if ids else 'lab', body)] diff --git a/dashboard/labs/sqs_labs.py b/dashboard/labs/sqs_labs.py index 57467df..6021748 100644 --- a/dashboard/labs/sqs_labs.py +++ b/dashboard/labs/sqs_labs.py @@ -357,8 +357,7 @@ 'command': 'aws sqs send-message-batch --queue-url --entries file://send-batch.json', 'explanation': 'Sends three independent task.created events in one request and verifies every entry succeeded.', 'artifact_label': 'send-batch.json', - 'artifact': json.dumps({ - 'Entries': [ + 'artifact': json.dumps([ { 'Id': f'task-{index}', 'MessageBody': text, @@ -370,8 +369,7 @@ }, } for index, text in enumerate(SQS_BATCH_MESSAGE_TEXTS, start=1) - ], - }, indent=2), + ], indent=2), }, { 'key': 'receive-message', @@ -385,12 +383,10 @@ 'command': 'aws sqs delete-message-batch --queue-url --entries file://delete-batch.json', 'explanation': 'Discovers current receipt handles for the three task events and acknowledges them in one batch request.', 'artifact_label': 'delete-batch.json', - 'artifact': json.dumps({ - 'Entries': [ + 'artifact': json.dumps([ {'Id': f'task-{index}', 'ReceiptHandle': ''} for index in range(1, 4) - ], - }, indent=2), + ], indent=2), }, ], } @@ -606,8 +602,7 @@ 'command': 'aws sqs send-message-batch --queue-url --entries file://cleanup-messages.json', 'explanation': 'Adds three attributed messages that belong only to this cleanup lab.', 'artifact_label': 'cleanup-messages.json', - 'artifact': json.dumps({ - 'Entries': [ + 'artifact': json.dumps([ { 'Id': f'cleanup-{index}', 'MessageBody': text, @@ -622,8 +617,7 @@ SQS_CLEANUP_MESSAGE_TEXTS, start=1, ) - ], - }, indent=2), + ], indent=2), }, { 'key': 'inspect-populated-queue', diff --git a/dashboard/tests_labs.py b/dashboard/tests_labs.py index bf6addd..d71a496 100644 --- a/dashboard/tests_labs.py +++ b/dashboard/tests_labs.py @@ -9,6 +9,7 @@ from io import BytesIO from unittest.mock import MagicMock, patch +from botocore import xform_name from botocore.exceptions import ClientError from django.core.cache import cache from django.test import SimpleTestCase, override_settings @@ -7468,11 +7469,12 @@ def test_every_lab_snippet_is_valid_python_and_real_terraform(self): """Every boto3 snippet compiles and calls real API operations with real parameters; every Terraform snippet only declares resource types the generator knows exist.""" import ast + import builtins import botocore.session from dashboard.labs.registry import all_labs - from dashboard.labs.snippets import _TF_RESOURCE_TYPES, get_step_snippets + from dashboard.labs.snippets import _TF_RESOURCE_TYPES, _fits_shape, get_step_snippets session = botocore.session.get_session() models = {} @@ -7503,8 +7505,191 @@ def test_every_lab_snippet_is_valid_python_and_real_terraform(self): members = shape.members if shape else {} for kw in node.keywords: self.assertIn(kw.arg, members, f'{where}: {service}.{node.func.attr}({kw.arg}=...)') + try: + value = ast.literal_eval(kw.value) + except ValueError: + continue + self.assertTrue(_fits_shape(value, members[kw.arg]), + f'{where}: {service}.{node.func.attr}({kw.arg}={value!r:.80})') + defined = set(dir(builtins)) + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + defined.update((a.asname or a.name).split('.')[0] for a in node.names) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + defined.add(node.id) + elif isinstance(node, ast.ExceptHandler) and node.name: + defined.add(node.name) + used = {n.id for n in ast.walk(tree) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} + self.assertFalse(used - defined, f'{where}: undefined names in the boto3 snippet') tf = snippets['terraform'] self.assertEqual(tf.count('{'), tf.count('}'), where) for resource_type in re.findall(r'^resource "([^"]+)"', tf, re.M): self.assertIn(resource_type, _TF_RESOURCE_TYPES, where) + + def test_every_complete_lab_cli_line_parses_with_the_aws_cli(self): + """Lines the lab shows in full (no "...") are accepted by the AWS CLI's own argument parser.""" + from dashboard.labs import snippets as S + from dashboard.labs.registry import all_labs + + for lab in all_labs(): + for step in lab.get('steps', []): + for line in S._logical_lines(step.get('command', '')): + code, _ = S._split_comment(line.strip()) + tokens = S._tokenize(code) if code else None + for seg in S._segments(tokens or []): + start = S._aws_index(seg) + if start < 0: + continue + seg = [re.sub(r'<[^<>]+>', '1', t) for t in seg[start:]] + if len(seg) < 3 or seg[1] in ('s3', 'configure') or seg[2] == 'wait' or any( + '...' in t or '$' in t or t in ('--body', '--zip-file', '--outfile') for t in seg): + continue + service, operation = seg[1], seg[2] + _, op_cmd = S._operation_command(service, operation) + if op_cmd is None: + continue + args, rest = [], seg[3:] + while rest: + token = rest.pop(0) + if token in S._GLOBAL_WITH_VALUE: + rest = rest[1:] + elif token not in S._GLOBAL_FLAGS: + args.append(token) + args = [S._FILE_TOKEN % 0 if t.startswith(('file://', 'fileb://')) else t for t in args] + self.assertIsNotNone(S._cli_parse(service, operation, args), + f"{lab['service']}/{lab['key']}/{step['key']}: {' '.join(seg)[:160]}") + + def test_lab_commands_survive_the_shell(self): + """Unquoted {a,b} would be brace-expanded and $name replaced before the AWS CLI sees them.""" + from dashboard.labs.registry import all_labs + + for lab in all_labs(): + for step in lab.get('steps', []): + lines = [line.split(' #')[0] for line in step.get('command', '').split('\n') + if not line.lstrip().startswith('#')] + problems = _shell_expansions('\n'.join(lines)) + self.assertFalse(problems, f"{lab['service']}/{lab['key']}/{step['key']}: {problems}") + + def test_lab_artifacts_fit_the_cli_parameter_they_are_passed_to(self): + """A step's file (artifact) parses as the parameter its file:// argument fills.""" + import tempfile + + from botocore.validate import ParamValidator + + from dashboard.labs import snippets as S + from dashboard.labs.registry import all_labs + + for lab in all_labs(): + for step in lab.get('steps', []): + name, artifact = step.get('artifact_label'), step.get('artifact') + if not name or not artifact: + continue + for line in S._logical_lines(step.get('command', '')): + code, _ = S._split_comment(line.strip()) + for seg in S._segments(S._tokenize(code) or []) if code else []: + start = S._aws_index(seg) + if start < 0 or f'file://{name}' not in seg or any('...' in t for t in seg): + continue + seg = [re.sub(r'<[^<>]+>', 'x1234567890abcdef', t) for t in seg[start:]] + folder = tempfile.mkdtemp() + path = os.path.join(folder, name) + with open(path, 'w') as handle: + handle.write(artifact) + args, rest = [], seg[3:] + while rest: # keep only this artifact's file; drop other files and CLI-only options + token = rest.pop(0) + if token.startswith('--') and rest and rest[0].startswith(('file://', 'fileb://')) \ + and rest[0] != f'file://{name}': + rest.pop(0) + elif token in S._GLOBAL_WITH_VALUE: + rest = rest[1:] + else: + args.append(f'file://{path}' if token == f'file://{name}' else token) + _, op_cmd = S._operation_command(seg[1], seg[2]) + if op_cmd is None: + continue + where = f"{lab['service']}/{lab['key']}/{step['key']}: {name}" + parsed = S._cli_parse(seg[1], seg[2], args) + required = op_cmd._operation_model.input_shape.required_members + if parsed is None and any(xform_name(m, '-') not in ' '.join(args) for m in required): + continue # a required argument came from another file + self.assertIsNotNone(parsed, where) + report = ParamValidator().validate(parsed[0], op_cmd._operation_model.input_shape) + problems = [line for line in report.generate_report().splitlines()[1:] + if 'Missing required' not in line and '<' not in line and 'length' not in line] + self.assertFalse(problems, f'{where}: {problems}') + + def test_boto3_snippet_catches_the_errors_a_lab_expects(self): + from dashboard.labs.snippets import generate_boto3_snippet + + snippet = generate_boto3_snippet( + 'aws iam create-login-profile --user-name a --password weak # refused\n' + 'aws iam create-login-profile --user-name a --password Strong-Enough-1!' + ) + self.assertIn('import botocore.exceptions', snippet) + self.assertIn('try:\n response = iam.create_login_profile(', snippet) + self.assertIn('except botocore.exceptions.ClientError as err:', snippet) + self.assertEqual(snippet.count('try:'), 1) + compile(snippet, 'snippet', 'exec') + + def test_boto3_snippet_renders_cli_doubles_as_plain_numbers(self): + from dashboard.labs.snippets import generate_boto3_snippet + + snippet = generate_boto3_snippet('aws cloudwatch put-metric-data --namespace N --metric-name M --value 1') + self.assertIn("'Value': 1}", snippet) + self.assertNotIn('Decimal', snippet) + snippet = generate_boto3_snippet('aws cloudwatch put-metric-data --namespace N --metric-name M --value 2.5') + self.assertIn("'Value': 2.5}", snippet) + + def test_boto3_snippet_keeps_placeholders_in_shorthand_and_drops_fragments(self): + from dashboard.labs.snippets import generate_boto3_snippet + + snippet = generate_boto3_snippet( + 'aws cloudwatch put-metric-data --namespace N --metric-data ' + 'MetricName=cpu,Value=,Dimensions=[{Name=InstanceId,Value=}]' + ) + self.assertIn("'Value': PERCENT", snippet) + self.assertIn("'Value': ''", snippet) + snippet = generate_boto3_snippet('aws events put-events --entries ... ref=disk-2') + self.assertIn('events.put_events()', snippet) + self.assertNotIn('ref', snippet.split('put_events', 1)[1]) + + def test_terraform_flow_log_uses_log_destination(self): + from dashboard.labs.snippets import generate_terraform_snippet + + tf = generate_terraform_snippet( + 'aws ec2 create-flow-logs --resource-type VPC --resource-ids vpc-1 --traffic-type REJECT ' + '--log-group-name /lab/flow --deliver-logs-permission-arn arn:aws:iam::123456789012:role/flow' + ) + self.assertNotIn('log_group_name', tf) + self.assertIn('log_destination', tf) + self.assertIn('arn:aws:logs:us-east-1:123456789012:log-group:/lab/flow', tf) + + +def _shell_expansions(command): + """Unquoted brace lists and undefined lowercase $variables a shell would expand in a lab command.""" + defined = set(re.findall(r'\bfor\s+(\w+)\s+in\b', command)) | set(re.findall(r'(?:^|[\s;(])(\w+)=', command)) + problems, quote, i = [], '', 0 + while i < len(command): + ch = command[i] + if quote == "'": + quote = '' if ch == "'" else quote + elif ch == '\\': + i += 1 + elif ch in '"\'' and (not quote or quote == ch): + quote = '' if quote else ch + elif ch == '$' and re.match(r'[a-z_]\w*', command[i + 1:]): + name = re.match(r'\w+', command[i + 1:]).group(0) + if name not in defined: + problems.append(f'${name}') + elif ch == '{' and not quote and (i == 0 or command[i - 1] != '$'): + depth, j = 1, i + 1 + while j < len(command) and depth and command[j] not in '\'"': + depth += {'{': 1, '}': -1}.get(command[j], 0) + j += 1 + body = command[i + 1:j - 1] + if depth == 0 and ',' in re.sub(r'\{[^{}]*\}', '', body) and not re.search(r'\s', body): + problems.append(command[max(0, i - 20):j][:60]) + i += 1 + return problems From f4fa472dcf12fb0fa1a2e539613f5ba2578a1bc6 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Nov 2026 17:41:28 -0500 Subject: [PATCH 2/4] Terraform snippets refer to resources declared in the same step A role policy, group membership, event rule or subscription named its parent with a literal string, so terraform apply could create it before the parent and fail with NoSuchEntity / not found. Names and ARNs of resources declared earlier in a snippet now become references (aws_iam_role.x.name, .arn), which orders creation. All 359 Terraform snippets pass terraform validate. Co-Authored-By: Claude Opus 5.5 --- dashboard/labs/snippets.py | 51 ++++++++++++++++++++++++++++++++++++++ dashboard/tests_labs.py | 15 +++++++++++ 2 files changed, 66 insertions(+) diff --git a/dashboard/labs/snippets.py b/dashboard/labs/snippets.py index 0f3e24f..1238dbb 100644 --- a/dashboard/labs/snippets.py +++ b/dashboard/labs/snippets.py @@ -2510,6 +2510,7 @@ def _generate_terraform(command: str) -> str: used: set[str] = set() variables: dict[str, str] = {} previous: dict[str, str] = {} + refs: dict[str, tuple[str, _Hcl]] = {} # literal name or ARN -> (resource type, reference) for call, files, reason in _terraform_calls(command): if call is None: notes.append(f'# aws {reason}: not managed by Terraform') @@ -2540,6 +2541,8 @@ def _generate_terraform(command: str) -> str: body = [(k, _Hcl(v.expr.replace('PREVIOUS', previous.get('aws_secretsmanager_secret', ''))) if isinstance(v, _Hcl) and 'PREVIOUS' in v.expr else v) for k, v in res.body] previous[res.type] = f'{res.type}.{label}' + body = _link_references(body, res.type, refs) + _register_references(body, res.type, label, refs) lines = [f'resource "{res.type}" "{label}" {{', *_render_body(body, 1, tf), '}'] blocks.append(lines) skipped = sorted(set(params) - params.seen - _TF_IMPLICIT) @@ -2570,6 +2573,54 @@ def _generate_terraform(command: str) -> str: return '\n'.join(_align(out)).rstrip() + '\n' +# Attributes that name a resource, and how its ARN ends, so later blocks can refer to it. +_TF_NAME_KEYS = ('name', 'bucket', 'function_name', 'alarm_name', 'family') +_TF_ARN_SHAPES = { + 'aws_iam_role': r'arn:aws:iam::\d{12}:role/(?:[\w+=,.@-]+/)*', + 'aws_iam_policy': r'arn:aws:iam::\d{12}:policy/(?:[\w+=,.@-]+/)*', + 'aws_iam_user': r'arn:aws:iam::\d{12}:user/(?:[\w+=,.@-]+/)*', + 'aws_lambda_function': r'arn:aws:lambda:[\w-]+:\d{12}:function:', + 'aws_sqs_queue': r'arn:aws:sqs:[\w-]+:\d{12}:', + 'aws_sns_topic': r'arn:aws:sns:[\w-]+:\d{12}:', + 'aws_cloudwatch_event_bus': r'arn:aws:events:[\w-]+:\d{12}:event-bus/', + 'aws_dynamodb_table': r'arn:aws:dynamodb:[\w-]+:\d{12}:table/', + 'aws_sfn_state_machine': r'arn:aws:states:[\w-]+:\d{12}:stateMachine:', + 'aws_kinesis_stream': r'arn:aws:kinesis:[\w-]+:\d{12}:stream/', + 'aws_s3_bucket': r'arn:aws:s3:::', +} + + +def _register_references(body: list[tuple[str, Any]], kind: str, label: str, refs: dict) -> None: + """Remember this resource's name (and ARN shape) so later blocks reference it instead of a literal.""" + for key, value in body: + if key in _TF_NAME_KEYS and isinstance(value, str) and value: + refs.setdefault(value, (kind, _Hcl(f'{kind}.{label}.{key}'))) + if kind in _TF_ARN_SHAPES: + refs.setdefault(('arn', kind, value), (kind, _Hcl(f'{kind}.{label}.arn'))) + return + + +def _link_references(entries: list[tuple[str, Any]], kind: str, refs: dict) -> list[tuple[str, Any]]: + """Swap literal names and ARNs of resources declared earlier in the snippet for references, + so Terraform creates them first instead of racing them.""" + def swap(value: Any) -> Any: + if isinstance(value, _Body): + return _Body(_link_references(value.entries, kind, refs)) + if isinstance(value, list): + return [swap(v) for v in value] + if not isinstance(value, str): + return value + hit = refs.get(value) + if hit and hit[0] != kind: + return hit[1] + for (tag, other, name), (_, ref) in ((k, v) for k, v in refs.items() if isinstance(k, tuple)): + if other != kind and re.fullmatch(_TF_ARN_SHAPES[other] + re.escape(name), value): + return ref + return value + + return [(k, swap(v)) for k, v in entries] + + def _dedupe(items: list[str]) -> list[str]: seen: set[str] = set() return [x for x in items if not (x in seen or seen.add(x))] diff --git a/dashboard/tests_labs.py b/dashboard/tests_labs.py index d71a496..fa235b3 100644 --- a/dashboard/tests_labs.py +++ b/dashboard/tests_labs.py @@ -7655,6 +7655,21 @@ def test_boto3_snippet_keeps_placeholders_in_shorthand_and_drops_fragments(self) self.assertIn('events.put_events()', snippet) self.assertNotIn('ref', snippet.split('put_events', 1)[1]) + def test_terraform_refers_to_resources_declared_in_the_same_step(self): + """Literal names would let Terraform create a policy before its role; references order them.""" + from dashboard.labs.snippets import generate_terraform_snippet + + tf = generate_terraform_snippet( + 'aws iam create-role --role-name worker --assume-role-policy-document file://trust.json\n' + 'aws iam put-role-policy --role-name worker --policy-name p --policy-document file://p.json\n' + 'aws sns create-topic --name alerts\n' + 'aws sns subscribe --topic-arn arn:aws:sns:us-east-1:000000000000:alerts --protocol email ' + '--notification-endpoint a@example.com' + ) + self.assertIn('role = aws_iam_role.worker.name', tf) + self.assertIn('topic_arn = aws_sns_topic.alerts.arn', tf.replace(' topic_arn', 'topic_arn')) + self.assertIn('name = "worker"', tf) + def test_terraform_flow_log_uses_log_destination(self): from dashboard.labs.snippets import generate_terraform_snippet From 30bc556a2bac4188646c68a5c1f813069593988a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Nov 2026 19:06:29 -0500 Subject: [PATCH 3/4] Snippets: values after a lab's ... belong to the elided option `--instance-ids i-1 ... commands=[...]` put the commands into InstanceIds. Co-Authored-By: Claude Opus 5.5 --- dashboard/labs/snippets.py | 6 +++++- dashboard/tests_labs.py | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/dashboard/labs/snippets.py b/dashboard/labs/snippets.py index 1238dbb..12328c5 100644 --- a/dashboard/labs/snippets.py +++ b/dashboard/labs/snippets.py @@ -271,6 +271,10 @@ def _model_parse(op_model, args: list[str]) -> dict[str, Any]: flag = token[2:] values = [] while i < len(args) and not args[i].startswith('--'): + if args[i] == '...': # the lab left the rest out; what follows belongs to an elided option + while i < len(args) and not args[i].startswith('--'): + i += 1 + break values.append(args[i]) i += 1 negated = flag.startswith('no-') and flag[3:] in by_flag @@ -514,7 +518,7 @@ def _parse_aws_uncached(tokens: list[str], files: list[tuple[str, bool]]) -> tup params, parsed_args = captured outfile = getattr(parsed_args, 'outfile', '') or outfile if params is None: - params = _model_parse(op_model, [a for a in args if a != '...']) + params = _model_parse(op_model, args) if zip_file is not None: if op_model.name == 'CreateFunction': params['Code'] = {**(params.get('Code') or {}), 'ZipFile': zip_file} diff --git a/dashboard/tests_labs.py b/dashboard/tests_labs.py index fa235b3..c536251 100644 --- a/dashboard/tests_labs.py +++ b/dashboard/tests_labs.py @@ -7651,6 +7651,9 @@ def test_boto3_snippet_keeps_placeholders_in_shorthand_and_drops_fragments(self) ) self.assertIn("'Value': PERCENT", snippet) self.assertIn("'Value': ''", snippet) + snippet = generate_boto3_snippet('aws ssm send-command --instance-ids i-1 ... commands=["df -h"]') + self.assertIn("InstanceIds=['i-1'],", snippet) + self.assertNotIn('commands=', snippet) snippet = generate_boto3_snippet('aws events put-events --entries ... ref=disk-2') self.assertIn('events.put_events()', snippet) self.assertNotIn('ref', snippet.split('put_events', 1)[1]) From 174aeb0380d7f2614876d982340cf77757406d79 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 7 Nov 2026 21:15:52 -0500 Subject: [PATCH 4/4] Terraform snippets link placeholder IDs to resources the same step creates A step that creates a KMS key and then aliases , or a security group and then adds rules to , asked for the ID as a variable, so one apply could never work. Those placeholders now reference the resource (aws_kms_key.x.key_id, aws_security_group.x.id, ...) and the unused variable goes away. KMS TagKey/TagValue tags render as a map instead of { None = null }. Co-Authored-By: Claude Opus 5.5 --- dashboard/labs/snippets.py | 74 +++++++++++++++++++++++++++++++++++++- dashboard/tests_labs.py | 16 +++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/dashboard/labs/snippets.py b/dashboard/labs/snippets.py index 12328c5..c55d544 100644 --- a/dashboard/labs/snippets.py +++ b/dashboard/labs/snippets.py @@ -1230,7 +1230,8 @@ def _tags(p: dict, tf: _Tf, key: str = 'Tags') -> dict | None: specs = p['TagSpecifications'] raw = specs[0].get('Tags') if isinstance(specs, list) and specs and isinstance(specs[0], dict) else None if isinstance(raw, list): - return {t.get('Key', t.get('key')): tf.value(t.get('Value', t.get('value'))) for t in raw if isinstance(t, dict)} + return {t.get('Key', t.get('key', t.get('TagKey'))): tf.value(t.get('Value', t.get('value', t.get('TagValue')))) + for t in raw if isinstance(t, dict)} if isinstance(raw, dict) and set(raw) != {'__floci_snippet_file__'}: return {k: tf.value(v) for k, v in raw.items()} return None @@ -2515,6 +2516,7 @@ def _generate_terraform(command: str) -> str: variables: dict[str, str] = {} previous: dict[str, str] = {} refs: dict[str, tuple[str, _Hcl]] = {} # literal name or ARN -> (resource type, reference) + declared: list[tuple[str, str]] = [] # (resource type, label) in order for call, files, reason in _terraform_calls(command): if call is None: notes.append(f'# aws {reason}: not managed by Terraform') @@ -2546,7 +2548,9 @@ def _generate_terraform(command: str) -> str: if isinstance(v, _Hcl) and 'PREVIOUS' in v.expr else v) for k, v in res.body] previous[res.type] = f'{res.type}.{label}' body = _link_references(body, res.type, refs) + body = _link_placeholders(body, declared, {**variables, **tf.variables}) _register_references(body, res.type, label, refs) + declared.append((res.type, label)) lines = [f'resource "{res.type}" "{label}" {{', *_render_body(body, 1, tf), '}'] blocks.append(lines) skipped = sorted(set(params) - params.seen - _TF_IMPLICIT) @@ -2568,6 +2572,8 @@ def _generate_terraform(command: str) -> str: return '\n'.join(header + [''] + _dedupe(notes)) + '\n' out: list[str] = [] + used_vars = set(re.findall(r'\bvar\.(\w+)', '\n'.join('\n'.join(b) for b in blocks))) + variables = {name: ph for name, ph in variables.items() if name in used_vars} for name, placeholder in variables.items(): out += [f'variable "{name}" {{', ' type = string', f' description = {_hcl_str("Value for <" + placeholder + ">")}', '}', ''] for lines in blocks: @@ -2625,6 +2631,72 @@ def swap(value: Any) -> Any: return [(k, swap(v)) for k, v in entries] +# Placeholder words -> (resource type, attribute) that the same snippet may already create. +_TF_PLACEHOLDER_TYPES = [ + ({'key'}, 'aws_kms_key', 'key_id'), + ({'rt'}, 'aws_route_table', 'id'), + ({'rtb'}, 'aws_route_table', 'id'), + ({'route', 'table'}, 'aws_route_table', 'id'), + ({'sg'}, 'aws_security_group', 'id'), + ({'security', 'group'}, 'aws_security_group', 'id'), + ({'igw'}, 'aws_internet_gateway', 'id'), + ({'nat'}, 'aws_nat_gateway', 'id'), + ({'eip'}, 'aws_eip', 'allocation_id'), + ({'allocation'}, 'aws_eip', 'allocation_id'), + ({'vpc'}, 'aws_vpc', 'id'), + ({'subnet'}, 'aws_subnet', 'id'), + ({'fs'}, 'aws_efs_file_system', 'id'), + ({'file', 'system'}, 'aws_efs_file_system', 'id'), + ({'topic'}, 'aws_sns_topic', 'arn'), + ({'pool'}, 'aws_cognito_user_pool', 'id'), + ({'instance'}, 'aws_instance', 'id'), + ({'tg'}, 'aws_lb_target_group', 'arn'), + ({'target', 'group'}, 'aws_lb_target_group', 'arn'), + ({'alb'}, 'aws_lb', 'arn'), + ({'zone'}, 'aws_route53_zone', 'zone_id'), + ({'api'}, 'aws_api_gateway_rest_api', 'id'), + ({'api'}, 'aws_apigatewayv2_api', 'id'), + ({'distribution'}, 'aws_cloudfront_distribution', 'id'), +] + + +def _link_placeholders(entries, declared: list[tuple[str, str]], variables: dict[str, str]): + """A for the ID of something this snippet already creates becomes a reference to it, + so one terraform apply works instead of asking for an ID that doesn't exist yet.""" + def target(placeholder: str): + words = set(re.findall(r'[a-z0-9]+', placeholder.lower())) + if words & {'profile', 'access', 'policy', 'version', 'url', 'name'}: + return None # something other than the resource's own ID or ARN + best, best_score, tie = None, -1, False + for need, kind, attr in _TF_PLACEHOLDER_TYPES: + if not need <= words or ('arn' in words) != (attr == 'arn') and words & {'arn', 'id'}: + continue + for other, label in declared: + if other != kind: + continue + score = len(words & set(label.split('_'))) + if score > best_score: + best, best_score, tie = f'{kind}.{label}.{attr}', score, False + elif score == best_score and best != f'{kind}.{label}.{attr}': + tie = True + return None if tie else best + + def swap(value): + if isinstance(value, _Body): + return _Body([(k, swap(v)) for k, v in value.entries]) + if isinstance(value, list): + return [swap(v) for v in value] + if isinstance(value, _Hcl): + m = re.fullmatch(r'var\.(\w+)', value.expr) + if m and m.group(1) in variables: + ref = target(variables[m.group(1)]) + if ref: + return _Hcl(ref) + return value + + return [(k, swap(v)) for k, v in entries] + + def _dedupe(items: list[str]) -> list[str]: seen: set[str] = set() return [x for x in items if not (x in seen or seen.add(x))] diff --git a/dashboard/tests_labs.py b/dashboard/tests_labs.py index c536251..36df73c 100644 --- a/dashboard/tests_labs.py +++ b/dashboard/tests_labs.py @@ -7673,6 +7673,22 @@ def test_terraform_refers_to_resources_declared_in_the_same_step(self): self.assertIn('topic_arn = aws_sns_topic.alerts.arn', tf.replace(' topic_arn', 'topic_arn')) self.assertIn('name = "worker"', tf) + def test_terraform_links_placeholder_ids_to_resources_the_step_creates(self): + from dashboard.labs.snippets import generate_terraform_snippet + + tf = generate_terraform_snippet( + 'aws kms create-key --description k --tags TagKey=Lab,TagValue=kms\n' + 'aws kms create-alias --alias-name alias/k --target-key-id \n' + 'aws ec2 create-security-group --group-name web-sg --description web --vpc-id \n' + 'aws ec2 authorize-security-group-ingress --group-id --protocol tcp --port 80 --cidr 0.0.0.0/0' + ) + self.assertIn('target_key_id = aws_kms_key.', tf) + self.assertIn('security_group_id = aws_security_group.web_sg.id', tf) + self.assertIn('tags = { Lab = "kms" }', re.sub(r' +', ' ', tf)) + self.assertIn('variable "vpc_id"', tf) + self.assertNotIn('variable "key_id"', tf) + self.assertNotIn('variable "web_sg_id"', tf) + def test_terraform_flow_log_uses_log_destination(self): from dashboard.labs.snippets import generate_terraform_snippet