UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

380 lines 19 kB
import fs from 'fs'; import path from 'path'; import { getAtlasGeneratedTerraformDir, resolveRootPath } from '../../utils/atlas.js'; import { writeJsonFile } from '../../utils/file.js'; import { logger } from '../../utils/index.js'; import { upsertSecret } from '../../utils/secrets.js'; import { normalizeOptionalString } from '../../utils/value.js'; import { getCurrentServiceConfig } from './selection.js'; import { getService } from './serviceRegistry.js'; import { createDefaultServicePlatformClusterConfig, createDefaultServicePlatformNodePoolConfig, createServiceResourceLabels } from './platform.js'; import { loadTerraformRootTemplateFiles, renderTerraformRootTemplateFiles, resolveSiblingTerraformModuleSource } from '../../utils/terraformRootTemplates.js'; import { importExistingServiceTerraformResources, readServicePlatformTerraformOutputs as readServicePlatformTerraformOutputsImpl, readServiceTerraformOutputs as readServiceTerraformOutputsImpl, runServicePlatformTerraformWorkflow as runServicePlatformTerraformWorkflowImpl, runServiceTerraformWorkflow as runServiceTerraformWorkflowImpl } from './terraformWorkflow.js'; export const SERVICE_TERRAFORM_CONTRACT_VERSION = 1; export const SERVICE_TERRAFORM_INPUT_VARIABLE = 'atlas_service_tfvars_path'; export const SERVICE_PLATFORM_TERRAFORM_CONTRACT_VERSION = 1; export const SERVICE_PLATFORM_TERRAFORM_INPUT_VARIABLE = 'atlas_service_platform_tfvars_path'; export const DEFAULT_SERVICE_PLATFORM_TERRAFORM_ROOT_DIR = 'services/terraform/platform'; const SERVICE_PLATFORM_TEMPLATE_ROOT_DIRECTORIES = ['templates/search-provider-platform-root']; export { importExistingServiceTerraformResources }; export const resolveServiceTerraformConfig = (serviceConfig, serviceName, serviceDeployHints = {}) => { const service = getService(serviceName); const configuredModuleSource = normalizeOptionalString(serviceConfig.moduleSource); let inferredModuleSource = null; if (normalizeOptionalString(serviceDeployHints.providerModuleSource)) { try { inferredModuleSource = resolveSiblingTerraformModuleSource(serviceDeployHints.providerModuleSource, service.moduleRelativePath); } catch { inferredModuleSource = null; } } return { moduleSource: configuredModuleSource ?? inferredModuleSource ?? service.defaultModuleSource, rootDir: normalizeOptionalString(serviceConfig.rootDir) ?? service.defaultRootDir, templateRootDirectories: service.templateRootDirectories ?? [] }; }; export const resolveServicePlatformTerraformConfig = (serviceConfig, serviceName, serviceDeployHints = {}) => { const serviceTerraformConfig = resolveServiceTerraformConfig(serviceConfig, serviceName, serviceDeployHints); return { clusterModuleSource: resolveSiblingTerraformModuleSource(serviceTerraformConfig.moduleSource, 'platform/gke-cluster'), nodePoolModuleSource: resolveSiblingTerraformModuleSource(serviceTerraformConfig.moduleSource, 'platform/gke-node-pool'), rootDir: DEFAULT_SERVICE_PLATFORM_TERRAFORM_ROOT_DIR, templateRootDirectories: SERVICE_PLATFORM_TEMPLATE_ROOT_DIRECTORIES }; }; export const resolveServiceTerraformRootPath = (serviceConfig, serviceName, serviceDeployHints = {}, cwd = process.cwd()) => resolveRootPath(resolveServiceTerraformConfig(serviceConfig, serviceName, serviceDeployHints).rootDir, cwd); export const createServiceTerraformWorkflowSummary = (context, serviceName, serviceConfig, serviceDeployHints = {}, cwd = process.cwd()) => { const service = getService(serviceName); const terraformConfig = resolveServiceTerraformConfig(serviceConfig, serviceName, serviceDeployHints); return { managedResources: [{ description: service.description, kind: serviceName, name: serviceConfig.releaseName ?? serviceName }], moduleSource: terraformConfig.moduleSource, rootDir: terraformConfig.rootDir, rootPath: resolveRootPath(terraformConfig.rootDir, cwd), templateRootDirectories: terraformConfig.templateRootDirectories }; }; export const createServicePlatformTerraformWorkflowSummary = (context, serviceName, serviceConfig, serviceDeployHints = {}, cwd = process.cwd()) => { const terraformConfig = resolveServicePlatformTerraformConfig(serviceConfig, serviceName, serviceDeployHints); const clusterConfig = createDefaultServicePlatformClusterConfig(context, serviceConfig, serviceDeployHints); const nodePoolConfig = createDefaultServicePlatformNodePoolConfig(serviceConfig); return { clusterModuleSource: terraformConfig.clusterModuleSource, managedResources: [{ description: 'Managed GKE cluster foundation for Atlas services.', kind: 'gke-cluster', name: clusterConfig.name }, { description: 'Managed GKE node pool for Atlas service workloads.', kind: 'gke-node-pool', name: nodePoolConfig.name }], nodePoolModuleSource: terraformConfig.nodePoolModuleSource, rootDir: terraformConfig.rootDir, rootPath: resolveRootPath(terraformConfig.rootDir, cwd), templateRootDirectories: terraformConfig.templateRootDirectories }; }; const loadServiceTerraformRootTemplateFiles = async (moduleSource, rootPath, templateRootDirectories, options = {}) => { const candidateDirectories = (templateRootDirectories ?? []).filter(Boolean); const errors = []; for (const templateRootDirectory of candidateDirectories) { try { return await loadTerraformRootTemplateFiles(moduleSource, rootPath, templateRootDirectory, options); } catch (error) { errors.push(error.message); } } throw new Error(errors.at(-1) ?? `Could not load Terraform root templates for module ${moduleSource}.`); }; const loadServicePlatformTerraformRootTemplateFiles = async (clusterModuleSource, rootPath, templateRootDirectories, options = {}) => { const candidateDirectories = (templateRootDirectories ?? []).filter(Boolean); const errors = []; for (const templateRootDirectory of candidateDirectories) { try { return await loadTerraformRootTemplateFiles(clusterModuleSource, rootPath, templateRootDirectory, options); } catch (error) { errors.push(error.message); } } throw new Error(errors.at(-1) ?? `Could not load Terraform root templates for module ${clusterModuleSource}.`); }; export const ensureServiceTerraformRoot = async (serviceConfig, serviceName, serviceDeployHints = {}, cwd = process.cwd(), options = {}) => { const { existsSyncImpl = fs.existsSync, mkdirSyncImpl = fs.mkdirSync, readFileSyncImpl = fs.readFileSync, templateDirectory, writeFileSyncImpl = fs.writeFileSync } = options; const workflowSummary = createServiceTerraformWorkflowSummary({ config: serviceConfig }, serviceName, getCurrentServiceConfig(serviceConfig, serviceName), serviceDeployHints, cwd); const templateFiles = await loadServiceTerraformRootTemplateFiles(workflowSummary.moduleSource, cwd, workflowSummary.templateRootDirectories, { ...options, templateDirectory }); const rootFiles = renderTerraformRootTemplateFiles(templateFiles, { __ATLAS_SEARCH_WORKLOAD_TERRAFORM_CONTRACT_VERSION__: SERVICE_TERRAFORM_CONTRACT_VERSION, __ATLAS_SEARCH_WORKLOAD_TERRAFORM_INPUT_VARIABLE__: SERVICE_TERRAFORM_INPUT_VARIABLE, __ATLAS_SEARCH_WORKLOAD_TERRAFORM_MODULE_SOURCE__: workflowSummary.moduleSource, __ATLAS_SERVICE_TERRAFORM_CONTRACT_VERSION__: SERVICE_TERRAFORM_CONTRACT_VERSION, __ATLAS_SERVICE_TERRAFORM_INPUT_VARIABLE__: SERVICE_TERRAFORM_INPUT_VARIABLE, __ATLAS_SERVICE_TERRAFORM_MODULE_SOURCE__: workflowSummary.moduleSource }); const createdFiles = []; const updatedFiles = []; if (!existsSyncImpl(workflowSummary.rootPath)) { mkdirSyncImpl(workflowSummary.rootPath, { recursive: true }); } for (const [fileName, content] of Object.entries(rootFiles)) { const filePath = path.join(workflowSummary.rootPath, fileName); if (!existsSyncImpl(filePath)) { writeFileSyncImpl(filePath, content); createdFiles.push(filePath); continue; } if (readFileSyncImpl(filePath, 'utf-8') === content) { continue; } writeFileSyncImpl(filePath, content); updatedFiles.push(filePath); } return { ...workflowSummary, createdFiles, updatedFiles }; }; export const ensureServicePlatformTerraformRoot = async (context, serviceName, serviceConfig, serviceDeployHints = {}, cwd = process.cwd(), options = {}) => { const { existsSyncImpl = fs.existsSync, mkdirSyncImpl = fs.mkdirSync, platformTemplateDirectory, readFileSyncImpl = fs.readFileSync, writeFileSyncImpl = fs.writeFileSync } = options; const workflowSummary = createServicePlatformTerraformWorkflowSummary(context, serviceName, serviceConfig, serviceDeployHints, cwd); const templateFiles = await loadServicePlatformTerraformRootTemplateFiles(workflowSummary.clusterModuleSource, cwd, workflowSummary.templateRootDirectories, { ...options, templateDirectory: platformTemplateDirectory }); const rootFiles = renderTerraformRootTemplateFiles(templateFiles, { __ATLAS_SEARCH_PROVIDER_GKE_CLUSTER_MODULE_SOURCE__: workflowSummary.clusterModuleSource, __ATLAS_SEARCH_PROVIDER_GKE_NODE_POOL_MODULE_SOURCE__: workflowSummary.nodePoolModuleSource, __ATLAS_SEARCH_PROVIDER_PLATFORM_TERRAFORM_CONTRACT_VERSION__: SERVICE_PLATFORM_TERRAFORM_CONTRACT_VERSION, __ATLAS_SEARCH_PROVIDER_PLATFORM_TERRAFORM_INPUT_VARIABLE__: SERVICE_PLATFORM_TERRAFORM_INPUT_VARIABLE }); const createdFiles = []; const updatedFiles = []; if (!existsSyncImpl(workflowSummary.rootPath)) { mkdirSyncImpl(workflowSummary.rootPath, { recursive: true }); } for (const [fileName, content] of Object.entries(rootFiles)) { const filePath = path.join(workflowSummary.rootPath, fileName); if (!existsSyncImpl(filePath)) { writeFileSyncImpl(filePath, content); createdFiles.push(filePath); continue; } if (readFileSyncImpl(filePath, 'utf-8') === content) { continue; } writeFileSyncImpl(filePath, content); updatedFiles.push(filePath); } return { ...workflowSummary, createdFiles, updatedFiles }; }; export const createServiceTerraformPayload = (context, serviceName, serviceConfig, options = {}) => { const service = getService(serviceName); return service.createTerraformPayload(context, serviceConfig, { contractVersion: SERVICE_TERRAFORM_CONTRACT_VERSION, resourceLabels: createServiceResourceLabels(context, serviceName), runtimeInputs: options.runtimeInputs, terraformConnection: options.terraformConnection }); }; export const createServicePlatformTerraformPayload = (context, serviceName, serviceConfig, serviceDeployHints = {}) => { const clusterConfig = createDefaultServicePlatformClusterConfig(context, serviceConfig, serviceDeployHints); const nodePoolConfig = createDefaultServicePlatformNodePoolConfig(serviceConfig); const searchProviderRuntimeConfig = serviceDeployHints.searchConfig?.deploy?.providerRuntime ?? {}; return { cluster: { deletion_protection: clusterConfig.deletionProtection, kubernetes_version: clusterConfig.kubernetesVersion, location: clusterConfig.location, name: clusterConfig.name, private_cluster: clusterConfig.privateCluster, release_channel: clusterConfig.releaseChannel }, contract_version: SERVICE_PLATFORM_TERRAFORM_CONTRACT_VERSION, environment: context.environment ?? null, network: normalizeOptionalString(searchProviderRuntimeConfig.network) ?? 'default', node_pool: { disk_size_gb: nodePoolConfig.diskSizeGb, disk_type: nodePoolConfig.diskType, machine_type: nodePoolConfig.machineType, max_node_count: nodePoolConfig.maxNodeCount, min_node_count: nodePoolConfig.minNodeCount, name: nodePoolConfig.name, node_labels: nodePoolConfig.nodeLabels, node_taints: nodePoolConfig.nodeTaints, service_account_email: nodePoolConfig.serviceAccountEmail, spot: nodePoolConfig.spot }, project_id: context.projectId, resource_labels: { ...createServiceResourceLabels(context, serviceName), 'atlas-platform': 'gke-platform', 'atlas-runtime': 'platform' }, subnetwork: normalizeOptionalString(searchProviderRuntimeConfig.subnetwork) ?? null }; }; export const getServiceTerraformArtifactPath = (projectId, serviceName, cwd = process.cwd()) => path.join(getAtlasGeneratedTerraformDir(cwd), 'services', `service.${serviceName}.${projectId}.tfvars.json`); export const getServicePlatformTerraformArtifactPath = (projectId, cwd = process.cwd()) => path.join(getAtlasGeneratedTerraformDir(cwd), 'services', `platform.gke.${projectId}.tfvars.json`); export const writeServiceTerraformArtifact = (context, serviceName, serviceConfig, options = {}, cwd = process.cwd()) => { const writeJsonFileImpl = options.writeJsonFile ?? writeJsonFile; const filePath = getServiceTerraformArtifactPath(context.projectId, serviceName, cwd); const payload = createServiceTerraformPayload(context, serviceName, serviceConfig, options); writeJsonFileImpl(filePath, payload); return { contractVersion: payload.contract_version, filePath, payload }; }; export const writeServicePlatformTerraformArtifact = (context, serviceName, serviceConfig, serviceDeployHints = {}, options = {}, cwd = process.cwd()) => { const writeJsonFileImpl = options.writeJsonFile ?? writeJsonFile; const filePath = getServicePlatformTerraformArtifactPath(context.projectId, cwd); const payload = createServicePlatformTerraformPayload(context, serviceName, serviceConfig, serviceDeployHints); writeJsonFileImpl(filePath, payload); return { contractVersion: payload.contract_version, filePath, payload }; }; export const runServicePlatformTerraformWorkflow = async (context, serviceName, serviceConfig, serviceDeployHints, terraformArtifact, options = {}, dependencies = {}, cwd = process.cwd()) => runServicePlatformTerraformWorkflowImpl(context, serviceName, serviceConfig, serviceDeployHints, terraformArtifact, options, { ...dependencies, ensureServicePlatformTerraformRoot: dependencies.ensureServicePlatformTerraformRoot ?? ensureServicePlatformTerraformRoot, terraformInputVariableName: SERVICE_PLATFORM_TERRAFORM_INPUT_VARIABLE }, cwd); export const readServicePlatformTerraformOutputs = async (context, serviceName, serviceConfig, serviceDeployHints, dependencies = {}, cwd = process.cwd()) => readServicePlatformTerraformOutputsImpl(context, serviceName, serviceConfig, serviceDeployHints, { ...dependencies, ensureServicePlatformTerraformRoot: dependencies.ensureServicePlatformTerraformRoot ?? ensureServicePlatformTerraformRoot }, cwd); export const runServiceTerraformWorkflow = async (context, serviceName, serviceConfig, serviceDeployHints, terraformArtifact, options = {}, dependencies = {}, cwd = process.cwd()) => runServiceTerraformWorkflowImpl(context, serviceName, serviceConfig, serviceDeployHints, terraformArtifact, options, { ...dependencies, ensureServiceTerraformRoot: dependencies.ensureServiceTerraformRoot ?? ensureServiceTerraformRoot, terraformInputVariableName: SERVICE_TERRAFORM_INPUT_VARIABLE }, cwd); export const readServiceTerraformOutputs = async (context, serviceName, serviceConfig, serviceDeployHints, dependencies = {}, cwd = process.cwd()) => readServiceTerraformOutputsImpl(context, serviceName, serviceConfig, serviceDeployHints, { ...dependencies, ensureServiceTerraformRoot: dependencies.ensureServiceTerraformRoot ?? ensureServiceTerraformRoot }, cwd); export const createServiceSecretPayload = async (serviceName, serviceConfig, outputs, dependencies = {}) => { const service = getService(serviceName); return await service.createSecretPayload(outputs, serviceConfig, dependencies); }; export const syncServiceAccessSecret = async (context, serviceName, serviceConfig, terraformOutputs, dependencies = {}) => { const upsertSecretImpl = dependencies.upsertSecret ?? upsertSecret; const secretName = normalizeOptionalString(serviceConfig.accessSecret) ?? getService(serviceName).createDefaultConfigSection().accessSecret; const payload = await createServiceSecretPayload(serviceName, serviceConfig, terraformOutputs, dependencies); const result = await upsertSecretImpl(secretName, payload, context.projectId); const payloadData = (() => { try { return JSON.parse(payload); } catch { return null; } })(); return { adminEmail: payloadData?.adminEmail ?? terraformOutputs.admin_email ?? null, ingressIpAddress: terraformOutputs.ingress_ip_address ?? null, secretName, status: result.status, url: payloadData?.url ?? terraformOutputs.url ?? null, warnings: [] }; }; export const logServiceDeploySummary = (context, serviceName, serviceConfig, clusterTarget, terraformWorkflow, terraformArtifact, secretSync, loggerImpl = logger) => { loggerImpl.summary('Service deployment', [{ label: 'Project', value: context.projectId }, context.environment ? { label: 'Environment', value: context.environment } : null, { label: 'Service', value: serviceName }, { label: 'Namespace', value: serviceConfig.namespace }, { label: 'Host', value: serviceConfig.host }, { label: 'GKE cluster', value: clusterTarget.clusterName }, { label: 'GKE location', value: clusterTarget.clusterLocation }, secretSync?.url ? { label: 'URL', value: secretSync.url } : null, secretSync ? { label: 'Access secret', value: `${secretSync.secretName} (${secretSync.status}).` } : null]); if (secretSync?.ingressIpAddress) { loggerImpl.summary('DNS setup', [{ label: 'Record type', value: 'A' }, { label: 'Host', value: serviceConfig.host }, { label: 'Target IP', value: secretSync.ingressIpAddress }, { label: 'Instruction', value: `Point ${serviceConfig.host} to ${secretSync.ingressIpAddress}.` }]); } if (secretSync) { loggerImpl.summary('Login', [secretSync.url ? { label: 'URL', value: secretSync.url } : null, secretSync.adminEmail ?? serviceConfig.adminEmail ? { label: 'Username', value: secretSync.adminEmail ?? serviceConfig.adminEmail } : null, { label: 'Password source', value: secretSync.status === 'dry-run' ? `${secretSync.secretName} (after apply).` : `${secretSync.secretName} (read the "password" field).` }]); } loggerImpl.summary('Terraform workspace', [{ label: 'Terraform root', value: terraformWorkflow?.rootPath ?? 'skipped' }, terraformArtifact ? { label: 'Terraform input', value: `${SERVICE_TERRAFORM_INPUT_VARIABLE}=${terraformArtifact.filePath}` } : null, terraformWorkflow?.moduleSource ? { label: 'Terraform module source', value: terraformWorkflow.moduleSource } : null]); };