@studiometa/forge-cli 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/cli.js +329 -26
- package/dist/cli.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/servers/command.d.ts.map +1 -1
- package/dist/commands/servers/handlers.d.ts +5 -0
- package/dist/commands/servers/handlers.d.ts.map +1 -1
- package/dist/commands/servers/help.d.ts.map +1 -1
- package/dist/commands/sites/apply/apply.d.ts +18 -0
- package/dist/commands/sites/apply/apply.d.ts.map +1 -1
- package/dist/commands/sites/apply/handler.d.ts.map +1 -1
- package/dist/commands/sites/apply/nginx.d.ts +31 -0
- package/dist/commands/sites/apply/nginx.d.ts.map +1 -0
- package/dist/commands/sites/apply/plan.d.ts +42 -1
- package/dist/commands/sites/apply/plan.d.ts.map +1 -1
- package/dist/commands/sites/apply/site-file.d.ts +37 -3
- package/dist/commands/sites/apply/site-file.d.ts.map +1 -1
- package/dist/commands/sites/apply/test-fixtures.d.ts +37 -1
- package/dist/commands/sites/apply/test-fixtures.d.ts.map +1 -1
- package/dist/commands/sites/help.d.ts.map +1 -1
- package/package.json +3 -3
- package/schemas/site.schema.json +123 -17
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { C as isOutputFormat, _ as setToken, a as runCommand, c as OutputFormatter, d as createConfigStore$1, f as deleteToken, g as setOrganizationSlug, h as getToken$1, l as colors, m as getOrganizationSlug, n as exitWithError, o as createContext, r as exitWithValidationError, t as ExitCode, u as setColorEnabled, v as ApiError, x as ValidationError, y as CliError } from "./error-handler-s-BOJ1-h.js";
|
|
3
3
|
import { STORAGE_PROVIDER_TYPES } from "@studiometa/forge-api";
|
|
4
|
-
import { PHP_CONFIG_TYPES, RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDaemon, createDatabase, createDatabaseUser, createDomain, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createServer, createSite, createSshKey, createStorageProvider, deleteBackupConfig, deleteDaemon, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deleteSshKey, deleteStorageProvider, deploySiteAndWait, disablePhpOpcache, enablePhpOpcache, getBackupConfig, getCertificate, getCommand, getDaemon, getDaemonLog, getDatabase, getDatabaseUser, getDeploymentLog, getDeploymentScript, getEnv, getFirewallRule, getMonitor, getNginxConfig, getNginxTemplate, getPhpConfig, getPhpMaxExecutionTime, getPhpMaxUploadSize, getPhpOpcache, getRecipe, getRedirectRule, getScheduledJob, getSecurityRule, getServer, getSite, getSshKey, getStorageProvider, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listMonitors, listNginxTemplates, listPhpVersions, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, listStorageProviders, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateDeploymentScript, updateEnv, updateNginxConfig, updateNginxTemplate, updatePhpConfig, updatePhpMaxExecutionTime, updatePhpMaxUploadSize, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
|
|
4
|
+
import { PHP_CONFIG_TYPES, RESTARTABLE_SERVICES, activateCertificate, createAuditLogger, createBackupConfig, createCommand, createDaemon, createDatabase, createDatabaseUser, createDomain, createMonitor, createNginxTemplate, createRedirectRule, createScheduledJob, createSecurityRule, createServer, createSite, createSshKey, createStorageProvider, deleteBackupConfig, deleteDaemon, deleteDatabaseUser, deleteMonitor, deleteNginxTemplate, deleteRedirectRule, deleteScheduledJob, deleteSecurityRule, deleteSshKey, deleteStorageProvider, deploySiteAndWait, disablePhpOpcache, enablePhpOpcache, getBackupConfig, getCertificate, getCommand, getDaemon, getDaemonLog, getDatabase, getDatabaseUser, getDeploymentLog, getDeploymentScript, getEnv, getFirewallRule, getMonitor, getNginxConfig, getNginxTemplate, getPhpConfig, getPhpMaxExecutionTime, getPhpMaxUploadSize, getPhpOpcache, getRecipe, getRedirectRule, getScheduledJob, getSecurityRule, getServer, getSite, getSshKey, getStorageProvider, getUser, listBackupConfigs, listCommands, listDaemons, listDatabaseUsers, listDatabases, listDeployments, listFirewallRules, listLoadBalancingNodes, listMonitors, listNginxTemplates, listPhpVersions, listRecipes, listRedirectRules, listScheduledJobs, listSecurityRules, listServers, listServices, listSites, listSshKeys, listStorageProviders, matchByName, rebootServer, restartDaemon, restartService, runRecipe, updateBackupConfig, updateDaemon, updateDatabaseUser, updateDeploymentScript, updateEnv, updateLoadBalancingNodes, updateNginxConfig, updateNginxTemplate, updatePhpConfig, updatePhpMaxExecutionTime, updatePhpMaxUploadSize, updateRecipe, updateSecurityRule, updateSite } from "@studiometa/forge-core";
|
|
5
5
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { join, posix } from "node:path";
|
|
7
|
+
import { dirname, join, posix, resolve } from "node:path";
|
|
8
8
|
import { diff } from "node:util";
|
|
9
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
10
|
import * as v from "valibot";
|
|
@@ -460,7 +460,7 @@ _forge_completions() {
|
|
|
460
460
|
|
|
461
461
|
# Subcommands for each command
|
|
462
462
|
local config_cmds="set get delete"
|
|
463
|
-
local servers_cmds="list ls get create reboot"
|
|
463
|
+
local servers_cmds="list ls get public-key create reboot"
|
|
464
464
|
local sites_cmds="list ls get apply"
|
|
465
465
|
local deployments_cmds="list ls deploy logs log script update-script"
|
|
466
466
|
local databases_cmds="list ls get"
|
|
@@ -672,6 +672,7 @@ _forge() {
|
|
|
672
672
|
'list:List all servers'
|
|
673
673
|
'ls:List all servers (alias)'
|
|
674
674
|
'get:Get server details'
|
|
675
|
+
'public-key:Print the public key of the forge user'
|
|
675
676
|
'create:Create a server'
|
|
676
677
|
'reboot:Reboot a server'
|
|
677
678
|
)
|
|
@@ -1030,6 +1031,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from config" -a "delete" -d "Del
|
|
|
1030
1031
|
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "list" -d "List all servers"
|
|
1031
1032
|
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "ls" -d "List all servers (alias)"
|
|
1032
1033
|
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "get" -d "Get server details"
|
|
1034
|
+
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "public-key" -d "Print the public key of the forge user"
|
|
1033
1035
|
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "create" -d "Create a server"
|
|
1034
1036
|
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "Reboot a server"
|
|
1035
1037
|
|
|
@@ -4290,6 +4292,23 @@ async function serversGet(args, ctx) {
|
|
|
4290
4292
|
ctx.formatter.output(result.data);
|
|
4291
4293
|
}, ctx.formatter);
|
|
4292
4294
|
}
|
|
4295
|
+
/**
|
|
4296
|
+
* Print the public key of the `forge` user of a server (`~/.ssh/id_rsa.pub`), the key that
|
|
4297
|
+
* Forge uses to clone repositories: add it to the git host before you create a site.
|
|
4298
|
+
*/
|
|
4299
|
+
async function serversPublicKey(args, ctx) {
|
|
4300
|
+
const [server] = args;
|
|
4301
|
+
if (!server) exitWithValidationError("server_id", "forge servers public-key <server_id>", ctx.formatter);
|
|
4302
|
+
await runCommand(async () => {
|
|
4303
|
+
const token = ctx.getToken();
|
|
4304
|
+
const execCtx = ctx.createExecutorContext(token);
|
|
4305
|
+
const result = await getServer({ server_id: await resolveServerId(server, execCtx) }, execCtx);
|
|
4306
|
+
const key = result.data.local_public_key?.trim();
|
|
4307
|
+
if (!key) throw new ApiError(`Server ${result.data.name} has no public key yet`, 404, ["Wait until Forge provisions the server"]);
|
|
4308
|
+
if (ctx.formatter.isJson()) ctx.formatter.output({ public_key: key });
|
|
4309
|
+
else console.log(key);
|
|
4310
|
+
}, ctx.formatter);
|
|
4311
|
+
}
|
|
4293
4312
|
var CREATE_USAGE$1 = "forge servers create --name <name> --ip-address <ip> [options]";
|
|
4294
4313
|
/**
|
|
4295
4314
|
* Create a server. Defaults to a Custom VPS app server on Ubuntu 26.04.
|
|
@@ -4363,6 +4382,7 @@ var handleServersCommand = createCommandRouter({
|
|
|
4363
4382
|
list: serversList,
|
|
4364
4383
|
ls: serversList,
|
|
4365
4384
|
get: [serversGet, "args"],
|
|
4385
|
+
"public-key": [serversPublicKey, "args"],
|
|
4366
4386
|
create: serversCreate,
|
|
4367
4387
|
reboot: [serversReboot, "args"]
|
|
4368
4388
|
},
|
|
@@ -4402,6 +4422,27 @@ ${colors.bold("OPTIONS:")}
|
|
|
4402
4422
|
${colors.bold("EXAMPLES:")}
|
|
4403
4423
|
forge servers get 123
|
|
4404
4424
|
forge servers get 123 --format json
|
|
4425
|
+
`);
|
|
4426
|
+
else if (subcommand === "public-key") console.log(`
|
|
4427
|
+
${colors.bold("forge servers public-key")} - Print the public key of the forge user
|
|
4428
|
+
|
|
4429
|
+
${colors.bold("USAGE:")}
|
|
4430
|
+
forge servers public-key <server_id> [options]
|
|
4431
|
+
|
|
4432
|
+
${colors.bold("ARGUMENTS:")}
|
|
4433
|
+
<server_id> Server ID or name (required)
|
|
4434
|
+
|
|
4435
|
+
${colors.bold("OPTIONS:")}
|
|
4436
|
+
-f, --format <fmt> Output format: json ({ "public_key": "..." }), human (the key only)
|
|
4437
|
+
|
|
4438
|
+
Forge clones repositories as the forge user, with this key (~/.ssh/id_rsa.pub).
|
|
4439
|
+
Add it to the git host (e.g. as a deploy key) before you create a site with a
|
|
4440
|
+
repository. It is not the key of GET /servers/{id}/key: that key lets Forge
|
|
4441
|
+
connect to the server.
|
|
4442
|
+
|
|
4443
|
+
${colors.bold("EXAMPLES:")}
|
|
4444
|
+
forge servers public-key 123
|
|
4445
|
+
forge servers public-key 123 > server.pub
|
|
4405
4446
|
`);
|
|
4406
4447
|
else if (subcommand === "create") console.log(`
|
|
4407
4448
|
${colors.bold("forge servers create")} - Create a server
|
|
@@ -4456,6 +4497,7 @@ ${colors.bold("ALIASES:")}
|
|
|
4456
4497
|
${colors.bold("SUBCOMMANDS:")}
|
|
4457
4498
|
list, ls List all servers
|
|
4458
4499
|
get <id> Get server details
|
|
4500
|
+
public-key <id> Print the public key of the forge user
|
|
4459
4501
|
create Create a server (requires --name, and --ip-address for custom)
|
|
4460
4502
|
reboot <id> Reboot a server
|
|
4461
4503
|
|
|
@@ -4849,6 +4891,14 @@ function planAliases(file, site, changes) {
|
|
|
4849
4891
|
name: alias
|
|
4850
4892
|
});
|
|
4851
4893
|
}
|
|
4894
|
+
/** The .env keys of a new database user, from `database.env` or the Laravel names. */
|
|
4895
|
+
function databaseEnvKeys(database) {
|
|
4896
|
+
return {
|
|
4897
|
+
name: database.env?.name ?? "DB_DATABASE",
|
|
4898
|
+
user: database.env?.user ?? "DB_USERNAME",
|
|
4899
|
+
password: database.env?.password ?? "DB_PASSWORD"
|
|
4900
|
+
};
|
|
4901
|
+
}
|
|
4852
4902
|
/**
|
|
4853
4903
|
* Plan the database and its user. Returns the names of the .env keys that get generated values.
|
|
4854
4904
|
*/
|
|
@@ -4867,10 +4917,11 @@ function planDatabase(file, state, changes, warnings) {
|
|
|
4867
4917
|
name: user,
|
|
4868
4918
|
database: name
|
|
4869
4919
|
});
|
|
4920
|
+
const keys = databaseEnvKeys(file.database);
|
|
4870
4921
|
return [
|
|
4871
|
-
|
|
4872
|
-
|
|
4873
|
-
|
|
4922
|
+
keys.name,
|
|
4923
|
+
keys.user,
|
|
4924
|
+
keys.password
|
|
4874
4925
|
];
|
|
4875
4926
|
}
|
|
4876
4927
|
if (!databaseExists) warnings.push(`Database user ${user} exists: apply does not give it access to the new database ${name}.`);
|
|
@@ -4884,10 +4935,29 @@ function planEnv(file, state, generated, changes, warnings) {
|
|
|
4884
4935
|
if (!remote.has(key)) keys.push(key);
|
|
4885
4936
|
else if (remote.get(key) !== String(value)) warnings.push(`.env key ${key} has another value on Forge: apply does not overwrite it.`);
|
|
4886
4937
|
}
|
|
4887
|
-
if (keys.length > 0 || generated.length > 0)
|
|
4888
|
-
|
|
4889
|
-
|
|
4890
|
-
|
|
4938
|
+
if (keys.length > 0 || generated.length > 0) {
|
|
4939
|
+
const password = file.database && generated.length > 0 ? databaseEnvKeys(file.database).password : void 0;
|
|
4940
|
+
changes.push({
|
|
4941
|
+
action: "add-env",
|
|
4942
|
+
keys,
|
|
4943
|
+
generated,
|
|
4944
|
+
...password ? { password } : {}
|
|
4945
|
+
});
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
function planNginx(file, state, changes) {
|
|
4949
|
+
if (file.nginxTemplate === void 0) return;
|
|
4950
|
+
if (!state.nginx) {
|
|
4951
|
+
changes.push({
|
|
4952
|
+
action: "update-nginx",
|
|
4953
|
+
diff: null
|
|
4954
|
+
});
|
|
4955
|
+
return;
|
|
4956
|
+
}
|
|
4957
|
+
const diff = unifiedDiff(state.nginx.remote, state.nginx.rendered);
|
|
4958
|
+
if (diff) changes.push({
|
|
4959
|
+
action: "update-nginx",
|
|
4960
|
+
diff
|
|
4891
4961
|
});
|
|
4892
4962
|
}
|
|
4893
4963
|
function describeOwnership(ownership) {
|
|
@@ -4999,6 +5069,29 @@ function planSchedule(file, state, changes, warnings) {
|
|
|
4999
5069
|
else warnings.push(`Kept scheduled job ${job.id} (${job.command}): not declared, but ${describeOwnership(ownership)}.`);
|
|
5000
5070
|
}
|
|
5001
5071
|
/**
|
|
5072
|
+
* Plan the nodes of the site on the load balancer: one node, the server of the site file, with
|
|
5073
|
+
* the default weight. Any other node is replaced.
|
|
5074
|
+
*/
|
|
5075
|
+
function planLoadBalancer(file, state, changes) {
|
|
5076
|
+
const balancer = state.loadBalancer;
|
|
5077
|
+
if (!file.loadBalancer || !balancer?.site) return;
|
|
5078
|
+
const to = {
|
|
5079
|
+
server_id: balancer.target,
|
|
5080
|
+
port: file.loadBalancer.port
|
|
5081
|
+
};
|
|
5082
|
+
const [node, ...others] = balancer.nodes;
|
|
5083
|
+
if (node !== void 0 && others.length === 0 && node.server_id === to.server_id && node.port === to.port && node.weight === 1 && !node.backup && !node.down) return;
|
|
5084
|
+
changes.push({
|
|
5085
|
+
action: "update-load-balancer",
|
|
5086
|
+
site: balancer.site.name,
|
|
5087
|
+
from: balancer.nodes.map(({ server_id, port }) => ({
|
|
5088
|
+
server_id,
|
|
5089
|
+
port
|
|
5090
|
+
})),
|
|
5091
|
+
to
|
|
5092
|
+
});
|
|
5093
|
+
}
|
|
5094
|
+
/**
|
|
5002
5095
|
* Compare the site file with the state of Forge. Keys that the file does not have are not
|
|
5003
5096
|
* compared. The plan creates and updates what differs, and deletes only the workers and
|
|
5004
5097
|
* scheduled jobs that surely belong to the site and that the file does not declare.
|
|
@@ -5010,8 +5103,10 @@ function buildPlan(file, state) {
|
|
|
5010
5103
|
planAliases(file, state.site, changes);
|
|
5011
5104
|
const generated = planDatabase(file, state, changes, warnings);
|
|
5012
5105
|
if (file.env || generated.length > 0) planEnv(file, state, generated, changes, warnings);
|
|
5106
|
+
planNginx(file, state, changes);
|
|
5013
5107
|
planWorkers(file, state, changes, warnings);
|
|
5014
5108
|
planSchedule(file, state, changes, warnings);
|
|
5109
|
+
planLoadBalancer(file, state, changes);
|
|
5015
5110
|
return {
|
|
5016
5111
|
changes,
|
|
5017
5112
|
warnings
|
|
@@ -5033,14 +5128,96 @@ function describeChange(change) {
|
|
|
5033
5128
|
case "add-alias": return `+ add alias ${change.name}`;
|
|
5034
5129
|
case "create-database": return `+ create database ${change.name}`;
|
|
5035
5130
|
case "create-database-user": return `+ create database user ${change.name} for ${change.database} (password: ${GENERATED})`;
|
|
5036
|
-
case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key ===
|
|
5131
|
+
case "add-env": return `+ add .env keys: ${[...change.keys, ...change.generated.map((key) => key === change.password ? `${key}=${GENERATED}` : key)].join(", ")}`;
|
|
5132
|
+
case "update-nginx": return change.diff === null ? "~ write Nginx configuration (rendered once the site exists)" : `~ update Nginx configuration:\n${change.diff}`;
|
|
5037
5133
|
case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
|
|
5038
5134
|
case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
|
|
5039
5135
|
case "create-job": return `+ create scheduled job: ${change.command} (${change.frequency}${change.cron ? ` ${change.cron}` : ""})`;
|
|
5136
|
+
case "update-load-balancer": {
|
|
5137
|
+
const node = ({ server_id, port }) => `server ${server_id}:${port}`;
|
|
5138
|
+
const from = change.from.length > 0 ? change.from.map(node).join(", ") : "(none)";
|
|
5139
|
+
return `~ update load balancer site ${change.site}: ${from} → ${node(change.to)}`;
|
|
5140
|
+
}
|
|
5040
5141
|
case "delete-job": return `- delete scheduled job ${change.id}: ${change.command} (${change.frequency.toLowerCase()} ${change.cron})`;
|
|
5041
5142
|
}
|
|
5042
5143
|
}
|
|
5043
5144
|
//#endregion
|
|
5145
|
+
//#region src/commands/sites/apply/nginx.ts
|
|
5146
|
+
/** The variables of Forge Nginx templates. Forge accepts spaces inside the braces. */
|
|
5147
|
+
var NGINX_VARIABLES = [
|
|
5148
|
+
"DIRECTORY",
|
|
5149
|
+
"DOMAINS",
|
|
5150
|
+
"PATH",
|
|
5151
|
+
"PORT",
|
|
5152
|
+
"PORT_V6",
|
|
5153
|
+
"PROXY_PASS",
|
|
5154
|
+
"ROOT_PATH",
|
|
5155
|
+
"SERVER_PUBLIC_IP",
|
|
5156
|
+
"SERVER_PRIVATE_IP",
|
|
5157
|
+
"SITE",
|
|
5158
|
+
"SITE_ID",
|
|
5159
|
+
"USER"
|
|
5160
|
+
];
|
|
5161
|
+
/** The variables that need the server, not only the site. */
|
|
5162
|
+
var SERVER_VARIABLES = /* @__PURE__ */ new Set(["SERVER_PUBLIC_IP", "SERVER_PRIVATE_IP"]);
|
|
5163
|
+
var VARIABLE = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
5164
|
+
/** The names of the variables in a template, each once, in order. */
|
|
5165
|
+
function templateVariables(template) {
|
|
5166
|
+
return [...new Set([...template.matchAll(VARIABLE)].map((match) => match[1]))];
|
|
5167
|
+
}
|
|
5168
|
+
/**
|
|
5169
|
+
* Exit with a validation error when the template has a variable that Forge does not define.
|
|
5170
|
+
*/
|
|
5171
|
+
function assertNginxTemplate(template, source) {
|
|
5172
|
+
const known = new Set(NGINX_VARIABLES);
|
|
5173
|
+
const unknown = templateVariables(template).filter((name) => !known.has(name));
|
|
5174
|
+
if (unknown.length > 0) throw new ValidationError(`Nginx file ${source} has unknown variables: ${unknown.map((name) => `{{${name}}}`).join(", ")}`, "nginx", [`Supported variables: ${NGINX_VARIABLES.map((name) => `{{${name}}}`).join(", ")}`]);
|
|
5175
|
+
}
|
|
5176
|
+
/** True when rendering the template needs the attributes of the server. */
|
|
5177
|
+
function needsServer(template) {
|
|
5178
|
+
return templateVariables(template).some((name) => SERVER_VARIABLES.has(name));
|
|
5179
|
+
}
|
|
5180
|
+
/** `PHP 8.3`, `php83` and `php8.3` all give `8.3`. */
|
|
5181
|
+
function phpSocketVersion(version) {
|
|
5182
|
+
const digits = version.replaceAll(/\D/g, "");
|
|
5183
|
+
if (digits.length < 2) throw new ValidationError(`Cannot read the PHP version ${version} of the site`, "nginx");
|
|
5184
|
+
return `${digits[0]}.${digits.slice(1)}`;
|
|
5185
|
+
}
|
|
5186
|
+
/**
|
|
5187
|
+
* The value of each variable for a site, as Forge renders its templates.
|
|
5188
|
+
* A server is needed only for the `SERVER_*` variables.
|
|
5189
|
+
*/
|
|
5190
|
+
function nginxValues(site, server = null) {
|
|
5191
|
+
const root = siteWorkDirectory(site);
|
|
5192
|
+
const path = isPathInside(site.web_directory, root) ? normalizePath(site.web_directory) : normalizePath(`${root}/${site.web_directory}`);
|
|
5193
|
+
const directory = path === root ? "/" : path.slice(root.length);
|
|
5194
|
+
const defaultServer = site.name === "default" ? " default_server" : "";
|
|
5195
|
+
const isolated = site.isolated ? `-${site.user}` : "";
|
|
5196
|
+
return {
|
|
5197
|
+
DIRECTORY: directory,
|
|
5198
|
+
DOMAINS: [site.name, ...site.aliases ?? []].join(" "),
|
|
5199
|
+
PATH: path,
|
|
5200
|
+
PORT: `80${defaultServer}`,
|
|
5201
|
+
PORT_V6: `[::]:80${defaultServer}`,
|
|
5202
|
+
get PROXY_PASS() {
|
|
5203
|
+
return `unix:/var/run/php/php${phpSocketVersion(site.php_version)}-fpm${isolated}.sock`;
|
|
5204
|
+
},
|
|
5205
|
+
ROOT_PATH: root,
|
|
5206
|
+
SERVER_PUBLIC_IP: server?.ip_address ?? "",
|
|
5207
|
+
SERVER_PRIVATE_IP: server?.private_ip_address ?? "",
|
|
5208
|
+
SITE: site.name,
|
|
5209
|
+
SITE_ID: String(site.id),
|
|
5210
|
+
USER: site.user
|
|
5211
|
+
};
|
|
5212
|
+
}
|
|
5213
|
+
/**
|
|
5214
|
+
* Replace each variable of the template with its value for the site.
|
|
5215
|
+
*/
|
|
5216
|
+
function renderNginx(template, site, server = null) {
|
|
5217
|
+
const values = nginxValues(site, server);
|
|
5218
|
+
return template.replaceAll(VARIABLE, (match, name) => name in values ? values[name] : match);
|
|
5219
|
+
}
|
|
5220
|
+
//#endregion
|
|
5044
5221
|
//#region src/commands/sites/apply/apply.ts
|
|
5045
5222
|
/**
|
|
5046
5223
|
* Read the state of Forge for a site file, and run a plan against Forge.
|
|
@@ -5065,6 +5242,24 @@ function findSite(sites, domain) {
|
|
|
5065
5242
|
return sites.find((site) => site.name.toLowerCase() === domain.toLowerCase()) ?? null;
|
|
5066
5243
|
}
|
|
5067
5244
|
/**
|
|
5245
|
+
* The site of the load balancer named `domain` or one of the aliases, compared without case.
|
|
5246
|
+
* Its name can be any of them, e.g. the domain without `www`. Two matches are an error: a guess
|
|
5247
|
+
* could send the traffic of another site.
|
|
5248
|
+
*/
|
|
5249
|
+
function findLoadBalancerSite(sites, names) {
|
|
5250
|
+
const wanted = new Set(names.map((name) => name.toLowerCase()));
|
|
5251
|
+
const matches = sites.filter((site) => wanted.has(site.name.toLowerCase()));
|
|
5252
|
+
if (matches.length > 1) throw new ValidationError(`Several sites of the load balancer match the site file: ${matches.map((site) => site.name).join(", ")}`, "loadBalancer", ["Keep one site for the domain and its aliases on the load balancer"]);
|
|
5253
|
+
return matches[0] ?? null;
|
|
5254
|
+
}
|
|
5255
|
+
/**
|
|
5256
|
+
* Exit with a validation error when the load balancer has no site for the site file: the Forge
|
|
5257
|
+
* API cannot create a site on a load balancer.
|
|
5258
|
+
*/
|
|
5259
|
+
function assertLoadBalancerSite(file, state) {
|
|
5260
|
+
if (file.loadBalancer && !state.loadBalancer?.site) throw new ValidationError(`The load balancer ${file.loadBalancer.server} has no site for ${file.domain}`, "loadBalancer", [`In Forge, create the site ${file.domain} on the load balancer (New site), then run apply again`, "Check the domain, the aliases and the server of the load balancer"]);
|
|
5261
|
+
}
|
|
5262
|
+
/**
|
|
5068
5263
|
* Read from Forge what the site file manages, and nothing else.
|
|
5069
5264
|
*/
|
|
5070
5265
|
async function readRemoteState(file, serverId, ctx) {
|
|
@@ -5090,6 +5285,7 @@ async function readRemoteState(file, serverId, ctx) {
|
|
|
5090
5285
|
site_id: siteId
|
|
5091
5286
|
}, ctx)).data.map((d) => d.id) : []);
|
|
5092
5287
|
}
|
|
5288
|
+
if (file.nginxTemplate !== void 0) state.nginx = site ? await readNginx(file.nginxTemplate, serverId, site, ctx) : null;
|
|
5093
5289
|
if (file.schedule) {
|
|
5094
5290
|
const siteJobs = siteId ? (await listScheduledJobs({
|
|
5095
5291
|
server_id: serverId,
|
|
@@ -5099,9 +5295,35 @@ async function readRemoteState(file, serverId, ctx) {
|
|
|
5099
5295
|
state.jobs = [...siteJobs, ...serverJobs];
|
|
5100
5296
|
state.siteJobIds = new Set(siteJobs.map((job) => job.id));
|
|
5101
5297
|
}
|
|
5298
|
+
if (file.loadBalancer) {
|
|
5299
|
+
const balancerId = await resolveApplyServer(file.loadBalancer.server, ctx);
|
|
5300
|
+
const balancerSites = (await listSites({ server_id: balancerId }, ctx)).data;
|
|
5301
|
+
const balancerSite = findLoadBalancerSite(balancerSites, [file.domain, ...file.aliases ?? []]);
|
|
5302
|
+
state.loadBalancer = {
|
|
5303
|
+
serverId: balancerId,
|
|
5304
|
+
site: balancerSite,
|
|
5305
|
+
nodes: balancerSite ? (await listLoadBalancingNodes({
|
|
5306
|
+
server_id: balancerId,
|
|
5307
|
+
site_id: String(balancerSite.id)
|
|
5308
|
+
}, ctx)).data : [],
|
|
5309
|
+
target: Number(serverId)
|
|
5310
|
+
};
|
|
5311
|
+
}
|
|
5102
5312
|
return state;
|
|
5103
5313
|
}
|
|
5104
5314
|
/**
|
|
5315
|
+
* Read the Nginx configuration of a site on Forge and render the Nginx file for the site.
|
|
5316
|
+
*/
|
|
5317
|
+
async function readNginx(template, serverId, site, ctx) {
|
|
5318
|
+
return {
|
|
5319
|
+
remote: (await getNginxConfig({
|
|
5320
|
+
server_id: serverId,
|
|
5321
|
+
site_id: String(site.id)
|
|
5322
|
+
}, ctx)).data,
|
|
5323
|
+
rendered: renderNginx(template, site, needsServer(template) ? (await getServer({ server_id: serverId }, ctx)).data : null)
|
|
5324
|
+
};
|
|
5325
|
+
}
|
|
5326
|
+
/**
|
|
5105
5327
|
* Exit with a validation error when the site does not exist and the site file cannot create it.
|
|
5106
5328
|
*/
|
|
5107
5329
|
function assertCanCreateSite(file, state) {
|
|
@@ -5138,10 +5360,12 @@ function setEnvValues(content, values) {
|
|
|
5138
5360
|
for (const [name, value] of pending) result.push(`${name}=${formatEnvValue(value)}`);
|
|
5139
5361
|
return `${result.join("\n")}\n`;
|
|
5140
5362
|
}
|
|
5363
|
+
/** Forge clones the repository with the key of the server: the git host must accept it. */
|
|
5364
|
+
var REPOSITORY_HINT = (serverId) => `Check that the git host accepts the key of the server: forge servers public-key ${serverId}`;
|
|
5141
5365
|
var defaultSleep = (ms) => new Promise((resolve) => {
|
|
5142
5366
|
setTimeout(resolve, ms);
|
|
5143
5367
|
});
|
|
5144
|
-
async function waitForSite(serverId, siteId, ctx, options) {
|
|
5368
|
+
async function waitForSite(file, serverId, siteId, ctx, options) {
|
|
5145
5369
|
const sleep = options.sleep ?? defaultSleep;
|
|
5146
5370
|
const interval = options.pollIntervalMs ?? 5e3;
|
|
5147
5371
|
const timeout = options.timeoutMs ?? 6e5;
|
|
@@ -5151,7 +5375,7 @@ async function waitForSite(serverId, siteId, ctx, options) {
|
|
|
5151
5375
|
site_id: siteId
|
|
5152
5376
|
}, ctx)).data;
|
|
5153
5377
|
if (site.status === "installed") return;
|
|
5154
|
-
if (waited >= timeout) throw new ApplyError(`Site ${site.name} is still ${site.status} on Forge`, ["Run forge sites apply again once the site is installed"]);
|
|
5378
|
+
if (waited >= timeout) throw new ApplyError(`Site ${site.name} is still ${site.status} on Forge`, ["Run forge sites apply again once the site is installed", ...file.repository ? [REPOSITORY_HINT(serverId)] : []]);
|
|
5155
5379
|
await sleep(interval);
|
|
5156
5380
|
}
|
|
5157
5381
|
}
|
|
@@ -5182,9 +5406,10 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
|
|
|
5182
5406
|
...databaseId === void 0 ? {} : { database_ids: [databaseId] }
|
|
5183
5407
|
}, ctx);
|
|
5184
5408
|
ids.database_user_id = String(user.data.id);
|
|
5185
|
-
|
|
5186
|
-
env.set(
|
|
5187
|
-
env.set(
|
|
5409
|
+
const keys = databaseEnvKeys(file.database);
|
|
5410
|
+
env.set(keys.name, change.database);
|
|
5411
|
+
env.set(keys.user, change.name);
|
|
5412
|
+
env.set(keys.password, password);
|
|
5188
5413
|
}
|
|
5189
5414
|
if (databaseId !== void 0) ids.database_id = databaseId;
|
|
5190
5415
|
return {
|
|
@@ -5193,6 +5418,29 @@ async function applyDatabase(file, plan, state, serverId, ctx, options) {
|
|
|
5193
5418
|
};
|
|
5194
5419
|
}
|
|
5195
5420
|
/**
|
|
5421
|
+
* Write the rendered Nginx file when it differs from Forge. After a change of the site or its
|
|
5422
|
+
* aliases, read the site again: the web directory and the domains are template variables.
|
|
5423
|
+
*/
|
|
5424
|
+
async function applyNginx(template, plan, serverId, site, ctx, options) {
|
|
5425
|
+
const siteChanged = plan.changes.some((change) => change.action === "update-site" || change.action === "add-alias");
|
|
5426
|
+
if (!plan.changes.some((change) => change.action === "update-nginx") && !siteChanged) return;
|
|
5427
|
+
const params = {
|
|
5428
|
+
server_id: serverId,
|
|
5429
|
+
site_id: String(site.id)
|
|
5430
|
+
};
|
|
5431
|
+
const { remote, rendered } = await readNginx(template, serverId, siteChanged ? (await getSite(params, ctx)).data : site, ctx);
|
|
5432
|
+
const diff = unifiedDiff(remote, rendered);
|
|
5433
|
+
if (!diff) return;
|
|
5434
|
+
options.onChange?.({
|
|
5435
|
+
action: "update-nginx",
|
|
5436
|
+
diff
|
|
5437
|
+
});
|
|
5438
|
+
await updateNginxConfig({
|
|
5439
|
+
...params,
|
|
5440
|
+
content: rendered
|
|
5441
|
+
}, ctx);
|
|
5442
|
+
}
|
|
5443
|
+
/**
|
|
5196
5444
|
* Run the changes of a plan for a site that exists.
|
|
5197
5445
|
*/
|
|
5198
5446
|
async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx, options) {
|
|
@@ -5250,6 +5498,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
|
|
|
5250
5498
|
content: setEnvValues(content, env)
|
|
5251
5499
|
}, ctx);
|
|
5252
5500
|
}
|
|
5501
|
+
if (file.nginxTemplate !== void 0) await applyNginx(file.nginxTemplate, plan, serverId, site, ctx, options);
|
|
5253
5502
|
for (const change of creations) {
|
|
5254
5503
|
options.onChange?.(change);
|
|
5255
5504
|
if (change.action === "create-worker") await createDaemon({
|
|
@@ -5269,6 +5518,20 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
|
|
|
5269
5518
|
...change.cron ? { cron: change.cron } : {}
|
|
5270
5519
|
}, ctx);
|
|
5271
5520
|
}
|
|
5521
|
+
const balancer = state.loadBalancer;
|
|
5522
|
+
const balancing = plan.changes.find((change) => change.action === "update-load-balancer");
|
|
5523
|
+
if (balancing?.action === "update-load-balancer" && balancer?.site) {
|
|
5524
|
+
options.onChange?.(balancing);
|
|
5525
|
+
await updateLoadBalancingNodes({
|
|
5526
|
+
server_id: balancer.serverId,
|
|
5527
|
+
site_id: String(balancer.site.id),
|
|
5528
|
+
balancer_method: "round_robin",
|
|
5529
|
+
balancing: [{
|
|
5530
|
+
...balancing.to,
|
|
5531
|
+
weight: 1
|
|
5532
|
+
}]
|
|
5533
|
+
}, ctx);
|
|
5534
|
+
}
|
|
5272
5535
|
for (const change of deletions) {
|
|
5273
5536
|
options.onChange?.(change);
|
|
5274
5537
|
if (change.action === "delete-worker") await deleteDaemon({
|
|
@@ -5288,6 +5551,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
|
|
|
5288
5551
|
*/
|
|
5289
5552
|
async function applySiteFile(file, serverId, ctx, options = {}) {
|
|
5290
5553
|
let state = await readRemoteState(file, serverId, ctx);
|
|
5554
|
+
assertLoadBalancerSite(file, state);
|
|
5291
5555
|
const firstPlan = buildPlan(file, state);
|
|
5292
5556
|
let plan = firstPlan;
|
|
5293
5557
|
let generatedEnv = /* @__PURE__ */ new Map();
|
|
@@ -5309,7 +5573,7 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
|
|
|
5309
5573
|
...settings.branch ? { branch: settings.branch } : {},
|
|
5310
5574
|
...database.ids
|
|
5311
5575
|
}, ctx);
|
|
5312
|
-
await waitForSite(serverId, String(created.data.id), ctx, options);
|
|
5576
|
+
await waitForSite(file, serverId, String(created.data.id), ctx, options);
|
|
5313
5577
|
state = await readRemoteState(file, serverId, ctx);
|
|
5314
5578
|
plan = buildPlan(file, state);
|
|
5315
5579
|
}
|
|
@@ -5325,6 +5589,8 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
|
|
|
5325
5589
|
* A key that is absent is not managed; only `server` and `domain` are required.
|
|
5326
5590
|
*/
|
|
5327
5591
|
var text = v.pipe(v.string(), v.trim(), v.nonEmpty());
|
|
5592
|
+
/** A server name (exact match) or ID. */
|
|
5593
|
+
var serverRef = v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]);
|
|
5328
5594
|
var SITE_TYPES = [
|
|
5329
5595
|
"laravel",
|
|
5330
5596
|
"symfony",
|
|
@@ -5364,9 +5630,25 @@ var ScheduledJobSchema = v.pipe(v.strictObject({
|
|
|
5364
5630
|
frequency: v.picklist(SCHEDULE_FREQUENCIES),
|
|
5365
5631
|
cron: v.optional(text)
|
|
5366
5632
|
}), v.check((job) => job.frequency === "custom" === (job.cron !== void 0), "cron is required with the custom frequency, and only with it"));
|
|
5633
|
+
var envName = v.pipe(v.string(), v.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "invalid .env variable name"));
|
|
5634
|
+
/** The .env keys that a new database user gets. Default: the Laravel names. */
|
|
5635
|
+
var DatabaseEnvSchema = v.pipe(v.strictObject({
|
|
5636
|
+
name: v.optional(envName),
|
|
5637
|
+
user: v.optional(envName),
|
|
5638
|
+
password: v.optional(envName)
|
|
5639
|
+
}), v.check((env) => (/* @__PURE__ */ new Set([
|
|
5640
|
+
env.name ?? "DB_DATABASE",
|
|
5641
|
+
env.user ?? "DB_USERNAME",
|
|
5642
|
+
env.password ?? "DB_PASSWORD"
|
|
5643
|
+
])).size === 3, "the .env keys of the database must be different"));
|
|
5644
|
+
/** The load balancer that sends the traffic of the site to its server. */
|
|
5645
|
+
var LoadBalancerSchema = v.strictObject({
|
|
5646
|
+
server: serverRef,
|
|
5647
|
+
port: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(65535)), 80)
|
|
5648
|
+
});
|
|
5367
5649
|
var SiteFileSchema = v.strictObject({
|
|
5368
5650
|
$schema: v.optional(v.string()),
|
|
5369
|
-
server:
|
|
5651
|
+
server: serverRef,
|
|
5370
5652
|
domain: text,
|
|
5371
5653
|
aliases: v.optional(v.array(text)),
|
|
5372
5654
|
type: v.optional(v.picklist(SITE_TYPES)),
|
|
@@ -5379,15 +5661,18 @@ var SiteFileSchema = v.strictObject({
|
|
|
5379
5661
|
})),
|
|
5380
5662
|
database: v.optional(v.strictObject({
|
|
5381
5663
|
name: text,
|
|
5382
|
-
user: text
|
|
5664
|
+
user: text,
|
|
5665
|
+
env: v.optional(DatabaseEnvSchema)
|
|
5383
5666
|
})),
|
|
5384
|
-
env: v.optional(v.record(
|
|
5667
|
+
env: v.optional(v.record(envName, v.union([
|
|
5385
5668
|
v.string(),
|
|
5386
5669
|
v.number(),
|
|
5387
5670
|
v.boolean()
|
|
5388
5671
|
]))),
|
|
5672
|
+
nginx: v.optional(text),
|
|
5389
5673
|
workers: v.optional(v.array(WorkerSchema)),
|
|
5390
|
-
schedule: v.optional(v.array(ScheduledJobSchema))
|
|
5674
|
+
schedule: v.optional(v.array(ScheduledJobSchema)),
|
|
5675
|
+
loadBalancer: v.optional(LoadBalancerSchema)
|
|
5391
5676
|
});
|
|
5392
5677
|
/**
|
|
5393
5678
|
* Validate parsed JSON as a site file. Throws a ValidationError that lists every issue.
|
|
@@ -5403,7 +5688,8 @@ function parseSiteFile(data, source = "site file") {
|
|
|
5403
5688
|
return result.output;
|
|
5404
5689
|
}
|
|
5405
5690
|
/**
|
|
5406
|
-
* Read and validate a site file from disk
|
|
5691
|
+
* Read and validate a site file from disk, with its Nginx file: a relative `nginx` path is
|
|
5692
|
+
* read against the folder of the site file.
|
|
5407
5693
|
*/
|
|
5408
5694
|
function readSiteFile(path) {
|
|
5409
5695
|
let content;
|
|
@@ -5418,7 +5704,17 @@ function readSiteFile(path) {
|
|
|
5418
5704
|
} catch (error) {
|
|
5419
5705
|
throw new ValidationError(`Site file ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, "file");
|
|
5420
5706
|
}
|
|
5421
|
-
|
|
5707
|
+
const file = parseSiteFile(data, `site file ${path}`);
|
|
5708
|
+
if (file.nginx) {
|
|
5709
|
+
const nginxPath = resolve(dirname(path), file.nginx);
|
|
5710
|
+
try {
|
|
5711
|
+
file.nginxTemplate = readFileSync(nginxPath, "utf8");
|
|
5712
|
+
} catch (error) {
|
|
5713
|
+
throw new ValidationError(`Cannot read Nginx file ${nginxPath}: ${error instanceof Error ? error.message : String(error)}`, "nginx");
|
|
5714
|
+
}
|
|
5715
|
+
assertNginxTemplate(file.nginxTemplate, nginxPath);
|
|
5716
|
+
}
|
|
5717
|
+
return file;
|
|
5422
5718
|
}
|
|
5423
5719
|
//#endregion
|
|
5424
5720
|
//#region src/commands/sites/apply/handler.ts
|
|
@@ -5439,6 +5735,7 @@ async function sitesApply(ctx, applyOptions = {}) {
|
|
|
5439
5735
|
if (check) {
|
|
5440
5736
|
const state = await readRemoteState(file, server_id, execCtx);
|
|
5441
5737
|
assertCanCreateSite(file, state);
|
|
5738
|
+
assertLoadBalancerSite(file, state);
|
|
5442
5739
|
const plan = buildPlan(file, state);
|
|
5443
5740
|
const inSync = plan.changes.length === 0;
|
|
5444
5741
|
if (ctx.formatter.isJson()) ctx.formatter.output({
|
|
@@ -5610,9 +5907,15 @@ ${colors.bold("SITE FILE:")}
|
|
|
5610
5907
|
A key that is absent is not managed: apply does not read or change it.
|
|
5611
5908
|
type, php, webDirectory, repository Create the site, or update php, web directory, branch
|
|
5612
5909
|
aliases Add missing aliases, never remove one
|
|
5613
|
-
database { name, user }
|
|
5614
|
-
password, written into the site .env only
|
|
5910
|
+
database { name, user, env } Create when missing; a new user gets a random
|
|
5911
|
+
password, written into the site .env only.
|
|
5912
|
+
env maps { name, user, password } to .env keys
|
|
5913
|
+
(default DB_DATABASE, DB_USERNAME, DB_PASSWORD)
|
|
5615
5914
|
env Add missing keys, never overwrite a value
|
|
5915
|
+
nginx Nginx file, relative to the site file, with Forge
|
|
5916
|
+
template variables such as {{PATH}}, {{SITE_ID}},
|
|
5917
|
+
{{PROXY_PASS}}: rendered for the site, written
|
|
5918
|
+
when it differs (an unknown variable exits 3)
|
|
5616
5919
|
workers, schedule Create missing ones, delete the ones of the site
|
|
5617
5920
|
that the file does not declare ([] deletes all)
|
|
5618
5921
|
|
|
@@ -6493,7 +6796,7 @@ function isVersionRequest({ command, options }) {
|
|
|
6493
6796
|
}
|
|
6494
6797
|
//#endregion
|
|
6495
6798
|
//#region src/cli.ts
|
|
6496
|
-
var VERSION = "0.5.
|
|
6799
|
+
var VERSION = "0.5.4";
|
|
6497
6800
|
function showHelp() {
|
|
6498
6801
|
console.log(`
|
|
6499
6802
|
${colors.bold("forge")} v${VERSION}
|