@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 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: v.union([text, v.pipe(v.number(), v.integer(), v.minValue(1))]),
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.3";
6799
+ var VERSION = "0.5.4";
6715
6800
  function showHelp() {
6716
6801
  console.log(`
6717
6802
  ${colors.bold("forge")} v${VERSION}