@anthusai/papyrus 1.0.0-next.44 → 1.0.0-next.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/content-actions/handler.py +62 -8
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/handler.py +94 -0
- package/amplify/functions/renditions/requirements.txt +4 -0
- package/amplify/publication/renditions-function.js +74 -0
- package/amplify/publication/renditions-function.ts +112 -0
- package/amplify/publication/renditions-settings.js +43 -0
- package/amplify/publication/renditions-settings.ts +50 -0
- package/amplify/publication/s3-projection.js +31 -4
- package/amplify/publication/s3-projection.ts +46 -4
- package/amplify/site-backend.js +12 -4
- package/amplify/site-backend.ts +12 -4
- package/package.json +1 -1
- package/routes.manifest.json +1 -1
|
@@ -6,7 +6,7 @@ from typing import Any
|
|
|
6
6
|
|
|
7
7
|
from papyrus_content.graphql_authoring import create_authoring_client
|
|
8
8
|
from papyrus_content.markus_renderer.derive import derive_body
|
|
9
|
-
from papyrus_content.projection.engine import project
|
|
9
|
+
from papyrus_content.projection.engine import project, project_item_pages
|
|
10
10
|
from papyrus_content.projection.runtime import (
|
|
11
11
|
DEAD_LETTER_QUEUE_ENV,
|
|
12
12
|
context_from_environment,
|
|
@@ -63,7 +63,14 @@ def derive_markus_action(arguments: dict) -> dict:
|
|
|
63
63
|
return response
|
|
64
64
|
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
PROJECTION_TASK_KEY = "projectionTask"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProjectionTaskFailed(Exception):
|
|
70
|
+
"""Raised by an asynchronous projection task so Lambda retries it and, finally, sends it to the dead letter queue."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def send_to_projection_dead_letter_queue(task: dict, error: str) -> None:
|
|
67
74
|
queue_url = (os.environ.get(DEAD_LETTER_QUEUE_ENV) or "").strip()
|
|
68
75
|
if not queue_url:
|
|
69
76
|
return
|
|
@@ -71,23 +78,50 @@ def send_to_projection_dead_letter_queue(item_id: str, unpublished: bool, error:
|
|
|
71
78
|
import boto3
|
|
72
79
|
|
|
73
80
|
boto3.client("sqs").send_message(
|
|
74
|
-
QueueUrl=queue_url,
|
|
75
|
-
MessageBody=json.dumps({"itemId": item_id, "unpublished": unpublished, "error": error}),
|
|
81
|
+
QueueUrl=queue_url, MessageBody=json.dumps({PROJECTION_TASK_KEY: task, "error": error})
|
|
76
82
|
)
|
|
77
83
|
except Exception: # noqa: BLE001 - the dead letter is best effort; the response already reports the failure
|
|
78
84
|
pass
|
|
79
85
|
|
|
80
86
|
|
|
87
|
+
def schedule_projection_task(task: dict, error: str) -> bool:
|
|
88
|
+
"""Run a projection task later, in this same function, as an asynchronous invocation.
|
|
89
|
+
|
|
90
|
+
The function has an on-failure destination (the dead letter queue), so a task
|
|
91
|
+
that keeps failing ends up there for ``papyrus ops content project --reconcile``.
|
|
92
|
+
"""
|
|
93
|
+
function_name = (os.environ.get("AWS_LAMBDA_FUNCTION_NAME") or "").strip()
|
|
94
|
+
if not function_name:
|
|
95
|
+
send_to_projection_dead_letter_queue(task, error)
|
|
96
|
+
return False
|
|
97
|
+
try:
|
|
98
|
+
import boto3
|
|
99
|
+
|
|
100
|
+
boto3.client("lambda").invoke(
|
|
101
|
+
FunctionName=function_name, InvocationType="Event", Payload=json.dumps({PROJECTION_TASK_KEY: task}).encode("utf-8")
|
|
102
|
+
)
|
|
103
|
+
return True
|
|
104
|
+
except Exception as scheduling_error: # noqa: BLE001 - fall back to the dead letter queue
|
|
105
|
+
send_to_projection_dead_letter_queue(task, f"{error}; scheduling failed: {scheduling_error}")
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def projection_context(client):
|
|
110
|
+
context = context_from_environment(client)
|
|
111
|
+
context.defer_follow_up = True
|
|
112
|
+
return context
|
|
113
|
+
|
|
114
|
+
|
|
81
115
|
def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
82
116
|
if not projection_configured():
|
|
83
117
|
return {}
|
|
84
118
|
liveness = {} if unpublished else {"live": False}
|
|
119
|
+
task = {"task": "item", "itemId": item_id, "unpublished": unpublished}
|
|
85
120
|
try:
|
|
86
|
-
|
|
87
|
-
report = project(context, item_id, unpublished=unpublished)
|
|
121
|
+
report = project(projection_context(client), item_id, unpublished=unpublished)
|
|
88
122
|
except Exception as error: # noqa: BLE001 - a projection failure never fails the save
|
|
89
123
|
message = f"{type(error).__name__}: {error}"
|
|
90
|
-
|
|
124
|
+
schedule_projection_task(task, message)
|
|
91
125
|
return {**liveness, "projected": False, "paths": [], "error": message}
|
|
92
126
|
response = {"projected": report.ok, "paths": [*report.written, *report.deleted]}
|
|
93
127
|
if not unpublished:
|
|
@@ -97,11 +131,29 @@ def project_item_action(client, item_id: str, *, unpublished: bool) -> dict:
|
|
|
97
131
|
response["url"] = url
|
|
98
132
|
if not report.ok:
|
|
99
133
|
message = "; ".join(report.errors)
|
|
100
|
-
|
|
134
|
+
schedule_projection_task(task, message)
|
|
101
135
|
response["error"] = message
|
|
136
|
+
if report.follow_up_item_pages:
|
|
137
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": item_id}, "follow-up")
|
|
102
138
|
return response
|
|
103
139
|
|
|
104
140
|
|
|
141
|
+
def run_projection_task(task: dict) -> dict:
|
|
142
|
+
"""An asynchronous projection task. Raising makes Lambda retry it, then send the event to the dead letter queue."""
|
|
143
|
+
client, _claims = create_authoring_client()
|
|
144
|
+
if task.get("task") == "item-pages":
|
|
145
|
+
report = project_item_pages(projection_context(client), except_item_id=task.get("exceptItemId"))
|
|
146
|
+
elif task.get("task") == "item":
|
|
147
|
+
report = project(projection_context(client), task["itemId"], unpublished=bool(task.get("unpublished")))
|
|
148
|
+
if report.follow_up_item_pages:
|
|
149
|
+
schedule_projection_task({"task": "item-pages", "exceptItemId": task["itemId"]}, "follow-up")
|
|
150
|
+
else:
|
|
151
|
+
raise ProjectionTaskFailed(f"Unknown projection task {task.get('task')!r}")
|
|
152
|
+
if not report.ok:
|
|
153
|
+
raise ProjectionTaskFailed("; ".join(report.errors))
|
|
154
|
+
return {"ok": True, "paths": [*report.written, *report.deleted]}
|
|
155
|
+
|
|
156
|
+
|
|
105
157
|
def save_item_draft_action(arguments: dict, actor: str) -> dict:
|
|
106
158
|
client, _claims = create_authoring_client()
|
|
107
159
|
fields = ItemFields(
|
|
@@ -183,6 +235,8 @@ def request_staging_build_action() -> dict:
|
|
|
183
235
|
|
|
184
236
|
|
|
185
237
|
def handler(event, context):
|
|
238
|
+
if PROJECTION_TASK_KEY in event:
|
|
239
|
+
return run_projection_task(event[PROJECTION_TASK_KEY])
|
|
186
240
|
field = event["fieldName"]
|
|
187
241
|
arguments = parse_input(event["arguments"]["input"])
|
|
188
242
|
actor = actor_from_event(event)
|
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { pythonLambdaCode } from "../shared/python-bundle.js";
|
|
8
8
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
10
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
10
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
11
11
|
function installCheckoutRequirements(outputDir) {
|
|
12
12
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
13
13
|
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
@@ -10,7 +10,7 @@ import { pythonLambdaCode, type SiteBundleFile } from "../shared/python-bundle";
|
|
|
10
10
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
11
|
const projectRoot = path.resolve(dirname, "../../..");
|
|
12
12
|
|
|
13
|
-
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=
|
|
13
|
+
export const contentActionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12", "awscrt>=0.23,<1"];
|
|
14
14
|
|
|
15
15
|
function installCheckoutRequirements(outputDir: string): void {
|
|
16
16
|
const requirementsPath = path.join(projectRoot, "amplify/functions/content-actions/requirements.txt");
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from papyrus_content.projection.runtime import SITE_BUCKET_ENV, SITE_STATIC_DIRECTORY_ENV
|
|
10
|
+
from papyrus_content.renditions import (
|
|
11
|
+
RenditionSettings,
|
|
12
|
+
S3RenditionStore,
|
|
13
|
+
make_renditions,
|
|
14
|
+
meta_key,
|
|
15
|
+
settings_from_static_directory,
|
|
16
|
+
source_from_media_key,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_loaded: dict[str, Any] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configured_settings() -> RenditionSettings | None:
|
|
23
|
+
if "settings" not in _loaded:
|
|
24
|
+
static_directory = (os.environ.get(SITE_STATIC_DIRECTORY_ENV) or "").strip()
|
|
25
|
+
_loaded["settings"] = settings_from_static_directory(Path(static_directory) if static_directory else None)
|
|
26
|
+
return _loaded["settings"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def site_store() -> S3RenditionStore:
|
|
30
|
+
if "store" not in _loaded:
|
|
31
|
+
_loaded["store"] = S3RenditionStore(os.environ[SITE_BUCKET_ENV])
|
|
32
|
+
return _loaded["store"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def s3_client():
|
|
36
|
+
if "s3" not in _loaded:
|
|
37
|
+
import boto3
|
|
38
|
+
|
|
39
|
+
_loaded["s3"] = boto3.client("s3")
|
|
40
|
+
return _loaded["s3"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def media_objects(event: dict) -> list[tuple[str, str]]:
|
|
44
|
+
if event.get("source") == "aws.s3" and "detail" in event:
|
|
45
|
+
detail = event["detail"]
|
|
46
|
+
return [(detail["bucket"]["name"], detail["object"]["key"])]
|
|
47
|
+
if "bucket" in event and "key" in event:
|
|
48
|
+
return [(event["bucket"], event["key"])]
|
|
49
|
+
raise ValueError("The event is neither an S3 object event nor a {bucket, key} request.")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_media_object(
|
|
53
|
+
settings: RenditionSettings, bucket: str, key: str, *, skip_existing: bool = False, dry_run: bool = False
|
|
54
|
+
) -> dict:
|
|
55
|
+
source = source_from_media_key(key)
|
|
56
|
+
if source is None:
|
|
57
|
+
return {"key": key, "action": "ignored", "reason": "not an image under media/"}
|
|
58
|
+
if skip_existing and site_store().get(meta_key(settings.pipeline, source)) is not None:
|
|
59
|
+
return {"key": key, "source": source, "action": "skipped-existing"}
|
|
60
|
+
if dry_run:
|
|
61
|
+
return {"key": key, "source": source, "action": "would-render"}
|
|
62
|
+
started = time.perf_counter()
|
|
63
|
+
data = s3_client().get_object(Bucket=bucket, Key=key)["Body"].read()
|
|
64
|
+
downloaded = time.perf_counter()
|
|
65
|
+
result = make_renditions(settings, site_store(), source, data)
|
|
66
|
+
finished = time.perf_counter()
|
|
67
|
+
return {
|
|
68
|
+
"key": key,
|
|
69
|
+
"source": source,
|
|
70
|
+
"action": "rendered",
|
|
71
|
+
"kind": result["kind"],
|
|
72
|
+
"written": result["written"],
|
|
73
|
+
"sourceBytes": len(data),
|
|
74
|
+
"downloadMilliseconds": round((downloaded - started) * 1000),
|
|
75
|
+
"renditionMilliseconds": round((finished - downloaded) * 1000),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def handler(event, context):
|
|
80
|
+
settings = configured_settings()
|
|
81
|
+
if settings is None:
|
|
82
|
+
return {"ok": True, "results": [], "skipped": "this site has no images settings in projection.json"}
|
|
83
|
+
results = [
|
|
84
|
+
render_media_object(
|
|
85
|
+
settings,
|
|
86
|
+
bucket,
|
|
87
|
+
key,
|
|
88
|
+
skip_existing=event.get("skipExisting") is True,
|
|
89
|
+
dry_run=event.get("dryRun") is True,
|
|
90
|
+
)
|
|
91
|
+
for bucket, key in media_objects(event)
|
|
92
|
+
]
|
|
93
|
+
print(json.dumps({"renditions": results}))
|
|
94
|
+
return {"ok": True, "results": results}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Duration, Size } from "aws-cdk-lib";
|
|
2
|
+
import * as events from "aws-cdk-lib/aws-events";
|
|
3
|
+
import * as targets from "aws-cdk-lib/aws-events-targets";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { pythonLambdaCode } from "../functions/shared/python-bundle.js";
|
|
11
|
+
import { RENDITIONS_EPHEMERAL_STORAGE_MB, RENDITIONS_MEDIA_PREFIX, RENDITIONS_MEMORY_MB, RENDITIONS_TIMEOUT_SECONDS, renditionsEnvironment, renditionsEventPattern, } from "./renditions-settings.js";
|
|
12
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const projectRoot = path.resolve(dirname, "../..");
|
|
14
|
+
export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
|
|
15
|
+
function installCheckoutRequirements(outputDir) {
|
|
16
|
+
const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
|
|
17
|
+
execSync(`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`, { cwd: projectRoot, stdio: "inherit" });
|
|
18
|
+
}
|
|
19
|
+
export function addRenditionsFunction(scope, props) {
|
|
20
|
+
const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
|
|
21
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
22
|
+
retentionPeriod: Duration.days(14),
|
|
23
|
+
enforceSSL: true,
|
|
24
|
+
});
|
|
25
|
+
const renditions = new LambdaFunction(scope, "RenditionsFunction", {
|
|
26
|
+
runtime: Runtime.PYTHON_3_12,
|
|
27
|
+
architecture: Architecture.ARM_64,
|
|
28
|
+
handler: "handler.handler",
|
|
29
|
+
timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
|
|
30
|
+
memorySize: RENDITIONS_MEMORY_MB,
|
|
31
|
+
ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
|
|
32
|
+
retryAttempts: 2,
|
|
33
|
+
deadLetterQueue,
|
|
34
|
+
environment: renditionsEnvironment(props.siteBucket.bucketName),
|
|
35
|
+
code: props.code ?? pythonLambdaCode({
|
|
36
|
+
functionDir: "renditions",
|
|
37
|
+
modules: ["papyrus_content"],
|
|
38
|
+
requirements: renditionsRequirements,
|
|
39
|
+
noDeps: true,
|
|
40
|
+
checkoutExtras: installCheckoutRequirements,
|
|
41
|
+
siteFiles: props.siteFiles,
|
|
42
|
+
}, [
|
|
43
|
+
"bash",
|
|
44
|
+
"-c",
|
|
45
|
+
[
|
|
46
|
+
"set -euo pipefail",
|
|
47
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
48
|
+
"cp amplify/functions/renditions/handler.py /asset-output/handler.py",
|
|
49
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
50
|
+
"python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
|
|
51
|
+
].join(" && "),
|
|
52
|
+
]),
|
|
53
|
+
});
|
|
54
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
55
|
+
effect: Effect.ALLOW,
|
|
56
|
+
actions: ["s3:GetObject"],
|
|
57
|
+
resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
|
|
58
|
+
}));
|
|
59
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
60
|
+
effect: Effect.ALLOW,
|
|
61
|
+
actions: ["s3:PutObject", "s3:GetObject"],
|
|
62
|
+
resources: [props.siteBucket.arnForObjects("*")],
|
|
63
|
+
}));
|
|
64
|
+
renditions.addToRolePolicy(new PolicyStatement({
|
|
65
|
+
effect: Effect.ALLOW,
|
|
66
|
+
actions: ["s3:ListBucket"],
|
|
67
|
+
resources: [props.siteBucket.bucketArn],
|
|
68
|
+
}));
|
|
69
|
+
const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
|
|
70
|
+
eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
|
|
71
|
+
});
|
|
72
|
+
rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
|
|
73
|
+
return { function: renditions, rule, deadLetterQueue };
|
|
74
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Duration, Size } from "aws-cdk-lib";
|
|
2
|
+
import * as events from "aws-cdk-lib/aws-events";
|
|
3
|
+
import * as targets from "aws-cdk-lib/aws-events-targets";
|
|
4
|
+
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Architecture, type Code, Function as LambdaFunction, Runtime } from "aws-cdk-lib/aws-lambda";
|
|
6
|
+
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
7
|
+
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
8
|
+
import type { Construct } from "constructs";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { pythonLambdaCode, type SiteBundleFile } from "../functions/shared/python-bundle";
|
|
13
|
+
import {
|
|
14
|
+
RENDITIONS_EPHEMERAL_STORAGE_MB,
|
|
15
|
+
RENDITIONS_MEDIA_PREFIX,
|
|
16
|
+
RENDITIONS_MEMORY_MB,
|
|
17
|
+
RENDITIONS_TIMEOUT_SECONDS,
|
|
18
|
+
renditionsEnvironment,
|
|
19
|
+
renditionsEventPattern,
|
|
20
|
+
} from "./renditions-settings";
|
|
21
|
+
|
|
22
|
+
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const projectRoot = path.resolve(dirname, "../..");
|
|
24
|
+
|
|
25
|
+
export const renditionsRequirements = ["PyYAML>=6.0.2,<7", "citeproc-py>=0.8.2,<1", "anthus-markus==0.5.1", "Pillow>=11.3,<12"];
|
|
26
|
+
|
|
27
|
+
export type RenditionsFunctionProps = {
|
|
28
|
+
siteBucket: s3.IBucket;
|
|
29
|
+
mediaBucket: s3.IBucket;
|
|
30
|
+
siteFiles: SiteBundleFile[];
|
|
31
|
+
code?: Code;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type RenditionsFunction = {
|
|
35
|
+
function: LambdaFunction;
|
|
36
|
+
rule: events.Rule;
|
|
37
|
+
deadLetterQueue: sqs.Queue;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function installCheckoutRequirements(outputDir: string): void {
|
|
41
|
+
const requirementsPath = path.join(projectRoot, "amplify/functions/renditions/requirements.txt");
|
|
42
|
+
execSync(
|
|
43
|
+
`python3 -m pip install -r "${requirementsPath}" -t "${outputDir}" --no-cache-dir --platform manylinux2014_aarch64 --implementation cp --python-version 3.12 --only-binary=:all: --ignore-requires-python`,
|
|
44
|
+
{ cwd: projectRoot, stdio: "inherit" },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function addRenditionsFunction(scope: Construct, props: RenditionsFunctionProps): RenditionsFunction {
|
|
49
|
+
const deadLetterQueue = new sqs.Queue(scope, "RenditionsDeadLetterQueue", {
|
|
50
|
+
encryption: sqs.QueueEncryption.SQS_MANAGED,
|
|
51
|
+
retentionPeriod: Duration.days(14),
|
|
52
|
+
enforceSSL: true,
|
|
53
|
+
});
|
|
54
|
+
const renditions = new LambdaFunction(scope, "RenditionsFunction", {
|
|
55
|
+
runtime: Runtime.PYTHON_3_12,
|
|
56
|
+
architecture: Architecture.ARM_64,
|
|
57
|
+
handler: "handler.handler",
|
|
58
|
+
timeout: Duration.seconds(RENDITIONS_TIMEOUT_SECONDS),
|
|
59
|
+
memorySize: RENDITIONS_MEMORY_MB,
|
|
60
|
+
ephemeralStorageSize: Size.mebibytes(RENDITIONS_EPHEMERAL_STORAGE_MB),
|
|
61
|
+
retryAttempts: 2,
|
|
62
|
+
deadLetterQueue,
|
|
63
|
+
environment: renditionsEnvironment(props.siteBucket.bucketName),
|
|
64
|
+
code: props.code ?? pythonLambdaCode(
|
|
65
|
+
{
|
|
66
|
+
functionDir: "renditions",
|
|
67
|
+
modules: ["papyrus_content"],
|
|
68
|
+
requirements: renditionsRequirements,
|
|
69
|
+
noDeps: true,
|
|
70
|
+
checkoutExtras: installCheckoutRequirements,
|
|
71
|
+
siteFiles: props.siteFiles,
|
|
72
|
+
},
|
|
73
|
+
[
|
|
74
|
+
"bash",
|
|
75
|
+
"-c",
|
|
76
|
+
[
|
|
77
|
+
"set -euo pipefail",
|
|
78
|
+
"mkdir -p /asset-output/papyrus_content",
|
|
79
|
+
"cp amplify/functions/renditions/handler.py /asset-output/handler.py",
|
|
80
|
+
"cp -R src/papyrus_content/. /asset-output/papyrus_content/",
|
|
81
|
+
"python3 -m pip install -r amplify/functions/renditions/requirements.txt -t /asset-output --no-cache-dir",
|
|
82
|
+
].join(" && "),
|
|
83
|
+
],
|
|
84
|
+
),
|
|
85
|
+
});
|
|
86
|
+
renditions.addToRolePolicy(
|
|
87
|
+
new PolicyStatement({
|
|
88
|
+
effect: Effect.ALLOW,
|
|
89
|
+
actions: ["s3:GetObject"],
|
|
90
|
+
resources: [props.mediaBucket.arnForObjects(`${RENDITIONS_MEDIA_PREFIX}*`)],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
renditions.addToRolePolicy(
|
|
94
|
+
new PolicyStatement({
|
|
95
|
+
effect: Effect.ALLOW,
|
|
96
|
+
actions: ["s3:PutObject", "s3:GetObject"],
|
|
97
|
+
resources: [props.siteBucket.arnForObjects("*")],
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
renditions.addToRolePolicy(
|
|
101
|
+
new PolicyStatement({
|
|
102
|
+
effect: Effect.ALLOW,
|
|
103
|
+
actions: ["s3:ListBucket"],
|
|
104
|
+
resources: [props.siteBucket.bucketArn],
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
const rule = new events.Rule(scope, "MediaObjectCreatedRule", {
|
|
108
|
+
eventPattern: renditionsEventPattern(props.mediaBucket.bucketName),
|
|
109
|
+
});
|
|
110
|
+
rule.addTarget(new targets.LambdaFunction(renditions, { retryAttempts: 2 }));
|
|
111
|
+
return { function: renditions, rule, deadLetterQueue };
|
|
112
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
12
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
13
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
14
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
15
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
16
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
17
|
+
export function projectionSettingsHaveImages(settingsPath) {
|
|
18
|
+
if (!fs.existsSync(settingsPath))
|
|
19
|
+
return false;
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
21
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
22
|
+
}
|
|
23
|
+
export function planRenditions(publication, workingDirectory) {
|
|
24
|
+
if (publication?.staticDir === undefined)
|
|
25
|
+
return { enabled: false, siteFiles: [] };
|
|
26
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
27
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile)))
|
|
28
|
+
return { enabled: false, siteFiles: [] };
|
|
29
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
30
|
+
}
|
|
31
|
+
export function renditionsEventPattern(mediaBucketName) {
|
|
32
|
+
return {
|
|
33
|
+
source: ["aws.s3"],
|
|
34
|
+
detailType: ["Object Created"],
|
|
35
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function renditionsEnvironment(siteBucketName) {
|
|
39
|
+
return {
|
|
40
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
41
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for the upload-time renditions function of an s3-projection
|
|
3
|
+
* site (no CDK imports, so it can be tested without synthesizing).
|
|
4
|
+
*
|
|
5
|
+
* The function exists only for a site whose `projection.json` carries an
|
|
6
|
+
* `images` section: that section is the one place the image pipeline settings
|
|
7
|
+
* live, shared by the renditions function and the projector.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const RENDITIONS_MEMORY_MB = 3008;
|
|
13
|
+
export const RENDITIONS_TIMEOUT_SECONDS = 300;
|
|
14
|
+
export const RENDITIONS_EPHEMERAL_STORAGE_MB = 2048;
|
|
15
|
+
export const RENDITIONS_STATIC_DIRECTORY = "/var/task/site_static";
|
|
16
|
+
export const RENDITIONS_MEDIA_PREFIX = "media/";
|
|
17
|
+
export const RENDITIONS_FUNCTION_NAME_ENV = "PAPYRUS_RENDITIONS_FUNCTION_NAME";
|
|
18
|
+
|
|
19
|
+
export type RenditionsPlan = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
siteFiles: Array<{ from: string; to: string }>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function projectionSettingsHaveImages(settingsPath: string): boolean {
|
|
25
|
+
if (!fs.existsSync(settingsPath)) return false;
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as Record<string, unknown>;
|
|
27
|
+
return typeof parsed.images === "object" && parsed.images !== null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function planRenditions(publication: { staticDir?: string } | undefined, workingDirectory: string): RenditionsPlan {
|
|
31
|
+
if (publication?.staticDir === undefined) return { enabled: false, siteFiles: [] };
|
|
32
|
+
const settingsFile = path.join(publication.staticDir, "projection.json");
|
|
33
|
+
if (!projectionSettingsHaveImages(path.resolve(workingDirectory, settingsFile))) return { enabled: false, siteFiles: [] };
|
|
34
|
+
return { enabled: true, siteFiles: [{ from: settingsFile, to: "site_static/projection.json" }] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renditionsEventPattern(mediaBucketName: string): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
source: ["aws.s3"],
|
|
40
|
+
detailType: ["Object Created"],
|
|
41
|
+
detail: { bucket: { name: [mediaBucketName] }, object: { key: [{ prefix: RENDITIONS_MEDIA_PREFIX }] } },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renditionsEnvironment(siteBucketName: string): Record<string, string> {
|
|
46
|
+
return {
|
|
47
|
+
PAPYRUS_SITE_BUCKET: siteBucketName,
|
|
48
|
+
PAPYRUS_SITE_STATIC_DIR: RENDITIONS_STATIC_DIRECTORY,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
1
|
+
import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
3
3
|
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
4
4
|
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
5
|
-
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
5
|
+
import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
|
|
6
7
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
7
8
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
9
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
9
10
|
import { dirname, resolve } from "node:path";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { assertValidPublicationConfig } from "../site-backend-config.js";
|
|
13
|
+
import { addRenditionsFunction } from "./renditions-function.js";
|
|
14
|
+
import { RENDITIONS_FUNCTION_NAME_ENV } from "./renditions-settings.js";
|
|
12
15
|
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings.js";
|
|
13
16
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
14
17
|
function createSiteBucket(scope, retainBucketOnDelete) {
|
|
@@ -60,7 +63,7 @@ function createDistribution(scope, props, bucket, redirectFunction) {
|
|
|
60
63
|
],
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
|
-
export function addS3ProjectionPublication(scope, props) {
|
|
66
|
+
export function addS3ProjectionPublication(scope, props, media) {
|
|
64
67
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
65
68
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
66
69
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
@@ -90,6 +93,9 @@ export function addS3ProjectionPublication(scope, props) {
|
|
|
90
93
|
conditions: { StringEquals: { "aws:SourceArn": props.edgeDistributionArn } },
|
|
91
94
|
}));
|
|
92
95
|
}
|
|
96
|
+
const renditions = media?.renditions.enabled === true
|
|
97
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
98
|
+
: undefined;
|
|
93
99
|
const settingsInput = {
|
|
94
100
|
bucketName: bucket.bucketName,
|
|
95
101
|
bucketArn: bucket.bucketArn,
|
|
@@ -111,7 +117,28 @@ export function addS3ProjectionPublication(scope, props) {
|
|
|
111
117
|
deadLetterQueue,
|
|
112
118
|
distribution,
|
|
113
119
|
distributionId: distribution?.distributionId,
|
|
114
|
-
|
|
120
|
+
renditions,
|
|
121
|
+
environment: {
|
|
122
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
123
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
124
|
+
},
|
|
115
125
|
policyStatements: s3ProjectionPolicyStatements(settingsInput).map((statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources })),
|
|
116
126
|
};
|
|
117
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The content-actions function runs a failed or follow-up projection again as an asynchronous
|
|
130
|
+
* invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
|
|
131
|
+
* what still fails. The self-invoke permission is a separate policy on the role so that the
|
|
132
|
+
* function does not depend on a policy that refers to the function.
|
|
133
|
+
*/
|
|
134
|
+
export function addProjectionRetries(lambda, deadLetterQueue) {
|
|
135
|
+
lambda.configureAsyncInvoke({
|
|
136
|
+
retryAttempts: 1,
|
|
137
|
+
maxEventAge: Duration.hours(1),
|
|
138
|
+
onFailure: new SqsDestination(deadLetterQueue),
|
|
139
|
+
});
|
|
140
|
+
new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
|
|
141
|
+
statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
|
|
142
|
+
roles: [lambda.role],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { Duration, RemovalPolicy } from "aws-cdk-lib";
|
|
1
|
+
import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
3
3
|
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
4
|
+
import type { Code } from "aws-cdk-lib/aws-lambda";
|
|
4
5
|
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
5
|
-
import { Effect, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
6
|
+
import { Effect, Policy, PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
|
|
7
|
+
import type { Function as LambdaFunction } from "aws-cdk-lib/aws-lambda";
|
|
8
|
+
import { SqsDestination } from "aws-cdk-lib/aws-lambda-destinations";
|
|
6
9
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
7
10
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
8
11
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
@@ -10,12 +13,20 @@ import type { Construct } from "constructs";
|
|
|
10
13
|
import { dirname, resolve } from "node:path";
|
|
11
14
|
import { fileURLToPath } from "node:url";
|
|
12
15
|
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
16
|
+
import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
|
|
17
|
+
import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
|
|
13
18
|
import { s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
14
19
|
|
|
15
20
|
const redirectFunctionSourcePath = resolve(dirname(fileURLToPath(import.meta.url)), "redirect-and-index.js");
|
|
16
21
|
|
|
17
22
|
export type S3ProjectionPublicationProps = Omit<PapyrusPublicationConfig, "kind"> & { kind?: "s3-projection" };
|
|
18
23
|
|
|
24
|
+
export type S3ProjectionMedia = {
|
|
25
|
+
mediaBucket: s3.IBucket;
|
|
26
|
+
renditions: RenditionsPlan;
|
|
27
|
+
renditionsCode?: Code;
|
|
28
|
+
};
|
|
29
|
+
|
|
19
30
|
export type S3ProjectionPublication = {
|
|
20
31
|
bucket: s3.Bucket;
|
|
21
32
|
keyValueStore: cloudfront.KeyValueStore;
|
|
@@ -23,6 +34,7 @@ export type S3ProjectionPublication = {
|
|
|
23
34
|
deadLetterQueue: sqs.Queue;
|
|
24
35
|
distribution?: cloudfront.Distribution;
|
|
25
36
|
distributionId?: string;
|
|
37
|
+
renditions?: RenditionsFunction;
|
|
26
38
|
environment: Record<string, string>;
|
|
27
39
|
policyStatements: PolicyStatement[];
|
|
28
40
|
};
|
|
@@ -88,7 +100,11 @@ function createDistribution(
|
|
|
88
100
|
});
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
export function addS3ProjectionPublication(
|
|
103
|
+
export function addS3ProjectionPublication(
|
|
104
|
+
scope: Construct,
|
|
105
|
+
props: S3ProjectionPublicationProps,
|
|
106
|
+
media?: S3ProjectionMedia,
|
|
107
|
+
): S3ProjectionPublication {
|
|
92
108
|
assertValidPublicationConfig({ kind: "s3-projection", ...props });
|
|
93
109
|
const bucket = createSiteBucket(scope, props.retainBucketOnDelete !== false);
|
|
94
110
|
|
|
@@ -123,6 +139,10 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
|
|
|
123
139
|
);
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
const renditions = media?.renditions.enabled === true
|
|
143
|
+
? addRenditionsFunction(scope, { siteBucket: bucket, mediaBucket: media.mediaBucket, siteFiles: media.renditions.siteFiles, code: media.renditionsCode })
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
126
146
|
const settingsInput = {
|
|
127
147
|
bucketName: bucket.bucketName,
|
|
128
148
|
bucketArn: bucket.bucketArn,
|
|
@@ -144,9 +164,31 @@ export function addS3ProjectionPublication(scope: Construct, props: S3Projection
|
|
|
144
164
|
deadLetterQueue,
|
|
145
165
|
distribution,
|
|
146
166
|
distributionId: distribution?.distributionId,
|
|
147
|
-
|
|
167
|
+
renditions,
|
|
168
|
+
environment: {
|
|
169
|
+
...s3ProjectionEnvironment(settingsInput),
|
|
170
|
+
...(renditions !== undefined ? { [RENDITIONS_FUNCTION_NAME_ENV]: renditions.function.functionName } : {}),
|
|
171
|
+
},
|
|
148
172
|
policyStatements: s3ProjectionPolicyStatements(settingsInput).map(
|
|
149
173
|
(statement) => new PolicyStatement({ effect: Effect.ALLOW, actions: statement.actions, resources: statement.resources }),
|
|
150
174
|
),
|
|
151
175
|
};
|
|
152
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The content-actions function runs a failed or follow-up projection again as an asynchronous
|
|
180
|
+
* invocation of itself: one retry, and an on-failure destination (the dead letter queue) for
|
|
181
|
+
* what still fails. The self-invoke permission is a separate policy on the role so that the
|
|
182
|
+
* function does not depend on a policy that refers to the function.
|
|
183
|
+
*/
|
|
184
|
+
export function addProjectionRetries(lambda: LambdaFunction, deadLetterQueue: sqs.IQueue): void {
|
|
185
|
+
lambda.configureAsyncInvoke({
|
|
186
|
+
retryAttempts: 1,
|
|
187
|
+
maxEventAge: Duration.hours(1),
|
|
188
|
+
onFailure: new SqsDestination(deadLetterQueue),
|
|
189
|
+
});
|
|
190
|
+
new Policy(Stack.of(lambda), "ContentActionsProjectionSelfInvoke", {
|
|
191
|
+
statements: [new PolicyStatement({ actions: ["lambda:InvokeFunction"], resources: [lambda.functionArn] })],
|
|
192
|
+
roles: [lambda.role!],
|
|
193
|
+
});
|
|
194
|
+
}
|
package/amplify/site-backend.js
CHANGED
|
@@ -7,7 +7,8 @@ import { CfnIndex, CfnVectorBucket, CfnVectorBucketPolicy } from "aws-cdk-lib/aw
|
|
|
7
7
|
import { dirname, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves.js";
|
|
10
|
-
import { addS3ProjectionPublication } from "./publication/s3-projection.js";
|
|
10
|
+
import { addProjectionRetries, addS3ProjectionPublication } from "./publication/s3-projection.js";
|
|
11
|
+
import { planRenditions } from "./publication/renditions-settings.js";
|
|
11
12
|
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings.js";
|
|
12
13
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings.js";
|
|
13
14
|
import { assertValidPublicationConfig } from "./site-backend-config.js";
|
|
@@ -68,6 +69,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
68
69
|
if (site.publication !== undefined)
|
|
69
70
|
assertValidPublicationConfig(site.publication);
|
|
70
71
|
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
72
|
+
const renditionsPlan = planRenditions(site.publication, process.cwd());
|
|
71
73
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
72
74
|
const backend = defineBackend({
|
|
73
75
|
assignmentAction,
|
|
@@ -224,8 +226,8 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
224
226
|
let storageBackupPlan;
|
|
225
227
|
let storageBackupVault;
|
|
226
228
|
const storageBucketCfn = storageBucket.node.defaultChild;
|
|
227
|
-
if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
|
|
228
|
-
// Backups
|
|
229
|
+
if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
|
|
230
|
+
// Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
|
|
229
231
|
storageBucketCfn.addPropertyOverride("NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled", true);
|
|
230
232
|
}
|
|
231
233
|
if (enableStorageBackups) {
|
|
@@ -311,9 +313,15 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
311
313
|
}
|
|
312
314
|
let s3ProjectionPublication;
|
|
313
315
|
if (site.publication !== undefined) {
|
|
314
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication
|
|
316
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
|
|
317
|
+
mediaBucket: storageBucket,
|
|
318
|
+
renditions: renditionsPlan,
|
|
319
|
+
});
|
|
315
320
|
contentActionsLambda.node.defaultChild.memorySize = publicationPlan.contentActionsMemorySize;
|
|
316
321
|
}
|
|
322
|
+
if (s3ProjectionPublication !== undefined) {
|
|
323
|
+
addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
|
|
324
|
+
}
|
|
317
325
|
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
|
318
326
|
for (const [name, value] of Object.entries(publicationWiring.environment)) {
|
|
319
327
|
contentActionsLambda.addEnvironment(name, value);
|
package/amplify/site-backend.ts
CHANGED
|
@@ -10,7 +10,8 @@ import type { Construct } from "constructs";
|
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { DEFAULT_RESOLVER_WAVE_SIZE, orderModelStacksInWaves } from "./resolver-waves";
|
|
13
|
-
import { addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
|
|
13
|
+
import { addProjectionRetries, addS3ProjectionPublication, type S3ProjectionPublication } from "./publication/s3-projection";
|
|
14
|
+
import { planRenditions } from "./publication/renditions-settings";
|
|
14
15
|
import { contentActionsPublicationWiring, planS3ProjectionBackend } from "./publication/s3-projection-settings";
|
|
15
16
|
import { rebuildTriggerSettings } from "./rebuild-trigger-settings";
|
|
16
17
|
import { assertValidPublicationConfig } from "./site-backend-config";
|
|
@@ -85,6 +86,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
85
86
|
};
|
|
86
87
|
if (site.publication !== undefined) assertValidPublicationConfig(site.publication);
|
|
87
88
|
const publicationPlan = planS3ProjectionBackend(site.publication);
|
|
89
|
+
const renditionsPlan = planRenditions(site.publication, process.cwd());
|
|
88
90
|
setSiteFilesForContentActionsBundle(publicationPlan.enabled ? publicationPlan.siteFiles : undefined);
|
|
89
91
|
const backend = defineBackend({
|
|
90
92
|
assignmentAction,
|
|
@@ -285,8 +287,8 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
285
287
|
let storageBackupVault: backup.BackupVault | undefined;
|
|
286
288
|
|
|
287
289
|
const storageBucketCfn = storageBucket.node.defaultChild as s3.CfnBucket | undefined;
|
|
288
|
-
if ((enableStorageBackups || enableInboundEmail) && storageBucketCfn) {
|
|
289
|
-
// Backups
|
|
290
|
+
if ((enableStorageBackups || enableInboundEmail || renditionsPlan.enabled) && storageBucketCfn) {
|
|
291
|
+
// Backups, inbound-email intake and upload-time renditions all rely on S3 → EventBridge notifications.
|
|
290
292
|
storageBucketCfn.addPropertyOverride(
|
|
291
293
|
"NotificationConfiguration.EventBridgeConfiguration.EventBridgeEnabled",
|
|
292
294
|
true,
|
|
@@ -396,9 +398,15 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
396
398
|
}
|
|
397
399
|
let s3ProjectionPublication: S3ProjectionPublication | undefined;
|
|
398
400
|
if (site.publication !== undefined) {
|
|
399
|
-
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication
|
|
401
|
+
s3ProjectionPublication = addS3ProjectionPublication(backend.createStack("s3-projection"), site.publication, {
|
|
402
|
+
mediaBucket: storageBucket,
|
|
403
|
+
renditions: renditionsPlan,
|
|
404
|
+
});
|
|
400
405
|
(contentActionsLambda.node.defaultChild as CfnFunction).memorySize = publicationPlan.contentActionsMemorySize;
|
|
401
406
|
}
|
|
407
|
+
if (s3ProjectionPublication !== undefined) {
|
|
408
|
+
addProjectionRetries(contentActionsLambda, s3ProjectionPublication.deadLetterQueue);
|
|
409
|
+
}
|
|
402
410
|
const publicationWiring = contentActionsPublicationWiring(s3ProjectionPublication);
|
|
403
411
|
for (const [name, value] of Object.entries(publicationWiring.environment)) {
|
|
404
412
|
contentActionsLambda.addEnvironment(name, value);
|
package/package.json
CHANGED
package/routes.manifest.json
CHANGED