Skip to content
Open
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
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ module.exports = {
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.json' }]
},
moduleNameMapper: {
'^cloudflare:workers$': '<rootDir>/src/tests/__mocks__/cloudflare-workers.js'
},
setupFilesAfterEnv: ['<rootDir>/src/tests/setup.ts'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
Expand Down
5 changes: 3 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

163 changes: 162 additions & 1 deletion src/lib/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
WaitOptions,
CancellationOptions,
StartAndWaitForPortsOptions,
SecretsStoreBinding,
} from '../types';
import { generateId, parseTimeExpression } from './helpers';
import { DurableObject } from 'cloudflare:workers';
Expand Down Expand Up @@ -232,6 +233,7 @@ export class Container<Env = unknown> extends DurableObject<Env> {
envVars: ContainerStartOptions['env'] = {};
entrypoint: ContainerStartOptions['entrypoint'];
enableInternet: ContainerStartOptions['enableInternet'] = true;
secretsStoreBindings: SecretsStoreBinding[] = [];

// =========================
// PUBLIC INTERFACE
Expand Down Expand Up @@ -261,6 +263,12 @@ export class Container<Env = unknown> extends DurableObject<Env> {
if (options) {
if (options.defaultPort !== undefined) this.defaultPort = options.defaultPort;
if (options.sleepAfter !== undefined) this.sleepAfter = options.sleepAfter;
if (options.secretsStoreBindings) this.secretsStoreBindings = options.secretsStoreBindings;
}

// Auto-detect Secrets Store bindings if not explicitly provided
if (this.secretsStoreBindings.length === 0) {
this.secretsStoreBindings = this.autoDetectSecretsStoreBindings(env);
}

// Create schedules table if it doesn't exist
Expand Down Expand Up @@ -912,7 +920,11 @@ export class Container<Env = unknown> extends DurableObject<Env> {
enableInternet,
};

if (envVars && Object.keys(envVars).length > 0) startConfig.env = envVars;
// Merge Secrets Store environment variables with existing environment variables
const secretsStoreEnvVars = this.setupSecretsStoreBindingEnvironment();
const mergedEnvVars = { ...secretsStoreEnvVars, ...envVars };

if (Object.keys(mergedEnvVars).length > 0) startConfig.env = mergedEnvVars;
if (entrypoint) startConfig.entrypoint = entrypoint;

this.renewActivityTimeout();
Expand Down Expand Up @@ -1289,6 +1301,155 @@ export class Container<Env = unknown> extends DurableObject<Env> {
return this.toSchedule(schedule);
}

// ==========================
// SECRETS STORE BINDING HELPERS
// ==========================

/**
* Generate environment variables for Secrets Store bindings
* Creates SECRETS_{BINDING_NAME}_BINDING, SECRETS_{BINDING_NAME}_STORE_ID, and SECRETS_{BINDING_NAME}_SECRET_NAME environment variables
* @private
*/
private setupSecretsStoreBindingEnvironment(): Record<string, string> {
const secretsEnvVars: Record<string, string> = {};
for (const binding of this.secretsStoreBindings) {
const envPrefix = binding.binding.toUpperCase().replace(/[^A-Z0-9]/g, '_');
secretsEnvVars[`SECRETS_${envPrefix}_BINDING`] = binding.binding;
secretsEnvVars[`SECRETS_${envPrefix}_STORE_ID`] = binding.storeId;
secretsEnvVars[`SECRETS_${envPrefix}_SECRET_NAME`] = binding.secretName;
}
return secretsEnvVars;
}

/**
* Get detailed information about configured Secrets Store bindings
* @public
*/
public getSecretsStoreBindingInfo(): Record<string, { binding: string; storeId: string; secretName: string; envVars: Record<string, string> }> {
const bindingInfo: Record<string, any> = {};
for (const binding of this.secretsStoreBindings) {
const envPrefix = binding.binding.toUpperCase().replace(/[^A-Z0-9]/g, '_');
const envVars: Record<string, string> = {
[`SECRETS_${envPrefix}_BINDING`]: binding.binding,
[`SECRETS_${envPrefix}_STORE_ID`]: binding.storeId,
[`SECRETS_${envPrefix}_SECRET_NAME`]: binding.secretName
};

bindingInfo[binding.binding] = {
binding: binding.binding,
storeId: binding.storeId,
secretName: binding.secretName,
envVars
};
}
return bindingInfo;
}

/**
* Validate Secrets Store binding environment variables are properly set
* @public
*/
public validateSecretsStoreBindingEnvironment(): { valid: boolean; bindings: Record<string, any>; errors: string[] } {
const errors: string[] = [];
const bindings: Record<string, any> = {};

// Get all Secrets Store environment variables from process.env
const secretsVars: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('SECRETS_') && value) {
secretsVars[key] = value;
}
}

// Group Secrets Store variables by binding name
const groupedBindings: Record<string, Record<string, string>> = {};
for (const [key, value] of Object.entries(secretsVars)) {
const match = key.match(/^SECRETS_([A-Z0-9_]+)_(BINDING|STORE_ID|SECRET_NAME)$/);
if (match) {
const bindingName = match[1];
const varType = match[2];
if (!groupedBindings[bindingName]) {
groupedBindings[bindingName] = {};
}
groupedBindings[bindingName][varType] = value;
}
}

// Check each configured binding
for (const binding of this.secretsStoreBindings) {
const envPrefix = binding.binding.toUpperCase().replace(/[^A-Z0-9]/g, '_');
const foundBinding = groupedBindings[envPrefix];

if (!foundBinding) {
errors.push(`Secrets Store binding '${binding.binding}' not found in environment variables`);
continue;
}

if (!foundBinding.BINDING) {
errors.push(`SECRETS_${envPrefix}_BINDING environment variable missing`);
}

if (!foundBinding.STORE_ID) {
errors.push(`SECRETS_${envPrefix}_STORE_ID environment variable missing`);
}

if (!foundBinding.SECRET_NAME) {
errors.push(`SECRETS_${envPrefix}_SECRET_NAME environment variable missing`);
}

bindings[binding.binding] = {
configured: binding,
environment: foundBinding,
valid: foundBinding.BINDING && foundBinding.STORE_ID && foundBinding.SECRET_NAME
};
}

return {
valid: errors.length === 0,
bindings,
errors
};
}

/**
* Get a summary of Secrets Store bindings for logging and debugging
* @public
*/
public getSecretsStoreBindingSummary(): { configured: number; bindings: Array<{ name: string; storeId: string; secretName: string }> } {
return {
configured: this.secretsStoreBindings.length,
bindings: this.secretsStoreBindings.map(binding => ({
name: binding.binding,
storeId: binding.storeId,
secretName: binding.secretName
}))
};
}

/**
* Auto-detect Secrets Store bindings from the environment
* @private
*/
private autoDetectSecretsStoreBindings(env: any): SecretsStoreBinding[] {
const secretsStoreBindings: SecretsStoreBinding[] = [];

// Look for Secrets Store binding properties in the environment
for (const [key, value] of Object.entries(env)) {
// Check if this property looks like a Secrets Store binding
if (value && typeof value === 'object' && 'get' in value && typeof value.get === 'function') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if you can detect a binding is a secret store from the runtime, but its definitely not this simple.
This will return loads of other things, KV namespaces, service bindings etc.

probably the closes thing is to check value.constructor.name - that'll work for KV and R2, but not secret store though.

// This looks like a Secrets Store binding - we need store_id and secret_name
// For auto-detection, we'll use sensible defaults based on the binding name
secretsStoreBindings.push({
binding: key,
storeId: `auto-detected-store-${key.toLowerCase()}`,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's not ever going to correspond to an actual secret store id?

secretName: key.toLowerCase().replace(/_/g, '-')
});
}
}

return secretsStoreBindings;
}

private isActivityExpired(): boolean {
return this.sleepAfterMs <= Date.now();
}
Expand Down
13 changes: 13 additions & 0 deletions src/tests/__mocks__/cloudflare-workers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Mock for cloudflare:workers module
class DurableObject {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
}

async fetch(request) {
return new Response('Mock DurableObject response');
}
}

module.exports = { DurableObject };
Loading