diff --git a/serverless-fleets/tutorials/clef-on-fleets/3_deploy_function b/serverless-fleets/tutorials/clef-on-fleets/3_deploy_function index 27233958..2b54f1e5 100755 --- a/serverless-fleets/tutorials/clef-on-fleets/3_deploy_function +++ b/serverless-fleets/tutorials/clef-on-fleets/3_deploy_function @@ -14,8 +14,7 @@ print_cmd() { echo -e " $1"; } # ============================== FN_NAME="${FN_NAME:=clef-classify-fn}" FLEET_ID="${1:-${FLEET_ID:-}}" -SECRET_NAME="${SECRET_NAME:=clef-fn-secrets}" -CE_API_KEY="${CE_API_KEY:=}" +TRUSTED_PROFILE_NAME="${TRUSTED_PROFILE_NAME:=clef-fn-access}" COS_PREFIX="${COS_PREFIX:=clef-result}" FILE_PREFIX="${FILE_PREFIX:=result}" PDS_NAME="${PDS_NAME:=fleet-output-store}" @@ -51,23 +50,71 @@ if [[ -z "${FLEET_ID}" ]]; then exit 1 fi -# Ensure secret exists or create it if CE_API_KEY was supplied in env -if [[ -n "${CE_API_KEY}" ]]; then - print_msg "Creating/updating secret '${SECRET_NAME}'..." - if ibmcloud ce secret get --name "${SECRET_NAME}" >/dev/null 2>&1; then - ibmcloud ce secret update --name "${SECRET_NAME}" --from-literal CE_API_KEY="${CE_API_KEY}" +# ============================== +# TRUSTED PROFILE (replaces the former CE_API_KEY secret) +# The function exchanges its Code Engine compute resource token for an IAM +# token of this profile. The profile is linked to the function by name and is +# granted: Writer on the Code Engine project (add fleet tasks) and +# Object Reader on the results bucket (poll results). +# ============================== +# 'project current' does not return the CRN, 'project get' does. +CE_PROJECT_NAME=$(ibmcloud ce project current --output json | jq -r '.name') +CE_PROJECT_JSON=$(ibmcloud ce project get --name "${CE_PROJECT_NAME}" --output json) +CE_PROJECT_CRN=$(echo "${CE_PROJECT_JSON}" | jq -r '.crn // empty') +CE_PROJECT_GUID=$(echo "${CE_PROJECT_JSON}" | jq -r '.guid // empty') +if [[ -z "${CE_PROJECT_CRN}" || -z "${CE_PROJECT_GUID}" ]]; then + print_error "Could not determine CRN/GUID of Code Engine project '${CE_PROJECT_NAME}'." + exit 1 +fi + +# COS instance hosting the results bucket: explicit CRN, explicit name, or the +# only COS instance in the targeted resource group. +if [[ -z "${COS_INSTANCE_ID:-}" ]]; then + if [[ -n "${COS_INSTANCE_NAME:-}" ]]; then + COS_INSTANCE_ID=$(ibmcloud resource service-instance "${COS_INSTANCE_NAME}" --output json 2>/dev/null | jq -r '.[0].id // empty' || echo "") else - ibmcloud ce secret create --name "${SECRET_NAME}" --from-literal CE_API_KEY="${CE_API_KEY}" - fi -else - if ! ibmcloud ce secret get --name "${SECRET_NAME}" >/dev/null 2>&1; then - print_error "Secret '${SECRET_NAME}' does not exist and CE_API_KEY environment variable is not set." - echo "Create the secret with:" - echo " ibmcloud ce secret create --name ${SECRET_NAME} --from-literal CE_API_KEY=" - echo "or set export CE_API_KEY= before running." - exit 1 + COS_INSTANCES=$(ibmcloud resource service-instances --service-name cloud-object-storage --output json 2>/dev/null || echo "[]") + if [[ "$(echo "${COS_INSTANCES}" | jq 'length')" == "1" ]]; then + COS_INSTANCE_ID=$(echo "${COS_INSTANCES}" | jq -r '.[0].id') + fi fi fi +if [[ -z "${COS_INSTANCE_ID:-}" ]]; then + print_error "Could not determine the COS instance of bucket '${COS_BUCKET}'." + echo "Set export COS_INSTANCE_NAME= or COS_INSTANCE_ID=." + exit 1 +fi + +print_msg "Ensuring trusted profile '${TRUSTED_PROFILE_NAME}'..." +if ! ibmcloud iam trusted-profile "${TRUSTED_PROFILE_NAME}" >/dev/null 2>&1; then + ibmcloud iam trusted-profile-create "${TRUSTED_PROFILE_NAME}" +fi + +if ! ibmcloud iam trusted-profile-links "${TRUSTED_PROFILE_NAME}" --output json 2>/dev/null \ + | jq -e --arg n "ce-fn-${FN_NAME}" '.[] | select(.name == $n)' >/dev/null 2>&1; then + ibmcloud iam trusted-profile-link-create "${TRUSTED_PROFILE_NAME}" \ + --name "ce-fn-${FN_NAME}" \ + --cr-type CE \ + --link-crn "${CE_PROJECT_CRN}" \ + --link-component-type function \ + --link-component-name "${FN_NAME}" +fi + +PROFILE_POLICIES=$(ibmcloud iam trusted-profile-policies "${TRUSTED_PROFILE_NAME}" --output json 2>/dev/null || echo "[]") +if ! echo "${PROFILE_POLICIES}" | jq -e --arg s "${CE_PROJECT_GUID}" '.[] | select(.resources[]?.attributes[]? | .value == $s)' >/dev/null 2>&1; then + ibmcloud iam trusted-profile-policy-create "${TRUSTED_PROFILE_NAME}" \ + --roles "Writer" \ + --service-name codeengine \ + --service-instance "${CE_PROJECT_GUID}" +fi +if ! echo "${PROFILE_POLICIES}" | jq -e --arg b "${COS_BUCKET}" '.[] | select(.resources[]?.attributes[]? | .value == $b)' >/dev/null 2>&1; then + ibmcloud iam trusted-profile-policy-create "${TRUSTED_PROFILE_NAME}" \ + --roles "Object Reader" \ + --service-name cloud-object-storage \ + --service-instance "${COS_INSTANCE_ID}" \ + --resource-type bucket \ + --resource "${COS_BUCKET}" +fi print_msg "\nDeploying Code Engine function '${FN_NAME}' from ${FUNCTION_DIR} ..." echo "" @@ -82,7 +129,8 @@ echo " --env COS_PREFIX=${COS_PREFIX}" echo " --env FILE_PREFIX=${FILE_PREFIX}" echo " --env COS_ENDPOINT=${COS_ENDPOINT}" echo " --env POLL_TIMEOUT=${POLL_TIMEOUT}" -echo " --env-from-secret ${SECRET_NAME}" +echo " --env TRUSTED_PROFILE_NAME=${TRUSTED_PROFILE_NAME}" +echo " --trusted-profiles-enabled=true" echo "" if ibmcloud ce fn get --name "${FN_NAME}" >/dev/null 2>&1; then @@ -98,7 +146,8 @@ if ibmcloud ce fn get --name "${FN_NAME}" >/dev/null 2>&1; then --env FILE_PREFIX="${FILE_PREFIX}" \ --env COS_ENDPOINT="${COS_ENDPOINT}" \ --env POLL_TIMEOUT="${POLL_TIMEOUT}" \ - --env-from-secret "${SECRET_NAME}" + --env TRUSTED_PROFILE_NAME="${TRUSTED_PROFILE_NAME}" \ + --trusted-profiles-enabled=true else ibmcloud ce fn create \ --name "${FN_NAME}" \ @@ -111,7 +160,8 @@ else --env FILE_PREFIX="${FILE_PREFIX}" \ --env COS_ENDPOINT="${COS_ENDPOINT}" \ --env POLL_TIMEOUT="${POLL_TIMEOUT}" \ - --env-from-secret "${SECRET_NAME}" + --env TRUSTED_PROFILE_NAME="${TRUSTED_PROFILE_NAME}" \ + --trusted-profiles-enabled=true fi FN_ENDPOINT=$(ibmcloud ce fn get --name "${FN_NAME}" --output json | jq -r '.endpoint // empty' 2>/dev/null || echo "") diff --git a/serverless-fleets/tutorials/clef-on-fleets/README.md b/serverless-fleets/tutorials/clef-on-fleets/README.md index 626c0b56..87a7046d 100644 --- a/serverless-fleets/tutorials/clef-on-fleets/README.md +++ b/serverless-fleets/tutorials/clef-on-fleets/README.md @@ -20,7 +20,7 @@ flowchart TD CallerGet["Caller (Poll/Get) · HTTPS GET\n?task_id=uuid"] CallerCLI["Caller · ibmcloud ce fleet task add\n--tasks-from-local-file\n{idx: task_id, args: [key, val, ...]}"] - Fn["Code Engine Function · function/__main__.py\n──────────────────────────────────\nGenerates / receives task_id\nValidates + assembles task spec\nGets IAM token via CE_API_KEY\nSubmits task to CE Fleet\nPolls COS (up to poll_timeout e.g. 60s)\nReturns 200 (completed) or 202 (pending with task_id)\nAlso handles GET/POST {task_id} to fetch result"] + Fn["Code Engine Function · function/__main__.py\n──────────────────────────────────\nGenerates / receives task_id\nValidates + assembles task spec\nGets IAM token via trusted profile (CR token)\nSubmits task to CE Fleet\nPolls COS (up to poll_timeout e.g. 60s)\nReturns 200 (completed) or 202 (pending with task_id)\nAlso handles GET/POST {task_id} to fetch result"] subgraph Fleet["Code Engine Fleet · gx3-16x80x1l4 (16 vCPU · 80 GiB · 1× L4 GPU)"] Prehook["Worker Prehook (runs once at startup)\n──────────────────────────────────\npodman run ollama (GPU-aware)\nskip if container already exists/stopped\nollama pull clef (skip if present)\nverify in /api/tags via jq\nwarm-up /v1/systemone → VRAM loaded"] @@ -69,7 +69,7 @@ flowchart TD `ibmcloud ce pds create --name fleet-output-store ...` - Subnetpool `fleet-subnetpool` provisioned - Container registry secret `ce-auto-icr-private-eu-de` (or set `FLEET_REGISTRY_SECRET`) -- An IBM Cloud API key with Code Engine Writer and COS access (set as `CE_API_KEY` or stored in `clef-fn-secrets`) +- Permission to create IAM trusted profiles and policies (used by `3_deploy_function`; no API key is needed) - Your IBM Container Registry namespace exported: `export ICR_NAMESPACE=` @@ -108,15 +108,25 @@ export FLEET_ID= ### 3 — Deploy the Code Engine function ```bash -# Set your API key if the secret doesn't exist yet -export CE_API_KEY="" - # Pass FLEET_ID as $1 or set via environment variable: ./3_deploy_function "${FLEET_ID}" ``` *(If `FLEET_ID` is not supplied as `$1` or exported in the environment, `3_deploy_function` attempts to auto-discover the latest clef fleet in the project.)* +The function authenticates with an [IAM trusted profile](../../../trusted-profiles/README.md) +instead of an API key. `3_deploy_function` idempotently: + +1. creates the trusted profile `clef-fn-access` (`TRUSTED_PROFILE_NAME`), +2. links it to the function (`--cr-type CE --link-component-type function`), +3. grants it **Writer** on the Code Engine project (to add fleet tasks) and **Object Reader** on the results bucket, +4. deploys the function with `--trusted-profiles-enabled=true` and `TRUSTED_PROFILE_NAME` as env var. + +At runtime the function reads the compute resource token mounted by Code Engine and exchanges it +at IAM (`grant_type=urn:ibm:params:oauth:grant-type:cr-token`) for a bearer token used for both +the Code Engine and COS calls. The COS instance is auto-discovered if it is the only one in the +targeted resource group; otherwise set `COS_INSTANCE_NAME` or `COS_INSTANCE_ID` (CRN). + --- ### 4 — Test via Code Engine Function @@ -278,8 +288,9 @@ Results will be stored in the COS bucket under `clef-result/result-task-001.json | `FLEET_CPU` | `4` | vCPUs per task container | | `FLEET_MEMORY` | `5G` | Memory per task container | | `FN_NAME` | `clef-classify-fn` | Code Engine Function name | -| `SECRET_NAME` | `clef-fn-secrets` | Secret holding `CE_API_KEY` | -| `CE_API_KEY` | `""` | IBM Cloud API key for IAM & COS | +| `TRUSTED_PROFILE_NAME` | `clef-fn-access` | IAM trusted profile used by the function (no API key) | +| `COS_INSTANCE_NAME` | auto-discovered | COS instance hosting the results bucket (for the profile's policy); auto-discovered if it is the only one in the resource group | +| `COS_INSTANCE_ID` | auto-discovered | Optional CRN override of `COS_INSTANCE_NAME` | | `COS_BUCKET` | `fleet-output-store` | Bucket where task results are stored | | `COS_PREFIX` | `clef-result` | Folder in COS bucket for results | | `FILE_PREFIX` | `result` | Filename prefix before `.json` | diff --git a/serverless-fleets/tutorials/clef-on-fleets/function/__main__.py b/serverless-fleets/tutorials/clef-on-fleets/function/__main__.py index bb0adcec..af6f6131 100755 --- a/serverless-fleets/tutorials/clef-on-fleets/function/__main__.py +++ b/serverless-fleets/tutorials/clef-on-fleets/function/__main__.py @@ -15,12 +15,21 @@ 6. Supports direct result retrieval when called with { "task_id": "" } or via HTTP query parameter / POST body without submit inputs. +Authentication: + The function authenticates with an IAM trusted profile (no API key). Code Engine + mounts a compute resource token into the function when it is created with + --trusted-profiles-enabled; that token is exchanged for an IAM access token. + The trusted profile needs: Code Engine Writer on the project and + Object Reader on the COS results bucket (see 3_deploy_function). + Required environment variables (set on the CE function): - FLEET_ID — UUID or name of the target fleet - CE_API_KEY — IBM Cloud API key used for IAM token exchange (and COS access) - COS_BUCKET — Name of the COS bucket containing results (default: fleet-output-store) + FLEET_ID — UUID or name of the target fleet + TRUSTED_PROFILE_NAME — Name of the IAM trusted profile linked to this function + COS_BUCKET — Name of the COS bucket containing results (default: fleet-output-store) Optional environment variables: + CR_TOKEN_FILENAME — Path of the compute resource token (default: + /var/run/secrets/codeengine.cloud.ibm.com/compute-resource-token/token) CE_API_BASE_URL — CE API base URL (injected automatically by CE runtime, e.g. https://api.private.eu-de.codeengine.cloud.ibm.com) CE_PROJECT_ID — CE project UUID (injected automatically by CE runtime) @@ -51,7 +60,11 @@ ) CE_PROJECT_ID = os.environ.get("CE_PROJECT_ID", "") FLEET_ID = os.environ.get("FLEET_ID", "") -CE_API_KEY = os.environ.get("CE_API_KEY", "") +TRUSTED_PROFILE_NAME = os.environ.get("TRUSTED_PROFILE_NAME", "") +CR_TOKEN_FILENAME = os.environ.get( + "CR_TOKEN_FILENAME", + "/var/run/secrets/codeengine.cloud.ibm.com/compute-resource-token/token", +) # Self-URL — used to give callers a ready-made retrieve_url in async responses # Format: https://{CE_FUNCTION}.{CE_SUBDOMAIN}.{CE_DOMAIN} @@ -96,11 +109,22 @@ def _decode_b64(encoded: str) -> str: return base64.urlsafe_b64decode(encoded).decode("utf-8") -def _get_iam_token(api_key: str) -> str: - """Exchange an IBM Cloud API key for an IAM bearer token.""" +def _get_iam_token() -> str: + """Exchange the Code Engine compute resource token for an IAM bearer token + of the configured trusted profile.""" + try: + with open(CR_TOKEN_FILENAME, encoding="utf-8") as fh: + cr_token = fh.read().strip() + except OSError as exc: + raise RuntimeError( + f"compute resource token not readable at {CR_TOKEN_FILENAME} " + f"(is the function deployed with --trusted-profiles-enabled?): {exc}" + ) from exc + body = urllib.parse.urlencode({ - "grant_type": "urn:ibm:params:oauth:grant-type:apikey", - "apikey": api_key, + "grant_type": "urn:ibm:params:oauth:grant-type:cr-token", + "cr_token": cr_token, + "profile_name": TRUSTED_PROFILE_NAME, }) conn = http.client.HTTPSConnection(IAM_TOKEN_URL, timeout=15) conn.request( @@ -302,14 +326,14 @@ def main(params: dict): )) if is_lookup_only: - if not CE_API_KEY: + if not TRUSTED_PROFILE_NAME: return { "headers": {"Content-Type": "application/json"}, "statusCode": 500, - "body": {"error": "Missing required CE_API_KEY configuration"}, + "body": {"error": "Missing required TRUSTED_PROFILE_NAME configuration"}, } try: - iam_token = _get_iam_token(CE_API_KEY) + iam_token = _get_iam_token() except Exception as exc: return { "headers": {"Content-Type": "application/json"}, @@ -416,7 +440,7 @@ def main(params: dict): k for k, v in { "FLEET_ID": fleet_id, "CE_PROJECT_ID": project_id, - "CE_API_KEY": CE_API_KEY, + "TRUSTED_PROFILE_NAME": TRUSTED_PROFILE_NAME, }.items() if not v ] if missing_config: @@ -428,7 +452,7 @@ def main(params: dict): # 4. Obtain IAM token try: - iam_token = _get_iam_token(CE_API_KEY) + iam_token = _get_iam_token() except Exception as exc: return { "headers": {"Content-Type": "application/json"}, @@ -501,17 +525,18 @@ def main(params: dict): } # 8. Timeout reached or async requested — return 202 with task_id - if FUNCTION_URL: - body_202["retrieve_url"] = f"{FUNCTION_URL}?task_id={task_id}" - return { - "headers": {"Content-Type": "application/json"}, - "statusCode": 202, - "body": { + body_202 = { "message": "Classification task submitted and in progress", "task_id": task_id, "status": "pending", "fleet_id": fleet_id, "question_name": question_name, "poll_timeout_seconds": poll_timeout_sec, - }, + } + if FUNCTION_URL: + body_202["retrieve_url"] = f"{FUNCTION_URL}?task_id={task_id}" + return { + "headers": {"Content-Type": "application/json"}, + "statusCode": 202, + "body": body_202, }