Secrets and environment variables

Read configuration and credentials on a worker, and see what the platform will and will not give you back.

Introduction

Configuration reaches your worker as environment variables, whether you set it with --env-var or attached a secret. This app reports which of the names it expects arrived, and never what they hold.

There are no weights, so the configuration is the whole subject.

The app

model.py
"""What a worker can see of its own configuration, and what it must never answer with."""

from __future__ import annotations

import os

from pydantic import BaseModel, Field

from runware_serverless import serve

EXPECTED = ("LOG_LEVEL", "CACHE_DIR", "API_TOKEN")
"""The names this app reads. Two are plain configuration and one is a secret, and
nothing below can tell which is which."""


class Binding(BaseModel):
    name: str
    present: bool = Field(description="Whether the name reached the worker. Never the value.")


class Report(BaseModel):
    bindings: list[Binding]
    missing: list[str] = Field(description="The expected names that did not arrive.")


@serve
class ConfigAndSecrets:
    def load(self) -> None:
        # Read once, not per request. Changing a variable or an attachment records a
        # new version and rolls the app, so a running worker never holds a stale
        # value: it is replaced by one that read the new set here.
        self.present = {name: name in os.environ for name in EXPECTED}

    def predict(self) -> Report:
        return Report(
            bindings=[Binding(name=name, present=ok) for name, ok in self.present.items()],
            missing=[name for name, ok in self.present.items() if not ok],
        )

How both arrive

LOG_LEVEL comes from --env-var and API_TOKEN from a secret, and model.py reads both with the same line. Your code cannot tell them apart, so they share one namespace. Name a plain variable the same as an attached secret's injected name and the platform rejects it, and the reverse too.

What differs is what can be read back:

apps env-vars list     CACHE_DIR = /weights             the value is printed
attachments            DOCS_EXAMPLE_TOKEN -> API_TOKEN  only the names

You can read a plain variable's value back from the API and you can never read a secret's, and an attachment is recorded as an audit event on your app.

Keeping the value in the worker

A handler that returned a secret would publish a credential to every caller and into the task record, where it outlives the request. The response here carries presence and nothing else, and a test asserts the value cannot appear in it.

That means your app cannot tell you whether a secret holds the right value. You check that on the attachment.

Reading it once

Changing a variable or an attachment records a new version and rolls the app, so the worker holding the old value is replaced by one that read the new set in load.

Re-reading os.environ on every request guards against something that cannot happen.

Renaming on attachment

The attachment decides which stored secret fills a variable, so the same code runs against a production and a staging credential without an edit. This app always reads API_TOKEN.

Deploying it

runware serverless deploy model.py \
  --id config-and-secrets \
  --gpu-type rtx-pro-6000 \
  --env-var LOG_LEVEL=info \
  --env-var CACHE_DIR=/weights

Then the secret, which is two steps because one secret usually serves several apps:

printf '%s' "$TOKEN" | runware serverless secrets set DOCS_EXAMPLE_TOKEN --value-file -
runware serverless secrets attach config-and-secrets DOCS_EXAMPLE_TOKEN --env-var-name API_TOKEN

--value-file - reads from stdin, so the value never reaches the process list. A value passed as --env-var does, which is one more reason anything sensitive belongs in a secret.

Calling it

import { createClient } from '@runware/sdk'

const client = await createClient({ apiKey: process.env.RUNWARE_API_KEY })

const task = await client.invoke({
  appId: 'config-and-secrets',
  endpointPath: 'predict',
  payload: {}
})
import asyncio
import os

from runware import Runware


async def main():
    client = Runware(api_key=os.environ["RUNWARE_API_KEY"])
    try:
        task = await client.invoke(
            "config-and-secrets",
            "predict",
            {},
        )
    finally:
        await client.close()


asyncio.run(main())
curl https://api.serverless.runware.ai/v1/apps/config-and-secrets/invoke-async/predict \
  -H "Authorization: Bearer $RUNWARE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "payload": {}
  }'
echo '{}' | runware serverless apps invoke config-and-secrets predict -f -
{
  "taskId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "payload": {}
}

Before the attach, API_TOKEN comes back under missing. After it, present, while the stored secret is still named DOCS_EXAMPLE_TOKEN.

The first call answered in 56 seconds, under the sync deadline, so there is no 202 to poll here. An app that declares no dependencies has a small image to pull, and the difference against one carrying torch is minutes.