Skip to content
5 changes: 5 additions & 0 deletions .changeset/bright-cache-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"varlock": minor
---

add caching system with cache() resolver, random value generators, and plugin cache API
54 changes: 49 additions & 5 deletions packages/plugins/1password/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Resolver, plugin } from 'varlock/plugin-lib';
import {
type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl,
} from 'varlock/plugin-lib';

import { createDeferredPromise, type DeferredPromise } from '@env-spec/utils/defer';
import { Client, createClient } from '@1password/sdk';
Expand All @@ -12,6 +14,15 @@ const OP_ICON = 'simple-icons:1password';
plugin.name = '1pass';
const { debug } = plugin;
debug('init - version =', plugin.version);

// capture cache accessor while the plugin proxy context is active
// (the `plugin` proxy is only valid during module initialization, not during resolve())
let pluginCache: PluginCacheAccessor | undefined;
try {
pluginCache = plugin.cache;
} catch {
// cache not available (e.g., no encryption key)
}
plugin.icon = OP_ICON;
plugin.standardVars = {
initDecorator: '@initOp',
Expand Down Expand Up @@ -95,6 +106,8 @@ class OpPluginInstance {
private connectHost?: string;
/** API token for authenticating with the Connect server */
private connectToken?: string;
/** optional cache TTL - when set, resolved values are cached */
cacheTtl?: string | number;

constructor(
readonly id: string,
Expand Down Expand Up @@ -429,13 +442,14 @@ plugin.registerRootDecorator({
id,
account,
connectHost,
cacheTtlResolver: objArgs.cacheTtl,
tokenResolver: objArgs.token,
allowAppAuthResolver: objArgs.allowAppAuth,
connectTokenResolver: objArgs.connectToken,
};
},
async execute({
id, account, connectHost, tokenResolver, allowAppAuthResolver, connectTokenResolver,
id, account, connectHost, cacheTtlResolver, tokenResolver, allowAppAuthResolver, connectTokenResolver,
}) {
// even if these are empty, we can't throw errors yet
// in case the instance is never actually used
Expand All @@ -449,6 +463,10 @@ plugin.registerRootDecorator({
connectHost,
connectToken as string | undefined,
);
const cacheTtl = await resolveCacheTtl(cacheTtlResolver);
if (cacheTtl !== undefined) {
pluginInstances[id].cacheTtl = cacheTtl;
}
},
});

Expand All @@ -463,7 +481,6 @@ plugin.registerDataType({
description: '1Password service accounts',
url: 'https://developer.1password.com/docs/service-accounts/',
},
'https://example.com',
],
async validate(val) {
if (!val.startsWith('ops_')) {
Expand Down Expand Up @@ -541,8 +558,21 @@ plugin.registerResolverFunction({
if (typeof opReference !== 'string') {
throw new SchemaError('expected op item location to resolve to a string');
}
const opValue = await selectedInstance.readItem(opReference);
return opValue;

// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `op:${instanceId}:${opReference}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const opValue = await selectedInstance.readItem(opReference);
await pluginCache.set(cacheKey, opValue, selectedInstance.cacheTtl);
return opValue;
}

return await selectedInstance.readItem(opReference);
},
});

Expand Down Expand Up @@ -602,6 +632,20 @@ plugin.registerResolverFunction({
if (typeof environmentId !== 'string') {
throw new SchemaError('expected environment ID to resolve to a string');
}

// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `opEnv:${instanceId}:${environmentId}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const result = await selectedInstance.readEnvironment(environmentId);
await pluginCache.set(cacheKey, result, selectedInstance.cacheTtl);
return result;
}

return await selectedInstance.readEnvironment(environmentId);
},
});
47 changes: 46 additions & 1 deletion packages/plugins/aws-secrets/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Resolver, plugin } from 'varlock/plugin-lib';
import {
type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl,
} from 'varlock/plugin-lib';

import {
SecretsManagerClient,
Expand All @@ -21,6 +23,15 @@ const { debug } = plugin;
debug('init - version =', plugin.version);
plugin.icon = AWS_ICON;

// capture cache accessor while the plugin proxy context is active
// (the `plugin` proxy is only valid during module initialization, not during resolve())
let pluginCache: PluginCacheAccessor | undefined;
try {
pluginCache = plugin.cache;
} catch {
// cache not available (e.g., no encryption key)
}

plugin.standardVars = {
initDecorator: '@initAws',
params: {
Expand All @@ -45,6 +56,8 @@ class AwsPluginInstance {
private sessionToken?: string;
private profile?: string;
private namePrefix?: string;
/** optional cache TTL - when set, resolved values are cached */
cacheTtl?: string | number;

constructor(
readonly id: string,
Expand Down Expand Up @@ -423,6 +436,7 @@ plugin.registerRootDecorator({
secretAccessKeyResolver: objArgs.secretAccessKey,
sessionTokenResolver: objArgs.sessionToken,
namePrefixResolver: objArgs.namePrefix,
cacheTtlResolver: objArgs.cacheTtl,
};
},
async execute({
Expand All @@ -433,6 +447,7 @@ plugin.registerRootDecorator({
secretAccessKeyResolver,
sessionTokenResolver,
namePrefixResolver,
cacheTtlResolver,
}) {
const region = await regionResolver.resolve();
const accessKeyId = await accessKeyIdResolver?.resolve();
Expand All @@ -441,6 +456,10 @@ plugin.registerRootDecorator({
const profile = await profileResolver?.resolve();
const namePrefix = await namePrefixResolver?.resolve();
pluginInstances[id].setAuth(region, accessKeyId, secretAccessKey, sessionToken, profile, namePrefix);
const cacheTtl = await resolveCacheTtl(cacheTtlResolver);
if (cacheTtl !== undefined) {
pluginInstances[id].cacheTtl = cacheTtl;
}
},
});

Expand Down Expand Up @@ -598,6 +617,19 @@ plugin.registerResolverFunction({
// Apply namePrefix
const finalSecretId = selectedInstance.applyNamePrefix(secretId);

// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `awsSecret:${instanceId}:${finalSecretId}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const secretValue = await selectedInstance.getSecret(finalSecretId, jsonKey);
await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl);
return secretValue;
}

const secretValue = await selectedInstance.getSecret(finalSecretId, jsonKey);
return secretValue;
},
Expand Down Expand Up @@ -717,6 +749,19 @@ plugin.registerResolverFunction({
// Apply namePrefix
const finalParameterName = selectedInstance.applyNamePrefix(parameterName);

// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `awsParam:${instanceId}:${finalParameterName}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const parameterValue = await selectedInstance.getParameter(finalParameterName, jsonKey);
await pluginCache.set(cacheKey, parameterValue, selectedInstance.cacheTtl);
return parameterValue;
}

const parameterValue = await selectedInstance.getParameter(finalParameterName, jsonKey);
return parameterValue;
},
Expand Down
39 changes: 36 additions & 3 deletions packages/plugins/bitwarden/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Resolver, plugin } from 'varlock/plugin-lib';
import {
type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl,
} from 'varlock/plugin-lib';
import ky from 'ky';
import { Buffer } from 'node:buffer';
import { webcrypto } from 'node:crypto';
Expand All @@ -13,6 +15,15 @@ const BITWARDEN_ICON = 'simple-icons:bitwarden';
plugin.name = 'bitwarden';
const { debug } = plugin;
debug('init - version =', plugin.version);

// capture cache accessor while the plugin proxy context is active
// (the `plugin` proxy is only valid during module initialization, not during resolve())
let pluginCache: PluginCacheAccessor | undefined;
try {
pluginCache = plugin.cache;
} catch {
// cache not available (e.g., no encryption key)
}
plugin.icon = BITWARDEN_ICON;
plugin.standardVars = {
initDecorator: '@initBitwarden',
Expand Down Expand Up @@ -57,6 +68,9 @@ class BitwardenPluginInstance {
/** In-flight auth promise - prevents parallel resolution from triggering multiple auth requests (rate limit fix) */
private authInFlight?: Promise<CachedAuth>;

/** optional cache TTL - when set, resolved values are cached */
cacheTtl?: string | number;

constructor(
readonly id: string,
) {}
Expand Down Expand Up @@ -336,13 +350,15 @@ plugin.registerRootDecorator({
apiUrl,
identityUrl,
accessTokenResolver: objArgs.accessToken,
cacheTtlResolver: objArgs.cacheTtl,
};
},
async execute({
id,
apiUrl,
identityUrl,
accessTokenResolver,
cacheTtlResolver,
}) {
// even if the token is empty, we can't throw errors yet
// in case the instance is never actually used
Expand All @@ -353,6 +369,11 @@ plugin.registerRootDecorator({
apiUrl,
identityUrl,
);

const cacheTtl = await resolveCacheTtl(cacheTtlResolver);
if (cacheTtl !== undefined) {
pluginInstances[id].cacheTtl = cacheTtl;
}
},
});

Expand Down Expand Up @@ -485,7 +506,19 @@ plugin.registerResolverFunction({
});
}

const secretValue = await selectedInstance.getSecret(secretId);
return secretValue;
// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `bw:${instanceId}:${secretId}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const secretValue = await selectedInstance.getSecret(secretId);
await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl);
return secretValue;
}

return await selectedInstance.getSecret(secretId);
},
});
37 changes: 33 additions & 4 deletions packages/plugins/google-secret-manager/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type Resolver, plugin } from 'varlock/plugin-lib';
import {
type Resolver, type PluginCacheAccessor, plugin, resolveCacheTtl,
} from 'varlock/plugin-lib';

import { GoogleAuth } from 'google-auth-library';

Expand All @@ -9,6 +11,14 @@ const GSM_ICON = 'devicon:googlecloud';
plugin.name = 'gsm';
const { debug } = plugin;
debug('init - version =', plugin.version);
// capture cache accessor while the plugin proxy context is active
// (the `plugin` proxy is only valid during module initialization, not during resolve())
let pluginCache: PluginCacheAccessor | undefined;
try {
pluginCache = plugin.cache;
} catch {
// cache not available (e.g., no encryption key)
}
plugin.icon = GSM_ICON;
plugin.standardVars = {
initDecorator: '@initGsm',
Expand All @@ -21,6 +31,8 @@ plugin.standardVars = {
class GsmPluginInstance {
private projectId?: string;
private credentials?: any;
/** optional cache TTL - when set, resolved values are cached */
cacheTtl?: string | number;

constructor(
readonly id: string,
Expand Down Expand Up @@ -187,16 +199,21 @@ plugin.registerRootDecorator({

return {
id,
cacheTtlResolver: objArgs.cacheTtl,
projectIdResolver: objArgs.projectId,
credentialsResolver: objArgs.credentials,
};
},
async execute({
id, projectIdResolver, credentialsResolver,
id, cacheTtlResolver, projectIdResolver, credentialsResolver,
}) {
const projectId = await projectIdResolver?.resolve();
const credentials = await credentialsResolver?.resolve();
pluginInstances[id].setAuth(projectId, credentials);
const cacheTtl = await resolveCacheTtl(cacheTtlResolver);
if (cacheTtl !== undefined) {
pluginInstances[id].cacheTtl = cacheTtl;
}
},
});

Expand Down Expand Up @@ -333,7 +350,19 @@ plugin.registerResolverFunction({
throw new SchemaError('No secret reference provided');
}

const secretValue = await selectedInstance.readSecret(secretRef);
return secretValue;
// check cache if cacheTtl is configured and cache is available
if (selectedInstance.cacheTtl !== undefined && pluginCache) {
const cacheKey = `gsm:${instanceId}:${secretRef}`;
const cached = await pluginCache.get(cacheKey);
if (cached !== undefined) {
debug('cache hit for %s', cacheKey);
return cached;
}
const secretValue = await selectedInstance.readSecret(secretRef);
await pluginCache.set(cacheKey, secretValue, selectedInstance.cacheTtl);
return secretValue;
}

return await selectedInstance.readSecret(secretRef);
},
});
Loading
Loading