From f156b21cab7d0be1bfe0edfb0d3de12ea8f06acb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 05:17:39 +0000 Subject: [PATCH 01/23] Add custom policies to S3 buckets, SQS queues, and Lambda functions Co-authored-by: boris.resnick --- src/easysam/schemas.json | 53 ++++++++++++++++++++++++++++-- src/easysam/template.j2 | 60 +++++++++++++++++++++++++++++++++- src/easysam/validate_schema.py | 23 ++++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/easysam/schemas.json b/src/easysam/schemas.json index 1248946..58d7998 100644 --- a/src/easysam/schemas.json +++ b/src/easysam/schemas.json @@ -1,18 +1,56 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "custom_policy_schema": { + "type": "object", + "properties": { + "action": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "effect": { + "type": "string", + "enum": ["allow", "deny"], + "default": "allow" + }, + "resource": { + "type": "string", + "default": "any" + }, + "principal": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ], + "default": null + } + }, + "required": ["action"], + "additionalProperties": false + }, "buckets_schema": { "type": "object", "properties": { "public": {"type": "boolean"}, - "extaccesspolicy": {"type": "string"} + "extaccesspolicy": {"type": "string"}, + "custompolicies": { + "type": "array", + "items": {"$ref": "#/definitions/custom_policy_schema"} + } }, "required": ["public"], "additionalProperties": false }, "queues_schema": { "type": "object", - "properties": {}, + "properties": { + "custompolicies": { + "type": "array", + "items": {"$ref": "#/definitions/custom_policy_schema"} + } + }, "additionalProperties": false }, "stream_bucket_schema": { @@ -85,6 +123,10 @@ "^[a-z0-9-]+$": {"type": "string"} }, "additionalProperties": false + }, + "custompolicies": { + "type": "array", + "items": {"$ref": "#/definitions/custom_policy_schema"} } }, "required": ["uri"], @@ -295,7 +337,12 @@ "queues": { "type": "object", "patternProperties": { - "^[a-z0-9-]+$": {"type": "null"} + "^[a-z0-9-]+$": { + "oneOf": [ + {"type": "null"}, + {"$ref": "#/definitions/queues_schema"} + ] + } }, "additionalProperties": false }, diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 314847a..12c1f12 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -334,8 +334,36 @@ Resources: Effect: Allow Principal: '*' Action: 's3:GetObjectAttributes' - Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* + Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* + {% if bucket.custompolicies is defined %} + {% for custom_policy in bucket.custompolicies %} + - Sid: CustomPolicy{{ loop.index }} + Effect: {{ custom_policy.effect | default('Allow') | capitalize }} + Action: {{ custom_policy.action | tojson }} + Resource: {% if custom_policy.resource | default('any') == 'any' %}!Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/*{% else %}{{ custom_policy.resource | tojson }}{% endif %} + {% if custom_policy.principal is defined and custom_policy.principal is not none %} + Principal: {{ custom_policy.principal | tojson }} + {% endif %} + {% endfor %} + {% endif %} {% endif %} # if bucket.public + {% if not bucket.public and bucket.custompolicies is defined %} + {{ lprefix }}{{ bucket_name.replace('-', '') }}BucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref {{ lprefix }}{{ bucket_name }}Bucket + PolicyDocument: + Version: 2012-10-17 + Statement: + {% for custom_policy in bucket.custompolicies %} + - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} + Action: {{ custom_policy.action | tojson }} + Resource: {% if custom_policy.resource | default('any') == 'any' %}!Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/*{% else %}{{ custom_policy.resource | tojson }}{% endif %} + {% if custom_policy.principal is defined and custom_policy.principal is not none %} + Principal: {{ custom_policy.principal | tojson }} + {% endif %} + {% endfor %} + {% endif %} # if not bucket.public and bucket.custompolicies is defined {% if bucket.extaccesspolicy is defined %} {{ lprefix }}{{ bucket_name.replace('-', '') }}ReadPolicy: @@ -363,6 +391,24 @@ Resources: Type: AWS::SQS::Queue Properties: QueueName: !Sub "{{ lprefix }}-{{ queue_name }}-${Stage}" + {% if queue is not none and queue.custompolicies is defined %} + {{ lprefix }}{{ queue_name.replace('-', '') }}QueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref {{ lprefix }}{{ queue_name.replace('-', '') }}Queue + PolicyDocument: + Version: "2012-10-17" + Statement: + {% for custom_policy in queue.custompolicies %} + - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} + Action: {{ custom_policy.action | tojson }} + Resource: {% if custom_policy.resource is defined and custom_policy.resource != 'any' %}{{ custom_policy.resource | tojson }}{% else %}!GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn{% endif %} + {% if custom_policy.principal is defined and custom_policy.principal is not none %} + Principal: {{ custom_policy.principal | tojson }} + {% endif %} + {% endfor %} + {% endif %} {% endfor %} {% endif %} # end queues @@ -516,6 +562,18 @@ Resources: Resource: - !GetAtt {{ search_collection.replace('-', '') }}Collection.Arn {% endfor %} + {% if function.custompolicies is defined %} + {% for custom_policy in function.custompolicies %} + - Version: "2012-10-17" + Statement: + - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} + Action: {{ custom_policy.action | tojson }} + Resource: {% if custom_policy.resource | default('any') == 'any' %}'*'{% else %}{{ custom_policy.resource | tojson }}{% endif %} + {% if custom_policy.principal is defined and custom_policy.principal is not none %} + Principal: {{ custom_policy.principal | tojson }} + {% endif %} + {% endfor %} + {% endif %} {% if enable_lambda_layer or function.layers %} Layers: {% endif %} diff --git a/src/easysam/validate_schema.py b/src/easysam/validate_schema.py index abbda46..158943a 100644 --- a/src/easysam/validate_schema.py +++ b/src/easysam/validate_schema.py @@ -47,11 +47,15 @@ def validate_buckets(resources_data: dict, errors: list[str]): for bucket, details in resources_data.get('buckets', {}).items(): if bucket == 'private' and details['public']: errors.append(f'Bucket \'{bucket}\' cannot be public') + if 'custompolicies' in details: + validate_custom_policies(details['custompolicies'], f'Bucket {bucket}', errors) def validate_queues(resources_data: dict, errors: list[str]): '''Validate queue-specific rules.''' - pass + for queue_name, queue in resources_data.get('queues', {}).items(): + if queue is not None and 'custompolicies' in queue: + validate_custom_policies(queue['custompolicies'], f'Queue {queue_name}', errors) def validate_tables(resources_data: dict, errors: list[str]): @@ -100,6 +104,21 @@ def validate_streams(resources_data: dict, errors: list[str]): errors.append(f"Stream '{stream}': 'extbucketarn' must be a valid ARN") +def validate_custom_policies(custompolicies: list, resource_name: str, errors: list[str]): + '''Validate custom policies structure.''' + if not isinstance(custompolicies, list): + errors.append(f'{resource_name}: custompolicies must be an array') + return + for idx, policy in enumerate(custompolicies): + if not isinstance(policy, dict): + errors.append(f'{resource_name}: custompolicies[{idx}] must be an object') + continue + if 'action' not in policy: + errors.append(f'{resource_name}: custompolicies[{idx}] must have an action field') + if 'effect' in policy and policy['effect'] not in ['allow', 'deny']: + errors.append(f'{resource_name}: custompolicies[{idx}].effect must be "allow" or "deny"') + + def validate_lambda(resources_data: dict, errors: list[str]): '''Validate lambda function-specific rules.''' lg.debug('Validating lambda functions') @@ -160,6 +179,8 @@ def validate_lambda(resources_data: dict, errors: list[str]): ) continue + if 'custompolicies' in details: + validate_custom_policies(details['custompolicies'], f'Lambda {lambda_name}', errors) def validate_paths(resources_dir: Path, resources_data: dict, errors: list[str]): From 1d47a732736f1677f68a407820a10a1e23ae5561 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 05:34:57 +0000 Subject: [PATCH 02/23] Add custom policies to buckets, queues, and lambdas Co-authored-by: boris.resnick --- README.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 65b19f4..9a24ab6 100644 --- a/README.md +++ b/README.md @@ -97,17 +97,33 @@ tables: ```yaml buckets: - - name: String (e.g., my-bucket) - public Boolean Optional (e.g., true), means Public read policy + my-bucket: + public: Boolean Optional (e.g., true), means Public read policy + custompolicies: Optional Array of custom IAM policies + - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) + effect: String Optional (e.g., "allow" or "deny", default: "allow") + resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for default, default: "any") + principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` +Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The `resource` value of "any" translates to the bucket ARN (`arn:aws:s3:::bucket-name/*`). If `principal` is null, it is omitted from the policy statement. + ### Queue Definitions ```yaml queues: - - name: String (e.g., my-queue) + my-queue: null # Simple queue (no custom policies) + # OR + my-queue: + custompolicies: Optional Array of custom SQS queue policies + - action: String or Array (e.g., "sqs:SendMessage" or ["sqs:SendMessage", "sqs:ReceiveMessage"]) + effect: String Optional (e.g., "allow" or "deny", default: "allow") + resource: String Optional (e.g., "arn:aws:sqs:*:*:my-queue" or "any" for queue ARN, default: "any") + principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` +Custom policies create an SQS QueuePolicy resource. The `resource` value of "any" translates to the queue's ARN. If `principal` is null, it is omitted from the policy statement. + ### Stream Definitions ```yaml @@ -118,7 +134,8 @@ streams: ## Lambda Definition ```yaml - - name: String (e.g., my-lambda) +functions: + my-lambda: uri: String (i.e., local path to the source) tables: - String (e.g., Items) @@ -131,8 +148,15 @@ streams: services: - comprehend # Grants ComprehendBasicAccessPolicy - bedrock # Grants bedrock:InvokeModel permission + custompolicies: Optional Array of custom IAM policies + - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) + effect: String Optional (e.g., "allow" or "deny", default: "allow") + resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for "*", default: "any") + principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` +Custom policies are added to the Lambda function's IAM execution role as inline policy statements. The `resource` value of "any" translates to "*" (any resource). If `principal` is null, it is omitted from the policy statement (which is appropriate for IAM role policies that don't need a principal field). + ### API Gateway Definition #### Lambda Function Integration @@ -193,6 +217,11 @@ lambda: - polls: - + custompolicies: Optional Array of custom IAM policies + - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) + effect: String Optional (e.g., "allow" or "deny", default: "allow") + resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for "*", default: "any") + principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) integration: path: open: @@ -200,7 +229,7 @@ lambda: authorizer: ``` -Locally-defined lambda URI is set to the path of the `easysam.yaml` file. +Locally-defined lambda URI is set to the path of the `easysam.yaml` file. Custom policies work the same way as in the main `resources.yaml` file. #### Local Import From 23b31c1cc9f8c73b481287bcb21bbe1af2a5800b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 05:44:24 +0000 Subject: [PATCH 03/23] Refactor custom policy resource definitions Co-authored-by: boris.resnick --- README.md | 8 +++--- src/easysam/schemas.json | 54 ++++++++++++++++++++++++++++++++++++++-- src/easysam/template.j2 | 6 ++--- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9a24ab6..e430445 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,13 @@ tables: buckets: my-bucket: public: Boolean Optional (e.g., true), means Public read policy - custompolicies: Optional Array of custom IAM policies + custompolicies: Optional Array of custom S3 bucket policies - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") - resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for default, default: "any") principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` -Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The `resource` value of "any" translates to the bucket ARN (`arn:aws:s3:::bucket-name/*`). If `principal` is null, it is omitted from the policy statement. +Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The resource is always set to the bucket's object ARN (`arn:aws:s3:::bucket-name/*`). If `principal` is null, it is omitted from the policy statement. ### Queue Definitions @@ -118,11 +117,10 @@ queues: custompolicies: Optional Array of custom SQS queue policies - action: String or Array (e.g., "sqs:SendMessage" or ["sqs:SendMessage", "sqs:ReceiveMessage"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") - resource: String Optional (e.g., "arn:aws:sqs:*:*:my-queue" or "any" for queue ARN, default: "any") principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` -Custom policies create an SQS QueuePolicy resource. The `resource` value of "any" translates to the queue's ARN. If `principal` is null, it is omitted from the policy statement. +Custom policies create an SQS QueuePolicy resource. The resource is always set to the queue's ARN. If `principal` is null, it is omitted from the policy statement. ### Stream Definitions diff --git a/src/easysam/schemas.json b/src/easysam/schemas.json index 58d7998..e27916c 100644 --- a/src/easysam/schemas.json +++ b/src/easysam/schemas.json @@ -30,6 +30,56 @@ "required": ["action"], "additionalProperties": false }, + "bucket_custom_policy_schema": { + "type": "object", + "properties": { + "action": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "effect": { + "type": "string", + "enum": ["allow", "deny"], + "default": "allow" + }, + "principal": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ], + "default": null + } + }, + "required": ["action"], + "additionalProperties": false + }, + "queue_custom_policy_schema": { + "type": "object", + "properties": { + "action": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ] + }, + "effect": { + "type": "string", + "enum": ["allow", "deny"], + "default": "allow" + }, + "principal": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ], + "default": null + } + }, + "required": ["action"], + "additionalProperties": false + }, "buckets_schema": { "type": "object", "properties": { @@ -37,7 +87,7 @@ "extaccesspolicy": {"type": "string"}, "custompolicies": { "type": "array", - "items": {"$ref": "#/definitions/custom_policy_schema"} + "items": {"$ref": "#/definitions/bucket_custom_policy_schema"} } }, "required": ["public"], @@ -48,7 +98,7 @@ "properties": { "custompolicies": { "type": "array", - "items": {"$ref": "#/definitions/custom_policy_schema"} + "items": {"$ref": "#/definitions/queue_custom_policy_schema"} } }, "additionalProperties": false diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 12c1f12..d72aa0d 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -340,7 +340,7 @@ Resources: - Sid: CustomPolicy{{ loop.index }} Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} - Resource: {% if custom_policy.resource | default('any') == 'any' %}!Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/*{% else %}{{ custom_policy.resource | tojson }}{% endif %} + Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* {% if custom_policy.principal is defined and custom_policy.principal is not none %} Principal: {{ custom_policy.principal | tojson }} {% endif %} @@ -358,7 +358,7 @@ Resources: {% for custom_policy in bucket.custompolicies %} - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} - Resource: {% if custom_policy.resource | default('any') == 'any' %}!Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/*{% else %}{{ custom_policy.resource | tojson }}{% endif %} + Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* {% if custom_policy.principal is defined and custom_policy.principal is not none %} Principal: {{ custom_policy.principal | tojson }} {% endif %} @@ -403,7 +403,7 @@ Resources: {% for custom_policy in queue.custompolicies %} - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} - Resource: {% if custom_policy.resource is defined and custom_policy.resource != 'any' %}{{ custom_policy.resource | tojson }}{% else %}!GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn{% endif %} + Resource: !GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn {% if custom_policy.principal is defined and custom_policy.principal is not none %} Principal: {{ custom_policy.principal | tojson }} {% endif %} From 759c89b77321c7427254db4eddb6b7ada27a7afd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 05:54:16 +0000 Subject: [PATCH 04/23] Refactor: Default principal to '*' and update docs Co-authored-by: boris.resnick --- README.md | 12 +++++------- src/easysam/schemas.json | 21 ++++----------------- src/easysam/template.j2 | 15 +++------------ 3 files changed, 12 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index e430445..1bd1440 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,10 @@ buckets: custompolicies: Optional Array of custom S3 bucket policies - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") - principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) + principal: String Optional (e.g., "arn:aws:iam::123456789012:root", default: "*") ``` -Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The resource is always set to the bucket's object ARN (`arn:aws:s3:::bucket-name/*`). If `principal` is null, it is omitted from the policy statement. +Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The resource is always set to the bucket's object ARN (`arn:aws:s3:::bucket-name/*`). The `principal` field defaults to `"*"` (all principals) if not specified. ### Queue Definitions @@ -117,10 +117,10 @@ queues: custompolicies: Optional Array of custom SQS queue policies - action: String or Array (e.g., "sqs:SendMessage" or ["sqs:SendMessage", "sqs:ReceiveMessage"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") - principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) + principal: String Optional (e.g., "arn:aws:iam::123456789012:root", default: "*") ``` -Custom policies create an SQS QueuePolicy resource. The resource is always set to the queue's ARN. If `principal` is null, it is omitted from the policy statement. +Custom policies create an SQS QueuePolicy resource. The resource is always set to the queue's ARN. The `principal` field defaults to `"*"` (all principals) if not specified. ### Stream Definitions @@ -150,10 +150,9 @@ functions: - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for "*", default: "any") - principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) ``` -Custom policies are added to the Lambda function's IAM execution role as inline policy statements. The `resource` value of "any" translates to "*" (any resource). If `principal` is null, it is omitted from the policy statement (which is appropriate for IAM role policies that don't need a principal field). +Custom policies are added to the Lambda function's IAM execution role as inline policy statements. The `resource` value of "any" translates to "*" (any resource). Note that IAM role policies do not include a `Principal` field (they are identity-based policies attached to the role). ### API Gateway Definition @@ -219,7 +218,6 @@ lambda: - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) effect: String Optional (e.g., "allow" or "deny", default: "allow") resource: String Optional (e.g., "arn:aws:s3:::my-bucket/*" or "any" for "*", default: "any") - principal: String or null Optional (e.g., "arn:aws:iam::123456789012:root" or null, default: null) integration: path: open: diff --git a/src/easysam/schemas.json b/src/easysam/schemas.json index e27916c..bf02d68 100644 --- a/src/easysam/schemas.json +++ b/src/easysam/schemas.json @@ -18,13 +18,6 @@ "resource": { "type": "string", "default": "any" - }, - "principal": { - "oneOf": [ - {"type": "string"}, - {"type": "null"} - ], - "default": null } }, "required": ["action"], @@ -45,11 +38,8 @@ "default": "allow" }, "principal": { - "oneOf": [ - {"type": "string"}, - {"type": "null"} - ], - "default": null + "type": "string", + "default": "*" } }, "required": ["action"], @@ -70,11 +60,8 @@ "default": "allow" }, "principal": { - "oneOf": [ - {"type": "string"}, - {"type": "null"} - ], - "default": null + "type": "string", + "default": "*" } }, "required": ["action"], diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index d72aa0d..886bd67 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -341,9 +341,7 @@ Resources: Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* - {% if custom_policy.principal is defined and custom_policy.principal is not none %} - Principal: {{ custom_policy.principal | tojson }} - {% endif %} + Principal: {{ custom_policy.principal | default('*') | tojson }} {% endfor %} {% endif %} {% endif %} # if bucket.public @@ -359,9 +357,7 @@ Resources: - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* - {% if custom_policy.principal is defined and custom_policy.principal is not none %} - Principal: {{ custom_policy.principal | tojson }} - {% endif %} + Principal: {{ custom_policy.principal | default('*') | tojson }} {% endfor %} {% endif %} # if not bucket.public and bucket.custompolicies is defined @@ -404,9 +400,7 @@ Resources: - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} Resource: !GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn - {% if custom_policy.principal is defined and custom_policy.principal is not none %} - Principal: {{ custom_policy.principal | tojson }} - {% endif %} + Principal: {{ custom_policy.principal | default('*') | tojson }} {% endfor %} {% endif %} {% endfor %} @@ -569,9 +563,6 @@ Resources: - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} Resource: {% if custom_policy.resource | default('any') == 'any' %}'*'{% else %}{{ custom_policy.resource | tojson }}{% endif %} - {% if custom_policy.principal is defined and custom_policy.principal is not none %} - Principal: {{ custom_policy.principal | tojson }} - {% endif %} {% endfor %} {% endif %} {% if enable_lambda_layer or function.layers %} From 92bbcb5d1358c6a7ecad647da8ed93587105fdb1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Nov 2025 05:58:16 +0000 Subject: [PATCH 05/23] feat: Add custom policies example for lambda, queue, and bucket Co-authored-by: boris.resnick --- example/custompolicies/README.md | 54 +++++++++++++++++++ .../backend/function/myfunction/easysam.yaml | 20 +++++++ .../backend/function/myfunction/index.py | 52 ++++++++++++++++++ example/custompolicies/common/utils.py | 3 ++ example/custompolicies/resources.yaml | 29 ++++++++++ .../thirdparty/requirements.txt | 1 + 6 files changed, 159 insertions(+) create mode 100644 example/custompolicies/README.md create mode 100644 example/custompolicies/backend/function/myfunction/easysam.yaml create mode 100644 example/custompolicies/backend/function/myfunction/index.py create mode 100644 example/custompolicies/common/utils.py create mode 100644 example/custompolicies/resources.yaml create mode 100644 example/custompolicies/thirdparty/requirements.txt diff --git a/example/custompolicies/README.md b/example/custompolicies/README.md new file mode 100644 index 0000000..fb7a4e7 --- /dev/null +++ b/example/custompolicies/README.md @@ -0,0 +1,54 @@ +# Custom Policies Example + +This example demonstrates the use of `custompolicies` for lambdas, queues, and buckets. + +## Features Demonstrated + +### 1. Lambda Custom Policies +The `myfunction` lambda has custom IAM policies that grant: +- `logs:CreateLogGroup` on any log group ARN +- `logs:CreateLogStream` and `logs:PutLogEvents` on any resource (`*`) + +These are identity-based policies attached to the Lambda execution role, so they don't include a `Principal` field. + +### 2. Queue Custom Policies +The `notifications` queue has custom SQS queue policies that: +- Allow account `123456789012` to send messages +- Allow any principal (`*`) to receive and delete messages + +Queue policies are resource-based and always include a `Principal` field (defaults to `*` if not specified). + +### 3. Bucket Custom Policies +The `documents` bucket has custom S3 bucket policies that: +- Allow account `123456789012` to get objects +- Allow account `987654321098` to put and delete objects + +Bucket policies are resource-based and always include a `Principal` field (defaults to `*` if not specified). + +## File Structure + +``` +custompolicies/ +??? resources.yaml # Main resources file with bucket and queue custom policies +??? backend/ +? ??? function/ +? ??? myfunction/ +? ??? easysam.yaml # Lambda definition with custom policies +? ??? index.py # Lambda function code +??? common/ + ??? utils.py # Common utilities +``` + +## Key Points + +- **Lambda policies**: Identity-based, no `Principal` field, `resource` can be `"any"` (translates to `"*"`) +- **Queue policies**: Resource-based, always require `Principal` (defaults to `"*"`), `resource` is always the queue ARN +- **Bucket policies**: Resource-based, always require `Principal` (defaults to `"*"`), `resource` is always the bucket ARN + +## Deployment + +```bash +easysam deploy custompolicies --environment dev --tag Environment=dev +``` + +Note: Update the principal ARNs in `resources.yaml` to match your AWS account IDs. diff --git a/example/custompolicies/backend/function/myfunction/easysam.yaml b/example/custompolicies/backend/function/myfunction/easysam.yaml new file mode 100644 index 0000000..9e2fbcc --- /dev/null +++ b/example/custompolicies/backend/function/myfunction/easysam.yaml @@ -0,0 +1,20 @@ +lambda: + name: myfunction + resources: + buckets: + - documents + send: + - notifications + custompolicies: + - action: logs:CreateLogGroup + effect: allow + resource: 'arn:aws:logs:*:*:*' + - action: + - logs:CreateLogStream + - logs:PutLogEvents + effect: allow + resource: 'any' + integration: + path: /process + open: true + greedy: false diff --git a/example/custompolicies/backend/function/myfunction/index.py b/example/custompolicies/backend/function/myfunction/index.py new file mode 100644 index 0000000..0dffa62 --- /dev/null +++ b/example/custompolicies/backend/function/myfunction/index.py @@ -0,0 +1,52 @@ +import json +import boto3 +import os + +s3 = boto3.client('s3') +sqs = boto3.client('sqs') + + +def handler(event, context): + ''' + Example Lambda function demonstrating custom policies usage. + This function has custom IAM policies that allow: + - Creating CloudWatch Logs groups and streams + - Putting log events + + The function can also access: + - S3 bucket 'documents' (via bucket resource definition) + - SQS queue 'notifications' (via send resource definition) + ''' + bucket_name = os.environ.get('DOCUMENTS_BUCKET_NAME', '') + queue_url = os.environ.get('NOTIFICATIONS_QUEUE_URL', '') + + # Example: List objects in the documents bucket + try: + response = s3.list_objects_v2(Bucket=bucket_name, MaxKeys=5) + objects = [obj['Key'] for obj in response.get('Contents', [])] + except Exception as e: + objects = [] + print(f'Error listing bucket: {e}') + + # Example: Send message to notifications queue + try: + sqs.send_message( + QueueUrl=queue_url, + MessageBody=json.dumps({'status': 'processed', 'timestamp': '2024-01-01T00:00:00Z'}) + ) + except Exception as e: + print(f'Error sending message: {e}') + + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*' + }, + 'body': json.dumps({ + 'message': 'Function executed successfully', + 'bucket_objects_count': len(objects), + 'note': 'This function uses custom IAM policies for CloudWatch Logs access' + }) + } diff --git a/example/custompolicies/common/utils.py b/example/custompolicies/common/utils.py new file mode 100644 index 0000000..e882d87 --- /dev/null +++ b/example/custompolicies/common/utils.py @@ -0,0 +1,3 @@ +def get_bucket_info(): + '''Example utility function''' + return 'Custom policies example' diff --git a/example/custompolicies/resources.yaml b/example/custompolicies/resources.yaml new file mode 100644 index 0000000..e473dbb --- /dev/null +++ b/example/custompolicies/resources.yaml @@ -0,0 +1,29 @@ +prefix: CustomPoliciesApp + +import: + - backend + +buckets: + documents: + public: false + custompolicies: + - action: s3:GetObject + effect: allow + principal: 'arn:aws:iam::123456789012:root' + - action: + - s3:PutObject + - s3:DeleteObject + effect: allow + principal: 'arn:aws:iam::987654321098:root' + +queues: + notifications: + custompolicies: + - action: sqs:SendMessage + effect: allow + principal: 'arn:aws:iam::123456789012:root' + - action: + - sqs:ReceiveMessage + - sqs:DeleteMessage + effect: allow + principal: '*' diff --git a/example/custompolicies/thirdparty/requirements.txt b/example/custompolicies/thirdparty/requirements.txt new file mode 100644 index 0000000..011ba23 --- /dev/null +++ b/example/custompolicies/thirdparty/requirements.txt @@ -0,0 +1 @@ +boto3>=1.34.0 From 086fa0e64c99f346c7ed7c44a0b6ff7895fee05b Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Mon, 3 Nov 2025 10:08:43 +0200 Subject: [PATCH 06/23] feat: add custom policies support to lambdas and queues --- CURSOR.md | 4 ++ example/custompolicies/.gitignore | 4 ++ src/easysam/template.j2 | 2 + src/easysam/validate_schema.py | 116 ++++++++++++++++-------------- 4 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 CURSOR.md create mode 100644 example/custompolicies/.gitignore diff --git a/CURSOR.md b/CURSOR.md new file mode 100644 index 0000000..6562c2b --- /dev/null +++ b/CURSOR.md @@ -0,0 +1,4 @@ +# Python Code Generation + +* Use `ruff` for linting and formatting. +* Use `flake8` for linting. diff --git a/example/custompolicies/.gitignore b/example/custompolicies/.gitignore new file mode 100644 index 0000000..773065e --- /dev/null +++ b/example/custompolicies/.gitignore @@ -0,0 +1,4 @@ +build +template.yml +template.yaml +.aws-sam diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 886bd67..84ae618 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -232,6 +232,7 @@ Resources: - 'sqs:GetQueueUrl' - 'sqs:SendMessageBatch' Resource: '*' + {% if authorizers is defined %} # check if authorizers are defined - PolicyName: AuthorizerPolicy PolicyDocument: Version: '2012-10-17' @@ -243,6 +244,7 @@ Resources: {% for auth_name, auth_params in authorizers.items() %} - !GetAtt {{ auth_name }}Function.Arn {% endfor %} + {% endif %} # end authorizers {% endif %} # end queues # Streams diff --git a/src/easysam/validate_schema.py b/src/easysam/validate_schema.py index 158943a..e949573 100644 --- a/src/easysam/validate_schema.py +++ b/src/easysam/validate_schema.py @@ -6,13 +6,13 @@ def validate(resources_dir: Path, resources_data: dict, errors: list[str]): - '''Validate resources data against the schema and perform custom validations. + """Validate resources data against the schema and perform custom validations. Args: resources_dir: The directory containing the resources.yaml file. resources_data: The pre-loaded resources data dictionary. errors: The list of errors. - ''' + """ schema = load_schema() validator = Draft7Validator(schema) @@ -36,30 +36,34 @@ def validate(resources_dir: Path, resources_data: dict, errors: list[str]): def load_schema() -> dict: - '''Load the JSON schema from the schemas.json file.''' + """Load the JSON schema from the schemas.json file.""" schema_path = Path(__file__).parent / 'schemas.json' with open(schema_path, 'r', encoding='utf-8') as f: return json.load(f) def validate_buckets(resources_data: dict, errors: list[str]): - '''Validate bucket-specific rules.''' + """Validate bucket-specific rules.""" for bucket, details in resources_data.get('buckets', {}).items(): if bucket == 'private' and details['public']: - errors.append(f'Bucket \'{bucket}\' cannot be public') + errors.append(f"Bucket '{bucket}' cannot be public") if 'custompolicies' in details: - validate_custom_policies(details['custompolicies'], f'Bucket {bucket}', errors) + validate_custom_policies( + details['custompolicies'], f'Bucket {bucket}', errors + ) def validate_queues(resources_data: dict, errors: list[str]): - '''Validate queue-specific rules.''' + """Validate queue-specific rules.""" for queue_name, queue in resources_data.get('queues', {}).items(): if queue is not None and 'custompolicies' in queue: - validate_custom_policies(queue['custompolicies'], f'Queue {queue_name}', errors) + validate_custom_policies( + queue['custompolicies'], f'Queue {queue_name}', errors + ) def validate_tables(resources_data: dict, errors: list[str]): - '''Validate table-specific rules.''' + """Validate table-specific rules.""" for table_name, table in resources_data.get('tables', {}).items(): if trigger_config := table.get('trigger'): # trigger_config is always an object at this point (processed by load.py) @@ -72,11 +76,13 @@ def validate_tables(resources_data: dict, errors: list[str]): def validate_streams(resources_data: dict, errors: list[str]): - '''Validate stream-specific rules.''' + """Validate stream-specific rules.""" for stream, details in resources_data.get('streams', {}).items(): for bucket in details.get('buckets', {}).values(): if 'bucketname' not in bucket and 'extbucketarn' not in bucket: - errors.append(f"Stream '{stream}': 'bucketname' or 'extbucketarn' is required") + errors.append( + f"Stream '{stream}': 'bucketname' or 'extbucketarn' is required" + ) continue if 'bucketname' in bucket and 'extbucketarn' in bucket: @@ -98,14 +104,18 @@ def validate_streams(resources_data: dict, errors: list[str]): if 'extbucketarn' in bucket: if ( - not bucket['extbucketarn'].startswith('arn:aws:s3:::') and - bucket['extbucketarn'] != '' + not bucket['extbucketarn'].startswith('arn:aws:s3:::') + and bucket['extbucketarn'] != '' ): - errors.append(f"Stream '{stream}': 'extbucketarn' must be a valid ARN") + errors.append( + f"Stream '{stream}': 'extbucketarn' must be a valid ARN" + ) -def validate_custom_policies(custompolicies: list, resource_name: str, errors: list[str]): - '''Validate custom policies structure.''' +def validate_custom_policies( + custompolicies: list, resource_name: str, errors: list[str] +): + """Validate custom policies structure.""" if not isinstance(custompolicies, list): errors.append(f'{resource_name}: custompolicies must be an array') return @@ -114,13 +124,17 @@ def validate_custom_policies(custompolicies: list, resource_name: str, errors: l errors.append(f'{resource_name}: custompolicies[{idx}] must be an object') continue if 'action' not in policy: - errors.append(f'{resource_name}: custompolicies[{idx}] must have an action field') + errors.append( + f'{resource_name}: custompolicies[{idx}] must have an action field' + ) if 'effect' in policy and policy['effect'] not in ['allow', 'deny']: - errors.append(f'{resource_name}: custompolicies[{idx}].effect must be "allow" or "deny"') + errors.append( + f'{resource_name}: custompolicies[{idx}].effect must be "allow" or "deny"' + ) def validate_lambda(resources_data: dict, errors: list[str]): - '''Validate lambda function-specific rules.''' + """Validate lambda function-specific rules.""" lg.debug('Validating lambda functions') for lambda_name, details in resources_data.get('functions', {}).items(): @@ -129,8 +143,7 @@ def validate_lambda(resources_data: dict, errors: list[str]): for bucket in details.get('buckets', []): if bucket not in resources_data.get('buckets', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Bucket {bucket} must be a valid bucket' + f'Lambda {lambda_name}: Bucket {bucket} must be a valid bucket' ) continue @@ -138,8 +151,7 @@ def validate_lambda(resources_data: dict, errors: list[str]): for table in details.get('tables', []): if table not in resources_data.get('tables', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Table {table} must be a valid table' + f'Lambda {lambda_name}: Table {table} must be a valid table' ) continue @@ -147,8 +159,7 @@ def validate_lambda(resources_data: dict, errors: list[str]): for poll in details.get('polls', []): if poll['name'] not in resources_data.get('queues', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Queue {poll["name"]} must be a valid queue' + f'Lambda {lambda_name}: Queue {poll["name"]} must be a valid queue' ) continue @@ -156,8 +167,7 @@ def validate_lambda(resources_data: dict, errors: list[str]): for send in details.get('send', []): if send not in resources_data.get('queues', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Send {send} must be a valid queue' + f'Lambda {lambda_name}: Send {send} must be a valid queue' ) continue @@ -165,8 +175,7 @@ def validate_lambda(resources_data: dict, errors: list[str]): for stream in details.get('streams', []): if stream not in resources_data.get('streams', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Stream {stream} must be a valid stream' + f'Lambda {lambda_name}: Stream {stream} must be a valid stream' ) continue @@ -174,17 +183,18 @@ def validate_lambda(resources_data: dict, errors: list[str]): for collection in details.get('searches', []): if collection not in resources_data.get('search', {}): errors.append( - f'Lambda {lambda_name}: ' - f'Search {collection} must be a valid search' + f'Lambda {lambda_name}: Search {collection} must be a valid search' ) continue if 'custompolicies' in details: - validate_custom_policies(details['custompolicies'], f'Lambda {lambda_name}', errors) + validate_custom_policies( + details['custompolicies'], f'Lambda {lambda_name}', errors + ) def validate_paths(resources_dir: Path, resources_data: dict, errors: list[str]): - '''Validate path-specific rules.''' + """Validate path-specific rules.""" for path, details in resources_data.get('paths', {}).items(): match details.get('integration', 'lambda'): case 'lambda': @@ -195,8 +205,10 @@ def validate_paths(resources_dir: Path, resources_data: dict, errors: list[str]) validate_sqs_path(resources_dir, resources_data, path, details, errors) -def validate_lambda_path(resources_data: dict, path: str, details: dict, errors: list[str]): - '''Validate lambda path-specific rules.''' +def validate_lambda_path( + resources_data: dict, path: str, details: dict, errors: list[str] +): + """Validate lambda path-specific rules.""" authorizer = details.get('authorizer') open_path = details.get('open') @@ -212,12 +224,9 @@ def validate_lambda_path(resources_data: dict, path: str, details: dict, errors: def validate_dynamo_path( - resources_dir: Path, - path: str, - details: dict, - errors: list[str] + resources_dir: Path, path: str, details: dict, errors: list[str] ): - '''Validate dynamo path-specific rules.''' + """Validate dynamo path-specific rules.""" validate_request_response_templates(resources_dir, path, details, errors) @@ -226,9 +235,9 @@ def validate_sqs_path( resources_data: dict, path: str, details: dict, - errors: list[str] + errors: list[str], ): - '''Validate SQS path-specific rules.''' + """Validate SQS path-specific rules.""" if details['queue'] not in resources_data['queues']: errors.append(f"SQS path '{path}' queue must be a valid queue") @@ -236,12 +245,9 @@ def validate_sqs_path( def validate_request_response_templates( - resources_dir: Path, - path: str, - details: dict, - errors: list[str] + resources_dir: Path, path: str, details: dict, errors: list[str] ): - '''Validate request and response templates.''' + """Validate request and response templates.""" request = False response = False @@ -285,7 +291,7 @@ def validate_request_response_templates( def validate_import(resources_dir: Path, resources_data: dict, errors: list[str]): - '''Validate import-specific rules.''' + """Validate import-specific rules.""" if import_list := resources_data.get('import'): for import_item in import_list: import_path = Path(resources_dir, import_item).resolve() @@ -295,7 +301,7 @@ def validate_import(resources_dir: Path, resources_data: dict, errors: list[str] def validate_prismarine(resources_dir: Path, resources_data: dict, errors: list[str]): - '''Validate prismarine-specific rules.''' + """Validate prismarine-specific rules.""" prismarine = resources_data.get('prismarine', {}) if not prismarine: @@ -305,7 +311,9 @@ def validate_prismarine(resources_dir: Path, resources_data: dict, errors: list[ default_base_dir = Path(resources_dir, default_base).resolve() if not default_base_dir.exists(): - errors.append(f"Prismarine default-base '{default_base}' must be a valid directory") + errors.append( + f"Prismarine default-base '{default_base}' must be a valid directory" + ) return for table in prismarine.get('tables', []): @@ -319,7 +327,7 @@ def validate_prismarine(resources_dir: Path, resources_data: dict, errors: list[ def validate_authorizers(resources_data: dict, errors: list[str]): - '''Validate authorizer-specific rules.''' + """Validate authorizer-specific rules.""" for authorizer, details in resources_data.get('authorizers', {}).items(): present_types = ['token' in details, 'query' in details, 'headers' in details] @@ -327,11 +335,13 @@ def validate_authorizers(resources_data: dict, errors: list[str]): errors.append(f"Authorizer '{authorizer}' cannot have multiple types") if details['function'] not in resources_data.get('functions', {}): - errors.append(f"Authorizer '{authorizer}' function must be a valid function") + errors.append( + f"Authorizer '{authorizer}' function must be a valid function" + ) def validate_search(resources_data: dict, errors: list[str]): - '''Validate search-specific rules - no rules yet.''' + """Validate search-specific rules - no rules yet.""" search = resources_data.get('search', {}) if not search: From 04758653cc65f3560ed44728b5d1756b363e55fc Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Mon, 3 Nov 2025 10:41:52 +0200 Subject: [PATCH 07/23] refactor(example): simplify resource names in custom policies example --- .../custompolicies/backend/function/myfunction/.gitignore | 1 + .../backend/function/myfunction/easysam.yaml | 4 ++-- example/custompolicies/resources.yaml | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 example/custompolicies/backend/function/myfunction/.gitignore diff --git a/example/custompolicies/backend/function/myfunction/.gitignore b/example/custompolicies/backend/function/myfunction/.gitignore new file mode 100644 index 0000000..30e1159 --- /dev/null +++ b/example/custompolicies/backend/function/myfunction/.gitignore @@ -0,0 +1 @@ +common diff --git a/example/custompolicies/backend/function/myfunction/easysam.yaml b/example/custompolicies/backend/function/myfunction/easysam.yaml index 9e2fbcc..f1b72b0 100644 --- a/example/custompolicies/backend/function/myfunction/easysam.yaml +++ b/example/custompolicies/backend/function/myfunction/easysam.yaml @@ -2,9 +2,9 @@ lambda: name: myfunction resources: buckets: - - documents + - docs send: - - notifications + - notify custompolicies: - action: logs:CreateLogGroup effect: allow diff --git a/example/custompolicies/resources.yaml b/example/custompolicies/resources.yaml index e473dbb..f6c0959 100644 --- a/example/custompolicies/resources.yaml +++ b/example/custompolicies/resources.yaml @@ -1,10 +1,10 @@ -prefix: CustomPoliciesApp +prefix: cpol import: - backend buckets: - documents: + docs: public: false custompolicies: - action: s3:GetObject @@ -17,7 +17,7 @@ buckets: principal: 'arn:aws:iam::987654321098:root' queues: - notifications: + notify: custompolicies: - action: sqs:SendMessage effect: allow @@ -26,4 +26,4 @@ queues: - sqs:ReceiveMessage - sqs:DeleteMessage effect: allow - principal: '*' + principal: 'arn:aws:iam::123456789012:root' From b6b4ff846a43777fe2e126c9cfc300331994b70f Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Mon, 3 Nov 2025 11:01:32 +0200 Subject: [PATCH 08/23] refactor: comment out custom policies in example configs --- .../backend/function/myfunction/easysam.yaml | 36 +++++++++---------- example/custompolicies/resources.yaml | 24 ++++++------- src/easysam/template.j2 | 5 ++- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/example/custompolicies/backend/function/myfunction/easysam.yaml b/example/custompolicies/backend/function/myfunction/easysam.yaml index f1b72b0..2dedd52 100644 --- a/example/custompolicies/backend/function/myfunction/easysam.yaml +++ b/example/custompolicies/backend/function/myfunction/easysam.yaml @@ -1,20 +1,20 @@ lambda: name: myfunction - resources: - buckets: - - docs - send: - - notify - custompolicies: - - action: logs:CreateLogGroup - effect: allow - resource: 'arn:aws:logs:*:*:*' - - action: - - logs:CreateLogStream - - logs:PutLogEvents - effect: allow - resource: 'any' - integration: - path: /process - open: true - greedy: false + # resources: + # buckets: + # - docs + # send: + # - notify + # custompolicies: + # - action: logs:CreateLogGroup + # effect: allow + # resource: 'arn:aws:logs:*:*:*' + # - action: + # - logs:CreateLogStream + # - logs:PutLogEvents + # effect: allow + # resource: 'any' + # integration: + # path: /process + # open: true + # greedy: false diff --git a/example/custompolicies/resources.yaml b/example/custompolicies/resources.yaml index f6c0959..e2f1d0e 100644 --- a/example/custompolicies/resources.yaml +++ b/example/custompolicies/resources.yaml @@ -3,18 +3,18 @@ prefix: cpol import: - backend -buckets: - docs: - public: false - custompolicies: - - action: s3:GetObject - effect: allow - principal: 'arn:aws:iam::123456789012:root' - - action: - - s3:PutObject - - s3:DeleteObject - effect: allow - principal: 'arn:aws:iam::987654321098:root' +# buckets: +# docs: +# public: false +# custompolicies: +# - action: s3:GetObject +# effect: allow +# principal: 'arn:aws:iam::123456789012:root' +# - action: +# - s3:PutObject +# - s3:DeleteObject +# effect: allow +# principal: 'arn:aws:iam::987654321098:root' queues: notify: diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 84ae618..83286f6 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -364,6 +364,7 @@ Resources: {% endif %} # if not bucket.public and bucket.custompolicies is defined {% if bucket.extaccesspolicy is defined %} + # External access bucket policy {{ lprefix }}{{ bucket_name.replace('-', '') }}ReadPolicy: Type: "AWS::IAM::ManagedPolicy" Properties: @@ -390,6 +391,7 @@ Resources: Properties: QueueName: !Sub "{{ lprefix }}-{{ queue_name }}-${Stage}" {% if queue is not none and queue.custompolicies is defined %} + {{ lprefix }}{{ queue_name.replace('-', '') }}QueuePolicy: Type: AWS::SQS::QueuePolicy Properties: @@ -402,7 +404,8 @@ Resources: - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} Action: {{ custom_policy.action | tojson }} Resource: !GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn - Principal: {{ custom_policy.principal | default('*') | tojson }} + Principal: + AWS: {{ custom_policy.principal | tojson }} {% endfor %} {% endif %} {% endfor %} From 73ea9364cfb54114c62a3873b7c38d9706bbde1a Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Fri, 7 Nov 2025 14:31:05 +0200 Subject: [PATCH 09/23] feat(policies): remove bucket and queue custom policy support --- CHANGELOG.md | 6 +- README.md | 17 ++---- example/custompolicies/README.md | 23 ++------ .../backend/function/myfunction/easysam.yaml | 26 +++------ example/custompolicies/resources.yaml | 25 --------- src/easysam/load.py | 4 ++ src/easysam/schemas.json | 56 +------------------ src/easysam/template.j2 | 42 -------------- src/easysam/validate_schema.py | 35 +----------- 9 files changed, 29 insertions(+), 205 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef1627d..b3ef60a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ -# 1.6.0 +# Unreleased - Added support for the AWS Bedrock permissions -- Changed `init` command to work in the current directory (requires `uv init` to be run first) +- Added support for custom policies on lambdas +- Changed `init` command to work in the current directory - Added `--prismarine` option to `init` command for scaffolding minimal Prismarine applications -- Removed `app-name` argument from `init` command # 1.5.1 diff --git a/README.md b/README.md index 1bd1440..664d952 100644 --- a/README.md +++ b/README.md @@ -99,28 +99,21 @@ tables: buckets: my-bucket: public: Boolean Optional (e.g., true), means Public read policy - custompolicies: Optional Array of custom S3 bucket policies - - action: String or Array (e.g., "s3:GetObject" or ["s3:GetObject", "s3:PutObject"]) - effect: String Optional (e.g., "allow" or "deny", default: "allow") - principal: String Optional (e.g., "arn:aws:iam::123456789012:root", default: "*") + extaccesspolicy: Optional String referencing an external managed policy prefix ``` -Custom policies are added to the bucket's S3 BucketPolicy. For public buckets, custom policies are merged with the existing public access policies. For non-public buckets, a new BucketPolicy is created if custompolicies are defined. The resource is always set to the bucket's object ARN (`arn:aws:s3:::bucket-name/*`). The `principal` field defaults to `"*"` (all principals) if not specified. +Bucket custom policies are no longer supported. For external access requirements, attach policies via `extaccesspolicy` or create the policy outside of `easysam`. ### Queue Definitions ```yaml queues: - my-queue: null # Simple queue (no custom policies) + my-queue: null # Simple queue definition # OR - my-queue: - custompolicies: Optional Array of custom SQS queue policies - - action: String or Array (e.g., "sqs:SendMessage" or ["sqs:SendMessage", "sqs:ReceiveMessage"]) - effect: String Optional (e.g., "allow" or "deny", default: "allow") - principal: String Optional (e.g., "arn:aws:iam::123456789012:root", default: "*") + my-queue: {} # Explicit empty queue configuration ``` -Custom policies create an SQS QueuePolicy resource. The resource is always set to the queue's ARN. The `principal` field defaults to `"*"` (all principals) if not specified. +Queue custom policies are no longer supported. Use IAM roles or separate CloudFormation stacks if you require additional resource policies. ### Stream Definitions diff --git a/example/custompolicies/README.md b/example/custompolicies/README.md index fb7a4e7..002a4cc 100644 --- a/example/custompolicies/README.md +++ b/example/custompolicies/README.md @@ -1,35 +1,23 @@ # Custom Policies Example -This example demonstrates the use of `custompolicies` for lambdas, queues, and buckets. +This example demonstrates the use of `custompolicies` for lambdas. ## Features Demonstrated -### 1. Lambda Custom Policies +### Lambda Custom Policies The `myfunction` lambda has custom IAM policies that grant: - `logs:CreateLogGroup` on any log group ARN - `logs:CreateLogStream` and `logs:PutLogEvents` on any resource (`*`) These are identity-based policies attached to the Lambda execution role, so they don't include a `Principal` field. -### 2. Queue Custom Policies -The `notifications` queue has custom SQS queue policies that: -- Allow account `123456789012` to send messages -- Allow any principal (`*`) to receive and delete messages - -Queue policies are resource-based and always include a `Principal` field (defaults to `*` if not specified). - -### 3. Bucket Custom Policies -The `documents` bucket has custom S3 bucket policies that: -- Allow account `123456789012` to get objects -- Allow account `987654321098` to put and delete objects - -Bucket policies are resource-based and always include a `Principal` field (defaults to `*` if not specified). +> Bucket and queue custom policies have been removed from `easysam`. Use separate CloudFormation resources or IAM roles if you need resource-based policies for those services. ## File Structure ``` custompolicies/ -??? resources.yaml # Main resources file with bucket and queue custom policies +??? resources.yaml # Main resources file importing the lambda example ??? backend/ ? ??? function/ ? ??? myfunction/ @@ -42,8 +30,7 @@ custompolicies/ ## Key Points - **Lambda policies**: Identity-based, no `Principal` field, `resource` can be `"any"` (translates to `"*"`) -- **Queue policies**: Resource-based, always require `Principal` (defaults to `"*"`), `resource` is always the queue ARN -- **Bucket policies**: Resource-based, always require `Principal` (defaults to `"*"`), `resource` is always the bucket ARN +- **Buckets/queues**: Resource-based custom policies are no longer managed by `easysam` ## Deployment diff --git a/example/custompolicies/backend/function/myfunction/easysam.yaml b/example/custompolicies/backend/function/myfunction/easysam.yaml index 2dedd52..1dd05f1 100644 --- a/example/custompolicies/backend/function/myfunction/easysam.yaml +++ b/example/custompolicies/backend/function/myfunction/easysam.yaml @@ -1,20 +1,10 @@ lambda: name: myfunction - # resources: - # buckets: - # - docs - # send: - # - notify - # custompolicies: - # - action: logs:CreateLogGroup - # effect: allow - # resource: 'arn:aws:logs:*:*:*' - # - action: - # - logs:CreateLogStream - # - logs:PutLogEvents - # effect: allow - # resource: 'any' - # integration: - # path: /process - # open: true - # greedy: false + custompolicies: + - action: ec2:CreateSecurityGroup + effect: allow + resource: 'arn:aws:ec2:*:*:security-group/*' + integration: + path: /process + open: true + greedy: false diff --git a/example/custompolicies/resources.yaml b/example/custompolicies/resources.yaml index e2f1d0e..051b40a 100644 --- a/example/custompolicies/resources.yaml +++ b/example/custompolicies/resources.yaml @@ -2,28 +2,3 @@ prefix: cpol import: - backend - -# buckets: -# docs: -# public: false -# custompolicies: -# - action: s3:GetObject -# effect: allow -# principal: 'arn:aws:iam::123456789012:root' -# - action: -# - s3:PutObject -# - s3:DeleteObject -# effect: allow -# principal: 'arn:aws:iam::987654321098:root' - -queues: - notify: - custompolicies: - - action: sqs:SendMessage - effect: allow - principal: 'arn:aws:iam::123456789012:root' - - action: - - sqs:ReceiveMessage - - sqs:DeleteMessage - effect: allow - principal: 'arn:aws:iam::123456789012:root' diff --git a/src/easysam/load.py b/src/easysam/load.py index d7cddcf..0ca0284 100644 --- a/src/easysam/load.py +++ b/src/easysam/load.py @@ -171,6 +171,10 @@ def preprocess_lambda( lg.debug(f'Adding lambda {lambda_name} to resources') resources_data['functions'][lambda_name] = lambda_resources + + if custompolicies := lambda_def.get('custompolicies', []): + lambda_resources['custompolicies'] = custompolicies + integration = lambda_def.get('integration', {}) if integration: diff --git a/src/easysam/schemas.json b/src/easysam/schemas.json index bf02d68..42a7a6a 100644 --- a/src/easysam/schemas.json +++ b/src/easysam/schemas.json @@ -23,71 +23,17 @@ "required": ["action"], "additionalProperties": false }, - "bucket_custom_policy_schema": { - "type": "object", - "properties": { - "action": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "effect": { - "type": "string", - "enum": ["allow", "deny"], - "default": "allow" - }, - "principal": { - "type": "string", - "default": "*" - } - }, - "required": ["action"], - "additionalProperties": false - }, - "queue_custom_policy_schema": { - "type": "object", - "properties": { - "action": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "effect": { - "type": "string", - "enum": ["allow", "deny"], - "default": "allow" - }, - "principal": { - "type": "string", - "default": "*" - } - }, - "required": ["action"], - "additionalProperties": false - }, "buckets_schema": { "type": "object", "properties": { "public": {"type": "boolean"}, - "extaccesspolicy": {"type": "string"}, - "custompolicies": { - "type": "array", - "items": {"$ref": "#/definitions/bucket_custom_policy_schema"} - } + "extaccesspolicy": {"type": "string"} }, "required": ["public"], "additionalProperties": false }, "queues_schema": { "type": "object", - "properties": { - "custompolicies": { - "type": "array", - "items": {"$ref": "#/definitions/queue_custom_policy_schema"} - } - }, "additionalProperties": false }, "stream_bucket_schema": { diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 83286f6..9301195 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -337,31 +337,7 @@ Resources: Principal: '*' Action: 's3:GetObjectAttributes' Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* - {% if bucket.custompolicies is defined %} - {% for custom_policy in bucket.custompolicies %} - - Sid: CustomPolicy{{ loop.index }} - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} - Action: {{ custom_policy.action | tojson }} - Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* - Principal: {{ custom_policy.principal | default('*') | tojson }} - {% endfor %} - {% endif %} {% endif %} # if bucket.public - {% if not bucket.public and bucket.custompolicies is defined %} - {{ lprefix }}{{ bucket_name.replace('-', '') }}BucketPolicy: - Type: AWS::S3::BucketPolicy - Properties: - Bucket: !Ref {{ lprefix }}{{ bucket_name }}Bucket - PolicyDocument: - Version: 2012-10-17 - Statement: - {% for custom_policy in bucket.custompolicies %} - - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} - Action: {{ custom_policy.action | tojson }} - Resource: !Sub arn:aws:s3:::${{ "{" }}{{ lprefix }}{{ bucket_name.replace('-', '') }}Bucket}/* - Principal: {{ custom_policy.principal | default('*') | tojson }} - {% endfor %} - {% endif %} # if not bucket.public and bucket.custompolicies is defined {% if bucket.extaccesspolicy is defined %} # External access bucket policy @@ -390,24 +366,6 @@ Resources: Type: AWS::SQS::Queue Properties: QueueName: !Sub "{{ lprefix }}-{{ queue_name }}-${Stage}" - {% if queue is not none and queue.custompolicies is defined %} - - {{ lprefix }}{{ queue_name.replace('-', '') }}QueuePolicy: - Type: AWS::SQS::QueuePolicy - Properties: - Queues: - - !Ref {{ lprefix }}{{ queue_name.replace('-', '') }}Queue - PolicyDocument: - Version: "2012-10-17" - Statement: - {% for custom_policy in queue.custompolicies %} - - Effect: {{ custom_policy.effect | default('Allow') | capitalize }} - Action: {{ custom_policy.action | tojson }} - Resource: !GetAtt {{ lprefix }}{{ queue_name.replace('-', '') }}Queue.Arn - Principal: - AWS: {{ custom_policy.principal | tojson }} - {% endfor %} - {% endif %} {% endfor %} {% endif %} # end queues diff --git a/src/easysam/validate_schema.py b/src/easysam/validate_schema.py index e949573..80e0fdf 100644 --- a/src/easysam/validate_schema.py +++ b/src/easysam/validate_schema.py @@ -47,18 +47,14 @@ def validate_buckets(resources_data: dict, errors: list[str]): for bucket, details in resources_data.get('buckets', {}).items(): if bucket == 'private' and details['public']: errors.append(f"Bucket '{bucket}' cannot be public") - if 'custompolicies' in details: - validate_custom_policies( - details['custompolicies'], f'Bucket {bucket}', errors - ) def validate_queues(resources_data: dict, errors: list[str]): """Validate queue-specific rules.""" for queue_name, queue in resources_data.get('queues', {}).items(): - if queue is not None and 'custompolicies' in queue: - validate_custom_policies( - queue['custompolicies'], f'Queue {queue_name}', errors + if queue not in (None, {}): + errors.append( + f'Queue {queue_name}: only empty queue definitions are supported' ) @@ -112,27 +108,6 @@ def validate_streams(resources_data: dict, errors: list[str]): ) -def validate_custom_policies( - custompolicies: list, resource_name: str, errors: list[str] -): - """Validate custom policies structure.""" - if not isinstance(custompolicies, list): - errors.append(f'{resource_name}: custompolicies must be an array') - return - for idx, policy in enumerate(custompolicies): - if not isinstance(policy, dict): - errors.append(f'{resource_name}: custompolicies[{idx}] must be an object') - continue - if 'action' not in policy: - errors.append( - f'{resource_name}: custompolicies[{idx}] must have an action field' - ) - if 'effect' in policy and policy['effect'] not in ['allow', 'deny']: - errors.append( - f'{resource_name}: custompolicies[{idx}].effect must be "allow" or "deny"' - ) - - def validate_lambda(resources_data: dict, errors: list[str]): """Validate lambda function-specific rules.""" lg.debug('Validating lambda functions') @@ -187,10 +162,6 @@ def validate_lambda(resources_data: dict, errors: list[str]): ) continue - if 'custompolicies' in details: - validate_custom_policies( - details['custompolicies'], f'Lambda {lambda_name}', errors - ) def validate_paths(resources_dir: Path, resources_data: dict, errors: list[str]): From 5eeda9e8e2387c70f30e8961778e0b0bcebfe214 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Fri, 7 Nov 2025 17:02:57 +0200 Subject: [PATCH 10/23] feat: add invoke permission support for lambda functions --- CHANGELOG.md | 3 ++- .../backend/function/myfunction/easysam.yaml | 2 +- src/easysam/load.py | 3 +++ src/easysam/schemas.json | 3 ++- src/easysam/template.j2 | 13 +++++++++++++ 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ef60a..21796cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Unreleased - Added support for the AWS Bedrock permissions -- Added support for custom policies on lambdas +- Custom policy support for lambda functions +- Invoke permission support for lambda functions - Changed `init` command to work in the current directory - Added `--prismarine` option to `init` command for scaffolding minimal Prismarine applications diff --git a/example/custompolicies/backend/function/myfunction/easysam.yaml b/example/custompolicies/backend/function/myfunction/easysam.yaml index 1dd05f1..7c94488 100644 --- a/example/custompolicies/backend/function/myfunction/easysam.yaml +++ b/example/custompolicies/backend/function/myfunction/easysam.yaml @@ -3,7 +3,7 @@ lambda: custompolicies: - action: ec2:CreateSecurityGroup effect: allow - resource: 'arn:aws:ec2:*:*:security-group/*' + allowinvoke: arn:aws:iam::123456789012:root integration: path: /process open: true diff --git a/src/easysam/load.py b/src/easysam/load.py index 0ca0284..b3f429e 100644 --- a/src/easysam/load.py +++ b/src/easysam/load.py @@ -175,6 +175,9 @@ def preprocess_lambda( if custompolicies := lambda_def.get('custompolicies', []): lambda_resources['custompolicies'] = custompolicies + if allow_invoke := lambda_def.get('allowinvoke'): + lambda_resources['allowinvoke'] = allow_invoke + integration = lambda_def.get('integration', {}) if integration: diff --git a/src/easysam/schemas.json b/src/easysam/schemas.json index 42a7a6a..4e1593a 100644 --- a/src/easysam/schemas.json +++ b/src/easysam/schemas.json @@ -110,7 +110,8 @@ "custompolicies": { "type": "array", "items": {"$ref": "#/definitions/custom_policy_schema"} - } + }, + "allowinvoke": {"type": "string"} }, "required": ["uri"], "additionalProperties": false diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index 9301195..e8a2c17 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -588,6 +588,19 @@ Resources: {% endfor %} {% endif %} # end functions + {% if functions is defined %} # Permissions for lambda functions + {% for function_name, function in functions.items() %} + {% if function.allowinvoke is defined %} + {{ function_name.replace('-', '') }}FunctionAllowInvoke: + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !GetAtt {{ function_name.replace('-', '') }}Function.Arn + Principal: {{ function.allowinvoke | tojson }} + {% endif %} + {% endfor %} + {% endif %} # end permissions for lambda functions + # DynamoDB Stream Event Source Mappings (from table definitions) {% if tables is defined %} {% for table_name, table in tables.items() %} From 7bf0f74f51c8c366e85565c598421f28f22d6627 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 08:00:44 +0200 Subject: [PATCH 11/23] refactor(deploy): rename cliparams to toolparams across modules --- src/easysam/deploy.py | 52 +++++++++++++++++------------------ src/easysam/generate.py | 8 +++--- src/easysam/init.py | 2 +- src/easysam/utils.py | 8 +++--- src/easysam/validate_cloud.py | 15 ++++++---- 5 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index d2bdc24..38a46c5 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -17,17 +17,17 @@ PIP_VERSION = '25.1.1' -def deploy(cliparams: dict, directory: Path, deploy_ctx: benedict): +def deploy(toolparams: dict, directory: Path, deploy_ctx: benedict): ''' Deploy a SAM template to AWS. Args: - cliparams: The CLI parameters. + toolparams: The CLI parameters. directory: The directory containing the SAM template. deploy_ctx: The deployment context. ''' - resources, errors = generate(cliparams, directory, [], deploy_ctx) + resources, errors = generate(toolparams, directory, [], deploy_ctx) if errors: lg.error(f'There were {len(errors)} errors:') @@ -38,26 +38,26 @@ def deploy(cliparams: dict, directory: Path, deploy_ctx: benedict): raise UserWarning('There were errors - aborting deployment') lg.info(f'Deploying SAM template from {directory}') - check_pip_version(cliparams) - check_sam_cli_version(cliparams) + check_pip_version(toolparams) + check_sam_cli_version(toolparams) remove_common_dependencies(directory) copy_common_dependencies(directory, resources) # Building the application from the SAM template - sam_build(cliparams, directory) + sam_build(toolparams, directory) # Deploying the application to AWS - sam_deploy(cliparams, directory, deploy_ctx, resources) + sam_deploy(toolparams, directory, deploy_ctx, resources) - if not cliparams.get('no_cleanup'): + if not toolparams.get('no_cleanup'): remove_common_dependencies(directory) -def delete(cliparams, environment): +def delete(toolparams, environment): lg.info(f'Deleting SAM template from {environment}') - force = cliparams.get('force') - await_deletion = cliparams.get('await_deletion') - cf = u.get_aws_client('cloudformation', cliparams) + force = toolparams.get('force') + await_deletion = toolparams.get('await_deletion') + cf = u.get_aws_client('cloudformation', toolparams) mode = 'FORCE_DELETE_STACK' if force else 'STANDARD' cf.delete_stack(StackName=environment, DeletionMode=mode) # type: ignore @@ -90,7 +90,7 @@ def delete(cliparams, environment): lg.info(f'Stack {environment} deleted') -def check_pip_version(cliparams): +def check_pip_version(toolparams): lg.info('Checking pip version') try: @@ -104,9 +104,9 @@ def check_pip_version(cliparams): raise UserWarning('pip not found') from e -def check_sam_cli_version(cliparams): +def check_sam_cli_version(toolparams): lg.info('Checking SAM CLI version') - sam_tool = cliparams['sam_tool'] + sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') sam_params.append('--version') @@ -122,13 +122,13 @@ def check_sam_cli_version(cliparams): raise UserWarning(f'SAM CLI not found. Error: {e}') from e -def sam_build(cliparams, directory): +def sam_build(toolparams, directory): lg.info(f'Building SAM template from {directory}') - sam_tool = cliparams['sam_tool'] + sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') sam_params.append('build') - if cliparams.get('verbose'): + if toolparams.get('verbose'): sam_params.append('--debug') try: @@ -141,9 +141,9 @@ def sam_build(cliparams, directory): raise UserWarning('Failed to build SAM template') from e -def sam_deploy(cliparams, directory, deploy_ctx, resources): +def sam_deploy(toolparams, directory, deploy_ctx, resources): lg.info(f'Deploying SAM template from {directory} to\n{json.dumps(deploy_ctx, indent=4)}') - sam_tool = cliparams['sam_tool'] + sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') aws_stack = deploy_ctx['environment'] @@ -166,7 +166,7 @@ def sam_deploy(cliparams, directory, deploy_ctx, resources): if region: sam_params.extend(['--region', region]) - aws_tags = list(cliparams.get('tag', [])) + aws_tags = list(toolparams.get('tag', [])) if 'tags' in resources: aws_tags.extend( @@ -178,16 +178,16 @@ def sam_deploy(cliparams, directory, deploy_ctx, resources): lg.info(f'AWS tag string: {aws_tag_string}') sam_params.extend(['--tags', aws_tag_string]) - if cliparams.get('verbose'): + if toolparams.get('verbose'): sam_params.append('--debug') - if cliparams['dry_run']: + if toolparams['dry_run']: lg.info(f'Would run: {" ".join(sam_params)}') return - if cliparams.get('aws_profile'): - lg.info(f'Using AWS profile: {cliparams["aws_profile"]}') - sam_params.extend(['--profile', cliparams['aws_profile']]) + if toolparams.get('aws_profile'): + lg.info(f'Using AWS profile: {toolparams["aws_profile"]}') + sam_params.extend(['--profile', toolparams['aws_profile']]) try: lg.debug(f'Running command: {" ".join(sam_params)}') diff --git a/src/easysam/generate.py b/src/easysam/generate.py index ae37a46..bca855a 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -15,7 +15,7 @@ def generate( - cliparams: dict, + toolparams: dict, resources_dir: Path, pypath: list[Path], deploy_ctx: dict[str, str], @@ -36,7 +36,7 @@ def generate( try: errors = [] resources_data = load_resources(resources_dir, pypath, deploy_ctx, errors) - aws_profile = cliparams.get('aws_profile') + aws_profile = toolparams.get('aws_profile') scan_cloud(resources_data, errors, aws_profile) lg.debug('Resources processed:\n' + yaml.dump(resources_data, indent=4)) @@ -61,7 +61,7 @@ def generate( template_path = 'template.j2' - if omt := cliparams.get('override_main_template'): + if omt := toolparams.get('override_main_template'): lg.info(f'Overriding main template with {omt}') template_path = str(omt.name) lg.info(f'Adding {omt.parent} to search path') @@ -83,7 +83,7 @@ def generate( lg.info(f'Swagger file generated: {swagger}') except Exception as e: - if cliparams.get('verbose'): + if toolparams.get('verbose'): traceback.print_exc() errors.append(f'Error generating template: {e}') diff --git a/src/easysam/init.py b/src/easysam/init.py index 1513b72..39feefd 100644 --- a/src/easysam/init.py +++ b/src/easysam/init.py @@ -201,7 +201,7 @@ def handler(event, context): ''' -def init(cliparams, prismarine=False): +def init(toolparams, prismarine=False): app_dir = Path('.') pyproject_path = app_dir / 'pyproject.toml' if not pyproject_path.exists(): diff --git a/src/easysam/utils.py b/src/easysam/utils.py index 42e5455..bc5de4f 100644 --- a/src/easysam/utils.py +++ b/src/easysam/utils.py @@ -1,9 +1,9 @@ import boto3 -def get_aws_client(service, cliparams, resource=False): +def get_aws_client(service, toolparams, resource=False): '''Create and return an AWS client with optional profile''' - profile = cliparams.get('aws_profile') + profile = toolparams.get('aws_profile') session = boto3.Session(profile_name=profile) if profile else boto3.Session() params = {} @@ -13,6 +13,6 @@ def get_aws_client(service, cliparams, resource=False): return session.client(service, **params) -def get_aws_resource(service, cliparams): +def get_aws_resource(service, toolparams): '''Create and return an AWS resource with optional profile''' - return get_aws_client(service, cliparams, resource=True) + return get_aws_client(service, toolparams, resource=True) diff --git a/src/easysam/validate_cloud.py b/src/easysam/validate_cloud.py index a6464ea..4a5327b 100644 --- a/src/easysam/validate_cloud.py +++ b/src/easysam/validate_cloud.py @@ -3,20 +3,25 @@ from easysam.utils import get_aws_client -def validate(cliparams: dict, resources_data: dict, environment: str, errors: list[str]): +def validate( + toolparams: dict, + resources_data: dict, + environment: str, + errors: list[str], +): ''' Validate required external cloud resources. Args: - cliparams (dict): The CLI parameters (used: aws_profile) + toolparams (dict): The CLI parameters (used: aws_profile) resources_data (dict): The resources data. environment (str): The environment name. errors (list[str]): The list of errors. ''' - iam = get_aws_client('iam', cliparams) - ssm = get_aws_client('ssm', cliparams) - lambdas = get_aws_client('lambda', cliparams) + iam = get_aws_client('iam', toolparams) + ssm = get_aws_client('ssm', toolparams) + lambdas = get_aws_client('lambda', toolparams) validate_bucket_policy(iam, resources_data, environment, errors) validate_custom_layers(ssm, lambdas, resources_data, errors) From 2dadaf6caa789b02fadc622cca48deece5f8876e Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 08:04:43 +0200 Subject: [PATCH 12/23] refactor: convert triple quotes to double quotes and update f-strings --- src/easysam/cli.py | 68 ++++++++++++++--------------- src/easysam/deploy.py | 37 +++++++++------- src/easysam/init.py | 80 ++++++++++++++++++++--------------- src/easysam/inspect.py | 27 +++++------- src/easysam/load.py | 63 +++++++++++---------------- src/easysam/prismarine.py | 3 +- src/easysam/validate_cloud.py | 20 +++++---- 7 files changed, 150 insertions(+), 148 deletions(-) diff --git a/src/easysam/cli.py b/src/easysam/cli.py index d007c9a..fee79be 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -16,35 +16,38 @@ from easysam.inspect import inspect -@click.group(help='EasySAM is a tool for generating SAM templates from simple YAML files') +@click.group( + help='EasySAM is a tool for generating SAM templates from simple YAML files' +) @click.version_option(version('easysam')) @click.pass_context +@click.option('--aws-profile', type=str, help='AWS profile to use') @click.option( - '--aws-profile', type=str, help='AWS profile to use' -) -@click.option( - '--context-file', type=click.Path(exists=True), + '--context-file', + type=click.Path(exists=True), help='A YAML file containing additional context for the resources.yaml file. ' - 'For example, overrides for resource properties.' -) -@click.option( - '--target-region', type=str, help='A region to use for generation' + 'For example, overrides for resource properties.', ) +@click.option('--target-region', type=str, help='A region to use for generation') @click.option( - '--environment', type=str, help='An environment (AWS stack) to use in generation', - default='dev' + '--environment', + type=str, + help='An environment (AWS stack) to use in generation', + default='dev', ) -@click.option( - '--verbose', is_flag=True -) -def easysam(ctx, verbose, aws_profile, context_file, target_region, environment): +@click.option('--verbose', is_flag=True) +def easysam( + ctx, + verbose, + aws_profile, + context_file, + target_region, + environment, +): ctx.obj = { 'verbose': verbose, 'aws_profile': aws_profile, - 'deploy_ctx': { - 'target_region': target_region, - 'environment': environment - } + 'deploy_ctx': {'target_region': target_region, 'environment': environment}, } if context_file: @@ -86,15 +89,9 @@ def generate_cmd(obj, directory, path): @easysam.command(name='deploy', help='Deploy the application to an AWS environment') @click.pass_obj -@click.option( - '--tag', type=str, multiple=True, help='AWS Tags' -) -@click.option( - '--dry-run', is_flag=True, help='Dry run the deployment' -) -@click.option( - '--sam-tool', type=str, help='Path to the SAM CLI', default='uv run sam' -) +@click.option('--tag', type=str, multiple=True, help='AWS Tags') +@click.option('--dry-run', is_flag=True, help='Dry run the deployment') +@click.option('--sam-tool', type=str, help='Path to the SAM CLI', default='uv run sam') @click.option( '--no-cleanup', is_flag=True, help='Do not clean the directory before deploying' ) @@ -112,9 +109,7 @@ def deploy_cmd(obj, directory, **kwargs): @easysam.command(name='delete', help='Delete the environment from AWS') @click.pass_obj -@click.option( - '--force', is_flag=True, help='Force delete the environment' -) +@click.option('--force', is_flag=True, help='Force delete the environment') @click.option( '--await', 'await_deletion', is_flag=True, help='Await the deletion to complete' ) @@ -131,9 +126,16 @@ def cleanup_cmd(obj, directory): remove_common_dependencies(directory) -@easysam.command(name='init', help='Initialize a new application in the current directory (requires uv init to be run first)') +@easysam.command( + name='init', + help='Initialize a new application in the current directory (requires uv init to be run first)', +) @click.pass_obj -@click.option('--prismarine', is_flag=True, help='Scaffold a minimal application with Prismarine support') +@click.option( + '--prismarine', + is_flag=True, + help='Scaffold a minimal application with Prismarine support', +) def init_cmd(obj, prismarine): init(obj, prismarine=prismarine) diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index 38a46c5..ccfdd16 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -18,14 +18,14 @@ def deploy(toolparams: dict, directory: Path, deploy_ctx: benedict): - ''' + """ Deploy a SAM template to AWS. Args: toolparams: The CLI parameters. directory: The directory containing the SAM template. deploy_ctx: The deployment context. - ''' + """ resources, errors = generate(toolparams, directory, [], deploy_ctx) @@ -142,7 +142,9 @@ def sam_build(toolparams, directory): def sam_deploy(toolparams, directory, deploy_ctx, resources): - lg.info(f'Deploying SAM template from {directory} to\n{json.dumps(deploy_ctx, indent=4)}') + lg.info( + f'Deploying SAM template from {directory} to\n{json.dumps(deploy_ctx, indent=4)}' + ) sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') @@ -151,15 +153,21 @@ def sam_deploy(toolparams, directory, deploy_ctx, resources): if not aws_stack: raise UserWarning('No AWS stack found in deploy context') - sam_params.extend([ - 'deploy', - '--parameter-overrides', f'ParameterKey=Stage,ParameterValue={aws_stack}', - '--stack-name', aws_stack, - '--no-fail-on-empty-changeset', - '--no-confirm-changeset', - '--resolve-s3', - '--capabilities', 'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM' - ]) + sam_params.extend( + [ + 'deploy', + '--parameter-overrides', + f'ParameterKey=Stage,ParameterValue={aws_stack}', + '--stack-name', + aws_stack, + '--no-fail-on-empty-changeset', + '--no-confirm-changeset', + '--resolve-s3', + '--capabilities', + 'CAPABILITY_IAM', + 'CAPABILITY_NAMED_IAM', + ] + ) region = deploy_ctx.get('target_region') @@ -169,10 +177,7 @@ def sam_deploy(toolparams, directory, deploy_ctx, resources): aws_tags = list(toolparams.get('tag', [])) if 'tags' in resources: - aws_tags.extend( - f'{name}={value}' - for name, value in resources['tags'].items() - ) + aws_tags.extend(f'{name}={value}' for name, value in resources['tags'].items()) aws_tag_string = ' '.join(aws_tags) lg.info(f'AWS tag string: {aws_tag_string}') diff --git a/src/easysam/init.py b/src/easysam/init.py index 39feefd..5e7c8f0 100644 --- a/src/easysam/init.py +++ b/src/easysam/init.py @@ -2,14 +2,14 @@ import logging as lg -UTILS_PY = '''\ +UTILS_PY = """\ # Common code def my_common_function(): return 'Hello, world!' -''' +""" -RESOURCES_YAML = '''\ +RESOURCES_YAML = """\ prefix: MyApp tags: @@ -17,9 +17,9 @@ def my_common_function(): import: - backend -''' +""" -LAMBDA_EASY_SAM = '''\ +LAMBDA_EASY_SAM = """\ lambda: name: myfunction resources: @@ -29,9 +29,9 @@ def my_common_function(): path: /items open: true greedy: false -''' +""" -INDEX_PY = '''\ +INDEX_PY = """\ # Lambda function code import json @@ -51,18 +51,18 @@ def handler(event, context): }, 'body': json.dumps({'data': data}) } -''' +""" -DATABASE_EASY_SAM = '''\ +DATABASE_EASY_SAM = """\ tables: MyItem: attributes: - hash: true name: ItemID -''' +""" -ROOT_GITIGNORE = '''\ +ROOT_GITIGNORE = """\ build template.yml template.yaml @@ -70,24 +70,24 @@ def handler(event, context): prismarine_client.py .aws-sam __pycache__/ -''' +""" -FUNCTION_GITIGNORE = '''\ +FUNCTION_GITIGNORE = """\ *.py[oc] **/common/ prismarine_client.py -''' +""" -REQUIREMENTS_TXT = '''\ +REQUIREMENTS_TXT = """\ boto3 -''' +""" -REQUIREMENTS_TXT_PRISMARINE = '''\ +REQUIREMENTS_TXT_PRISMARINE = """\ boto3 prismarine -''' +""" -RESOURCES_YAML_PRISMARINE = '''\ +RESOURCES_YAML_PRISMARINE = """\ prefix: MyApp import: @@ -98,17 +98,17 @@ def handler(event, context): access-module: common.dynamo_access tables: - package: myobject -''' +""" -UTILS_PY_PRISMARINE = '''\ +UTILS_PY_PRISMARINE = """\ import os def get_env(): return os.environ.get('ENV', 'dev') -''' +""" -DYNAMO_ACCESS_PY = '''\ +DYNAMO_ACCESS_PY = """\ import boto3 from prismarine.runtime.dynamo_access import DynamoAccess @@ -129,9 +129,9 @@ def get_table(self, full_model_name: str): def get_dynamo_access(): return dynamoaccess -''' +""" -MODELS_PY = '''\ +MODELS_PY = """\ from typing import TypedDict, NotRequired from prismarine.runtime import Cluster @@ -144,23 +144,23 @@ class Item(TypedDict): Foo: str Bar: str Baz: NotRequired[str] -''' +""" -DB_PY = '''\ +DB_PY = """\ import prismarine_client as pc class ItemModel(pc.ItemModel): pass -''' +""" -TRIGGER_LAMBDA_EASY_SAM = '''\ +TRIGGER_LAMBDA_EASY_SAM = """\ lambda: name: itemlogger resources: tables: - Item -''' +""" TRIGGER_INDEX_PY = '''\ import json @@ -205,8 +205,12 @@ def init(toolparams, prismarine=False): app_dir = Path('.') pyproject_path = app_dir / 'pyproject.toml' if not pyproject_path.exists(): - raise UserWarning('pyproject.toml not found. Please run "uv init" first to initialize the project.') - lg.info(f'Initializing app in current directory {"with Prismarine support" if prismarine else ""}') + raise UserWarning( + 'pyproject.toml not found. Please run "uv init" first to initialize the project.' + ) + lg.info( + f'Initializing app in current directory {"with Prismarine support" if prismarine else ""}' + ) lg.debug(f'Creating resources EasySAM file {app_dir / "resources.yaml"}') resources_content = RESOURCES_YAML_PRISMARINE if prismarine else RESOURCES_YAML app_dir.joinpath('resources.yaml').write_text(resources_content) @@ -216,7 +220,9 @@ def init(toolparams, prismarine=False): if gitignore_path.exists(): existing_entries = set(gitignore_path.read_text().strip().split('\n')) merged_entries = existing_entries | easysam_entries - gitignore_path.write_text('\n'.join(sorted(merged_entries, key=str.lower)) + '\n') + gitignore_path.write_text( + '\n'.join(sorted(merged_entries, key=str.lower)) + '\n' + ) else: gitignore_path.write_text(ROOT_GITIGNORE) @@ -274,6 +280,10 @@ def init(toolparams, prismarine=False): third_party_dir = app_dir / 'thirdparty' lg.debug(f'Creating third party directory {third_party_dir}') third_party_dir.mkdir(parents=True, exist_ok=True) - lg.debug(f'Creating third party requirements.txt file {third_party_dir / "requirements.txt"}') - requirements_content = REQUIREMENTS_TXT_PRISMARINE if prismarine else REQUIREMENTS_TXT + lg.debug( + f'Creating third party requirements.txt file {third_party_dir / "requirements.txt"}' + ) + requirements_content = ( + REQUIREMENTS_TXT_PRISMARINE if prismarine else REQUIREMENTS_TXT + ) third_party_dir.joinpath('requirements.txt').write_text(requirements_content) diff --git a/src/easysam/inspect.py b/src/easysam/inspect.py index db37c76..c25d000 100644 --- a/src/easysam/inspect.py +++ b/src/easysam/inspect.py @@ -19,12 +19,12 @@ def inspect(obj): @inspect.command(name='common-deps', help='Inspect a lambda function') @click.option( - '--common-dir', type=str, default='common', - help='The directory containing the common dependencies' -) -@click.argument( - 'lambda-dir', type=click.Path(exists=True) + '--common-dir', + type=str, + default='common', + help='The directory containing the common dependencies', ) +@click.argument('lambda-dir', type=click.Path(exists=True)) def common_deps(common_dir, lambda_dir): common_dir = Path(common_dir) lambda_dir = Path(lambda_dir) @@ -38,13 +38,12 @@ def common_deps(common_dir, lambda_dir): @inspect.command(help='Validate the resources.yaml file') @click.pass_obj +@click.option('--path', multiple=True, help='Add a path to the Python path') @click.option( - '--path', multiple=True, help='Add a path to the Python path' -) -@click.option( - '--select', type=str, + '--select', + type=str, help='Select a specific resource to render after the schema is validated. ' - 'Uses the keystring syntax to select the resource' + 'Uses the keystring syntax to select the resource', ) @click.argument('directory', type=click.Path(exists=True)) def schema(obj, directory, path, select): @@ -54,9 +53,7 @@ def schema(obj, directory, path, select): deploy_ctx = obj.get('deploy_ctx', {}) try: - resources_data = load_resources( - directory, pypath, deploy_ctx, errors - ) + resources_data = load_resources(directory, pypath, deploy_ctx, errors) except FatalError as e: lg.error('There were fatal errors. Interrupting schema validation.') @@ -92,9 +89,7 @@ def cloud(obj, directory, path): environment = deploy_ctx['environment'] try: - resources_data = load_resources( - directory, pypath, deploy_ctx, errors - ) + resources_data = load_resources(directory, pypath, deploy_ctx, errors) if errors: rich.print( diff --git a/src/easysam/load.py b/src/easysam/load.py index b3f429e..efc2107 100644 --- a/src/easysam/load.py +++ b/src/easysam/load.py @@ -34,9 +34,9 @@ def resources( resources_dir: Path, pypath: list[Path], deploy_ctx: dict[str, str], - errors: list[str] + errors: list[str], ) -> benedict: - ''' + """ Load the resources from the resources.yaml file. Args: @@ -47,13 +47,15 @@ def resources( Returns: A dictionary containing the resources. - ''' + """ resources = Path(resources_dir, 'resources.yaml') try: yaml.SafeLoader.add_constructor('!Conditional', conditional_constructor) - raw_resources_data = benedict(yaml.safe_load(Path(resources).read_text(encoding='utf-8'))) + raw_resources_data = benedict( + yaml.safe_load(Path(resources).read_text(encoding='utf-8')) + ) except Exception as e: errors.append(f'Error loading resources file {resources}: {e}') return benedict() @@ -86,7 +88,7 @@ def prismarine_dynamo_tables( package: str, resources_dir: Path, pypath: list[Path], - errors: list[str] + errors: list[str], ): lg.debug(f'Generating prismarine dynamo tables for {prefix}') @@ -104,10 +106,7 @@ def prismarine_dynamo_tables( def preprocess_prismarine( - resources_data: dict, - resources_dir: Path, - pypath: list[Path], - errors: list[str] + resources_data: dict, resources_dir: Path, pypath: list[Path], errors: list[str] ): prefix = resources_data['prefix'] prisma = resources_data['prismarine'] @@ -146,7 +145,7 @@ def preprocess_lambda( lambda_def: dict, entry_path: Path, entry_dir: Path, - errors: list[str] + errors: list[str], ): if 'functions' not in resources_data: resources_data['functions'] = {} @@ -202,17 +201,16 @@ def preprocess_lambda( def preprocess_tables( - resources_data: dict, - table_def: dict, - entry_path: Path, - errors: list[str] + resources_data: dict, table_def: dict, entry_path: Path, errors: list[str] ): if 'tables' not in resources_data: resources_data['tables'] = {} for table_name, table_data in table_def.items(): if table_name in resources_data['tables']: - errors.append(f'Import file {entry_path} contains duplicate table {table_name}') + errors.append( + f'Import file {entry_path} contains duplicate table {table_name}' + ) continue lg.debug(f'Adding table {table_name} to resources') @@ -220,10 +218,7 @@ def preprocess_tables( def preprocess_file( - resources_data: dict, - resources_dir: Path, - entry_path: Path, - errors: list[str] + resources_data: dict, resources_dir: Path, entry_path: Path, errors: list[str] ): lg.info(f'Processing import file {entry_path}') try: @@ -296,7 +291,7 @@ def process_default_streams(resources_data: dict, errors: list[str]): 'private': { 'bucketname': stream['bucketname'], 'bucketprefix': stream.get('bucketprefix', ''), - 'intervalinseconds': stream.get('intervalinseconds') + 'intervalinseconds': stream.get('intervalinseconds'), } } @@ -362,10 +357,7 @@ def preprocess_defaults(resources_data: dict, errors: list[str]): def preprocess_resources( - resources_data: dict, - resources_dir: Path, - pypath: list[Path], - errors: list[str] + resources_data: dict, resources_dir: Path, pypath: list[Path], errors: list[str] ): def sort_dict(d): return dict(sorted(d.items(), key=lambda x: x[0])) @@ -427,10 +419,7 @@ def conditional_constructor(loader, node): def check_condition( - condition: str, - value: str, - deploy_ctx: dict[str, str], - errors: list[str] + condition: str, value: str, deploy_ctx: dict[str, str], errors: list[str] ): if value == 'any': return True @@ -459,9 +448,7 @@ def check_condition( def resolve_conditionals( - resources_data: dict, - deploy_ctx: dict[str, str], - errors: list[str] + resources_data: dict, deploy_ctx: dict[str, str], errors: list[str] ): resolved = benedict() @@ -472,10 +459,12 @@ def resolve_conditionals( resolved_value = value if isinstance(key, Conditional): - include = all([ - check_condition('environment', key.environment, deploy_ctx, errors), - check_condition('target_region', key.region, deploy_ctx, errors), - ]) + include = all( + [ + check_condition('environment', key.environment, deploy_ctx, errors), + check_condition('target_region', key.region, deploy_ctx, errors), + ] + ) if include: resolved[key.key] = resolved_value @@ -501,6 +490,4 @@ def process_default_searches(resources_data: dict, errors: list[str]): if not resources_data['search']: lg.debug('Search is empty, adding searchable') - resources_data['search'] = { - 'searchable': {} - } + resources_data['search'] = {'searchable': {}} diff --git a/src/easysam/prismarine.py b/src/easysam/prismarine.py index 9e293a2..a8aa4d4 100644 --- a/src/easysam/prismarine.py +++ b/src/easysam/prismarine.py @@ -48,8 +48,7 @@ def generate(directory: Path, resources: dict, errors: list[str]): continue content = client.build_client( - cluster, base_dir, base, access_module, - extra_imports=extra_imports + cluster, base_dir, base, access_module, extra_imports=extra_imports ) if not errors: diff --git a/src/easysam/validate_cloud.py b/src/easysam/validate_cloud.py index 4a5327b..88d7960 100644 --- a/src/easysam/validate_cloud.py +++ b/src/easysam/validate_cloud.py @@ -9,7 +9,7 @@ def validate( environment: str, errors: list[str], ): - ''' + """ Validate required external cloud resources. Args: @@ -17,7 +17,7 @@ def validate( resources_data (dict): The resources data. environment (str): The environment name. errors (list[str]): The list of errors. - ''' + """ iam = get_aws_client('iam', toolparams) ssm = get_aws_client('ssm', toolparams) @@ -31,7 +31,7 @@ def validate_bucket_policy(iam, resources_data, environment, errors): for bucket, details in resources_data.get('buckets', {}).items(): if policy_name := details.get('extaccesspolicy'): full_policy_name = f'{policy_name}-{environment}' - lg.info(f"Validating bucket policy: {full_policy_name}") + lg.info(f'Validating bucket policy: {full_policy_name}') try: paginator = iam.get_paginator('list_policies') @@ -41,20 +41,20 @@ def validate_bucket_policy(iam, resources_data, environment, errors): policies = page['Policies'] for item in policies: - lg.info(f"Found policy: {item['PolicyName']}") + lg.info(f'Found policy: {item["PolicyName"]}') if item['PolicyName'] == full_policy_name: policy = item break except Exception as e: - lg.error(f"Error listing policy {policy_name}: {e}") + lg.error(f'Error listing policy {policy_name}: {e}') policy = None if not policy: errors.append( f"Bucket '{bucket}' has an invalid extaccesspolicy: {policy_name}. " - f"Please create a policy with the name {full_policy_name}." + f'Please create a policy with the name {full_policy_name}.' ) @@ -77,7 +77,9 @@ def validate_custom_layers(ssm, lambdas, resources_data, errors): lg.info(f'Validating SSM layer name: {ssm_param}') try: - layer_handle = ssm.get_parameter(Name=ssm_param)['Parameter']['Value'] + layer_handle = ssm.get_parameter(Name=ssm_param)['Parameter'][ + 'Value' + ] lg.info(f'Successfully resolved SSM layer name: {ssm_param}') lg.info(f'Layer ARN received: {layer_handle}') @@ -98,4 +100,6 @@ def validate_custom_layers(ssm, lambdas, resources_data, errors): errors.append(f'Layer ARN {layer_handle} not found') continue - errors.append(f"Custom layer {layer} in function {function} is not supported") + errors.append( + f'Custom layer {layer} in function {function} is not supported' + ) From cd6dbafad5f8a455937b073c13a94161203f9240 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 08:18:56 +0200 Subject: [PATCH 13/23] feat(cli): refactor context loading to support deployment overrides --- src/easysam/cli.py | 28 ++++++++++++++++++++++------ src/easysam/generate.py | 9 +++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/easysam/cli.py b/src/easysam/cli.py index fee79be..b51609f 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -25,10 +25,13 @@ @click.option( '--context-file', type=click.Path(exists=True), - help='A YAML file containing additional context for the resources.yaml file. ' - 'For example, overrides for resource properties.', + help='A YAML file containing a default context for deployments. The context can override target_profile, target_region and environment. It can also be used to override resources.yaml properties.', # noqa: E501 +) +@click.option( + '--target-region', + type=str, + help='A region to use for generation' ) -@click.option('--target-region', type=str, help='A region to use for generation') @click.option( '--environment', type=str, @@ -44,19 +47,32 @@ def easysam( target_region, environment, ): + default_deploy_ctx = benedict({ + 'target_profile': aws_profile, + 'target_region': target_region, + 'environment': environment, + }) + ctx.obj = { 'verbose': verbose, 'aws_profile': aws_profile, - 'deploy_ctx': {'target_region': target_region, 'environment': environment}, + 'deploy_ctx': default_deploy_ctx, } + lg.basicConfig(level=lg.DEBUG if verbose else lg.INFO) + if context_file: - ctx.obj['deploy_ctx'] = benedict.from_yaml(Path(context_file)) + ctx.obj['deploy_ctx'].update( + benedict.from_yaml(Path(context_file)) + ) lg.info(f'Loaded context from {context_file}') - lg.basicConfig(level=lg.DEBUG if verbose else lg.INFO) lg.debug(f'Verbose: {verbose}') + lg.info( + f'Default deployment context:\n\n{ctx.obj.get("deploy_ctx").to_yaml(indent=4)}' + ) + @easysam.command(name='generate', help='Generate a SAM template from a directory') @click.pass_obj diff --git a/src/easysam/generate.py b/src/easysam/generate.py index bca855a..e46c1ad 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -5,7 +5,6 @@ from benedict import benedict from jinja2 import Environment, FileSystemLoader -import yaml from mergedeep import merge from easysam.prismarine import generate as generate_prismarine_clients @@ -18,7 +17,7 @@ def generate( toolparams: dict, resources_dir: Path, pypath: list[Path], - deploy_ctx: dict[str, str], + default_deploy_ctx: benedict, ) -> ProcessingResult: """ Generate a SAM template from a directory. @@ -35,11 +34,13 @@ def generate( try: errors = [] - resources_data = load_resources(resources_dir, pypath, deploy_ctx, errors) + resources_data = load_resources(resources_dir, pypath, default_deploy_ctx, errors) aws_profile = toolparams.get('aws_profile') scan_cloud(resources_data, errors, aws_profile) - lg.debug('Resources processed:\n' + yaml.dump(resources_data, indent=4)) + lg.debug( + f'Resources processed:\n\n{resources_data.to_yaml(indent=4)}' + ) try: build_dir = Path(resources_dir, 'build') From bed312ee84cf16faaa50ad4c6acc30131cbf3a9b Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 08:42:20 +0200 Subject: [PATCH 14/23] multideploy: default contexts reside in the 'default' folder --- src/easysam/cli.py | 9 ++- src/easysam/generate.py | 153 +++++++++++++++++++++++++--------------- src/easysam/inspect.py | 13 ++-- src/easysam/load.py | 5 +- 4 files changed, 112 insertions(+), 68 deletions(-) diff --git a/src/easysam/cli.py b/src/easysam/cli.py index b51609f..838f28e 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -74,7 +74,10 @@ def easysam( ) -@easysam.command(name='generate', help='Generate a SAM template from a directory') +@easysam.command( + name='generate', + help='Generate a SAM template from a directory', +) @click.pass_obj @click.option( '--path', multiple=True, help='A additional Python path to use for generation' @@ -82,9 +85,9 @@ def easysam( @click.argument('directory', type=click.Path(exists=True)) def generate_cmd(obj, directory, path): directory = Path(directory) - pypath = [Path(p) for p in path] + obj['pypath'] = [Path(p) for p in path] deploy_ctx = obj.get('deploy_ctx') - resources_data, errors = generate(obj, directory, pypath, deploy_ctx) + resources_data, errors = generate(obj, directory, deploy_ctx) if errors: for error in errors: diff --git a/src/easysam/generate.py b/src/easysam/generate.py index e46c1ad..4f04084 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -16,7 +16,6 @@ def generate( toolparams: dict, resources_dir: Path, - pypath: list[Path], default_deploy_ctx: benedict, ) -> ProcessingResult: """ @@ -24,84 +23,122 @@ def generate( Args: resources_dir: The directory containing the resources. - pypath: The Python path to use. - deploy_ctx: The additional deployment context, including: + default_deploy_ctx: The default deployment context, including: + - target_profile: the AWS profile to use + - target_region: the AWS region to use - environment: the name of the environment (AWS stack) to deploy to - - region: the region to deploy to (AWS region) A tuple containing the processed resources and any errors as a list. + + Note: other deployment contexts can be discovered by inspecting the resources.yaml file. """ try: errors = [] - resources_data = load_resources(resources_dir, pypath, default_deploy_ctx, errors) - aws_profile = toolparams.get('aws_profile') - scan_cloud(resources_data, errors, aws_profile) - lg.debug( - f'Resources processed:\n\n{resources_data.to_yaml(indent=4)}' + resources_data = load_resources( + toolparams=toolparams, + resources_dir=resources_dir, + deploy_ctx=default_deploy_ctx, + errors=errors, ) - try: - build_dir = Path(resources_dir, 'build') - swagger = Path(build_dir, 'swagger.yaml') - template = Path(resources_dir, 'template.yml') - - if plugins := resources_data.get('plugins'): - lg.info('The template has plugins, executing them') - - for plugin_name, plugin in cast(dict, plugins).items(): - invoke_plugin( - resources_dir, resources_data, plugin_name, plugin, errors - ) - - searchpath = [ - str(Path(__file__).parent.resolve()), - str(resources_dir.resolve()), - ] - - template_path = 'template.j2' - - if omt := toolparams.get('override_main_template'): - lg.info(f'Overriding main template with {omt}') - template_path = str(omt.name) - lg.info(f'Adding {omt.parent} to search path') - searchpath.append(str(omt.parent)) - - loader = FileSystemLoader(searchpath=searchpath) - jenv = Environment(loader=loader) - - sam_template = jenv.get_template(template_path) - sam_output = sam_template.render(resources_data) + return generate_with_context( + toolparams=toolparams, + resources_dir=resources_dir, + resources_data=resources_data, + deploy_ctx=default_deploy_ctx, + errors=errors, + ) - write_result(template, sam_output) - lg.info(f'SAM template generated: {template}') + except FatalError as e: + return benedict(), e.errors - if resources_data.get('paths'): - swagger_template = jenv.get_template('swagger.j2') - swagger_output = swagger_template.render(resources_data) - write_result(swagger, swagger_output) - lg.info(f'Swagger file generated: {swagger}') - except Exception as e: - if toolparams.get('verbose'): - traceback.print_exc() +def generate_with_context( + *, + toolparams: dict, + resources_dir: Path, + resources_data: benedict, + deploy_ctx: benedict, + errors: list[str], +) -> ProcessingResult: + """ + Generate a SAM template from a directory with a specific deployment context. + """ - errors.append(f'Error generating template: {e}') - return resources_data, errors + aws_profile = deploy_ctx.get('target_profile') + scan_cloud(resources_data, errors, aws_profile) - if 'prismarine' in resources_data: - lg.info('Generating prismarine clients') - generate_prismarine_clients(resources_dir, resources_data, errors) + lg.debug( + f'Resources processed:\n\n{resources_data.to_yaml(indent=4)}' + ) + try: + ctx_name = deploy_ctx.get('name', 'default') + build_dir = Path(resources_dir, 'build', ctx_name) + swagger = Path(build_dir, 'swagger.yaml') + template = Path(build_dir, 'template.yml') + + if plugins := resources_data.get('plugins'): + lg.info('The template has plugins, executing them') + + for plugin_name, plugin in cast(dict, plugins).items(): + invoke_plugin( + resources_dir=resources_dir, + resources_data=resources_data, + plugin_name=plugin_name, + plugin=plugin, + build_dir=build_dir, + errors=errors, + ) + + searchpath = [ + str(Path(__file__).parent.resolve()), + str(resources_dir.resolve()), + ] + + template_path = 'template.j2' + + if omt := toolparams.get('override_main_template'): + lg.info(f'Overriding main template with {omt}') + template_path = str(omt.name) + lg.info(f'Adding {omt.parent} to search path') + searchpath.append(str(omt.parent)) + + loader = FileSystemLoader(searchpath=searchpath) + jenv = Environment(loader=loader) + + sam_template = jenv.get_template(template_path) + sam_output = sam_template.render(resources_data) + + write_result(template, sam_output) + lg.info(f'SAM template generated: {template}') + + if resources_data.get('paths'): + swagger_template = jenv.get_template('swagger.j2') + swagger_output = swagger_template.render(resources_data) + write_result(swagger, swagger_output) + lg.info(f'Swagger file generated: {swagger}') + + except Exception as e: + if toolparams.get('verbose'): + traceback.print_exc() + + errors.append(f'Error generating template: {e}') return resources_data, errors - except FatalError as e: - return benedict(), e.errors + if 'prismarine' in resources_data: + lg.info('Generating prismarine clients') + generate_prismarine_clients(resources_dir, resources_data, errors) + + return resources_data, errors def invoke_plugin( + *, resources_dir: Path, + build_dir: Path, resources_data: benedict, plugin_name: str, plugin: benedict, @@ -121,7 +158,7 @@ def invoke_plugin( template = jenv.get_template(template_j2_filename) aux_data = dict(plugin.get('aux', {})) output = template.render(merge(resources_data, aux_data)) - output_yaml_path = Path(resources_dir, plugin_name).with_suffix('.yaml') + output_yaml_path = Path(build_dir, plugin_name).with_suffix('.yaml') write_result(output_yaml_path, output) diff --git a/src/easysam/inspect.py b/src/easysam/inspect.py index c25d000..46b1d25 100644 --- a/src/easysam/inspect.py +++ b/src/easysam/inspect.py @@ -49,11 +49,16 @@ def common_deps(common_dir, lambda_dir): def schema(obj, directory, path, select): errors = [] directory = Path(directory) - pypath = [Path(p) for p in path] + obj['pypath'] = [Path(p) for p in path] deploy_ctx = obj.get('deploy_ctx', {}) try: - resources_data = load_resources(directory, pypath, deploy_ctx, errors) + resources_data = load_resources( + toolparams=obj, + resources_dir=directory, + deploy_ctx=deploy_ctx, + errors=errors, + ) except FatalError as e: lg.error('There were fatal errors. Interrupting schema validation.') @@ -79,7 +84,7 @@ def schema(obj, directory, path, select): @click.argument('directory', type=click.Path(exists=True)) def cloud(obj, directory, path): directory = Path(directory) - pypath = [Path(p) for p in path] + obj['pypath'] = [Path(p) for p in path] errors = [] deploy_ctx = obj.get('deploy_ctx', {}) @@ -89,7 +94,7 @@ def cloud(obj, directory, path): environment = deploy_ctx['environment'] try: - resources_data = load_resources(directory, pypath, deploy_ctx, errors) + resources_data = load_resources(directory, deploy_ctx, errors) if errors: rich.print( diff --git a/src/easysam/load.py b/src/easysam/load.py index efc2107..a5b1f93 100644 --- a/src/easysam/load.py +++ b/src/easysam/load.py @@ -31,8 +31,8 @@ def resources( + toolparams: dict, resources_dir: Path, - pypath: list[Path], deploy_ctx: dict[str, str], errors: list[str], ) -> benedict: @@ -41,7 +41,6 @@ def resources( Args: resources_dir: The directory containing the resources.yaml file. - pypath: The additional Python path to use. deploy_ctx: The deployment context dictionary. errors: The list of errors. @@ -70,7 +69,7 @@ def resources( apply_overrides(resources_data, deploy_ctx) lg.info('Processing resources') - pypath = [resources_dir] + list(pypath) + pypath = [resources_dir] + list(toolparams.get('pypath', [])) preprocess_resources(resources_data, resources_dir, pypath, errors) lg.info('Validating resources') From f3b42aa4db667ff285f6b800fd7161decb53fd8a Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 10:19:31 +0200 Subject: [PATCH 15/23] refactor(deploy): extract deployment logic into context-aware helper --- example/myapp/thirdparty/requirements.txt | 1 - src/easysam/deploy.py | 127 ++++++++++++++++------ src/easysam/generate.py | 4 +- src/easysam/template.j2 | 6 +- src/easysam/utils.py | 5 + 5 files changed, 106 insertions(+), 37 deletions(-) diff --git a/example/myapp/thirdparty/requirements.txt b/example/myapp/thirdparty/requirements.txt index 0e2fd20..30ddf82 100644 --- a/example/myapp/thirdparty/requirements.txt +++ b/example/myapp/thirdparty/requirements.txt @@ -1,2 +1 @@ boto3 - diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index ccfdd16..b414c53 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -17,17 +17,25 @@ PIP_VERSION = '25.1.1' -def deploy(toolparams: dict, directory: Path, deploy_ctx: benedict): +def deploy( + toolparams: dict, + directory: Path, + default_deploy_ctx: benedict, +): """ Deploy a SAM template to AWS. Args: toolparams: The CLI parameters. directory: The directory containing the SAM template. - deploy_ctx: The deployment context. + default_deploy_ctx: The default deployment context. + + Note: other deployment contexts can be discovered by inspecting the resources.yaml file. """ - resources, errors = generate(toolparams, directory, [], deploy_ctx) + resources, errors = generate( + toolparams, directory, default_deploy_ctx + ) if errors: lg.error(f'There were {len(errors)} errors:') @@ -37,20 +45,12 @@ def deploy(toolparams: dict, directory: Path, deploy_ctx: benedict): raise UserWarning('There were errors - aborting deployment') - lg.info(f'Deploying SAM template from {directory}') - check_pip_version(toolparams) - check_sam_cli_version(toolparams) - remove_common_dependencies(directory) - copy_common_dependencies(directory, resources) - - # Building the application from the SAM template - sam_build(toolparams, directory) - - # Deploying the application to AWS - sam_deploy(toolparams, directory, deploy_ctx, resources) - - if not toolparams.get('no_cleanup'): - remove_common_dependencies(directory) + return _deploy_with_context( + toolparams=toolparams, + resources=resources, + directory=directory, + deploy_ctx=default_deploy_ctx, + ) def delete(toolparams, environment): @@ -90,7 +90,30 @@ def delete(toolparams, environment): lg.info(f'Stack {environment} deleted') -def check_pip_version(toolparams): +def _deploy_with_context( + *, + toolparams: dict, + resources: benedict, + directory: Path, + deploy_ctx: benedict, +): + lg.info(f'Deploying SAM template from {directory}') + _check_pip_version(toolparams) + _check_sam_cli_version(toolparams) + remove_common_dependencies(directory) + copy_common_dependencies(directory, resources) + + # Building the application from the SAM template + _sam_build(toolparams, directory, deploy_ctx) + + # Deploying the application to AWS + _sam_deploy(toolparams, directory, deploy_ctx, resources) + + if not toolparams.get('no_cleanup'): + remove_common_dependencies(directory) + + +def _check_pip_version(toolparams): lg.info('Checking pip version') try: @@ -104,7 +127,7 @@ def check_pip_version(toolparams): raise UserWarning('pip not found') from e -def check_sam_cli_version(toolparams): +def _check_sam_cli_version(toolparams): lg.info('Checking SAM CLI version') sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') @@ -122,18 +145,48 @@ def check_sam_cli_version(toolparams): raise UserWarning(f'SAM CLI not found. Error: {e}') from e -def sam_build(toolparams, directory): +def _sam_build( + toolparams: dict, + directory: Path, + deploy_ctx: benedict, +): lg.info(f'Building SAM template from {directory}') - sam_tool = toolparams['sam_tool'] + build_dir = u.get_build_dir(directory, deploy_ctx) + template = Path(build_dir, 'template.yml') + + if not template.exists(): + raise UserWarning( + f'Template {template} not found in build directory {build_dir}' + ) + + sam_tool: str = toolparams['sam_tool'] sam_params = sam_tool.split(' ') - sam_params.append('build') + + sam_build_dir = Path( + build_dir.relative_to(directory), '.aws-sam' + ) + + sam_params.extend( + [ + 'build', + '--template-file', + template.relative_to(directory).as_posix(), + '--build-dir', + sam_build_dir.as_posix(), + ] + ) if toolparams.get('verbose'): sam_params.append('--debug') try: - lg.debug(f'Running command: {" ".join(sam_params)}') - subprocess.run(sam_params, cwd=directory.resolve(), text=True, check=True) + lg.info(f'Running command: {" ".join(sam_params)}') + subprocess.run( + sam_params, + cwd=directory.as_posix(), + text=True, + check=True, + ) lg.info('Successfully built SAM template') except subprocess.CalledProcessError as e: @@ -141,13 +194,12 @@ def sam_build(toolparams, directory): raise UserWarning('Failed to build SAM template') from e -def sam_deploy(toolparams, directory, deploy_ctx, resources): +def _sam_deploy(toolparams, directory, deploy_ctx, resources): lg.info( f'Deploying SAM template from {directory} to\n{json.dumps(deploy_ctx, indent=4)}' ) sam_tool = toolparams['sam_tool'] sam_params = sam_tool.split(' ') - aws_stack = deploy_ctx['environment'] if not aws_stack: @@ -173,6 +225,8 @@ def sam_deploy(toolparams, directory, deploy_ctx, resources): if region: sam_params.extend(['--region', region]) + else: + lg.info('No AWS region found in target. Relying on the environment or profile to infer the region.') aws_tags = list(toolparams.get('tag', [])) @@ -186,17 +240,28 @@ def sam_deploy(toolparams, directory, deploy_ctx, resources): if toolparams.get('verbose'): sam_params.append('--debug') + if target_profile := deploy_ctx.get('target_profile'): + lg.info( + f'Using AWS profile from target: {target_profile}' + ) + sam_params.extend(['--profile', target_profile]) + else: + lg.warning('No AWS profile found in target. Relying on the environment to infer the profile.') + if toolparams['dry_run']: lg.info(f'Would run: {" ".join(sam_params)}') return - if toolparams.get('aws_profile'): - lg.info(f'Using AWS profile: {toolparams["aws_profile"]}') - sam_params.extend(['--profile', toolparams['aws_profile']]) + build_dir = u.get_build_dir(directory, deploy_ctx) try: - lg.debug(f'Running command: {" ".join(sam_params)}') - subprocess.run(sam_params, cwd=directory.resolve(), text=True, check=True) + lg.info(f'Running command: {" ".join(sam_params)}') + subprocess.run( + sam_params, + cwd=(build_dir / '.aws-sam').as_posix(), + text=True, + check=True, + ) lg.info('Successfully deployed SAM template') except subprocess.CalledProcessError as e: diff --git a/src/easysam/generate.py b/src/easysam/generate.py index 4f04084..448eb0d 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -7,6 +7,7 @@ from jinja2 import Environment, FileSystemLoader from mergedeep import merge +import easysam.utils as u from easysam.prismarine import generate as generate_prismarine_clients from easysam.definitions import FatalError, ProcessingResult from easysam.load import resources as load_resources @@ -75,8 +76,7 @@ def generate_with_context( ) try: - ctx_name = deploy_ctx.get('name', 'default') - build_dir = Path(resources_dir, 'build', ctx_name) + build_dir = u.get_build_dir(resources_dir, deploy_ctx) swagger = Path(build_dir, 'swagger.yaml') template = Path(build_dir, 'template.yml') diff --git a/src/easysam/template.j2 b/src/easysam/template.j2 index e8a2c17..54ce8f2 100644 --- a/src/easysam/template.j2 +++ b/src/easysam/template.j2 @@ -112,7 +112,7 @@ Resources: 'Fn::Transform': Name: 'AWS::Include' Parameters: - Location: './build/swagger.yaml' + Location: './swagger.yaml' ApiLogGroup: Type: AWS::Logs::LogGroup @@ -437,7 +437,7 @@ Resources: Properties: LayerName: !Sub {{ lprefix }}-pythonthirdparty-${Stage} Description: Dependencies for all python lambdas - ContentUri: thirdparty/. + ContentUri: ../../thirdparty/. CompatibleRuntimes: - python3.13 Metadata: @@ -455,7 +455,7 @@ Resources: Timeout: {{ function.timeout}} {% endif %} FunctionName: !Sub "{{ function_name }}-${Stage}" - CodeUri: {{ function.uri }} + CodeUri: ../../{{ function.uri }} Policies: - AWSSecretsManagerGetSecretValuePolicy: SecretArn: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:* diff --git a/src/easysam/utils.py b/src/easysam/utils.py index bc5de4f..70bf79d 100644 --- a/src/easysam/utils.py +++ b/src/easysam/utils.py @@ -1,3 +1,4 @@ +from pathlib import Path import boto3 @@ -16,3 +17,7 @@ def get_aws_client(service, toolparams, resource=False): def get_aws_resource(service, toolparams): '''Create and return an AWS resource with optional profile''' return get_aws_client(service, toolparams, resource=True) + + +def get_build_dir(directory, deploy_ctx): + return Path(directory, 'build', deploy_ctx.get('name', 'default')) From 90720bbc674311e1466b8158db92d816c0b6eb57 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 10:38:11 +0200 Subject: [PATCH 16/23] multiregion: example stub --- example/multiregion/.gitignore | 4 ++++ example/multiregion/README.md | 9 +++++++ .../multiregion/backend/function/.gitignore | 1 + .../backend/function/myfunction/easysam.yaml | 2 ++ .../backend/function/myfunction/index.py | 13 ++++++++++ example/multiregion/common/utils.py | 2 ++ example/multiregion/resources.yaml | 4 ++++ example/multiregion/test/invoke_lambda.py | 24 +++++++++++++++++++ .../multiregion/thirdparty/requirements.txt | 2 ++ 9 files changed, 61 insertions(+) create mode 100644 example/multiregion/.gitignore create mode 100644 example/multiregion/README.md create mode 100644 example/multiregion/backend/function/.gitignore create mode 100644 example/multiregion/backend/function/myfunction/easysam.yaml create mode 100644 example/multiregion/backend/function/myfunction/index.py create mode 100644 example/multiregion/common/utils.py create mode 100644 example/multiregion/resources.yaml create mode 100644 example/multiregion/test/invoke_lambda.py create mode 100644 example/multiregion/thirdparty/requirements.txt diff --git a/example/multiregion/.gitignore b/example/multiregion/.gitignore new file mode 100644 index 0000000..773065e --- /dev/null +++ b/example/multiregion/.gitignore @@ -0,0 +1,4 @@ +build +template.yml +template.yaml +.aws-sam diff --git a/example/multiregion/README.md b/example/multiregion/README.md new file mode 100644 index 0000000..51b85b7 --- /dev/null +++ b/example/multiregion/README.md @@ -0,0 +1,9 @@ +# Deploy to multiple regions + +This example shows how to deploy a EasySAM application to multiple regions. It assumes that you have an AWS profile named `easysam-a` that has access to the regions `eu-west-1` and `eu-west-2`. + +## CLI Deployment + +``` +uv run easysam --environment easysamdev --aws-profile easysam-a deploy .\example\multiregion\ +``` \ No newline at end of file diff --git a/example/multiregion/backend/function/.gitignore b/example/multiregion/backend/function/.gitignore new file mode 100644 index 0000000..73aac5e --- /dev/null +++ b/example/multiregion/backend/function/.gitignore @@ -0,0 +1 @@ +**/common/ diff --git a/example/multiregion/backend/function/myfunction/easysam.yaml b/example/multiregion/backend/function/myfunction/easysam.yaml new file mode 100644 index 0000000..3e9138b --- /dev/null +++ b/example/multiregion/backend/function/myfunction/easysam.yaml @@ -0,0 +1,2 @@ +lambda: + name: myfunction diff --git a/example/multiregion/backend/function/myfunction/index.py b/example/multiregion/backend/function/myfunction/index.py new file mode 100644 index 0000000..ab06963 --- /dev/null +++ b/example/multiregion/backend/function/myfunction/index.py @@ -0,0 +1,13 @@ +import json + + +def handler(event, context): + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': '*' + }, + 'body': json.dumps({'data': 'Hello, World!'}) + } diff --git a/example/multiregion/common/utils.py b/example/multiregion/common/utils.py new file mode 100644 index 0000000..3736b4f --- /dev/null +++ b/example/multiregion/common/utils.py @@ -0,0 +1,2 @@ +def my_common_function(): + return 'Hello, world!' diff --git a/example/multiregion/resources.yaml b/example/multiregion/resources.yaml new file mode 100644 index 0000000..f04cb79 --- /dev/null +++ b/example/multiregion/resources.yaml @@ -0,0 +1,4 @@ +prefix: Onelambda + +import: + - backend diff --git a/example/multiregion/test/invoke_lambda.py b/example/multiregion/test/invoke_lambda.py new file mode 100644 index 0000000..4cc229b --- /dev/null +++ b/example/multiregion/test/invoke_lambda.py @@ -0,0 +1,24 @@ +import boto3 +import json + +import click + + +@click.command() +@click.option('--env', default='dev') +@click.argument('message', default='{"message": "Hello, World!"}') +def send_message(message, env): + lambda_client = boto3.client('lambda') + + response = lambda_client.invoke( + FunctionName=f'myfunction-{env}', + Payload=json.dumps(message), + ) + + answer = json.loads(response['Payload'].read()) + body = json.loads(answer['body']) + click.echo(json.dumps(body, indent=2)) + + +if __name__ == '__main__': + send_message() diff --git a/example/multiregion/thirdparty/requirements.txt b/example/multiregion/thirdparty/requirements.txt new file mode 100644 index 0000000..0e2fd20 --- /dev/null +++ b/example/multiregion/thirdparty/requirements.txt @@ -0,0 +1,2 @@ +boto3 + From bdbc5392355b6b9cc877d3fac787bb7900a6c434 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 10:48:56 +0200 Subject: [PATCH 17/23] refactor(cli): rename --context-file to --with-context option --- README.md | 4 ++-- example/multiregion/README.md | 6 ++++-- example/multiregion/on_eu_west_1.yaml | 3 +++ src/easysam/cli.py | 10 +++++----- 4 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 example/multiregion/on_eu_west_1.yaml diff --git a/README.md b/README.md index 664d952..96e58b7 100644 --- a/README.md +++ b/README.md @@ -278,10 +278,10 @@ overrides: buckets/my-bucket/public: true ``` -Use the `--context-file` option to specify the deploy context file. +Use the `--with-context` option to specify the deploy context file. ```pwsh -easysam deploy --environment --context-file deploy-context.yaml +easysam deploy --environment --with-context deploy-context.yaml ``` The deploy context file is a YAML file that contains the overrides. diff --git a/example/multiregion/README.md b/example/multiregion/README.md index 51b85b7..731cf00 100644 --- a/example/multiregion/README.md +++ b/example/multiregion/README.md @@ -5,5 +5,7 @@ This example shows how to deploy a EasySAM application to multiple regions. It a ## CLI Deployment ``` -uv run easysam --environment easysamdev --aws-profile easysam-a deploy .\example\multiregion\ -``` \ No newline at end of file +uv run easysam --environment easysamdev --with-context ./on_eu_west_1.yaml deploy ./example/multiregion/ +``` + +Note that all paths here are relative to the root of the EasySAM project. Amend accordingly to your project structure. diff --git a/example/multiregion/on_eu_west_1.yaml b/example/multiregion/on_eu_west_1.yaml new file mode 100644 index 0000000..99f3af8 --- /dev/null +++ b/example/multiregion/on_eu_west_1.yaml @@ -0,0 +1,3 @@ +name: on_eu_west_1 +target_profile: easysam-a +target_region: eu-west-1 diff --git a/src/easysam/cli.py b/src/easysam/cli.py index 838f28e..7d6fae1 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -23,7 +23,7 @@ @click.pass_context @click.option('--aws-profile', type=str, help='AWS profile to use') @click.option( - '--context-file', + '--with-context', type=click.Path(exists=True), help='A YAML file containing a default context for deployments. The context can override target_profile, target_region and environment. It can also be used to override resources.yaml properties.', # noqa: E501 ) @@ -43,7 +43,7 @@ def easysam( ctx, verbose, aws_profile, - context_file, + with_context, target_region, environment, ): @@ -61,11 +61,11 @@ def easysam( lg.basicConfig(level=lg.DEBUG if verbose else lg.INFO) - if context_file: + if with_context: ctx.obj['deploy_ctx'].update( - benedict.from_yaml(Path(context_file)) + benedict.from_yaml(Path(with_context)) ) - lg.info(f'Loaded context from {context_file}') + lg.info(f'Loaded context from {with_context}') lg.debug(f'Verbose: {verbose}') From aea9db0779a3bae15dfb50860e808f8a51aa1dab Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 11:15:36 +0200 Subject: [PATCH 18/23] multiregion: generation --- example/multiregion/on_eu_west_2.yaml | 3 ++ src/easysam/cli.py | 50 ++++++++++++++-------- src/easysam/generate.py | 60 ++++++++++++++++++--------- 3 files changed, 76 insertions(+), 37 deletions(-) create mode 100644 example/multiregion/on_eu_west_2.yaml diff --git a/example/multiregion/on_eu_west_2.yaml b/example/multiregion/on_eu_west_2.yaml new file mode 100644 index 0000000..7c14bb8 --- /dev/null +++ b/example/multiregion/on_eu_west_2.yaml @@ -0,0 +1,3 @@ +name: on_eu_west_2 +target_profile: easysam-a +target_region: eu-west-2 diff --git a/src/easysam/cli.py b/src/easysam/cli.py index 7d6fae1..983bb7f 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -7,6 +7,7 @@ from benedict import benedict import click +from rich.logging import RichHandler from easysam.generate import generate from easysam.deploy import deploy, delete @@ -24,8 +25,10 @@ @click.option('--aws-profile', type=str, help='AWS profile to use') @click.option( '--with-context', - type=click.Path(exists=True), - help='A YAML file containing a default context for deployments. The context can override target_profile, target_region and environment. It can also be used to override resources.yaml properties.', # noqa: E501 + 'with_contexts', + multiple=True, + type=click.Path(exists=True, path_type=Path, dir_okay=False), + help='A YAML file containing a default context for deployments. The context can override target_profile, target_region and environment. It can also be used to override resources.yaml properties. Can be used multiple times to deploy to multiple contexts.', # noqa: E501 ) @click.option( '--target-region', @@ -43,7 +46,7 @@ def easysam( ctx, verbose, aws_profile, - with_context, + with_contexts, target_region, environment, ): @@ -56,22 +59,35 @@ def easysam( ctx.obj = { 'verbose': verbose, 'aws_profile': aws_profile, - 'deploy_ctx': default_deploy_ctx, + 'default_deploy_ctx': default_deploy_ctx, } - lg.basicConfig(level=lg.DEBUG if verbose else lg.INFO) + lg.basicConfig( + level=lg.DEBUG if verbose else lg.INFO, + handlers=[RichHandler(rich_tracebacks=True)], + ) + lg.debug(f'Verbose: {verbose}') - if with_context: - ctx.obj['deploy_ctx'].update( - benedict.from_yaml(Path(with_context)) - ) - lg.info(f'Loaded context from {with_context}') + if with_contexts: + ctx.obj['deploy_ctxs'] = [] - lg.debug(f'Verbose: {verbose}') + for with_context in with_contexts: + deploy_ctx = default_deploy_ctx.copy() - lg.info( - f'Default deployment context:\n\n{ctx.obj.get("deploy_ctx").to_yaml(indent=4)}' - ) + deploy_ctx.update( + benedict.from_yaml(with_context) + ) + + ctx.obj['deploy_ctxs'].append(deploy_ctx) + lg.info(f'Loaded context from {with_context}') + else: + ctx.obj['deploy_ctxs'] = [ctx.obj['default_deploy_ctx']] + + lg.info('Deployment contexts:') + + for deploy_ctx in ctx.obj['deploy_ctxs']: + name = deploy_ctx.get("name", "default") + lg.info(f'\n--- {name} ---\n{deploy_ctx.to_yaml(indent=4)}') @easysam.command( @@ -86,8 +102,8 @@ def easysam( def generate_cmd(obj, directory, path): directory = Path(directory) obj['pypath'] = [Path(p) for p in path] - deploy_ctx = obj.get('deploy_ctx') - resources_data, errors = generate(obj, directory, deploy_ctx) + deploy_ctxs = obj.get('deploy_ctxs') + resources_set, errors = generate(obj, directory, deploy_ctxs) if errors: for error in errors: @@ -101,7 +117,7 @@ def generate_cmd(obj, directory, path): sys.exit(1) else: - click.echo(resources_data.to_yaml()) + click.echo(resources_set.to_yaml()) lg.info('Resources generated successfully') sys.exit(0) diff --git a/src/easysam/generate.py b/src/easysam/generate.py index 448eb0d..951e1fb 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -17,43 +17,63 @@ def generate( toolparams: dict, resources_dir: Path, - default_deploy_ctx: benedict, -) -> ProcessingResult: + deploy_ctxs: list[benedict], +) -> benedict[str, ProcessingResult]: """ Generate a SAM template from a directory. Args: resources_dir: The directory containing the resources. - default_deploy_ctx: The default deployment context, including: + deploy_ctxs: The deployment contexts, including: - target_profile: the AWS profile to use - target_region: the AWS region to use - environment: the name of the environment (AWS stack) to deploy to A tuple containing the processed resources and any errors as a list. - - Note: other deployment contexts can be discovered by inspecting the resources.yaml file. """ try: errors = [] + context_names = set() + + for deploy_ctx in deploy_ctxs: + name = deploy_ctx.get('name', 'default') + if name in context_names: + errors.append( + f'Deployment context {name} already defined' + ) + continue + + context_names.add(name) + + if 'name' not in deploy_ctx: + deploy_ctx['name'] = 'default' + + if errors: + raise FatalError(errors) + + results = benedict() + + for deploy_ctx in deploy_ctxs: + resources_data = load_resources( + toolparams=toolparams, + resources_dir=resources_dir, + deploy_ctx=deploy_ctx, + errors=errors, + ) + + results[deploy_ctx['name']] = generate_with_context( + toolparams=toolparams, + resources_dir=resources_dir, + resources_data=resources_data, + deploy_ctx=deploy_ctx, + errors=errors, + ) - resources_data = load_resources( - toolparams=toolparams, - resources_dir=resources_dir, - deploy_ctx=default_deploy_ctx, - errors=errors, - ) - - return generate_with_context( - toolparams=toolparams, - resources_dir=resources_dir, - resources_data=resources_data, - deploy_ctx=default_deploy_ctx, - errors=errors, - ) + return results, errors except FatalError as e: - return benedict(), e.errors + return results, e.errors def generate_with_context( From 1638fd5dfa5c681431544c35780325d534039e22 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 11:42:48 +0200 Subject: [PATCH 19/23] multiregion: deployment - WIP --- src/easysam/cli.py | 11 ++++++---- src/easysam/deploy.py | 46 +++++++++++++++++++++++++---------------- src/easysam/generate.py | 12 ++++++----- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/easysam/cli.py b/src/easysam/cli.py index 983bb7f..5fd2455 100644 --- a/src/easysam/cli.py +++ b/src/easysam/cli.py @@ -138,8 +138,8 @@ def generate_cmd(obj, directory, path): @click.argument('directory', type=click.Path(exists=True, path_type=Path)) def deploy_cmd(obj, directory, **kwargs): obj.update(kwargs) # noqa: F821 - deploy_ctx = obj.get('deploy_ctx') - deploy(obj, directory, deploy_ctx) + deploy_ctxs = obj.get('deploy_ctxs') + deploy(obj, directory, deploy_ctxs) @easysam.command(name='delete', help='Delete the environment from AWS') @@ -150,8 +150,11 @@ def deploy_cmd(obj, directory, **kwargs): ) def delete_cmd(obj, **kwargs): obj.update(kwargs) # noqa: F821 - environment = obj.get('deploy_ctx').get('environment') - delete(obj, environment) + + for deploy_ctx in obj.get('deploy_ctxs', []): + environment = deploy_ctx.get('environment') + lg.warning(f'Deleting environment {environment}') + delete(obj, environment) @easysam.command(name='cleanup', help='Remove common dependencies from the directory') diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index b414c53..de77dfd 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -20,7 +20,7 @@ def deploy( toolparams: dict, directory: Path, - default_deploy_ctx: benedict, + deploy_ctxs: list[benedict], ): """ Deploy a SAM template to AWS. @@ -28,13 +28,13 @@ def deploy( Args: toolparams: The CLI parameters. directory: The directory containing the SAM template. - default_deploy_ctx: The default deployment context. + deploy_ctxs: The deployment contexts. Note: other deployment contexts can be discovered by inspecting the resources.yaml file. """ - resources, errors = generate( - toolparams, directory, default_deploy_ctx + resources_set, errors = generate( + toolparams, directory, deploy_ctxs ) if errors: @@ -43,14 +43,24 @@ def deploy( for error in errors: lg.error(error) - raise UserWarning('There were errors - aborting deployment') + raise UserWarning( + f'There were {len(errors)} errors - aborting deployment' + ) - return _deploy_with_context( - toolparams=toolparams, - resources=resources, - directory=directory, - deploy_ctx=default_deploy_ctx, - ) + _check_pip_version(toolparams) + _check_sam_cli_version(toolparams) + + for deploy_ctx in deploy_ctxs: + name = deploy_ctx.get('name', 'default') + lg.info(f'Invoking deployment for {name}') + resources = resources_set[name] + + _deploy_with_context( + toolparams=toolparams, + resources=resources, + directory=directory, + deploy_ctx=deploy_ctx, + ) def delete(toolparams, environment): @@ -98,8 +108,6 @@ def _deploy_with_context( deploy_ctx: benedict, ): lg.info(f'Deploying SAM template from {directory}') - _check_pip_version(toolparams) - _check_sam_cli_version(toolparams) remove_common_dependencies(directory) copy_common_dependencies(directory, resources) @@ -248,17 +256,19 @@ def _sam_deploy(toolparams, directory, deploy_ctx, resources): else: lg.warning('No AWS profile found in target. Relying on the environment to infer the profile.') + build_dir = u.get_build_dir(directory, deploy_ctx) + sam_build_dir = Path(build_dir, '.aws-sam') + run_description = f'{" ".join(sam_params)} in {sam_build_dir}' + if toolparams['dry_run']: - lg.info(f'Would run: {" ".join(sam_params)}') + lg.info(f'Would run: {run_description}') return - build_dir = u.get_build_dir(directory, deploy_ctx) - try: - lg.info(f'Running command: {" ".join(sam_params)}') + lg.info(f'Running command: {run_description}') subprocess.run( sam_params, - cwd=(build_dir / '.aws-sam').as_posix(), + cwd=sam_build_dir.as_posix(), text=True, check=True, ) diff --git a/src/easysam/generate.py b/src/easysam/generate.py index 951e1fb..b17408d 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -30,6 +30,8 @@ def generate( - environment: the name of the environment (AWS stack) to deploy to A tuple containing the processed resources and any errors as a list. + + Note: other deployment contexts can be discovered by inspecting the resources.yaml file. """ try: @@ -62,7 +64,7 @@ def generate( errors=errors, ) - results[deploy_ctx['name']] = generate_with_context( + results[deploy_ctx['name']] = _generate_with_context( toolparams=toolparams, resources_dir=resources_dir, resources_data=resources_data, @@ -73,10 +75,10 @@ def generate( return results, errors except FatalError as e: - return results, e.errors + return None, e.errors -def generate_with_context( +def _generate_with_context( *, toolparams: dict, resources_dir: Path, @@ -146,13 +148,13 @@ def generate_with_context( traceback.print_exc() errors.append(f'Error generating template: {e}') - return resources_data, errors + return None if 'prismarine' in resources_data: lg.info('Generating prismarine clients') generate_prismarine_clients(resources_dir, resources_data, errors) - return resources_data, errors + return resources_data def invoke_plugin( From 564c8c56776b3f6fe3f9f27526f5ba4092be5538 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 11:54:45 +0200 Subject: [PATCH 20/23] feat(deploy): add region parameter support for multi-region deployment --- example/multiregion/common/utils.py | 2 -- src/easysam/deploy.py | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 example/multiregion/common/utils.py diff --git a/example/multiregion/common/utils.py b/example/multiregion/common/utils.py deleted file mode 100644 index 3736b4f..0000000 --- a/example/multiregion/common/utils.py +++ /dev/null @@ -1,2 +0,0 @@ -def my_common_function(): - return 'Hello, world!' diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index de77dfd..217aaa6 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -184,6 +184,11 @@ def _sam_build( ] ) + if region := deploy_ctx.get('target_region'): + sam_params.extend(['--region', region]) + else: + lg.info('No AWS region found in the target. Relying on the environment or profile to infer the region.') + if toolparams.get('verbose'): sam_params.append('--debug') @@ -234,7 +239,7 @@ def _sam_deploy(toolparams, directory, deploy_ctx, resources): if region: sam_params.extend(['--region', region]) else: - lg.info('No AWS region found in target. Relying on the environment or profile to infer the region.') + lg.info('No AWS region found in the target. Relying on the environment or profile to infer the region.') aws_tags = list(toolparams.get('tag', [])) From cb0836b75fb22e2c051926d7fd82307ed22a1506 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Sun, 9 Nov 2025 12:00:35 +0200 Subject: [PATCH 21/23] docs(multiregion): update CLI command with multiple context files --- example/multiregion/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/multiregion/README.md b/example/multiregion/README.md index 731cf00..bf6c59e 100644 --- a/example/multiregion/README.md +++ b/example/multiregion/README.md @@ -5,7 +5,7 @@ This example shows how to deploy a EasySAM application to multiple regions. It a ## CLI Deployment ``` -uv run easysam --environment easysamdev --with-context ./on_eu_west_1.yaml deploy ./example/multiregion/ +uv run easysam --environment easysamdev --with-context .\example\multiregion\on_eu_west_1.yaml --with-context .\example\multiregion\on_eu_west_2.yaml deploy .\example\multiregion\ ``` Note that all paths here are relative to the root of the EasySAM project. Amend accordingly to your project structure. From cfdf0c09efb5808a661a1607214b70a22888172d Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Mon, 10 Nov 2025 19:26:49 +0200 Subject: [PATCH 22/23] feat(deploy): add default SAM tool and improve parameter handling --- src/easysam/deploy.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/easysam/deploy.py b/src/easysam/deploy.py index 217aaa6..cc7cccc 100644 --- a/src/easysam/deploy.py +++ b/src/easysam/deploy.py @@ -15,6 +15,7 @@ SAM_CLI_VERSION = '1.138.0' PIP_VERSION = '25.1.1' +DEFAULT_SAM_TOOL = 'uv run sam' def deploy( @@ -137,7 +138,7 @@ def _check_pip_version(toolparams): def _check_sam_cli_version(toolparams): lg.info('Checking SAM CLI version') - sam_tool = toolparams['sam_tool'] + sam_tool = toolparams.get('sam_tool', DEFAULT_SAM_TOOL) sam_params = sam_tool.split(' ') sam_params.append('--version') @@ -167,7 +168,7 @@ def _sam_build( f'Template {template} not found in build directory {build_dir}' ) - sam_tool: str = toolparams['sam_tool'] + sam_tool: str = toolparams.get('sam_tool', DEFAULT_SAM_TOOL) sam_params = sam_tool.split(' ') sam_build_dir = Path( @@ -211,7 +212,7 @@ def _sam_deploy(toolparams, directory, deploy_ctx, resources): lg.info( f'Deploying SAM template from {directory} to\n{json.dumps(deploy_ctx, indent=4)}' ) - sam_tool = toolparams['sam_tool'] + sam_tool = toolparams.get('sam_tool', DEFAULT_SAM_TOOL) sam_params = sam_tool.split(' ') aws_stack = deploy_ctx['environment'] @@ -265,6 +266,9 @@ def _sam_deploy(toolparams, directory, deploy_ctx, resources): sam_build_dir = Path(build_dir, '.aws-sam') run_description = f'{" ".join(sam_params)} in {sam_build_dir}' + if 'dry_run' not in toolparams: + raise UserWarning('dry_run must be present in `toolparams`') + if toolparams['dry_run']: lg.info(f'Would run: {run_description}') return From 0166ac276caba3213077fc881bda2c647a207914 Mon Sep 17 00:00:00 2001 From: Boris Resnick Date: Mon, 10 Nov 2025 19:31:36 +0200 Subject: [PATCH 23/23] refactor: remove mergedeep dependency and use benedict merge --- pyproject.toml | 1 - src/easysam/generate.py | 3 +-- uv.lock | 11 ----------- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f83ad6b..57f1895 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "click>=8.2.0", "jinja2-cli[yaml]>=0.8.2", "jsonschema>=4.23.0", - "mergedeep>=1.3.4", "python-benedict[yaml]>=0.34.1", "rich>=13.9.4", "prismarine>=1.4.1", diff --git a/src/easysam/generate.py b/src/easysam/generate.py index b17408d..4f15f42 100644 --- a/src/easysam/generate.py +++ b/src/easysam/generate.py @@ -5,7 +5,6 @@ from benedict import benedict from jinja2 import Environment, FileSystemLoader -from mergedeep import merge import easysam.utils as u from easysam.prismarine import generate as generate_prismarine_clients @@ -179,7 +178,7 @@ def invoke_plugin( jenv = Environment(loader=loader) template = jenv.get_template(template_j2_filename) aux_data = dict(plugin.get('aux', {})) - output = template.render(merge(resources_data, aux_data)) + output = template.render(resources_data.merge(aux_data)) output_yaml_path = Path(build_dir, plugin_name).with_suffix('.yaml') write_result(output_yaml_path, output) diff --git a/uv.lock b/uv.lock index bd58574..edf9044 100644 --- a/uv.lock +++ b/uv.lock @@ -499,7 +499,6 @@ dependencies = [ { name = "click" }, { name = "jinja2-cli", extra = ["yaml"] }, { name = "jsonschema" }, - { name = "mergedeep" }, { name = "prismarine" }, { name = "python-benedict", extra = ["yaml"] }, { name = "rich" }, @@ -520,7 +519,6 @@ requires-dist = [ { name = "click", specifier = ">=8.2.0" }, { name = "jinja2-cli", extras = ["yaml"], specifier = ">=0.8.2" }, { name = "jsonschema", specifier = ">=4.23.0" }, - { name = "mergedeep", specifier = ">=1.3.4" }, { name = "prismarine", specifier = ">=1.4.1" }, { name = "python-benedict", extras = ["yaml"], specifier = ">=0.34.1" }, { name = "rich", specifier = ">=13.9.4" }, @@ -820,15 +818,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - [[package]] name = "more-itertools" version = "10.7.0"