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..c55d544 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 @@ -270,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 @@ -287,17 +292,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 +326,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 @@ -452,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} @@ -702,6 +768,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 +876,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 +947,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') @@ -1149,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 @@ -1980,8 +2062,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)] @@ -2427,6 +2515,8 @@ 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) + 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') @@ -2457,6 +2547,10 @@ 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) + 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) @@ -2478,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: @@ -2487,6 +2583,120 @@ 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] + + +# 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/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..36df73c 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,225 @@ 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 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]) + + 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_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 + + 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