Functions + Object Storage integration
This integration uses object storage as the intake point and a Function as the processor. A trigger lists the bucket on an interval and POSTs each new object to the function, so you can validate, transform, or index a file without hand-wiring a worker loop.
Two measured facts decide the shape of everything below, so they come first:
- The object's content is the request body. Your handler is handed the bytes, not a JSON description of them. It needs no S3 client and never talks to the bucket.
- The object's key is not delivered. Nothing in the request says which file this was — see The object's key is not delivered before you write a filename check.
Use it when
- files arrive in a bucket,
- each object can be processed independently, and is smaller than 8 MiB,
- up to a minute of latency is fine — the trigger polls, it does not listen.
The pieces
- Bucket — the source of new files, in any store that speaks S3. The platform does not host it, so the trigger always needs an endpoint.
- Trigger — lists the bucket every
poll_seconds(default 60), POSTs what it finds, and retires each object according toafter_read. - Function — receives the object's bytes and does the work.
Step 1: Deploy the processor
def handle(event: dict) -> dict:
text = event.get("data") # the object's own bytes, decoded as UTF-8
if text is None: # not UTF-8 text: event["data_base64"] holds it
print("skipped: object is not text")
return {"statusCode": 200}
attrs = event.get("_cloudevent", {}) # id, source, specversion, subject, time, type
title = next((line[2:].strip() for line in text.splitlines()
if line.startswith("# ")), "untitled")
print(f"ingesting {len(text)} characters as {title!r} (ce.id={attrs.get('id')})")
# ... write the result somewhere durable ...
return {"statusCode": 200}
platformctl functions deploy ./doc-ingest --name doc-ingest
Three things about the delivery that change how you write the handler:
- The shape depends on the object. A JSON object arrives parsed as the event itself. Any other text — markdown, CSV, a log line — arrives as
event["data"]. A JSON scalar or array arrives asevent["data"]. Bytes that are not valid UTF-8 arrive asevent["data_base64"], base64-encoded. None of those is an error. - The return value is discarded. Every delivery carries a
ce-idheader, so the shim treats it as a CloudEvent: it answers204and throws the result away. The work has to happen insidehandle(), not in what it returns. A handler exception becomes a terminal400and the delivery is not retried. - This is the Python runtime specifically. The Node.js, Go and Ruby shims answer
400 invalid JSON body: ...for a body that is not JSON, and none of them exposes_cloudevent. A markdown or CSV drop therefore never reaches those handlers. Write bucket-triggered functions in Python, or make sure the objects you drop are JSON.
The object's key is not delivered
This is the trap that costs an afternoon. The CloudEvent attributes under event["_cloudevent"] were measured carrying id, source, specversion, subject, time and type — and subject is the literal string aws-s3-source, the name of the source, not the name of your object. It has no prefix and no extension, so a suffix check on it:
if not attrs.get("subject", "").endswith(".md"): # rejects every file in the bucket
return {"statusCode": 200}
rejects everything, and a dedupe keyed on it collapses every delivery into one. Nothing errors: the poller keeps retiring objects, the function keeps returning 200, and the work silently never happens.
Print event["_cloudevent"] on your first delivery and read what your store actually sends. When nothing there looks like a key — an object key contains a / or a . — name the object yourself:
- Derive the name from the content — a markdown
# H1, a CSV header row, anidfield, as the handler above does. Fall back to a hash of the content so two unnamed objects stay distinct. - Or write the identity into the object when you produce it, so the delivery carries it in the body.
Step 2: Connect the trigger
An object-store trigger needs four fields: bucket, endpoint, credentials_secret_name and after_read. None of them has a default.
- platformctl
- curl
- Console
platformctl serverless triggers create doc-drop \
--target doc-ingest \
--type objectstore \
--bucket uploads \
--endpoint https://object.us-southcentral1-a.crusoecloudcompute.com \
--region us-southcentral1-a \
--credentials-secret crusoe-object-store \
--prefix incoming/ \
--after-read move \
--move-to-prefix processed/
curl -sX POST "$CAI_SERVERLESS_API/v1/projects/$CAI_PROJECT/triggers" \
-H "Authorization: Bearer ***" -H 'Content-Type: application/json' \
-d '{"name": "doc-drop",
"target": {"service": "doc-ingest"},
"source": {"type": "objectstore",
"objectstore": {
"bucket": "uploads",
"endpoint": "https://object.us-southcentral1-a.crusoecloudcompute.com",
"region": "us-southcentral1-a",
"credentials_secret_name": "crusoe-object-store",
"prefix": "incoming/",
"after_read": "move",
"move_to": {"prefix": "processed/"}}}}'
The CLI flag is --credentials-secret; the API field it fills is credentials_secret_name.
- Open the function and press Add trigger.
- Pick Object store - a new object in an S3 bucket fires it.
- Choose the bucket. On a project connected to Crusoe Cloud, that fills the endpoint, overwrites the region with the bucket's own, and fills Credentials secret with
crusoe-object-store. - Choose After reading an object. The dialog will not create a trigger without it.
Four things to get right here:
-
credentials_secret_nameis not your Crusoe Cloud API key. Object storage answers that key with403. On a project connected to Crusoe Cloud the platform has already minted a real S3 key and stored it as the Secretcrusoe-object-store(keysaws.accessKeyandaws.secretKey) — use that name. If it is missing, open Project Settings (the page that connects a project to Crusoe Cloud) and press Check again — the platform mints the key only for a project that has none. -
regionmust match the bucket's. It goes straight to the poller's S3 client, so a mismatch fails at connect time, inside the poller, with nothing on the trigger to say why. The default isus-east-1, which is almost certainly wrong for a Crusoe bucket. -
after_readis required and has no default, because a trigger that never retires an object fires on it forever. Leaving it out is refused:source.objectstore.after_read must be "move", "delete" or "none" - there is no default, because a bucket trigger that never retires an object fires on it forevermoveneeds a destination (--move-to-bucket,--move-to-prefix, or both), or it would move the object onto itself and re-deliver it on every poll. -
retrydoes not apply to this source. A trigger'sretry.max_attemptsandretry.backoffreach schedule and Pub/Sub sources only; the bucket poller reads neither, and there is no dead-letter destination anywhere in this path.
Step 3: Test the pipeline
Upload a small test file under the watched prefix and watch the function's logs.
Wait a full minute before deciding nothing happened. This is a poller, not a notification listener: poll_seconds defaults to 60, so a drop can sit for up to a minute before your function runs. One poll consumes at most max_messages_per_poll objects (default 10), so a bucket that gains 10,000 files does not become 10,000 simultaneous invocations either.
The acceptance signal is not "the upload succeeded"; it is "the function logged the content and produced the downstream result", and — with after_read: move or delete — "the object is no longer sitting in the watched prefix".
Common mistakes
- Reading
event["bucket"]orevent["key"]. Neither field exists. The body is the object itself; see Step 1. - Trusting
subjectas a filename. It is the source's own name,aws-s3-source, on every delivery. - Sending objects larger than 8 MiB. The body is the object, so the shim's request cap is a cap on object size: a bigger object is refused with
413 body exceeds 8388608 bytesbeforehandle()runs. Keep the watched prefix to small files, and drop a manifest that names the large object instead. - Making the function non-idempotent. With
after_read: nonethe same object is delivered again on every poll, forever, by design — the handler has to be safe to run twice on the same file. The trigger says so too: its No repeat delivery readiness check stays waiting and names the bucket. - Expecting a retry budget or a dead-letter path. There is neither on this source. If the work can fail transiently, catch it in the handler and write the object somewhere you can retry from.
Next steps
- ObjectStore triggers — the full field reference and the credentials-secret mechanics.
- Functions overview
- Event-driven functions