UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

419 lines (417 loc) 14.1 kB
import { useLogger } from "../logger/index.js"; import { getMilliseconds } from "../utils/get-milliseconds.js"; import { getCache, getCacheValueWithTTL, setCacheValueWithExpiry } from "../cache.js"; import { ItemsService } from "./items.js"; import { getDeploymentDriver } from "../deployment.js"; import { parseValue } from "../utils/parse-value.js"; import { DeploymentProjectsService } from "./deployment-projects.js"; import { DeploymentRunsService } from "./deployment-runs.js"; import { useEnv } from "@directus/env"; import { InvalidPayloadError, InvalidProviderConfigError } from "@directus/errors"; import { mergeFilters } from "@directus/utils"; import { has, isEmpty } from "lodash-es"; //#region src/services/deployment.ts const env = useEnv(); const DEPLOYMENT_CACHE_TTL = getMilliseconds(env["CACHE_DEPLOYMENT_TTL"]) || 5e3; const SYNC_THRESHOLD_MS = 3600 * 1e3; var DeploymentService = class DeploymentService extends ItemsService { constructor(options) { super("directus_deployments", options); } async createOne(data, opts) { const provider = data.provider; if (!provider) throw new InvalidPayloadError({ reason: "Provider is required" }); if (isEmpty(data.credentials)) throw new InvalidPayloadError({ reason: "Credentials are required" }); let credentials; try { credentials = parseValue(data.credentials, {}); } catch { throw new InvalidPayloadError({ reason: "Credentials must be valid JSON" }); } let options; try { options = parseValue(data.options, void 0); } catch { throw new InvalidPayloadError({ reason: "Options must be valid JSON" }); } const driver = getDeploymentDriver(provider, credentials, options); try { await driver.testConnection(); } catch { throw new InvalidProviderConfigError({ provider, reason: "Invalid config connection" }); } const payload = { ...data, credentials: JSON.stringify(credentials) }; if (!isEmpty(options)) payload.options = JSON.stringify(options); return super.createOne(payload, opts); } async updateOne(key, data, opts) { const hasCredentials = has(data, "credentials"); const hasOptions = has(data, "options"); if (!hasCredentials && !hasOptions) return super.updateOne(key, data, opts); const existing = await this.readOne(key); const provider = existing.provider; let credentials = parseValue((await this.readConfig(provider)).credentials, {}); if (hasCredentials) try { const parsed = parseValue(data.credentials, {}); credentials = { ...credentials, ...parsed }; } catch { throw new InvalidPayloadError({ reason: "Credentials must be valid JSON or object" }); } let options = existing.options ?? void 0; if (hasOptions) { try { options = parseValue(data.options, void 0); } catch { throw new InvalidPayloadError({ reason: "Options must be valid JSON" }); } if (isEmpty(options)) throw new InvalidPayloadError({ reason: "Options must not be empty" }); } const driver = getDeploymentDriver(provider, credentials, options); try { await driver.testConnection(); } catch { throw new InvalidProviderConfigError({ provider, reason: "Invalid config connection" }); } return super.updateOne(key, { credentials: JSON.stringify(credentials), ...!isEmpty(options) ? { options: JSON.stringify(options) } : {} }, opts); } /** * Read deployment config by provider */ async readByProvider(provider, query) { const results = await this.readByQuery({ ...query, filter: mergeFilters({ provider: { _eq: provider } }, query?.filter ?? null), limit: 1 }); if (!results || results.length === 0) throw new Error(`Deployment config for "${provider}" not found`); return results[0]; } /** * Update deployment config by provider */ async updateByProvider(provider, data) { const deployment = await this.readByProvider(provider); return this.updateOne(deployment.id, data); } /** * Delete deployment config by provider */ async deleteByProvider(provider) { const deployment = await this.readByProvider(provider); const primaryKey = await this.deleteOne(deployment.id); if (deployment.webhook_ids && deployment.webhook_ids.length > 0) try { await (await this.getDriver(provider)).unregisterWebhook(deployment.webhook_ids); } catch (err) { useLogger().error(`Failed to unregister webhook for ${provider}: ${err}`); } return primaryKey; } /** * Read deployment config with decrypted credentials (internal use) */ async readConfig(provider) { const results = await new ItemsService("directus_deployments", { knex: this.knex, schema: this.schema, accountability: null }).readByQuery({ filter: { provider: { _eq: provider } }, limit: 1 }); if (!results || results.length === 0) throw new Error(`Deployment config for "${provider}" not found`); return results[0]; } /** * Get webhook config for a provider */ async getWebhookConfig(provider) { const config = await this.readConfig(provider); return { deployment_id: config.id, webhook_secret: config.webhook_secret ?? null, credentials: parseValue(config.credentials, {}), options: parseValue(config.options, {}) }; } /** * Get a deployment driver instance with decrypted credentials */ async getDriver(provider) { const deployment = await this.readConfig(provider); const credentials = parseValue(deployment.credentials, {}); const options = parseValue(deployment.options, {}); return getDeploymentDriver(deployment.provider, credentials, options); } /** * Sync webhook registration with current tracked projects. */ async syncWebhook(provider) { const logger = useLogger(); logger.debug(`[webhook:${provider}] Starting webhook sync`); const config = await this.readConfig(provider); const projectExternalIds = (await new ItemsService("directus_deployment_projects", { knex: this.knex, schema: this.schema, accountability: null }).readByQuery({ filter: { deployment: { _eq: config.id } }, limit: -1 })).map((p) => p.external_id); const driver = await this.getDriver(provider); if (projectExternalIds.length === 0) { if (config.webhook_ids && config.webhook_ids.length > 0) { logger.debug(`[webhook:${provider}] No projects, unregistering ${config.webhook_ids.length} webhook(s)`); try { await driver.unregisterWebhook(config.webhook_ids); } catch (err) { logger.warn(`[webhook:${provider}] Failed to unregister: ${err}`); } await super.updateOne(config.id, { webhook_ids: null, webhook_secret: null }); } return; } if (config.webhook_ids && config.webhook_ids.length > 0) { logger.debug(`[webhook:${provider}] Unregistering ${config.webhook_ids.length} existing webhook(s)`); try { await driver.unregisterWebhook(config.webhook_ids); } catch (err) { logger.warn(`[webhook:${provider}] Failed to unregister: ${err}`); } } const webhookUrl = `${env["PUBLIC_URL"]}/deployments/webhooks/${provider}`; logger.debug(`[webhook:${provider}] Registering webhook → ${webhookUrl} for ${projectExternalIds.length} project(s)`); const result = await driver.registerWebhook(webhookUrl, projectExternalIds); await super.updateOne(config.id, { webhook_ids: result.webhook_ids, webhook_secret: result.webhook_secret }); logger.info(`[webhook:${provider}] Registered ${result.webhook_ids.length} webhook(s): [${result.webhook_ids.join(", ")}]`); } /** * List projects from provider with caching */ async listProviderProjects(provider) { const cacheKey = `${provider}:projects`; const { deploymentCache } = getCache(); const cached = await getCacheValueWithTTL(deploymentCache, cacheKey); if (cached) return { data: cached.data, remainingTTL: cached.remainingTTL }; const projects = await (await this.getDriver(provider)).listProjects(); await setCacheValueWithExpiry(deploymentCache, cacheKey, projects, DEPLOYMENT_CACHE_TTL); return { data: projects, remainingTTL: DEPLOYMENT_CACHE_TTL }; } /** * Get project details from provider with caching */ async getProviderProject(provider, projectId) { const cacheKey = `${provider}:project:${projectId}`; const { deploymentCache } = getCache(); const cached = await getCacheValueWithTTL(deploymentCache, cacheKey); if (cached) return { data: cached.data, remainingTTL: cached.remainingTTL }; const project = await (await this.getDriver(provider)).getProject(projectId); await setCacheValueWithExpiry(deploymentCache, cacheKey, project, DEPLOYMENT_CACHE_TTL); return { data: project, remainingTTL: DEPLOYMENT_CACHE_TTL }; } /** * Dashboard: projects + latest run status + stats */ async getDashboard(provider, sinceDate) { const projectsService = new DeploymentProjectsService({ accountability: this.accountability, schema: this.schema }); const runsService = new DeploymentRunsService({ accountability: this.accountability, schema: this.schema }); const deployment = await this.readByProvider(provider); const selectedProjects = await projectsService.readByQuery({ filter: { deployment: { _eq: deployment.id } }, limit: -1 }); if (selectedProjects.length === 0) return { projects: [], stats: { active_deployments: 0, successful_builds: 0, failed_builds: 0 } }; const projectIds = selectedProjects.map((p) => p.id); const [latestRuns, activeResult, statusCounts] = await Promise.all([ Promise.all(projectIds.map(async (projectId) => { return { projectId, run: (await runsService.readByQuery({ filter: { project: { _eq: projectId } }, sort: ["-date_created"], limit: 1 }))?.[0] ?? null }; })), runsService.readByQuery({ filter: { project: { _in: projectIds }, status: { _eq: "building" } }, aggregate: { count: ["*"] } }), runsService.readByQuery({ filter: { _and: [ { project: { _in: projectIds } }, { status: { _in: ["ready", "error"] } }, { date_created: { _gte: sinceDate.toISOString() } } ] }, aggregate: { count: ["*"] }, group: ["status"] }) ]); const latestRunMap = new Map(latestRuns.map((r) => [r.projectId, r.run])); const countByStatus = (status) => Number(statusCounts.find((r) => r["status"] === status)?.["count"] ?? 0); this.syncProjectMetadataIfStale(provider, deployment).catch((err) => { useLogger().error(`Failed to sync project metadata for ${provider}: ${err}`); }); return { projects: selectedProjects.map((p) => { const latestRun = latestRunMap.get(p.id); return { id: p.id, external_id: p.external_id, name: p.name, url: p.url, framework: p.framework, deployable: p.deployable, ...latestRun && { latest_deployment: { status: latestRun.status, created_at: latestRun.started_at ?? latestRun.date_created, finished_at: latestRun.completed_at ?? null } } }; }), stats: { active_deployments: Number(activeResult[0]?.["count"] ?? 0), successful_builds: countByStatus("ready"), failed_builds: countByStatus("error") } }; } /** * Refresh project metadata (name, url, framework, deployable) if stale. */ async syncProjectMetadataIfStale(provider, deployment) { if (deployment.last_synced_at) { const lastSync = new Date(deployment.last_synced_at).getTime(); if (Date.now() - lastSync < SYNC_THRESHOLD_MS) return; } useLogger().debug(`[metadata:${provider}] Syncing project metadata`); const projectsService = new DeploymentProjectsService({ accountability: null, schema: this.schema }); const driver = await this.getDriver(provider); const selectedProjects = await projectsService.readByQuery({ filter: { deployment: { _eq: deployment.id } }, limit: -1 }); const updates = await Promise.all(selectedProjects.map(async (p) => { const details = await driver.getProject(p.external_id); return { id: p.id, name: details.name, url: details.url ?? null, framework: details.framework ?? null, deployable: details.deployable }; })); if (updates.length > 0) await projectsService.updateBatch(updates); await new DeploymentService({ accountability: null, schema: this.schema }).updateOne(deployment.id, { last_synced_at: (/* @__PURE__ */ new Date()).toISOString() }); } /** * Trigger a deployment for a project */ async triggerDeployment(provider, projectId, options) { const projectsService = new DeploymentProjectsService({ accountability: this.accountability, schema: this.schema }); const runsService = new DeploymentRunsService({ accountability: this.accountability, schema: this.schema }); const project = await projectsService.readOne(projectId); const result = await (await this.getDriver(provider)).triggerDeployment(project.external_id, { preview: options.preview, clearCache: options.clearCache }); const runId = await runsService.createOne({ project: projectId, external_id: result.deployment_id, target: options.preview ? "preview" : "production", status: result.status, started_at: result.created_at.toISOString(), ...result.url ? { url: result.url } : {} }); return runsService.readOne(runId); } /** * Cancel a deployment run */ async cancelDeployment(provider, runId) { const runsService = new DeploymentRunsService({ accountability: this.accountability, schema: this.schema }); const run = await runsService.readOne(runId); const status = await (await this.getDriver(provider)).cancelDeployment(run.external_id); await runsService.updateOne(runId, { status }); return runsService.readOne(runId); } /** * Get a run with its logs from the provider */ async getRunWithLogs(provider, runId, since) { const run = await new DeploymentRunsService({ accountability: this.accountability, schema: this.schema }).readOne(runId); const logs = await (await this.getDriver(provider)).getDeploymentLogs(run.external_id, since ? { since } : void 0); return { ...run, logs }; } }; //#endregion export { DeploymentService };