Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- Setup now supports an optional Quilt package creation workflow and persists it through config, secret sync, and runtime package requests

### Fixed

- Benchling tenant input is normalized during setup and validation, so bare slugs, hostnames, and full URLs all resolve to the stored tenant slug
- Setup now warns when `--profile` would create a new benchling-webhook config profile without an explicit `--aws-profile`, reducing AWS credential profile confusion
- `logs` auto-refresh now prepares the next frame before clearing the screen, avoiding visible blank redraws
- README setup guidance now recommends `--aws-profile`/`AWS_PROFILE` for AWS credentials and clarifies that Benchling project access must be granted to the app service account

## [0.12.1] - 2026-03-02

### Added
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,27 @@ All you need to do is use `npx` to run the package:
npx @quiltdata/benchling-webhook@latest
```

If you need to choose AWS credentials explicitly, prefer `--aws-profile`:

```bash
npx @quiltdata/benchling-webhook@latest --aws-profile myaws
```

You can also use `AWS_PROFILE`:

```bash
AWS_PROFILE=myaws npx @quiltdata/benchling-webhook@latest
```

`--profile` is different: it selects a local `benchling-webhook` config profile under `~/.config/benchling-webhook/`, not your AWS credential profile.

The wizard will guide you through:

1. **Catalog discovery** - Detect your Quilt catalog configuration
2. **Stack validation** - Extract settings from your CloudFormation stack
3. **Credential collection** - Enter Benchling app credentials
4. **Deployment mode selection**:
4. **Package settings** - Configure bucket, metadata key, and optional Quilt workflow
5. **Deployment mode selection**:
- **Integrated**: Uses your Quilt stack's built-in webhook, if any
- **Standalone**: Deploys a separate webhook stack for testing

Expand All @@ -135,6 +150,8 @@ Add the webhook URL (displayed after setup) to your [Benchling app settings](htt

**Important**: The endpoint URL format is `https://{api-id}.execute-api.{region}.amazonaws.com/{stage}/webhook` (includes stage prefix like `/prod/webhook` or `/dev/webhook`).

If your integration reads or writes within a specific Benchling project, share that project with the service account behind the Benchling App Client ID. This integration uses the app/service-account identity, not an end-user OAuth session. If project access appears broken, verify the service account can perform a simple read or list API call for the target project.

### 4. Test Integration

In Benchling:
Expand Down
9 changes: 7 additions & 2 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,12 @@ program
.option("--inherit-from <name>", "Base profile to inherit from")
.option("--region <region>", "AWS region")
.option("--aws-profile <name>", "AWS credentials profile")
.action(async (options) => {
.action(async (options, command) => {
try {
await setupWizardCommand(options);
await setupWizardCommand({
...options,
explicitProfile: command.getOptionValueSource("profile") === "cli",
});
process.exit(0);
} catch (error) {
console.error(chalk.red((error as Error).message));
Expand Down Expand Up @@ -486,6 +489,7 @@ if (
const options: {
yes?: boolean;
profile?: string;
explicitProfile?: boolean;
inheritFrom?: string;
awsRegion?: string;
awsProfile?: string;
Expand All @@ -500,6 +504,7 @@ if (
options.setupOnly = true;
} else if (args[i] === "--profile" && i + 1 < args.length) {
options.profile = args[i + 1];
options.explicitProfile = true;
i++;
} else if (args[i] === "--inherit-from" && i + 1 < args.length) {
options.inheritFrom = args[i + 1];
Expand Down
3 changes: 3 additions & 0 deletions bin/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface InstallCommandOptions {
* @default "default"
*/
profile?: string;
explicitProfile?: boolean;

/**
* Base profile to inherit from
Expand Down Expand Up @@ -87,6 +88,7 @@ export interface InstallCommandOptions {
export async function installCommand(options: InstallCommandOptions = {}): Promise<void> {
const {
profile = "default",
explicitProfile = false,
inheritFrom,
awsProfile,
awsRegion,
Expand All @@ -105,6 +107,7 @@ export async function installCommand(options: InstallCommandOptions = {}): Promi
try {
setupResult = await setupWizardCommand({
profile,
explicitProfile,
inheritFrom,
awsProfile,
awsRegion,
Expand Down
80 changes: 58 additions & 22 deletions bin/commands/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export interface LogGroupInfo {
streamPrefix?: string;
}

export interface LogsFrameData {
logGroups: LogGroupInfo[];
totalEntries: number;
hasLogs: boolean;
hasLogGroups: boolean;
rolloutStatus?: string;
}

/**
* Get AWS region and deployment info from profile configuration
*/
Expand Down Expand Up @@ -649,6 +657,50 @@ async function fetchAllLogs(
return result;
}

export async function loadLogsFrameData(
stackName: string,
region: string,
currentSince: string,
limit: number,
type: string,
integratedMode: boolean,
includeHealth: boolean,
filter: string | undefined,
awsProfile: string | undefined,
rolloutStackName: string,
): Promise<LogsFrameData> {
const logGroups = await fetchAllLogs(
stackName,
region,
currentSince,
limit,
type,
integratedMode,
includeHealth,
filter,
awsProfile,
);

const totalEntries = logGroups.reduce((sum, lg) => sum + lg.entries.length, 0);
const hasLogs = totalEntries > 0;
const hasLogGroups = logGroups.length > 0;

let rolloutStatus: string | undefined;
try {
rolloutStatus = await getEcsRolloutStatus(rolloutStackName, region, awsProfile);
} catch {
// Silently ignore errors - rollout status is optional
}

return {
logGroups,
totalEntries,
hasLogs,
hasLogGroups,
rolloutStatus,
};
}

/**
* Logs command implementation
*/
Expand Down Expand Up @@ -748,13 +800,7 @@ export async function logsCommand(options: LogsCommandOptions = {}): Promise<Log

// Watch loop
while (true) {
// Clear screen on subsequent runs
if (!isFirstRun && refreshInterval) {
clearScreen();
}

// Fetch logs from all relevant log groups
const logGroups = await fetchAllLogs(
const frame = await loadLogsFrameData(
stackNameForQuery,
region,
currentSince,
Expand All @@ -764,12 +810,9 @@ export async function logsCommand(options: LogsCommandOptions = {}): Promise<Log
includeHealth,
filter,
awsProfile,
integratedMode && quiltStackName ? quiltStackName : stackName,
);

// Check if any log group has entries
const totalEntries = logGroups.reduce((sum, lg) => sum + lg.entries.length, 0);
const hasLogs = totalEntries > 0;
const hasLogGroups = logGroups.length > 0;
const { logGroups, hasLogs, hasLogGroups, rolloutStatus } = frame;

// If no log groups found at all, show error and exit
if (!hasLogGroups) {
Expand Down Expand Up @@ -819,16 +862,9 @@ export async function logsCommand(options: LogsCommandOptions = {}): Promise<Log
continue;
}

// Fetch ECS rollout status (optional, non-blocking)
let rolloutStatus: string | undefined;
try {
rolloutStatus = await getEcsRolloutStatus(
integratedMode && quiltStackName ? quiltStackName : stackName,
region,
awsProfile,
);
} catch {
// Silently ignore errors - rollout status is optional
// Clear the previous frame only after the next frame is ready
if (!isFirstRun && refreshInterval) {
clearScreen();
}

// Display logs
Expand Down
12 changes: 12 additions & 0 deletions bin/commands/setup-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { runValidation } from "../../lib/wizard/phase4-validation";
import { runUnifiedFlowDecision } from "../../lib/wizard/phase5-unified-flow";
import { buildProfileConfigFromExisting, buildProfileConfigFromParameters } from "../../lib/wizard/profile-config-builder";
import { pollStackStatus, waitForBenchlingSecretArn } from "../../lib/wizard/stack-waiter";
import { maybeWarnAboutProfileConfusion } from "../../lib/wizard/profile-warning";
import { syncSecretsToAWS } from "./sync-secrets";
import { deployCommand } from "./deploy";
import { CFN_PARAMS } from "../../lib/types/config";
Expand All @@ -45,6 +46,8 @@ import { updateStackParameter } from "../../lib/utils/stack-parameter-update";
export interface SetupWizardOptions {
/** Configuration profile name */
profile?: string;
/** Whether --profile was explicitly provided on the CLI */
explicitProfile?: boolean;
/** Inherit from another profile (legacy, unused in phase-based wizard) */
inheritFrom?: string;
/** Non-interactive mode (use defaults/CLI args) */
Expand All @@ -71,6 +74,7 @@ export interface SetupWizardOptions {
userBucket?: string;
pkgPrefix?: string;
pkgKey?: string;
workflow?: string;
logLevel?: string;
webhookAllowList?: string;
}
Expand Down Expand Up @@ -163,6 +167,7 @@ function printStepHeader(stepNumber: number, title: string): void {
export async function runSetupWizard(options: SetupWizardOptions = {}): Promise<SetupWizardResult> {
const {
profile = "default",
explicitProfile = false,
yes = false,
skipValidation = false,
awsProfile,
Expand All @@ -174,6 +179,12 @@ export async function runSetupWizard(options: SetupWizardOptions = {}): Promise<
const xdg = configStorage || new XDGConfig();

printWelcomeBanner();
maybeWarnAboutProfileConfusion({
profile,
explicitProfile,
awsProfile,
configStorage: xdg,
});

// Load existing configuration if it exists
let existingConfig: ProfileConfig | null = null;
Expand Down Expand Up @@ -295,6 +306,7 @@ export async function runSetupWizard(options: SetupWizardOptions = {}): Promise<
userBucket: options.userBucket,
pkgPrefix: options.pkgPrefix,
pkgKey: options.pkgKey,
workflow: options.workflow,
logLevel: options.logLevel,
webhookAllowList: options.webhookAllowList,
});
Expand Down
1 change: 1 addition & 0 deletions bin/commands/sync-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ function buildSecretValue(config: ProfileConfig, clientSecret: string): string {
user_bucket: config.packages.bucket,
pkg_prefix: config.packages.prefix,
pkg_key: config.packages.metadataKey,
...(config.packages.workflow ? { workflow: config.packages.workflow } : {}),
log_level: config.logging?.level || "INFO",
webhook_allow_list: config.security?.webhookAllowList || "",
enable_webhook_verification: config.security?.enableVerification !== false ? "true" : "false",
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: quilt-docker
description: Packaging Benchling Notebooks as Quilt packages
version: 0.12.1
version: 0.13.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.12.1"
version = "0.13.0"
description = "Benchling-Quilt Integration Webhook Service"
license = {text = "Apache-2.0"}
authors = [
Expand Down
2 changes: 2 additions & 0 deletions docker/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Config:
athena_user_workgroup: str = ""
enable_webhook_verification: bool = True
pkg_prefix: str = ""
workflow: str = ""
quilt_write_role_arn: str = ""

# Secret fetching infrastructure (not the secrets themselves)
Expand Down Expand Up @@ -222,6 +223,7 @@ def apply_benchling_secrets(self, secret_data: BenchlingSecretData) -> None:
self.s3_prefix = secret_data.pkg_prefix or "benchling"
self.pkg_prefix = self.s3_prefix
self.package_key = secret_data.pkg_key or "experiment_id"
self.workflow = secret_data.workflow or ""

# Security configuration ALWAYS comes from secret
self.enable_webhook_verification = secret_data.enable_webhook_verification
Expand Down
2 changes: 2 additions & 0 deletions docker/src/entry_packager.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ def _send_to_sqs(self, package_name: str, timestamp: str) -> Dict[str, Any]:
"metadata_uri": "entry.json",
"commit_message": f"Benchling webhook payload - {timestamp}",
}
if getattr(self.config, "workflow", ""):
message_body["workflow"] = self.config.workflow

try:
queue_url = self.config.queue_url
Expand Down
8 changes: 5 additions & 3 deletions docker/src/secrets_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,8 @@ def format(self) -> str:
class BenchlingSecretData:
"""All runtime parameters from Benchling secret.

All fields are REQUIRED. Missing fields cause startup failure.
This dataclass contains all 9 runtime configuration parameters
that must be stored in AWS Secrets Manager.
This dataclass contains the runtime configuration parameters
stored in AWS Secrets Manager.

Attributes:
tenant: Benchling subdomain (e.g., 'quilt-dtt'). Can also be provided as
Expand All @@ -61,6 +60,7 @@ class BenchlingSecretData:
app_definition_id: App definition ID for webhook signature verification
pkg_prefix: Quilt package name prefix
pkg_key: Metadata key for linking Benchling entries to Quilt packages
workflow: Optional Quilt workflow name for package creation
user_bucket: S3 bucket name for Benchling exports
log_level: Application logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
enable_webhook_verification: Enable Lambda authorizer webhook verification (boolean)
Expand All @@ -81,6 +81,7 @@ class BenchlingSecretData:
# Application Behavior
log_level: str
enable_webhook_verification: bool
workflow: str = ""

# Optional: SQS queue URL (v0.8.0+ gets from environment variable instead)
queue_url: str = ""
Expand Down Expand Up @@ -264,6 +265,7 @@ def fetch_benchling_secret(client, region: str, secret_identifier: str) -> Bench
pkg_prefix=data["pkg_prefix"],
pkg_key=data["pkg_key"],
user_bucket=data["user_bucket"],
workflow=data.get("workflow", ""),
log_level=data["log_level"],
enable_webhook_verification=enable_webhook_verification,
queue_url=queue_url,
Expand Down
21 changes: 21 additions & 0 deletions docker/tests/test_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,27 @@ def test_valid_secret_all_parameters(self, mock_sm_client, valid_secret_data):
assert secret.user_bucket == "test-bucket"
assert secret.log_level == "INFO"
assert secret.enable_webhook_verification is True
assert secret.workflow == ""

def test_optional_workflow_is_accepted(self, mock_sm_client, valid_secret_data):
"""Test that optional workflow is parsed when present."""
valid_secret_data["workflow"] = "custom-workflow"

mock_sm_client.get_secret_value.return_value = {"SecretString": json.dumps(valid_secret_data)}

secret = fetch_benchling_secret(mock_sm_client, "us-east-1", "test-secret")

assert secret.workflow == "custom-workflow"

def test_tenant_is_normalized_from_hostname(self, mock_sm_client, valid_secret_data):
"""Test that tenant hostnames are normalized to the bare slug."""
valid_secret_data["tenant"] = "test-tenant.benchling.com"

mock_sm_client.get_secret_value.return_value = {"SecretString": json.dumps(valid_secret_data)}

secret = fetch_benchling_secret(mock_sm_client, "us-east-1", "test-secret")

assert secret.tenant == "test-tenant"

def test_missing_single_parameter(self, mock_sm_client, valid_secret_data):
"""Test that missing single parameter raises clear error."""
Expand Down
Loading