@puls-atlas/cli
Version:
The Puls Atlas CLI tool for managing Atlas projects
116 lines • 5.62 kB
JavaScript
import { execFileSync as nodeExecFileSync } from 'child_process';
import { isString } from 'es-toolkit/compat';
import { runGcloudFileCommand } from './gcloud.js';
export const DEFAULT_ATLAS_ARTIFACT_BUCKET_PREFIX = 'puls-atlas-artifacts';
export const DEFAULT_ATLAS_DLQ_BUCKET_PREFIX = 'puls-atlas-dlq';
export const DEFAULT_ATLAS_ARTIFACT_BUCKET_LOCATION = 'europe-west1';
export const DEFAULT_ATLAS_DLQ_BUCKET_RETENTION_DAYS = 365;
const getBucketInspectionErrorText = error => {
const stderr = error?.stderr?.toString?.() ?? '';
return [error?.message, stderr].filter(Boolean).join('\n');
};
const isMissingBucketError = error => /404|not found|does not exist|bucket.*could not be found/i.test(getBucketInspectionErrorText(error));
const normalizeProjectIdSegment = (projectId, {
bucketPrefix = DEFAULT_ATLAS_ARTIFACT_BUCKET_PREFIX,
errorPrefix = 'Atlas artifact bucket'
} = {}) => {
if (!isString(projectId) || projectId.trim().length === 0) {
throw new Error(`${errorPrefix} resolution requires a valid project id.`);
}
const maxSegmentLength = 63 - `${bucketPrefix}-`.length;
const normalizedSegment = projectId.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '').slice(0, maxSegmentLength).replace(/-+$/g, '');
if (normalizedSegment.length === 0) {
throw new Error(`${errorPrefix} could not derive a valid bucket suffix from project id ${projectId}.`);
}
return normalizedSegment;
};
const runBucketCommand = (args, runCommand) => runGcloudFileCommand(args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}, runCommand);
export const resolveAtlasArtifactBucketName = projectId => `${DEFAULT_ATLAS_ARTIFACT_BUCKET_PREFIX}-${normalizeProjectIdSegment(projectId)}`;
export const resolveAtlasDlqBucketName = projectId => `${DEFAULT_ATLAS_DLQ_BUCKET_PREFIX}-${normalizeProjectIdSegment(projectId, {
bucketPrefix: DEFAULT_ATLAS_DLQ_BUCKET_PREFIX,
errorPrefix: 'Atlas DLQ bucket'
})}`;
export const resolveAtlasArtifactUri = (projectId, objectPath) => {
if (!isString(objectPath) || objectPath.trim().length === 0) {
throw new Error('Atlas artifact URI resolution requires a non-empty object path.');
}
return `gs://${resolveAtlasArtifactBucketName(projectId)}/${objectPath.trim().replace(/^\/+/, '')}`;
};
export const resolveAtlasDlqUri = (projectId, objectPath) => {
if (!isString(objectPath) || objectPath.trim().length === 0) {
throw new Error('Atlas DLQ URI resolution requires a non-empty object path.');
}
return `gs://${resolveAtlasDlqBucketName(projectId)}/${objectPath.trim().replace(/^\/+/, '')}`;
};
const normalizeArtifactPathSegment = segment => {
if (!isString(segment) || segment.trim().length === 0) {
throw new Error('Atlas artifact path segments must be non-empty strings.');
}
return segment.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
};
export const buildAtlasArtifactObjectPath = (namespace, ...pathSegments) => [namespace, ...pathSegments].map(normalizeArtifactPathSegment).join('/');
export const buildAtlasDlqObjectPath = (namespace, ...pathSegments) => buildAtlasArtifactObjectPath(namespace, ...pathSegments);
export const resolveAtlasNamespacedArtifactUri = (projectId, namespace, ...pathSegments) => resolveAtlasArtifactUri(projectId, buildAtlasArtifactObjectPath(namespace, ...pathSegments));
export const resolveAtlasNamespacedDlqUri = (projectId, namespace, ...pathSegments) => resolveAtlasDlqUri(projectId, buildAtlasDlqObjectPath(namespace, ...pathSegments));
export const parseGsUri = gsUri => {
if (!isString(gsUri) || !gsUri.startsWith('gs://')) {
throw new Error(`Atlas artifact destination must be a gs:// URI. Received ${gsUri}.`);
}
const normalizedGsUri = gsUri.trim().replace(/\/+$/g, '');
const pathPart = normalizedGsUri.slice('gs://'.length);
const slashIndex = pathPart.indexOf('/');
if (slashIndex <= 0 || slashIndex === pathPart.length - 1) {
throw new Error('Atlas artifact destination must include both a bucket name and an object path.');
}
return {
bucketName: pathPart.slice(0, slashIndex),
objectPath: pathPart.slice(slashIndex + 1),
uri: normalizedGsUri
};
};
export const ensureAtlasArtifactBucket = ({
location = DEFAULT_ATLAS_ARTIFACT_BUCKET_LOCATION,
projectId
}, options = {}) => {
const runCommand = options.runCommand ?? nodeExecFileSync;
const bucketName = options.bucketName ?? (options.destinationUri ? parseGsUri(options.destinationUri).bucketName : null) ?? resolveAtlasArtifactBucketName(projectId);
try {
runBucketCommand(['storage', 'buckets', 'describe', `gs://${bucketName}`, `--project=${projectId}`], runCommand);
return {
bucketName,
location,
status: 'existing'
};
} catch (error) {
if (options.dryRun && !isMissingBucketError(error)) {
return {
bucketName,
location,
status: 'unverified'
};
}
if (!isMissingBucketError(error)) {
throw new Error(`Failed to inspect Atlas artifact bucket gs://${bucketName}. ${getBucketInspectionErrorText(error)}`);
}
}
if (options.dryRun) {
return {
bucketName,
location,
status: 'would-create'
};
}
try {
runBucketCommand(['storage', 'buckets', 'create', `gs://${bucketName}`, `--project=${projectId}`, `--location=${location}`, '--uniform-bucket-level-access'], runCommand);
} catch (error) {
throw new Error(`Failed to create Atlas artifact bucket gs://${bucketName}. ${getBucketInspectionErrorText(error)}`);
}
return {
bucketName,
location,
status: 'created'
};
};