@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
504 lines • 21 kB
JavaScript
import fs from 'fs';
import { createHash } from 'node:crypto';
import * as features from '../../utils/feature.js';
import { spreadIf } from '../../utils/value.js';
import { uploadSyncRuntimeConfig } from './runtimeArtifacts.js';
import { resolveSyncCloudRunDeployConfig } from './deploymentConfig.js';
import { compileSyncPlan, resolveSyncConfigLocation } from './config/syncConfig.js';
import { logger, getAtlasGeneratedFeatureConfigPath, loadAtlasFeatureCache, writeAtlasFeatureCache, writeAtlasGeneratedFeatureConfig } from '../../utils/index.js';
import { createSyncConfigFingerprint, promoteSyncReleaseState, resolveSyncReleaseTarget, resolveSyncRolloutPlan } from './release.js';
import { createSyncReadAliasEntryRows, createSyncReadAliasSummaryRows, createSyncSchemaPlanSummaryRows, inspectSyncManagedSchemas, inspectSyncReadAliases, reconcileSyncReadAliases } from './schemaPlan.js';
const SYNC_RUNTIME_CONFIG_VERSION = 3;
const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
const BIGQUERY_IDENTIFIER_MAX_LENGTH = 1024;
const UNSUPPORTED_SYNC_SOURCE_TYPES = new Set(['http', 'sql']);
export const assertNoUnsupportedEnabledSources = syncPlan => {
const unsupported = syncPlan.pipelines.filter(pipeline => UNSUPPORTED_SYNC_SOURCE_TYPES.has(pipeline.source.type));
if (unsupported.length === 0) {
return;
}
const workloadList = unsupported.map(p => ` - ${p.workloadKey} (source: ${p.source.type})`).join('\n');
throw new Error(`The following enabled workloads use source types that are not yet production-ready:\n${workloadList}\n` + 'The "http" and "sql" source types are scaffold-only and cannot be deployed. ' + 'Set workload.enabled = false or switch to a supported source type (firestore).');
};
const STABLE_SORT_EXCLUDED_ARRAY_PATHS = new Set(['destination.bigquery.primaryKey', 'destination.postgres.primaryKey']);
const normalizeIdentifierBase = (value, fallbackValue) => {
const normalizedValue = value?.trim()?.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') ?? null;
return normalizedValue || fallbackValue;
};
const createReleaseSuffix = releaseId => createHash('sha1').update(releaseId).digest('hex').slice(0, 12);
const createManagedIdentifier = (baseName, releaseId, maxLength, fallbackName) => {
const normalizedBaseName = normalizeIdentifierBase(baseName, fallbackName);
const suffix = createReleaseSuffix(releaseId);
const baseLength = Math.max(1, maxLength - suffix.length - 2);
return `${normalizedBaseName.slice(0, baseLength)}__${suffix}`;
};
const createBigQueryPhysicalTargets = (destinationConfig, release) => {
const baseDataset = destinationConfig.bigquery?.dataset ?? null;
const baseTable = destinationConfig.bigquery?.table ?? null;
return {
active: release.active ? {
dataset: baseDataset,
releaseId: release.active,
table: createManagedIdentifier(baseTable, release.active, BIGQUERY_IDENTIFIER_MAX_LENGTH, destinationConfig.name),
target: 'active'
} : null,
candidate: release.candidate ? {
dataset: baseDataset,
releaseId: release.candidate,
table: createManagedIdentifier(baseTable, release.candidate, BIGQUERY_IDENTIFIER_MAX_LENGTH, destinationConfig.name),
target: 'candidate'
} : null,
direct: {
dataset: baseDataset,
releaseId: null,
table: baseTable,
target: 'direct'
}
};
};
const createPostgresPhysicalTargets = (destinationConfig, release) => {
const baseSchema = destinationConfig.postgres?.schema ?? null;
const baseTable = destinationConfig.postgres?.table ?? null;
return {
active: release.active ? {
releaseId: release.active,
schema: baseSchema,
table: createManagedIdentifier(baseTable, release.active, POSTGRES_IDENTIFIER_MAX_LENGTH, destinationConfig.name),
target: 'active'
} : null,
candidate: release.candidate ? {
releaseId: release.candidate,
schema: baseSchema,
table: createManagedIdentifier(baseTable, release.candidate, POSTGRES_IDENTIFIER_MAX_LENGTH, destinationConfig.name),
target: 'candidate'
} : null,
direct: {
releaseId: null,
schema: baseSchema,
table: baseTable,
target: 'direct'
}
};
};
const DESTINATION_PHYSICAL_TARGET_FACTORIES = {
bigquery: createBigQueryPhysicalTargets,
postgres: createPostgresPhysicalTargets
};
const createDestinationPhysicalTargets = (destinationConfig, release) => {
const destinationType = destinationConfig.type?.trim().toLowerCase();
const createPhysicalTargets = DESTINATION_PHYSICAL_TARGET_FACTORIES[destinationType];
if (!createPhysicalTargets) {
throw new Error(`Atlas sync destination type "${destinationType}" is not supported for runtime target generation.`);
}
return createPhysicalTargets(destinationConfig, release);
};
const createStableFingerprintValue = (value, path = '') => {
if (Array.isArray(value)) {
const normalizedEntries = value.map((entry, index) => createStableFingerprintValue(entry, `${path}[${index}]`));
if (STABLE_SORT_EXCLUDED_ARRAY_PATHS.has(path)) {
return normalizedEntries;
}
if (path === 'pipelines') {
return [...normalizedEntries].sort((left, right) => String(left?.workloadKey ?? '').localeCompare(String(right?.workloadKey ?? '')));
}
return normalizedEntries;
}
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)).map(([entryKey, entryValue]) => [entryKey, createStableFingerprintValue(entryValue, path ? `${path}.${entryKey}` : entryKey)]));
}
return value;
};
export const createSyncRuntimeFingerprintPayload = syncPlan => createStableFingerprintValue({
pipelines: syncPlan.pipelines,
projectId: syncPlan.projectId,
version: syncPlan.version
});
const createPhysicalTargetSignature = target => {
if (!target) {
return null;
}
if (target.dataset && target.table) {
return `${target.dataset}.${target.table}`;
}
if (target.schema && target.table) {
return `${target.schema}.${target.table}`;
}
return target.target ?? null;
};
const resolveRoutingTarget = (physicalTargets, targetName) => {
if (!targetName) {
return null;
}
return physicalTargets[targetName] ?? null;
};
const resolveRoutingTargets = (physicalTargets, targetNames = []) => {
const dedupedTargets = new Map();
for (const targetName of targetNames) {
const target = resolveRoutingTarget(physicalTargets, targetName);
if (!target) {
continue;
}
const signature = createPhysicalTargetSignature(target);
if (!signature || dedupedTargets.has(signature)) {
continue;
}
dedupedTargets.set(signature, target);
}
return [...dedupedTargets.values()];
};
const createWriteTargetNames = rollout => {
const targetNames = [rollout.writeTarget];
if (rollout.mode === 'candidate-backfill' && rollout.backfillTarget && rollout.backfillTarget !== rollout.writeTarget) {
targetNames.push(rollout.backfillTarget);
}
return [...new Set(targetNames.filter(Boolean))];
};
const createRuntimePipeline = (pipeline, releasePlan) => {
const physicalTargets = createDestinationPhysicalTargets(pipeline.destination, releasePlan.release);
const writeTargets = resolveRoutingTargets(physicalTargets, createWriteTargetNames(releasePlan.rollout));
return {
...pipeline,
destination: {
...pipeline.destination,
physicalTargets,
routing: {
backfill: resolveRoutingTarget(physicalTargets, releasePlan.rollout.backfillTarget),
read: resolveRoutingTarget(physicalTargets, releasePlan.rollout.readTarget),
write: writeTargets[0] ?? resolveRoutingTarget(physicalTargets, releasePlan.rollout.writeTarget),
writeTargets
}
}
};
};
export const buildSyncRuntimeConfig = (context, options = {}) => {
const syncPlan = options.syncPlan ?? context.syncPlan ?? compileSyncPlan(context.config);
const configFingerprint = createSyncConfigFingerprint(createSyncRuntimeFingerprintPayload(syncPlan));
const releasePlan = options.releasePlan ?? resolveSyncRolloutPlan(configFingerprint, options.cacheArtifact?.cache ?? null, {
now: options.now
});
const runtimePipelines = syncPlan.pipelines.map(pipeline => createRuntimePipeline(pipeline, releasePlan));
return {
destinations: Object.fromEntries(runtimePipelines.map(pipeline => [pipeline.destination.name, pipeline.destination])),
pipelines: runtimePipelines,
projectId: syncPlan.projectId,
release: releasePlan.release,
releaseTarget: releasePlan.releaseTarget ?? resolveSyncReleaseTarget(releasePlan.release),
rollout: releasePlan.rollout,
...spreadIf(options.readAliasPlan, {
readAliasPlan: options.readAliasPlan
}),
...spreadIf(options.schemaPlan, {
schemaPlan: options.schemaPlan
}),
version: Math.max(syncPlan.version ?? 1, SYNC_RUNTIME_CONFIG_VERSION)
};
};
const createSyncDeployCachePayload = ({
cacheArtifact,
context,
runtimeConfig,
runtimeConfigArtifact
}) => ({
...(cacheArtifact?.cache ?? {}),
cachedAt: new Date().toISOString(),
config: context.config,
configFingerprint: runtimeConfig.rollout?.configFingerprint ?? null,
configPath: context.configPath,
environment: context.environment,
feature: context.featureName,
projectId: context.projectId,
release: runtimeConfig.release,
releaseTarget: runtimeConfig.releaseTarget,
rollout: runtimeConfig.rollout,
readAliasPlan: runtimeConfig.readAliasPlan ?? null,
schemaPlan: runtimeConfig.schemaPlan ?? null,
runtimeConfigPath: runtimeConfigArtifact.filePath,
version: 1
});
const createSyncApplySummaryRows = (context, generationResult) => [{
label: 'Project',
value: context.projectId
}, context.environment ? {
label: 'Environment',
value: context.environment
} : null, {
label: 'Version',
value: generationResult.runtimeConfig.version
}, {
label: 'Enabled pipelines',
value: generationResult.enabledPipelineCount
}, {
label: 'Release strategy',
value: `${generationResult.release.strategy}` + `${generationResult.release.active ? ` [active=${generationResult.release.active}]` : ''}` + `${generationResult.release.candidate ? ` [candidate=${generationResult.release.candidate}]` : ''}`
}, {
label: 'Read target',
value: generationResult.rollout.readTarget ?? 'not set'
}, {
label: 'Write target',
value: generationResult.rollout.writeTarget ?? 'not set'
}, {
label: 'Backfill target',
value: generationResult.rollout.backfillTarget ?? 'not required'
}, ...createSyncReadAliasSummaryRows(generationResult.runtimeConfig.readAliasPlan), ...createSyncSchemaPlanSummaryRows(generationResult.runtimeConfig.schemaPlan)];
export const generateSyncRuntimeConfig = (context, options = {}, cwd = process.cwd()) => {
const {
cacheArtifact = null,
dryRun = false,
now,
readAliasPlan,
runtimeConfig: prebuiltRuntimeConfig,
schemaPlan,
writeGeneratedFeatureConfig = writeAtlasGeneratedFeatureConfig
} = options;
const syncPlan = context.syncPlan ?? compileSyncPlan(context.config);
const runtimeConfig = prebuiltRuntimeConfig ?? buildSyncRuntimeConfig({
...context,
syncPlan
}, {
cacheArtifact,
now,
readAliasPlan,
schemaPlan
});
const runtimeConfigArtifact = dryRun === true ? {
filePath: getAtlasGeneratedFeatureConfigPath('sync', context.projectId, cwd)
} : writeGeneratedFeatureConfig('sync', context.projectId, runtimeConfig, cwd);
return {
enabledPipelineCount: runtimeConfig.pipelines.length,
projectId: context.projectId,
release: runtimeConfig.release,
releaseTarget: runtimeConfig.releaseTarget,
rollout: runtimeConfig.rollout,
runtimeConfig,
runtimeConfigArtifact,
status: dryRun === true ? 'dry-run' : 'updated'
};
};
export const runSyncApply = async (options = {}, dependencies = {}, workingDirectory = process.cwd()) => {
const {
existsSyncImpl = fs.existsSync,
generateSyncRuntimeConfigImpl = generateSyncRuntimeConfig,
inspectSyncManagedSchemasImpl = inspectSyncManagedSchemas,
inspectSyncReadAliasesImpl = inspectSyncReadAliases,
loadFeatureContextImpl = features.loadFeatureContext,
loadFeatureCacheImpl = loadAtlasFeatureCache,
loggerImpl = logger,
resolveProjectSelectionImpl = features.resolveProjectSelection,
writeFeatureCacheImpl = writeAtlasFeatureCache
} = dependencies;
let spinner;
try {
const projectSelection = await resolveProjectSelectionImpl(options);
const configLocation = resolveSyncConfigLocation(workingDirectory, {
existsSyncImpl
});
if (!configLocation.hasPreferred) {
loggerImpl.info(`Atlas sync config is not configured for project ${projectSelection.projectId}. Run "atlas sync init" first.`);
return {
projectId: projectSelection.projectId,
reason: 'missing-config',
status: 'skipped'
};
}
const context = await loadFeatureContextImpl('sync', options, {
cwd: workingDirectory,
resolveProjectSelectionImpl: async () => projectSelection
});
assertNoUnsupportedEnabledSources(compileSyncPlan(context.config));
const cacheArtifact = loadFeatureCacheImpl('sync', context.projectId, workingDirectory);
const previewRuntimeConfig = buildSyncRuntimeConfig(context, {
cacheArtifact
});
const schemaPlan = await inspectSyncManagedSchemasImpl(context, previewRuntimeConfig, {
strict: false
});
const readAliasPlan = await inspectSyncReadAliasesImpl(context, previewRuntimeConfig, {
strict: false
});
spinner = loggerImpl.spinner('Generating Atlas sync runtime config...');
const generationResult = generateSyncRuntimeConfigImpl(context, {
cacheArtifact,
dryRun: options.dryRun === true,
runtimeConfig: {
...previewRuntimeConfig,
...(schemaPlan ? {
schemaPlan
} : {}),
...(readAliasPlan ? {
readAliasPlan
} : {})
},
readAliasPlan,
schemaPlan
}, workingDirectory);
const writtenCacheArtifact = options.dryRun === true ? null : writeFeatureCacheImpl('sync', context.projectId, createSyncDeployCachePayload({
cacheArtifact,
context,
runtimeConfig: generationResult.runtimeConfig,
runtimeConfigArtifact: generationResult.runtimeConfigArtifact
}), workingDirectory);
spinner.succeed(options.dryRun === true ? 'Atlas sync runtime config dry run is ready.' : 'Atlas sync runtime config is ready.');
loggerImpl.summary('Apply summary', createSyncApplySummaryRows(context, generationResult));
loggerImpl.summary('Local files', [{
label: 'Config',
value: context.configPath
}, {
label: 'Runtime config',
value: generationResult.runtimeConfigArtifact.filePath
}, writtenCacheArtifact ? {
label: 'Cache',
value: writtenCacheArtifact.filePath
} : null]);
if (context.configPaths.length > 0) {
loggerImpl.section('Workload files', context.configPaths, {
detailOnly: true
});
}
if (schemaPlan?.entries?.length > 0) {
loggerImpl.summary('Managed schema targets', schemaPlan.entries.map(entry => ({
label: `${entry.destinationName} (${entry.target}: ${entry.dataset}.${entry.table})`,
value: entry.schemaStatus
})));
}
if (readAliasPlan?.entries?.length > 0) {
loggerImpl.summary('BigQuery read aliases', createSyncReadAliasEntryRows(readAliasPlan));
}
for (const warning of schemaPlan?.warnings ?? []) {
loggerImpl.warning(warning);
}
for (const warning of readAliasPlan?.warnings ?? []) {
loggerImpl.warning(warning);
}
if (options.dryRun === true) {
loggerImpl.info('Dry run requested: Atlas did not write the generated runtime config.');
loggerImpl.info('Dry run requested: Atlas did not update the local sync rollout cache.');
}
if (generationResult.enabledPipelineCount === 0) {
loggerImpl.warning('Atlas sync config is valid, but no workloads are enabled for the selected project.');
}
return {
...generationResult,
cacheArtifact: writtenCacheArtifact
};
} catch (error) {
spinner?.fail('Failed to generate Atlas sync runtime config.');
loggerImpl.error(error.message);
return null;
}
};
export default async options => runSyncApply(options);
export const runSyncPromote = async (options = {}, dependencies = {}, workingDirectory = process.cwd()) => {
const {
generateSyncRuntimeConfigImpl = generateSyncRuntimeConfig,
inspectSyncManagedSchemasImpl = inspectSyncManagedSchemas,
inspectSyncReadAliasesImpl = inspectSyncReadAliases,
loadFeatureContextImpl = features.loadFeatureContext,
loadFeatureCacheImpl = loadAtlasFeatureCache,
loggerImpl = logger,
reconcileSyncReadAliasesImpl = reconcileSyncReadAliases,
resolveProjectSelectionImpl = features.resolveProjectSelection,
resolveSyncCloudRunDeployConfigImpl = resolveSyncCloudRunDeployConfig,
uploadSyncRuntimeConfigImpl = uploadSyncRuntimeConfig,
writeFeatureCacheImpl = writeAtlasFeatureCache
} = dependencies;
let spinner;
try {
const projectSelection = await resolveProjectSelectionImpl(options);
const context = await loadFeatureContextImpl('sync', options, {
cwd: workingDirectory,
resolveProjectSelectionImpl: async () => projectSelection
});
const cacheArtifact = loadFeatureCacheImpl('sync', context.projectId, workingDirectory);
if (!cacheArtifact?.cache) {
throw new Error('Atlas sync release promotion requires an existing local sync rollout cache. Run "atlas sync apply" first.');
}
spinner = loggerImpl.spinner('Promoting Atlas sync release...');
const promotedReleasePlan = promoteSyncReleaseState(cacheArtifact.cache ?? null);
const previewRuntimeConfig = buildSyncRuntimeConfig(context, {
cacheArtifact,
releasePlan: promotedReleasePlan
});
const schemaPlan = await inspectSyncManagedSchemasImpl(context, previewRuntimeConfig, {
strict: false
});
const readAliasPlan = await inspectSyncReadAliasesImpl(context, previewRuntimeConfig, {
strict: false
});
const generationResult = generateSyncRuntimeConfigImpl(context, {
cacheArtifact,
releasePlan: promotedReleasePlan,
runtimeConfig: {
...previewRuntimeConfig,
...(schemaPlan ? {
schemaPlan
} : {}),
...(readAliasPlan ? {
readAliasPlan
} : {})
},
readAliasPlan,
schemaPlan
}, workingDirectory);
const deployConfig = resolveSyncCloudRunDeployConfigImpl(context);
uploadSyncRuntimeConfigImpl(generationResult.runtimeConfigArtifact.filePath, deployConfig.runtimeConfigUri);
const reconciledReadAliasPlan = await reconcileSyncReadAliasesImpl(context, generationResult.runtimeConfig, {
strict: true
});
if (reconciledReadAliasPlan.blockingIssues.length > 0) {
throw new Error(reconciledReadAliasPlan.blockingIssues.join(' '));
}
const writtenCacheArtifact = writeFeatureCacheImpl('sync', context.projectId, createSyncDeployCachePayload({
cacheArtifact,
context,
runtimeConfig: {
...generationResult.runtimeConfig,
readAliasPlan: reconciledReadAliasPlan
},
runtimeConfigArtifact: generationResult.runtimeConfigArtifact
}), workingDirectory);
spinner.succeed('Atlas sync release promoted.');
loggerImpl.summary('Promotion summary', [{
label: 'Project',
value: context.projectId
}, context.environment ? {
label: 'Environment',
value: context.environment
} : null, {
label: 'Active release',
value: generationResult.release.active
}, {
label: 'Candidate release',
value: generationResult.release.candidate ?? 'cleared'
}, {
label: 'Runtime config',
value: generationResult.runtimeConfigArtifact.filePath
}, {
label: 'Runtime config URI',
value: deployConfig.runtimeConfigUri
}, {
label: 'Cache',
value: writtenCacheArtifact.filePath
}, ...createSyncReadAliasSummaryRows(reconciledReadAliasPlan), ...createSyncSchemaPlanSummaryRows(generationResult.runtimeConfig.schemaPlan)]);
for (const warning of schemaPlan?.warnings ?? []) {
loggerImpl.warning(warning);
}
for (const warning of reconciledReadAliasPlan?.warnings ?? []) {
loggerImpl.warning(warning);
}
if (reconciledReadAliasPlan?.entries?.length > 0) {
loggerImpl.summary('BigQuery read aliases', createSyncReadAliasEntryRows(reconciledReadAliasPlan));
}
return {
...generationResult,
cacheArtifact: writtenCacheArtifact,
readAliasPlan: reconciledReadAliasPlan,
status: 'promoted'
};
} catch (error) {
spinner?.fail('Failed to promote Atlas sync release.');
loggerImpl.error(error.message, false);
return {
status: 'failed'
};
}
};