@studiometa/forge-cli 0.5.3 → 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 +5 -1
- package/dist/cli.js +89 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/sites/apply/apply.d.ts +11 -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/plan.d.ts +23 -1
- package/dist/commands/sites/apply/plan.d.ts.map +1 -1
- package/dist/commands/sites/apply/site-file.d.ts +9 -0
- package/dist/commands/sites/apply/site-file.d.ts.map +1 -1
- package/dist/commands/sites/apply/test-fixtures.d.ts +7 -1
- package/dist/commands/sites/apply/test-fixtures.d.ts.map +1 -1
- package/package.json +3 -3
- package/schemas/site.schema.json +96 -17
package/README.md
CHANGED
|
@@ -158,7 +158,8 @@ forge sites apply --file site.json # Make the site match the file
|
|
|
158
158
|
"env": { "APP_ENV": "production" },
|
|
159
159
|
"nginx": "nginx.conf",
|
|
160
160
|
"workers": [{ "command": "php artisan queue:work database --queue=default", "processes": 4 }],
|
|
161
|
-
"schedule": [{ "command": "php artisan schedule:run", "frequency": "minutely" }]
|
|
161
|
+
"schedule": [{ "command": "php artisan schedule:run", "frequency": "minutely" }],
|
|
162
|
+
"loadBalancer": { "server": "lb-1", "port": 80 }
|
|
162
163
|
}
|
|
163
164
|
```
|
|
164
165
|
|
|
@@ -172,6 +173,7 @@ forge sites apply --file site.json # Make the site match the file
|
|
|
172
173
|
| `nginx` | Nginx file, relative to the site file, with Forge template variables. Rendered for the site and written when it differs from Forge (see below) |
|
|
173
174
|
| `workers` | Forge background processes of the site. Create the missing ones, delete the ones of the site that the file does not declare. The directory defaults to the site root |
|
|
174
175
|
| `schedule` | Forge scheduled jobs of the site (`minutely`, `hourly`, `nightly`, `weekly`, `monthly`, `reboot`, or `custom` with `cron`). A relative `artisan` becomes the absolute path of the site |
|
|
176
|
+
| `loadBalancer` | Set the nodes of the site on the load balancer to `server` only, on `port` (default `80`), with round robin and weight 1. The site of the load balancer must exist (see below) |
|
|
175
177
|
|
|
176
178
|
For an application that does not read the Laravel names, `database.env` maps the database name, the user and the password to other `.env` keys. Each key is optional and defaults to the Laravel name. Other values, such as the host, go in `env`:
|
|
177
179
|
|
|
@@ -216,6 +218,8 @@ A worker or a scheduled job matches the file when its command, directory, proces
|
|
|
216
218
|
- Without the `workers` key, no worker is read or deleted. With `"workers": []`, all the workers of the site are deleted. The same applies to `schedule`.
|
|
217
219
|
- The site, its aliases, its database and its `.env` keys are never deleted.
|
|
218
220
|
|
|
221
|
+
`loadBalancer` sends the traffic of the load balancer to the server of the file. The site of the load balancer is the one named `domain` or one of the `aliases`. When no site matches, `--check` and `apply` exit `3` before any change. When two sites match, they exit `3` too. The Forge API cannot create a site on a load balancer: create it in Forge (New site on the load balancer), with its certificate. To move a site to another server, change `server`: `apply` creates the site there, then moves the node, with no DNS change. It does not delete the site on the old server.
|
|
222
|
+
|
|
219
223
|
With `--check`, `apply` prints the plan and changes nothing. It exits `0` when Forge matches the file, `1` when it differs, and another code on an error (`3` for an invalid file). Every deletion shows its ID and full command. The plan gives `.env` key names only, never values, and shows a generated password as `<generated>`. With `--format json`, it prints `{ "inSync": false, "changes": [...], "warnings": [...] }`.
|
|
220
224
|
|
|
221
225
|
Run `--check` in merge request pipelines and `apply` on the main branch:
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
import { dirname, join, posix, resolve } from "node:path";
|
|
@@ -5069,6 +5069,29 @@ function planSchedule(file, state, changes, warnings) {
|
|
|
5069
5069
|
else warnings.push(`Kept scheduled job ${job.id} (${job.command}): not declared, but ${describeOwnership(ownership)}.`);
|
|
5070
5070
|
}
|
|
5071
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
|
+
/**
|
|
5072
5095
|
* Compare the site file with the state of Forge. Keys that the file does not have are not
|
|
5073
5096
|
* compared. The plan creates and updates what differs, and deletes only the workers and
|
|
5074
5097
|
* scheduled jobs that surely belong to the site and that the file does not declare.
|
|
@@ -5083,6 +5106,7 @@ function buildPlan(file, state) {
|
|
|
5083
5106
|
planNginx(file, state, changes);
|
|
5084
5107
|
planWorkers(file, state, changes, warnings);
|
|
5085
5108
|
planSchedule(file, state, changes, warnings);
|
|
5109
|
+
planLoadBalancer(file, state, changes);
|
|
5086
5110
|
return {
|
|
5087
5111
|
changes,
|
|
5088
5112
|
warnings
|
|
@@ -5109,6 +5133,11 @@ function describeChange(change) {
|
|
|
5109
5133
|
case "create-worker": return `+ create worker: ${change.command} (directory: ${change.directory}, processes: ${change.processes})`;
|
|
5110
5134
|
case "delete-worker": return `- delete worker ${change.id}: ${change.command} (directory: ${quoted(change.directory)}, processes: ${change.processes})`;
|
|
5111
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
|
+
}
|
|
5112
5141
|
case "delete-job": return `- delete scheduled job ${change.id}: ${change.command} (${change.frequency.toLowerCase()} ${change.cron})`;
|
|
5113
5142
|
}
|
|
5114
5143
|
}
|
|
@@ -5213,6 +5242,24 @@ function findSite(sites, domain) {
|
|
|
5213
5242
|
return sites.find((site) => site.name.toLowerCase() === domain.toLowerCase()) ?? null;
|
|
5214
5243
|
}
|
|
5215
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
|
+
/**
|
|
5216
5263
|
* Read from Forge what the site file manages, and nothing else.
|
|
5217
5264
|
*/
|
|
5218
5265
|
async function readRemoteState(file, serverId, ctx) {
|
|
@@ -5248,6 +5295,20 @@ async function readRemoteState(file, serverId, ctx) {
|
|
|
5248
5295
|
state.jobs = [...siteJobs, ...serverJobs];
|
|
5249
5296
|
state.siteJobIds = new Set(siteJobs.map((job) => job.id));
|
|
5250
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
|
+
}
|
|
5251
5312
|
return state;
|
|
5252
5313
|
}
|
|
5253
5314
|
/**
|
|
@@ -5457,6 +5518,20 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
|
|
|
5457
5518
|
...change.cron ? { cron: change.cron } : {}
|
|
5458
5519
|
}, ctx);
|
|
5459
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
|
+
}
|
|
5460
5535
|
for (const change of deletions) {
|
|
5461
5536
|
options.onChange?.(change);
|
|
5462
5537
|
if (change.action === "delete-worker") await deleteDaemon({
|
|
@@ -5476,6 +5551,7 @@ async function applyChanges(file, plan, state, serverId, site, generatedEnv, ctx
|
|
|
5476
5551
|
*/
|
|
5477
5552
|
async function applySiteFile(file, serverId, ctx, options = {}) {
|
|
5478
5553
|
let state = await readRemoteState(file, serverId, ctx);
|
|
5554
|
+
assertLoadBalancerSite(file, state);
|
|
5479
5555
|
const firstPlan = buildPlan(file, state);
|
|
5480
5556
|
let plan = firstPlan;
|
|
5481
5557
|
let generatedEnv = /* @__PURE__ */ new Map();
|
|
@@ -5513,6 +5589,8 @@ async function applySiteFile(file, serverId, ctx, options = {}) {
|
|
|
5513
5589
|
* A key that is absent is not managed; only `server` and `domain` are required.
|
|
5514
5590
|
*/
|
|
5515
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))]);
|
|
5516
5594
|
var SITE_TYPES = [
|
|
5517
5595
|
"laravel",
|
|
5518
5596
|
"symfony",
|
|
@@ -5563,9 +5641,14 @@ var DatabaseEnvSchema = v.pipe(v.strictObject({
|
|
|
5563
5641
|
env.user ?? "DB_USERNAME",
|
|
5564
5642
|
env.password ?? "DB_PASSWORD"
|
|
5565
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
|
+
});
|
|
5566
5649
|
var SiteFileSchema = v.strictObject({
|
|
5567
5650
|
$schema: v.optional(v.string()),
|
|
5568
|
-
server:
|
|
5651
|
+
server: serverRef,
|
|
5569
5652
|
domain: text,
|
|
5570
5653
|
aliases: v.optional(v.array(text)),
|
|
5571
5654
|
type: v.optional(v.picklist(SITE_TYPES)),
|
|
@@ -5588,7 +5671,8 @@ var SiteFileSchema = v.strictObject({
|
|
|
5588
5671
|
]))),
|
|
5589
5672
|
nginx: v.optional(text),
|
|
5590
5673
|
workers: v.optional(v.array(WorkerSchema)),
|
|
5591
|
-
schedule: v.optional(v.array(ScheduledJobSchema))
|
|
5674
|
+
schedule: v.optional(v.array(ScheduledJobSchema)),
|
|
5675
|
+
loadBalancer: v.optional(LoadBalancerSchema)
|
|
5592
5676
|
});
|
|
5593
5677
|
/**
|
|
5594
5678
|
* Validate parsed JSON as a site file. Throws a ValidationError that lists every issue.
|
|
@@ -5651,6 +5735,7 @@ async function sitesApply(ctx, applyOptions = {}) {
|
|
|
5651
5735
|
if (check) {
|
|
5652
5736
|
const state = await readRemoteState(file, server_id, execCtx);
|
|
5653
5737
|
assertCanCreateSite(file, state);
|
|
5738
|
+
assertLoadBalancerSite(file, state);
|
|
5654
5739
|
const plan = buildPlan(file, state);
|
|
5655
5740
|
const inSync = plan.changes.length === 0;
|
|
5656
5741
|
if (ctx.formatter.isJson()) ctx.formatter.output({
|
|
@@ -6711,7 +6796,7 @@ function isVersionRequest({ command, options }) {
|
|
|
6711
6796
|
}
|
|
6712
6797
|
//#endregion
|
|
6713
6798
|
//#region src/cli.ts
|
|
6714
|
-
var VERSION = "0.5.
|
|
6799
|
+
var VERSION = "0.5.4";
|
|
6715
6800
|
function showHelp() {
|
|
6716
6801
|
console.log(`
|
|
6717
6802
|
${colors.bold("forge")} v${VERSION}
|