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. - Every runtime behaves this way. A body that is not JSON arrives as
event["data"], and the CloudEvent attributes arrive asevent["_cloudevent"], in Python, Node.js, Go and Ruby alike — so a markdown or CSV drop reaches any of them. (Until D0144 only Python accepted a non-JSON body; the other three answered400and the object was gone. Pages that still tell you to write bucket-triggered functions in Python are out of date.)
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