src/domain/hosting-platform-profiles.js
Rama main · réplica de lectura del repositorio original.
import { LOCAL_MODEL_RUNTIME_IMAGE } from "./local-n8n-model.js";
import { createSearchImageFiles, modelEnvironment, searchEnvironment } from "./hosting-deployment-common.js";
const RENDER_REGIONS = ["oregon", "ohio", "frankfurt", "singapore", "virginia"];
const RENDER_PLANS = ["2c-4g", "2c-8g", "2c-16g", "4c-16g", "4c-32g", "8c-32g"];
const option = value => ({ value, label: value });
const renderRegion = { name: "region", label: "Existing n8n region", type: "select", required: true, options: RENDER_REGIONS.map(option), description: "Choose the actual Render region with private-network access to the n8n backend." };
const flyRegion = { name: "region", label: "Existing n8n Fly region", type: "text", required: true, pattern: "^[a-z]{3}$", description: "Three-letter Fly region code where the n8n backend can reach this private app." };
const searchRequirements = ["n8n's execution backend must reach the private search service on port 8080; approve outbound HTTPS/DNS from SearXNG to search engines.", "Generate a unique, cryptographically random secret (at least 32 characters) in the provider secret store as SEARXNG_SECRET; never place it in this bundle or source control."];
const searchLimitations = ["NOT-RUN on a provider: JSON search, backend routing, egress/rate limits, memory and account policy require an operator-owned runtime check.", "Search cache is intentionally disposable; configuration is baked into the image. This is not a public search proxy."];
const modelLimitations = ["NOT-RUN on a provider: model download, real inference, capacity, private DNS and account policy require an operator-owned runtime check.", "The Ollama image is pinned; manual pulling of a mutable model tag is not Relmio's managed model-digest attestation. A successful pull or TCP health is not inference-ready evidence."];
function checkModelCapacity(model, memoryGB, diskGB) {
if (model.memoryBytes + 1024 ** 3 > memoryGB * 1024 ** 3 ||
(diskGB !== undefined && model.expectedDownloadBytes * 2 + 5 * 1024 ** 3 > diskGB * 1024 ** 3)) {
throw new RangeError("The selected compute or persistent cache cannot cover this model's planning allowance.");
}
}
function modelPullSteps(model, baseUrl) {
return [
`After deploying the private service, from an operator-controlled n8n backend on its permitted private network use an HTTP Request: POST ${baseUrl.replace(/\/v1$/u, "")}/api/pull, JSON body ${JSON.stringify({ model: model.id, stream: false })}, with a download-appropriate timeout. Require terminal status success; do not publish the unauthenticated model-management port to make the pull work.`,
`Then execute a fresh, bounded n8n Chat Completions workflow using ${baseUrl}, model ${model.id}, Responses OFF and reasoning_effort none when applicable. Require a real answer; a successful pull or model list is insufficient.`,
];
}
function yamlEnvironment(env) {
return Object.entries(env).map(([key, value]) => ` - key: ${key}\n value: '${value}'`).join("\n");
}
function renderModel({ resourceName, model, inputs }) {
const memoryGB = Number(inputs.plan.split("-")[1].replace("g", ""));
if (inputs.diskGB % 5 !== 0) throw new TypeError("Render disk size must be a multiple of 5 GB.");
checkModelCapacity(model, memoryGB, inputs.diskGB);
const env = { ...modelEnvironment(model), PORT: "11434" };
const baseUrl = "the actual Render Connect Internal address on port 11434/v1";
return {
files: [{ name: "render.yaml", mediaType: "text/yaml", content: `services:
- type: pserv
runtime: image
name: ${resourceName}
region: ${inputs.region}
plan: ${inputs.plan}
numInstances: 1
image:
url: ${LOCAL_MODEL_RUNTIME_IMAGE}
disk:
name: ${resourceName}-data
mountPath: /var/data
sizeGB: ${inputs.diskGB}
envVars:
${yamlEnvironment(env)}
` }],
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: [
"Review service name against your account before creating a NEW Render Blueprint for only this private service. Do not sync this file into a Blueprint managing existing n8n, databases or other services: names alone cannot prove non-adoption; do not remove existing resources.",
`Deploy this single private service in ${inputs.region} on ${inputs.plan}, with its own ${inputs.diskGB} GB runtime disk. Leave the image's default ollama serve startup intact; disk is mounted only while service runs. Verify that the private service has no public onrender.com route.`,
"In Render Connect copy the actual Internal address, append :11434/v1 for the n8n credential, and use its same host on port 11434 for model acquisition; do not use a guessed hostname. Check the n8n worker is in the same permitted private network/region.",
...modelPullSteps(model, baseUrl),
],
connection: { discovery: "Copy Render Connect Internal address and append :11434/v1", protocol: "OpenAI-compatible Chat Completions", authentication: "private-network-only; no public raw model API" },
requirements: ["Render paid private service and persistent disk; confirm current price/capacity before creation.", "Render and the n8n execution backend must share an allowed private network; model download needs approved outbound access."],
limitations: [...modelLimitations, "Render disks are per instance and unavailable during build/pre-deploy; disk attachment disables zero-downtime deploys."],
};
}
function renderSearch({ resourceName, inputs }) {
const env = { ...searchEnvironment(), PORT: "8080" };
const files = createSearchImageFiles();
files.push({ name: "render.yaml", mediaType: "text/yaml", content: `services:
- type: pserv
runtime: docker
name: ${resourceName}
region: ${inputs.region}
plan: ${inputs.plan}
numInstances: 1
envVars:
${yamlEnvironment(env)}
- key: SEARXNG_SECRET
sync: false
` });
return {
files,
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: ["In a fresh Git repository containing ONLY these generated search files, review the unique name and create a new Render Blueprint private service. Do not synchronize a Blueprint that owns existing n8n/resources; the source repository supplies the Dockerfile build context.", "At Blueprint creation enter SEARXNG_SECRET through Render's secret prompt; use a newly generated 32+ character random value, never a tracked env file. Keep the service private and the n8n backend in the same permitted Render region/network.", "From the n8n execution backend copy the actual Render Connect Internal address, request GET /search?q=n8n&format=json at port 8080 and require a JSON object with results array; upstream blocking is distinct from transport failure. Add its private base URL as N8N_INSTANCE_AI_SEARXNG_URL only if this is your selected Assistant search provider; Brave or UI credentials may take precedence."],
connection: { discovery: "Copy Render Connect Internal address and append :8080", testPath: "/search?q=n8n&format=json" },
requirements: searchRequirements,
limitations: searchLimitations,
};
}
function railwayModel({ resourceName, model, inputs }) {
checkModelCapacity(model, inputs.memoryGB, inputs.volumeGB);
const env = modelEnvironment(model, "[::]:11434");
const baseUrl = `http://${resourceName}.railway.internal:11434/v1`;
return {
files: [],
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: [
`In the existing Railway project and exact environment reachable by n8n, create a NEW private service named ${resourceName} from the Docker image ${LOCAL_MODEL_RUNTIME_IMAGE}. Do not select existing n8n, add a public domain/TCP proxy, use deprecated railway.json, or apply a whole-project IaC plan. Preserve the image's default serve command.`,
`Create a new Railway volume for only this service, mount it at /var/data, choose ${inputs.volumeGB} GB in Railway's volume size control (${inputs.volumeGB * 1024} MB), and attach it to the service. The volume is available at runtime, not build/pre-deploy time.`,
`Set the listed environment variables in this new service and review its memory limit: reserve at least ${inputs.memoryGB} GB for this model plus n8n elsewhere. Keep one replica and confirm no public domain/TCP proxy exists. Railway private DNS is environment-scoped; legacy environments may resolve IPv6 only.`,
...modelPullSteps(model, baseUrl),
],
connection: { baseUrl, protocol: "OpenAI-compatible Chat Completions", authentication: "same Railway environment private network only" },
requirements: ["An existing Railway project/environment with n8n backend network access and a separately paid persistent volume.", "Confirm service resource allocation, volume capacity, current billing and outbound registry access."],
limitations: [...modelLimitations, "Railway services are nonprivileged; this is not a DinD Assistant runner."],
};
}
function railwaySearch({ resourceName, inputs }) {
const env = searchEnvironment("::");
return {
files: createSearchImageFiles(),
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: [
`Put only the generated Dockerfile, settings.yml, start.sh and .dockerignore in a new Git repository. In n8n's existing Railway project/environment, add a NEW service ${resourceName} from that repository with Dockerfile builder and one replica. Do not adopt n8n or apply whole-project IaC.`,
"Set the listed runtime variables and create SEARXNG_SECRET as an owner-generated random 32+ character value in Railway service Variables; do not put it in the repo or a generated download. Do not create a public domain or TCP proxy. Search cache is ephemeral; no volume is required.",
`From the n8n backend in this exact environment GET http://${resourceName}.railway.internal:8080/search?q=n8n&format=json; require JSON results array. If using Assistant, use this private base URL for N8N_INSTANCE_AI_SEARXNG_URL and confirm search precedence.`,
],
connection: { baseUrl: `http://${resourceName}.railway.internal:8080`, testPath: "/search?q=n8n&format=json" },
requirements: searchRequirements,
limitations: [...searchLimitations, "Legacy Railway environments may have IPv6-only private DNS; runtime listens on IPv6."],
};
}
function flyModel({ resourceName, model, inputs }) {
const minMB = (inputs.cpuKind === "shared" ? 256 : 2048) * inputs.cpus;
const maxMB = (inputs.cpuKind === "shared" ? 2048 : 8192) * inputs.cpus;
if ((inputs.cpuKind === "shared" && inputs.cpus > 8) || inputs.memoryMB < minMB || inputs.memoryMB > maxMB) {
throw new RangeError("Fly CPU and memory selection is outside reviewed Machine sizes.");
}
checkModelCapacity(model, inputs.memoryMB / 1024, inputs.volumeGB);
const env = modelEnvironment(model, "[::]:11434");
const baseUrl = `http://${resourceName}.internal:11434/v1`;
return {
files: [{ name: "fly.toml", mediaType: "text/plain", content: `app = "${resourceName}"
primary_region = "${inputs.region}"
[build]
image = "${LOCAL_MODEL_RUNTIME_IMAGE}"
[env]
${Object.entries(env).map(([key, value]) => ` ${key} = "${value}"`).join("\n")}
[[mounts]]
source = "${resourceName.replace(/-/gu, "_")}_data"
destination = "/var/data"
initial_size = "${inputs.volumeGB}gb"
[[vm]]
cpu_kind = "${inputs.cpuKind}"
cpus = ${inputs.cpus}
memory = "${inputs.memoryMB}mb"
` }],
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: [
`In the existing permitted Fly organization create only a NEW app ${resourceName} in ${inputs.region}, not the existing n8n app. Review its name for collisions and deploy this fly.toml from an isolated directory. Do not allocate a public IPv4/IPv6/Flycast address; this file intentionally has no services/http_service block.`,
`Provision/attach exactly one ${inputs.volumeGB} GB volume to one Machine in ${inputs.region} (the mount initial_size applies on first deploy). Explicitly set/verify exactly ONE running Machine and one attached volume after deployment; fly deploy alone does not prove singleton placement. Leave the machine running: direct 6PN traffic does not use Fly Proxy autostart.`,
"Confirm the actual n8n execution backend joins the same permitted Fly organization 6PN and can reach the running private Machine; never publish the raw model API as a connectivity workaround.",
...modelPullSteps(model, baseUrl),
],
connection: { baseUrl, protocol: "OpenAI-compatible Chat Completions", authentication: "same-organization Fly 6PN only" },
requirements: ["Fly organization, region, persistent per-Machine volume and private 6PN from the n8n backend.", "Review paid resources and memory/CPU capacity before deployment."],
limitations: [...modelLimitations, "A Fly volume is local to one Machine/region with no automatic replication, and stopped Machines disappear from .internal DNS."],
};
}
function flySearch({ resourceName, inputs }) {
const env = searchEnvironment("::");
const files = createSearchImageFiles();
files.push({ name: "fly.toml", mediaType: "text/plain", content: `app = "${resourceName}"
primary_region = "${inputs.region}"
[build]
dockerfile = "Dockerfile"
[env]
${Object.entries(env).map(([key, value]) => ` ${key} = "${value}"`).join("\n")}
[[vm]]
cpu_kind = "shared"
cpus = 1
memory = "${inputs.memoryMB}mb"
` });
return {
files,
settings: Object.entries(env).map(([name, value]) => ({ name, value })),
steps: [`In the existing Fly organization create a NEW app ${resourceName}, review name collision, deploy only these files from an isolated directory and verify one running Machine in ${inputs.region}. No services/http_service, public IPv4/IPv6 or Flycast address: direct .internal 6PN reachability requires an already permitted n8n backend.`, "Create SEARXNG_SECRET as a 32+ character cryptographically random Fly app secret through the operator's secure secret workflow, not as a fly.toml environment value or repository file. The startup wrapper refuses a missing/short secret.", `From the n8n execution backend GET http://${resourceName}.internal:8080/search?q=n8n&format=json; require a JSON results array. Configure N8N_INSTANCE_AI_SEARXNG_URL to the private base URL only if Assistant uses SearXNG and check provider precedence.`],
connection: { baseUrl: `http://${resourceName}.internal:8080`, testPath: "/search?q=n8n&format=json" },
requirements: searchRequirements,
limitations: [...searchLimitations, "A stopped Fly Machine is absent from .internal DNS; no Fly Proxy autostart is configured."],
};
}
function digitaloceanSearch({ resourceName, inputs }) {
const env = searchEnvironment();
return {
files: createSearchImageFiles(),
settings: [...Object.entries(env).map(([name, value]) => ({ name, value })), { name: "instance_size_slug", value: inputs.instanceSize }, { name: "internal_ports", value: "8080" }],
steps: [
`Put only these four generated files in a new Git repository. In the EXISTING DigitalOcean App Platform app (same app as its n8n execution component), add a NEW Service component ${resourceName} sourced from this repo and Dockerfile; do not replace the existing app spec or existing n8n component. Verify the component name is unused before creation.`,
`In the app spec editor for ONLY the new service, choose instance_size_slug ${inputs.instanceSize}, instance_count 1, internal_ports [8080]; remove its http_port and any ingress rules matching this service. Preserve all existing app ingress routes, n8n, database and other components. Save only after reviewing the complete proposed diff.`,
"Set the listed Granian runtime variables and an independently generated 32+ character SEARXNG_SECRET as an encrypted RUN_TIME SECRET through App Platform settings, not a tracked app spec/env file. This service must have outbound DNS/HTTPS but no public route.",
`From the n8n execution component in this same app GET http://${resourceName}:8080/search?q=n8n&format=json and require a JSON results array. Only then optionally use its base URL for N8N_INSTANCE_AI_SEARXNG_URL; check Brave/UI precedence.`,
],
connection: { baseUrl: `http://${resourceName}:8080`, testPath: "/search?q=n8n&format=json" },
requirements: ["Existing DigitalOcean App Platform app with an n8n execution component and access to add one internal service; confirm billing before operator action.", ...searchRequirements],
limitations: [...searchLimitations, "App Platform has no persistent volumes; cache is disposable, local filesystem is capped and replaced on deployments. This is not a persistent Ollama model lane."],
};
}
const daytonaPlatforms = ["render", "railway", "fly", "digitalocean-app", "ecs-ec2", "ecs-fargate", "eks", "eks-fargate", "gke", "gke-autopilot", "aks", "cloud-run", "azure-container-apps", "azure-container-instances", "lightsail-containers"];
function daytonaAssistant({ providerId }) {
return {
files: [],
settings: [
{ name: "N8N_INSTANCE_AI_SANDBOX_ENABLED", value: "true" },
{ name: "N8N_INSTANCE_AI_SANDBOX_PROVIDER", value: "daytona" },
{ name: "N8N_INSTANCE_AI_SANDBOX_IMAGE", value: "daytonaio/sandbox:0.5.3-slim" },
{ name: "DAYTONA_API_URL", value: "https://app.daytona.io/api" },
],
steps: [
`For self-hosted n8n on ${providerId}, first check the instance's Assistant/instance-ai entitlement and operator-owned Daytona account/capacity. Daytona replaces only sandbox execution; choose and configure a real model provider independently. This is NOT a Relmio-managed runner.`,
"In the EXISTING n8n environment configuration, preserve every N8N_ENABLED_MODULES entry and include instance-ai. An operator, not this plan, reviews and applies all n8n configuration changes and handles any necessary restart in their maintenance window; do not let this guide mutate or restart n8n.",
"Add the listed nonsecret settings to the n8n runtime. Put the actual DAYTONA_API_KEY from the operator's own Daytona account into its existing secret manager as a runtime secret; never write its value into this plan, a downloaded file or a shared app spec.",
"Select the exact supported N8N_INSTANCE_AI_MODEL and supply its independent N8N_INSTANCE_AI_MODEL_API_KEY as an operator-owned runtime secret when that provider requires a key. A custom endpoint may additionally need N8N_INSTANCE_AI_MODEL_URL; it must be reachable from the n8n backend.",
"If search is desired, configure one reachable N8N_INSTANCE_AI_SEARXNG_URL or an operator-owned Brave key, then check n8n search-provider precedence. Generate and execute one harmless workflow that actually performs a Daytona sandbox operation; a login or health check alone is not proof.",
],
connection: { provider: "daytona", apiUrl: "https://app.daytona.io/api", integration: "operator-configured n8n self-hosted Assistant sandbox" },
requirements: ["Self-hosted n8n with instance-ai available, operator-owned Daytona account/capacity and approved outbound HTTPS.", "Operator-owned Assistant model and secret management; no credentials are collected by the generator."],
limitations: ["NOT-RUN: Daytona account capacity, sandbox action and model/search compatibility need a real operator-owned execution.", "The n8n-documented Daytona image tag is date-qualified guidance, not a Relmio-attested immutable image digest; review current vendor guidance before applying.", "This handoff does not install a local privileged runner, alter existing n8n, or provide Ollama, SearXNG or credential bridges."],
};
}
const modelFields = [renderRegion, { name: "plan", label: "New service plan", type: "select", required: true, options: RENDER_PLANS.map(option), description: "Current Render compute slugs; review live price and model memory headroom." }, { name: "diskGB", label: "Persistent disk GB", type: "number", required: true, min: 5, max: 500, defaultValue: 20, description: "Use 5 GB multiples; model cache and download need capacity." }];
const railwayModelFields = [{ name: "volumeGB", label: "New persistent volume GB", type: "number", required: true, min: 10, max: 500, defaultValue: 20 }, { name: "memoryGB", label: "Reviewed service memory GB", type: "number", required: true, min: 4, max: 128, defaultValue: 8 }];
const flyModelFields = [flyRegion, { name: "cpuKind", label: "Machine CPU type", type: "select", required: true, options: ["shared", "performance"].map(option) }, { name: "cpus", label: "Machine CPUs", type: "select", required: true, options: [1, 2, 4, 8, 16].map(value => ({ value, label: `${value} CPU${value === 1 ? "" : "s"}` })), defaultValue: 2, description: "Shared supports at most 8 CPUs; 16 requires performance. Memory limits depend on CPU kind and count." }, { name: "memoryMB", label: "Machine memory MB", type: "select", required: true, options: [4096, 8192, 16384, 32768].map(value => ({ value, label: `${value} MB` })) }, { name: "volumeGB", label: "New persistent volume GB", type: "number", required: true, min: 10, max: 500, defaultValue: 20 }];
const flySearchFields = [flyRegion, { name: "memoryMB", label: "Machine memory MB", type: "select", required: true, options: [1024, 2048].map(value => ({ value, label: `${value} MB` })) }];
export const PLATFORM_HOSTING_PROFILES = [
{ providerId: "render", component: "model", fields: modelFields, render: renderModel },
{ providerId: "render", component: "searxng", fields: [renderRegion, { name: "plan", label: "New private service plan", type: "select", required: true, options: ["1c-2g", "2c-4g", "2c-8g"].map(option) }], render: renderSearch },
{ providerId: "railway", component: "model", fields: railwayModelFields, render: railwayModel },
{ providerId: "railway", component: "searxng", fields: [], render: railwaySearch },
{ providerId: "fly", component: "model", fields: flyModelFields, render: flyModel },
{ providerId: "fly", component: "searxng", fields: flySearchFields, render: flySearch },
{ providerId: "digitalocean-app", component: "searxng", fields: [{ name: "instanceSize", label: "New service size", type: "select", required: true, options: ["apps-s-1vcpu-2gb", "apps-s-2vcpu-4gb"].map(option) }], render: digitaloceanSearch },
...daytonaPlatforms.map(providerId => ({ providerId, component: "assistant", fields: [], render: daytonaAssistant })),
];