src/domain/hosting-deployment.js
Rama main · réplica de lectura del repositorio original.
import { getLocalModelDefinition } from "../local-model/catalog.mjs";
import { getHostingProvider } from "./hosting-providers.js";
import { PLATFORM_HOSTING_PROFILES } from "./hosting-platform-profiles.js";
import { SCHEDULER_HOSTING_PROFILES } from "./hosting-scheduler-profiles.js";
import { CLOUD_HOSTING_PROFILES } from "./hosting-cloud-profiles.js";
import { EDGE_HOSTING_PROFILES } from "./hosting-edge-profiles.js";
const profiles = [...PLATFORM_HOSTING_PROFILES, ...SCHEDULER_HOSTING_PROFILES, ...CLOUD_HOSTING_PROFILES, ...EDGE_HOSTING_PROFILES];
const deploymentIdPattern = /^[a-f0-9]{12}$/u;
const pathSegment = /^(?:[a-zA-Z0-9][a-zA-Z0-9._-]*|\.dockerignore)$/u;
const documentationSources = {
model: [{ label: "Ollama pull API", url: "https://docs.ollama.com/api/pull" }],
searxng: [{ label: "SearXNG JSON search API", url: "https://docs.searxng.org/dev/search_api.html" }],
assistant: [{ label: "Self-hosted n8n Assistant and Daytona", url: "https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-n8n-assistant/" }],
endpoint: [],
};
function plainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
}
function exactOwnDataKeys(value, allowed, required = allowed) {
if (!plainObject(value)) return false;
const keys = Object.keys(value);
return keys.every(key => allowed.includes(key) && Object.hasOwn(value, key) && Object.getOwnPropertyDescriptor(value, key)?.get === undefined && Object.getOwnPropertyDescriptor(value, key)?.set === undefined) &&
required.every(key => Object.hasOwn(value, key));
}
function normalizedInputs(value, fields) {
const allowed = fields.map(field => field.name);
if (!exactOwnDataKeys(value, allowed, fields.filter(field => field.required).map(field => field.name))) {
throw new TypeError("Deployment fields must match the selected profile exactly.");
}
const normalized = {};
for (const field of fields) {
if (!Object.hasOwn(value, field.name)) continue;
const input = value[field.name];
if (field.type === "number") {
if (!Number.isSafeInteger(input) || (field.min !== undefined && input < field.min) ||
(field.max !== undefined && input > field.max)) throw new TypeError("A deployment number is outside its allowed range.");
} else if (field.type === "text") {
if (typeof input !== "string" || input.length === 0 || input.length > 512 ||
!field.pattern || !new RegExp(field.pattern, "u").test(input) || /[\u0000-\u001f\u007f]/u.test(input)) {
throw new TypeError("A deployment text field is invalid.");
}
} else if (field.type !== "select") {
throw new TypeError("The selected deployment profile is invalid.");
}
if (field.type === "select" && !field.options.some(option => option.value === input)) {
throw new TypeError("A deployment choice is not available.");
}
normalized[field.name] = input;
}
return normalized;
}
function validFile(file) {
return exactOwnDataKeys(file, ["name", "content", "mediaType"]) &&
typeof file.name === "string" && file.name.length <= 128 && file.name.split("/").every(segment => pathSegment.test(segment) && segment !== "." && segment !== "..") &&
typeof file.content === "string" && file.content.length > 0 && typeof file.mediaType === "string" &&
/^[a-z]+\/[a-z0-9.+-]+$/u.test(file.mediaType);
}
function validateRenderedBundle(bundle) {
if (!plainObject(bundle) || !Array.isArray(bundle.files) || !Array.isArray(bundle.settings) ||
!Array.isArray(bundle.steps) || !Array.isArray(bundle.requirements) || !Array.isArray(bundle.limitations) ||
!plainObject(bundle.connection) || bundle.steps.length === 0 ||
!bundle.files.every(validFile) || new Set(bundle.files.map(file => file.name)).size !== bundle.files.length ||
bundle.files.some(file => file.name === "INSTRUCTIONS.md") ||
!bundle.settings.every(row => exactOwnDataKeys(row, ["name", "value"]) && typeof row.name === "string" && typeof row.value === "string") ||
![bundle.steps, bundle.requirements, bundle.limitations].every(items => items.every(item => typeof item === "string" && item.length > 0))) {
throw new TypeError("The deployment profile returned an incomplete plan.");
}
}
function instructionDocument({ provider, component, resourceName, files, settings, steps, connection, requirements, limitations, sources }) {
const list = items => items.length ? items.map(item => `- ${item}`).join("\n") : "- None.";
const settingsList = settings.map(({ name, value }) => `- ${name}: ${value}`);
const connectionList = Object.entries(connection).map(([name, value]) => `- ${name}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
return `# ${provider.label}: ${component} handoff
Resource name: ${resourceName}
Status: local artifacts only; remote runtime NOT-RUN. Operator-owned manual deployment, not a managed host install.
## Before any operator action
Review the exact new resource name and all proposed files/settings against your account to avoid adopting or replacing an existing resource. Check current cost, privileges, network access and approval. This generator has not connected to a provider, executed a command, written a remote file, changed n8n, or deployed anything.
## Requirements
${list(requirements)}
## Downloaded files
${list(files.map(file => file.name))}
Keep these files together in their relative paths. Only a reviewed operator deploys them; INSTRUCTIONS.md is this guide, not a provider configuration file. If there is no provider file, the exact dashboard/operator procedure below is the deployment handoff.
## Nonsecret settings
${list(settingsList)}
Create any named runtime secret in the provider's secret store using an independently generated or account-owned value. Secret values are never in this bundle. Do not paste secrets into source, command histories, shared plans, or downloads.
## Connection after operator deployment
${list(connectionList)}
## Operator procedure and meaningful verification
${steps.map((step, index) => `${index + 1}. ${step}`).join("\n")}
## Limits and unverified runtime
${list(limitations)}
## Reviewed sources
${list(sources.map(source => `[${source.label}](${source.url})`))}
`;
}
export function getHostingDeploymentProfiles() {
return profiles.map(({ providerId, component, fields }) => ({
providerId, component,
fields: fields.map(field => ({ ...field, ...(field.options ? { options: field.options.map(option => ({ ...option })) } : {}) })),
}));
}
export function createHostingDeploymentPlan(request) {
if (!exactOwnDataKeys(request, ["providerId", "component", "deploymentId", "modelId", "inputs"], ["providerId", "component", "deploymentId", "inputs"]) ||
typeof request.providerId !== "string" || typeof request.component !== "string" ||
typeof request.deploymentId !== "string" || !deploymentIdPattern.test(request.deploymentId) ||
!["model", "searxng", "assistant", "endpoint"].includes(request.component) ||
Object.hasOwn(request, "modelId") !== (request.component === "model")) {
throw new TypeError("Invalid deployment request.");
}
const provider = getHostingProvider(request.providerId);
const profile = profiles.find(item => item.providerId === request.providerId && item.component === request.component);
if (!profile) throw new RangeError("No manual deployment profile exists for this provider and component.");
const model = request.component === "model" ? getLocalModelDefinition(request.modelId) : null;
const inputs = normalizedInputs(request.inputs, profile.fields);
const resourceName = `relmio-${request.component}-${request.deploymentId}`;
const context = { providerId: provider.id, component: request.component, deploymentId: request.deploymentId, resourceName, model, inputs };
let rendered;
try {
rendered = profile.render(context);
} catch {
throw new TypeError("The selected deployment fields cannot be combined safely.");
}
validateRenderedBundle(rendered);
const sources = [...provider.sources, ...documentationSources[request.component]];
const guide = instructionDocument({ provider, component: request.component, resourceName, ...rendered, sources });
return {
providerId: provider.id, component: request.component, deploymentId: request.deploymentId,
resourceName, kind: "manual", verification: "not-live-tested", reviewedOn: provider.reviewedOn,
sources, ...rendered,
files: [...rendered.files, { name: "INSTRUCTIONS.md", content: guide, mediaType: "text/markdown" }],
};
}