Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.19.0] - 2026-07-02

### Added

- Benchling App Configuration Items can now drive runtime package routing with a three-tier fallback chain: Benchling App Config, Secrets Manager, then environment/defaults
- Per-event and per-project routing supports field-level overrides for package bucket, prefix, metadata key, and workflow, with routing metadata preserved through the packaging SQS queue
- New `config:inspect`, `config:seed`, and `config:clear` npm commands help inspect effective routing, seed App Configuration Items, and list items that require manual cleanup
- Profile config now supports `packages.extraBuckets` so standalone stacks can grant the ECS task role access to additional routed package buckets

### Changed

- Runtime log level and package consumer concurrency can be supplied by Benchling App Configuration Items, falling back to existing values when Tier 1 config is unavailable
- Integrated-stack deploys warn when `packages.extraBuckets` is set, because IAM is owned by the Quilt stack in that mode

## [0.18.0] - 2026-06-15

### Added
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,32 @@ npx @quiltdata/benchling-webhook@latest logs --profile sales
# Destroy stack
npx @quiltdata/benchling-webhook@latest destroy --profile sales
```

## Benchling App Configuration Routing

Runtime routing uses a three-tier fallback chain:

```text
Benchling App Configuration Items -> AWS Secrets Manager -> environment/defaults
```

App Configuration Items are read under the `["quilt"]` path namespace:

- `["quilt", "default", "bucket" | "prefix" | "package_key" | "workflow"]`
- `["quilt", "routing", "<event-type>", "<key>"]`
- `["quilt", "projects", "<project-name>", "<key>"]`
- `["quilt", "settings", "log_level" | "package_event_concurrency" | "packaging_request_concurrency"]`

Project routing is merged field-by-field over event defaults, global defaults, and legacy secret values. For example, a project rule that sets only `bucket` inherits `prefix`, `package_key`, and `workflow` from lower-priority tiers.

CLI helpers:

```bash
npm run config:inspect -- --profile sales
npm run config:seed -- --profile sales
npm run config:clear -- --profile sales
```

`config:seed` requires matching App Configuration schema paths to be defined in the Benchling app manifest and deployed to Benchling first. With the current Benchling SDK, `config:clear` cannot delete/archive items automatically; it lists `["quilt"]` items and points you to manual cleanup in Benchling.

For standalone stacks, add any routed destination buckets to `packages.extraBuckets` so the ECS task role gets S3 permissions. In `integratedStack: true` mode, the Quilt stack owns IAM, so Tier 1 routes can only use buckets covered by the Quilt stack's `BucketWritePolicy`.
41 changes: 41 additions & 0 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { installCommand } from "./commands/install";
import { statusCommand } from "./commands/status";
import { logsCommand } from "./commands/logs";
import { cleanCommand } from "./commands/clean";
import { appConfigCommand } from "./commands/app-config";
import pkg from "../package.json";

const DEFAULT_LOG_LIMIT = 5; // Number of log entries to show per log group (health checks dominate, so keep this small)
Expand Down Expand Up @@ -96,6 +97,46 @@ For more information: https://github.com/quiltdata/benchling-webhook#deployment
}
});

program
.command("config:inspect")
.description("Inspect Benchling App Configuration Items and effective routing config")
.option("--profile <name>", "Configuration profile to use (default: default)")
.option("--event-type <type>", "Event type to resolve for effective routing")
.action(async (options) => {
try {
await appConfigCommand("inspect", options);
} catch (error) {
console.error(chalk.red((error as Error).message));
process.exit(1);
}
});

program
.command("config:seed")
.description("Seed Benchling App Configuration Items from profile and Secrets Manager")
.option("--profile <name>", "Configuration profile to use (default: default)")
.action(async (options) => {
try {
await appConfigCommand("seed", options);
} catch (error) {
console.error(chalk.red((error as Error).message));
process.exit(1);
}
});

program
.command("config:clear")
.description("List App Configuration Items that require manual cleanup")
.option("--profile <name>", "Configuration profile to use (default: default)")
.action(async (options) => {
try {
await appConfigCommand("clear", options);
} catch (error) {
console.error(chalk.red((error as Error).message));
process.exit(1);
}
});

// Destroy command
program
.command("destroy")
Expand Down
35 changes: 35 additions & 0 deletions bin/commands/app-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { spawn } from "child_process";
import path from "path";

interface AppConfigOptions {
profile?: string;
eventType?: string;
}

export async function appConfigCommand(
command: "inspect" | "seed" | "clear",
options: AppConfigOptions,
): Promise<void> {
const dockerDir = path.resolve(__dirname, "../../docker");
const args = ["run", "python", "-m", "scripts.app_config", command, "--profile", options.profile || "default"];

if (options.eventType) {
args.push("--event-type", options.eventType);
}

await new Promise<void>((resolve, reject) => {
const child = spawn("uv", args, {
cwd: dockerDir,
stdio: "inherit",
});

child.on("error", reject);
child.on("close", code => {
if (code === 0 || (command === "clear" && code === 2)) {
resolve();
return;
}
reject(new Error(`config:${command} failed with exit code ${code}`));
});
});
}
8 changes: 8 additions & 0 deletions bin/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,14 @@ export async function deploy(
): Promise<void> {
// Check if this is an integrated stack - warn and ask user
if (config.integratedStack === true) {
if (config.packages.extraBuckets?.length) {
console.warn(
chalk.yellow(
`⚠️ packages.extraBuckets is ignored in integrated mode (${config.packages.extraBuckets.join(", ")}).\n` +
" The Quilt stack owns IAM; make sure its BucketWritePolicy covers every Tier 1 routing bucket.",
),
);
}
console.log();
console.log(boxen(
chalk.yellow.bold("⚠️ Integrated Stack Mode") + "\n\n" +
Expand Down
2 changes: 1 addition & 1 deletion docker/app-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ manifestVersion: 1
info:
name: nightly-quilttest-com
description: Packaging Benchling Notebooks as Quilt packages
version: 0.18.0
version: 0.19.0
features:
- name: Quilt Connector
id: quilt_entry
Expand Down
2 changes: 1 addition & 1 deletion docker/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "benchling-quilt-integration"
version = "0.18.0"
version = "0.19.0"
description = "Benchling-Quilt Integration Webhook Service"
license = {text = "Apache-2.0"}
authors = [
Expand Down
204 changes: 204 additions & 0 deletions docker/scripts/app_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any

_SCRIPTS_DIR = Path(__file__).resolve().parent
_PROJECT_ROOT = _SCRIPTS_DIR.parent
sys.path.insert(0, str(_PROJECT_ROOT / "src"))

import boto3
from benchling_sdk.auth.client_credentials_oauth2 import ClientCredentialsOAuth2
from benchling_sdk.benchling import Benchling
from botocore.config import Config as BotocoreConfig

from src.routing_config import (
ROUTING_NAMESPACE,
RoutingConfig,
find_app_id,
legacy_target_from_secret,
)
from src.secrets_manager import BenchlingSecretData, fetch_benchling_secret
from src.xdg_config import XDGConfig


def load_profile(profile: str) -> dict[str, Any]:
return XDGConfig(profile=profile).load_complete_config()


def fetch_secret(region: str, secret_name_or_arn: str) -> BenchlingSecretData:
session = boto3.Session(region_name=region)
client = session.client(
"secretsmanager",
config=BotocoreConfig(retries={"max_attempts": 3, "mode": "standard"}, connect_timeout=5, read_timeout=10),
)
return fetch_benchling_secret(client, region, secret_name_or_arn)


def create_benchling_client(secrets: BenchlingSecretData) -> Benchling:
auth_method = ClientCredentialsOAuth2(client_id=secrets.client_id, client_secret=secrets.client_secret)
return Benchling(url=f"https://{secrets.tenant}.benchling.com", auth_method=auth_method)


def flatten_pages(iterator: Any) -> list[Any]:
return [item for page in iterator for item in (page if isinstance(page, list) else [page])]


def item_path(item: Any) -> list[str]:
path = getattr(item, "path", None)
if path is None and hasattr(item, "additional_properties"):
path = item.additional_properties.get("path")
return [str(part) for part in path] if isinstance(path, list) else []


def item_value(item: Any) -> Any:
if hasattr(item, "value"):
return item.value
if hasattr(item, "additional_properties"):
return item.additional_properties.get("value")
return None


def item_to_dict(item: Any) -> dict[str, Any]:
return {
"id": getattr(item, "id", None),
"path": item_path(item),
"type": str(getattr(item, "type", "")),
"value": item_value(item),
}


def load_context(profile: str) -> tuple[dict[str, Any], BenchlingSecretData, Benchling, str | None, list[Any]]:
config = load_profile(profile)
region = config.get("deployment", {}).get("region") or config.get("quilt", {}).get("region")
secret_name = config.get("benchling", {}).get("secretArn")
if not region:
raise RuntimeError("Profile is missing deployment.region/quilt.region")
if not secret_name:
raise RuntimeError("Profile is missing benchling.secretArn")
secrets = fetch_secret(region, secret_name)
benchling = create_benchling_client(secrets)
app_id = find_app_id(benchling, secrets.app_definition_id)
items = flatten_pages(benchling.apps.list_app_configuration_items(app_id=app_id)) if app_id else []
return config, secrets, benchling, app_id, items


def seed_items(
config: dict[str, Any],
secrets: BenchlingSecretData,
benchling: Benchling,
app_id: str,
existing_items: list[Any],
) -> dict[str, list[str]]:
from benchling_api_client.v2.stable.models.app_config_item_generic_create import AppConfigItemGenericCreate
from benchling_api_client.v2.stable.models.app_config_item_generic_create_type import (
AppConfigItemGenericCreateType,
)
from benchling_api_client.v2.stable.models.app_config_item_generic_update import AppConfigItemGenericUpdate
from benchling_api_client.v2.stable.models.app_config_item_generic_update_type import (
AppConfigItemGenericUpdateType,
)

packages = config.get("packages", {})
logging_config = config.get("logging", {})
seed_defs = [
(["quilt", "default", "bucket"], packages.get("bucket") or secrets.user_bucket),
(["quilt", "default", "prefix"], packages.get("prefix") or secrets.pkg_prefix or "benchling"),
(["quilt", "default", "package_key"], packages.get("metadataKey") or secrets.pkg_key or "experiment_id"),
(["quilt", "default", "workflow"], packages.get("workflow") or secrets.workflow or ""),
(["quilt", "settings", "log_level"], logging_config.get("level") or secrets.log_level or "INFO"),
(["quilt", "settings", "package_event_concurrency"], "5"),
(["quilt", "settings", "packaging_request_concurrency"], "5"),
]

existing_by_path = {tuple(item_path(item)): getattr(item, "id", None) for item in existing_items}
created: list[str] = []
updated: list[str] = []
for path, value in seed_defs:
existing_id = existing_by_path.get(tuple(path))
if existing_id:
update = AppConfigItemGenericUpdate(AppConfigItemGenericUpdateType("text"), str(value))
benchling.apps.update_app_configuration_item(str(existing_id), update)
updated.append(str(existing_id))
else:
create = AppConfigItemGenericCreate(AppConfigItemGenericCreateType("text"), app_id, path, str(value))
result = benchling.apps.create_app_configuration_item(create)
created.append(str(getattr(result, "id", "")))
return {"created_ids": created, "updated_ids": updated}


def command_inspect(args: argparse.Namespace) -> int:
_config, secrets, _benchling, app_id, items = load_context(args.profile)
quilt_items = [item for item in items if item_path(item)[:1] == [ROUTING_NAMESPACE]]
routing_config = RoutingConfig.from_app_configuration_items(quilt_items)
legacy = legacy_target_from_secret(secrets)
effective = routing_config.resolve(event_type=args.event_type or "", legacy=legacy)
output = {
"profile": args.profile,
"app_id": app_id,
"items": [item_to_dict(item) for item in quilt_items],
"effective_default": effective.target.to_dict(),
"routing": {
"event_routes": {key: value.to_dict() for key, value in routing_config.event_routes.items()},
"project_routes": {key: value.to_dict() for key, value in routing_config.project_routes.items()},
"default": routing_config.default.to_dict(),
"settings": routing_config.settings.__dict__,
},
}
print(json.dumps(output, indent=2, default=str))
return 0


def command_seed(args: argparse.Namespace) -> int:
config, secrets, benchling, app_id, items = load_context(args.profile)
if not app_id:
raise RuntimeError("Could not resolve Benchling app id")
try:
result = seed_items(config, secrets, benchling, app_id, items)
except Exception as exc:
raise RuntimeError(
"Failed to seed App Configuration Items. Confirm docker/app-manifest.yaml "
"defines the exact config schema paths before running config:seed."
) from exc
print(json.dumps({"profile": args.profile, "app_id": app_id, **result}, indent=2))
return 0


def command_clear(args: argparse.Namespace) -> int:
_config, _secrets, _benchling, app_id, items = load_context(args.profile)
quilt_items = [item for item in items if item_path(item)[:1] == [ROUTING_NAMESPACE]]
print(
json.dumps(
{"profile": args.profile, "app_id": app_id, "items": [item_to_dict(item) for item in quilt_items]},
indent=2,
default=str,
)
)
print(
"Automatic clear is unavailable: Benchling SDK 1.23.1 exposes no usable delete/archive endpoint "
"for App Configuration Items. Remove these items in the Benchling UI.",
file=sys.stderr,
)
return 2


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["inspect", "seed", "clear"])
parser.add_argument("--profile", default="default")
parser.add_argument("--event-type")
args = parser.parse_args(argv)

if args.command == "inspect":
return command_inspect(args)
if args.command == "seed":
return command_seed(args)
return command_clear(args)


if __name__ == "__main__":
raise SystemExit(main())
Loading