Lambda Web Migration¶
Status¶
This is the accepted implementation plan for PR #1113 and issues #450, #699, and #1110. The target distribution is Lambda-only: the SAM deployment path must not create, configure, or depend on a virtual machine.
Plant Tracer still uses Flask as the web application runtime. Local Flask development and testing remain required. SAM local testing is useful as an additional Lambda event-routing check, but it does not replace the normal Flask test path.
Target Architecture¶
The stack has two application Lambda functions:
lambda-webRuns the existing Flask application behind API Gateway HTTP API events. It serves HTML pages, Flask
/api/*routes, and application static files under/static/*.lambda-resizeRemains the vision/video/tracing function. It serves
/resize-api/*routes and consumes SQS trace work. It should not become the general web application Lambda.
The public application should use one HTTPS front door. Separate Lambda functions do not require separate product domain names. HTTP API route selection should be explicit:
/resize-api/*routes tolambda-resize.HTML pages route to
lambda-web.Flask
/api/*routes route tolambda-web./static/*routes tolambda-webfor the initial migration.
When an HTTP API uses a named stage such as prod, execute-api URLs include
the stage segment, for example /prod/api/ver. lambda-web normalizes that
stage prefix before handing the request to Flask so Flask routes remain
/api/ver, /static/..., and / regardless of whether the request came
through execute-api or the custom domain.
lambda-web uses AWS Lambda Powertools for Python to parse HTTP API v2
events and emit request/route diagnostics. It still uses apig-wsgi for the
actual Flask WSGI handoff because the Powertools API Gateway resolver is a
native Lambda router, not a Flask WSGI adapter.
Resize-owned browser endpoints, including movie-data, live under
/resize-api/v1/*. The Lambda-only target does not preserve
/api/v1/movie-data as a public compatibility path.
Removed VM Surface¶
The Lambda-only SAM path must remove these VM-era deployment concerns:
EC2 instance, EIP, security group, VPC/subnet/route-table, internet gateway, instance profile, and VM DNS resources.
SSH, reload, and instance-log workflows from the Lambda deployment path.
VM bootstrap scripts as a required deployment step.
Branch selection parameters such as
GitBranch.Boot-time
git cloneof the application.
SAM builds and deploys the current checkout’s built artifacts. The branch in use is the branch being built, not a CloudFormation parameter.
Static Assets¶
Static files remain served by Flask through lambda-web for the first
Lambda-only migration. This keeps the current local Flask behavior and avoids
introducing a cache/versioning problem during the runtime migration.
Do not move application JavaScript, CSS, images, or templates to S3 or CloudFront until there is an explicit asset-versioning plan. That later plan should define hashed or otherwise versioned filenames, cache policy, and rollback behavior.
S3 remains the long-lived movie and frame archive. It is an existing bucket and must outlive the CloudFormation stack.
Static HTML Shell Decision¶
The initial Lambda-only migration keeps Jinja-rendered HTML pages in
lambda-web. Moving pages to static HTML shells should be a later,
incremental project after cookie/API cleanup and versioned static assets are
designed.
This decision is conservative because the current templates are not just static
markup. Routes call render_template(..., **page_dict(...)) and
page_dict() performs authentication lookup, course/admin lookup, demo-mode
selection, runtime URL selection, and JavaScript bootstrap injection. The base
template uses that state for:
browser globals:
API_BASE,LAMBDA_API_BASE,api_key,user_id,demo_mode,user_primary_course_id,primary_course_name,MAX_FILE_UPLOAD,MOVIE_STATE, andadmin;authenticated navigation and logged-in user/course display;
admin-only and demo-mode menu behavior;
version and git build display.
Current template classification:
Template group |
Examples |
Static-shell status |
|---|---|---|
Public informational pages |
|
Good later candidates, but today they still inherit dynamic navigation
and version display from |
Public account pages |
|
Possible later candidates after login links are consumed server-side, auth cookies are canonical, and the browser has a session endpoint. |
Authenticated app pages |
|
Not initial candidates. They rely on injected user, course, admin, demo, max-upload, and API-key state. |
Demo/tracer pages and shared includes |
|
Keep as Jinja/includes for now. A later migration should treat shared includes as normal static assets only after an asset versioning plan. |
Operational and email templates |
|
Keep server-rendered. These are diagnostics or MIME templates, not browser static-shell targets. |
A future static-shell migration must define replacement browser contracts before changing templates:
a public runtime config endpoint or static config artifact for
API_BASE,LAMBDA_API_BASE,MOVIE_STATE,MAX_FILE_UPLOAD, app version, and asset version;a same-origin
/api/sessionendpoint for logged-in state, user identity, primary course, admin status, and demo mode;a login-link flow that consumes
?api_key=..., sets the cookie, and redirects to a clean URL before browser JavaScript runs;a CSRF/log-safety decision for cookie-authenticated mutating endpoints;
a versioned static asset manifest before moving HTML, JavaScript, or CSS to long-lived external caching.
Any later implementation should update docs/Development/FlaskAPI.md,
docs/Development/ClientLambdaAPI.md,
docs/Development/EnvironmentVariables.rst, and local-development docs.
Tests should cover the new config/session contracts, login-link cleanup,
authenticated and anonymous navigation states, admin/demo behavior, and the
existing Selenium upload/list/analyze flows.
Data Ownership¶
DynamoDB tables are external to CloudFormation ownership. The stack receives
the table prefix and grants prefix-scoped permissions, but table creation and
schema maintenance remain handled by repository tooling such as
src/dbutil.py and etc/dynamodb_tables.json.
The migration must not make stack deletion delete the long-lived S3 archive or the DynamoDB data model.
Mail¶
The Lambda-only stack uses AWS SES for production mail. lambda-web sends
the existing MIME templates through ses:SendRawEmail when no SMTP
configuration is present. The stack sets SERVER_EMAIL and
SERVER_EMAIL_NAME as stack constants and passes both to lambda-web.
SERVER_EMAIL is the server sender address, not a course administrator
account. That exact address must be a verified SES sender identity in the
deployment region. See Identity Management.
The lambda-web IAM role is scoped to ses:SendRawEmail for that sender
identity only, and includes a ses:FromAddress condition requiring
SERVER_EMAIL. If the production sender changes, update the stack constant
and verify the new address in SES before deploying.
Developers who can deploy a stack but cannot send SES mail as
the configured SERVER_EMAIL should set the SAM MailerDryRun parameter
to true. The stack still deploys and registration/resend flows exercise
the mailer path, but the rendered email is written to Lambda logs instead of
being sent. Use this only for non-production stacks with test users and test
data: dry-run mail includes login links and API keys in CloudWatch logs. A
production stack must leave MailerDryRun at false and must have the SES
sender identity verified.
Do not put SMTP credentials in samconfig.toml or committed environment
files. Local development continues to use Mailpit through SMTPCONFIG_JSON.
The application mailer still supports PLANTTRACER_CREDENTIALS,
SMTPCONFIG_JSON, and SMTPCONFIG_ARN for explicit SMTP configurations,
but the Lambda-only stack does not set SMTPCONFIG_ARN and does not grant
Secrets Manager access by default. If SMTP-backed Lambda mail is needed later,
add a dedicated SAM parameter for the secret ARN and scope
secretsmanager:GetSecretValue to that one secret.
Cold Starts¶
lambda-web enables Lambda SnapStart on the published live alias. This
reduces cold-start initialization cost for the Flask web runtime, but it does
not keep an execution environment continuously warm. Any code that relies on
unique values, credentials, timestamps, temporary data, or network connections
from module initialization must tolerate Lambda restore behavior.
lambda-resize does not use SnapStart in the initial migration. The resize
function has different runtime characteristics and should be measured before
adding SnapStart or provisioned concurrency.
Deploy Version Guard¶
make sam-deploy and make sam-deploy-guided read the application version
from pyproject.toml. Runtime code exposes the same value through
app.constants.__version__ for existing version-display and API callers. For
an existing stack, the deploy guard resolves the deployed application URL from
CloudFormation and fetches /api/ver. If the deployed application reports the
same version as the local checkout, deploy is refused unless the operator
explicitly sets SAM_DEPLOY_ALLOW_SAME_VERSION=1 for an intentional
same-version redeploy.
The version bump is required because lambda-web uses SnapStart on published
Lambda versions. A normal deployment should publish a deliberately new
application version so the SnapStart snapshot and the user-visible version move
together. If deployment is blocked by the same-version guard, update
pyproject.toml before deploying again.
The deployed-version check is recovery tolerant. If the stack does not exist,
CloudFormation outputs are missing, DNS is not usable, or /api/ver cannot
return valid JSON, the guard prints a warning and allows the deploy. This keeps
first deploys and repair deploys from being blocked by a broken stack.
Local Testing¶
Local Flask testing remains the primary development loop:
make run-local-debugstarts the Flask app locally.make run-local-demo-debugstarts the Flask app in local demo mode.make pytestruns the Python test suite against local DynamoDB and MinIO.
Lambda-specific local testing should be additive:
lambda-webhandler tests should exercise API Gateway HTTP API events without bypassing Flask route behavior.SAM local targets may provide higher-fidelity routing smoke tests for the built template.
SAM local tests should not become a prerequisite for ordinary Flask route development unless the behavior depends on Lambda/API Gateway event shape.
Packaging Boundaries¶
lambda-web and lambda-resize need separate package boundaries.
lambda-web should include Flask, templates, static assets, and the
application modules needed by HTML and Flask API routes. It should not include
OpenCV/PyAV/video-processing dependencies unless a later measured need appears.
lambda-resize should continue to include only the video/tracing runtime and
the small app modules it already vendors for DynamoDB/S3/movie metadata work.
The Makefile is the source of truth for packaging, vendoring, tests, SAM validation, deployment, and smoke checks.
Implementation Steps¶
Add
lambda-webwith a small WSGI-to-HTTP-API adapter around the existing Flask app.Add Makefile targets to vendor web runtime files, build
lambda-webrequirements, and test the handler while preservingmake pytest.Split SAM resources into explicit
lambda-webandlambda-resizefunctions on one HTTP API.Remove VM parameters, resources, outputs, and VM-only deployment workflow hooks from the Lambda-only SAM path.
Preserve
lambda-resizeSQS trace processing and/resize-api/*routes.Preserve static file serving through Flask/
lambda-web.Update deployment and smoke targets so they name the web and resize Lambda functions separately.
Run Makefile-based validation for Flask tests, Lambda handler tests, SAM template validation, and documentation.
Validation Requirements¶
Before PR #1113 is ready to merge, validate at least:
existing Flask local tests still pass through
make pytest;lambda-webserves/pingor equivalent health, one static asset, and a representative Flask route through API Gateway event handling, including named-stage paths such as/prod/api/ver;lambda-resizestill serves/resize-api/v1/pingand keeps SQS trace event handling;SAM template validation/linting passes;
a deployed or SAM-local smoke path confirms route separation between
lambda-webandlambda-resize;documentation reflects the Lambda-only deployment shape and the continued Flask-local development workflow.
SAM Config Files¶
SAM stores deployment choices in a TOML config file. That file is not the
application template; template.yaml is still the source-controlled
CloudFormation/SAM definition. The SAM config records the local operator’s
selected deployment target and deploy-time parameters, including values such
as:
stack_name;AWS region;
artifact bucket or
resolve_s3behavior;CloudFormation capabilities;
parameter_overridesforHostedZoneId,BaseDomain,WildcardCertificateArn,ImageBucketName,LogLevel,MailerDryRun, andDynamoDBTablePrefix.
Because those values identify one concrete stack and its data resources, SAM
config files are local deployment state. They must not be committed to the
repository. The Makefile defaults to SAM_CONFIG=samconfig.toml for
compatibility with the SAM CLI, but .gitignore ignores samconfig*.toml,
.samconfig*.toml, and samconfig.toml-*. Deployment targets also refuse
to use a SAM config file that is tracked by Git.
Use one ignored SAM config file per stack. For example:
AWS_REGION=us-east-1 SAM_CONFIG=.samconfig.dev-stack.toml make sam-deploy
AWS_REGION=us-east-1 SAM_CONFIG=.samconfig.alice-test.toml make sam-deploy
AWS_REGION=us-east-1 SAM_CONFIG=.samconfig.prod.toml make sam-deploy
sam deploy --guided can also create the selected file:
AWS_REGION=us-east-1 SAM_CONFIG=.samconfig.alice-test.toml make sam-deploy-guided
SAM supports multiple profiles in a single config file, but this project should avoid that pattern. With multiple stacks in flight, separate files make the target stack visible in the command line and avoid accidentally using a stale default profile from a shared TOML file. Separate files also work better with SAM’s habit of rewriting config during guided deploys.
Cutover Runbook¶
Validate each stack on its own hostname, for example
https://{stack}.planttracer.com/. This migration does not depend on moving
a shared production DNS record to Lambda; new and test deployments come up as
separate named stacks. SAM config files are stack-local deployment state and
are not committed to the repository. Use SAM_CONFIG=<path> to select the
ignored config for the stack being tested. The branch must be pushed before
make sam-build will build artifacts. This prevents deploying local-only
commits that nobody else can inspect or rebuild.
Preflight:
confirm
git statusis clean and the branch has no unpushed commits;confirm
pyproject.tomlhas the intended version;confirm
SAM_CONFIGpoints to an untracked local SAM config for the intended non-production stack and table prefix;confirm the S3 movie bucket and DynamoDB table prefix are the intended test resources;
if the stack operator cannot send SES mail as the configured
SERVER_EMAIL, confirm the SAM config setsMailerDryRun="true"and uses only test users;run
make check;run
make template-lint;run
make sam-build.
Deploy:
run
AWS_REGION=us-east-1 SAM_CONFIG=<path> make sam-deployfor an existing configured stack, orAWS_REGION=us-east-1 SAM_CONFIG=<path> make sam-deploy-guidedfor a new stack;let
make sam-statusverify/ping,/static/planttracer.js, and/resize-api/v1/ping. The deploy targets stamp the builtlambda-resizeartifact beforesam deploy, and resize ping should report the application version and UTC deployment timestamp;inspect recent logs with
make sam-logs-webandmake sam-logs-resizeif any smoke check reports a failure.
Course initialization is a separate post-deploy data step. It replaces VM
etc/bootstrap.sh section 10 and must not run from Lambda cold start or from
CloudFormation resource creation. After the stack is deployed and the intended
DynamoDBTablePrefix is confirmed, create or verify the non-demo course with
make sam-course-create:
AWS_REGION=us-east-1 SAM_CONFIG=<path> \
COURSE_CREATE_FLAGS="--course_id BIO101 --course_name 'Plant Biology 101' --admin_email teacher@example.edu --admin_name 'Teacher Name'" \
make sam-course-create
The target reads the table prefix, application URL, and MailerDryRun value
from the selected stack, then delegates to src/dbutil.py create-course
--send-email. Rerunning it is safe when the course already exists with the
same name: it verifies the course administrator relationship and sends the
course setup/login email again. If the existing course id has a different
name, the command fails so operators do not silently reuse the wrong course.
For dry-run stacks, the email is rendered to logs through MAILER_DRY_RUN;
for production, verify SES sender access before running it.
Manual smoke checks on the non-production stack:
open
https://{stack}.planttracer.com/and verify the home page and static assets load;verify
/pingreturns{"status": "ok"}withstack_parameters;verify
/resize-api/v1/pingreturns{"status": "ok"},app_version,deployed_at, andstack_parameters;register a test user and confirm the registration email path works;
resend a login link and confirm the user can log in;
for
MailerDryRun=truestacks, confirm the login email appears in Lambda web logs and treat the logged link as test-only secret material;upload a small movie and confirm it appears in the list page;
open the analysis page, load the first frame, save at least one marker change, and start tracing;
confirm the trace job reaches SQS/
lambda-resizeand produces expected artifacts or a clear user-visible status;verify audit/admin pages still render for an admin user.
Do not treat a new stack hostname as ready for users until it passes the smoke matrix and the mail/secrets path for that stack is confirmed. Keep the previous known-good stack hostname available until the new stack has been accepted.
Rollback:
if a new stack fails validation, keep users on the previous known-good stack hostname;
if an existing stack was updated in place, redeploy the previous known-good version or rebuild the stack under a new hostname for comparison;
if login, registration, upload, or tracing fails after users begin testing a stack, stop directing testers to that hostname, then inspect CloudWatch logs and stack events;
do not delete the movie S3 bucket or DynamoDB tables during rollback;
preserve the failed stack until logs, CloudFormation events, and Lambda versions have been captured for diagnosis.