Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/sites-apply-nginx-db-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Added

- **CLI**: Add `nginx` to the site file of `forge sites apply`: an Nginx file with Forge template variables, rendered for the site, compared with Forge and written when it differs
- **CLI**: Add `database.env` to the site file of `forge sites apply` to write the database name, user and password into other `.env` keys
- **CLI**: Add `forge servers public-key <server>` to print the public key of the `forge` user, the key that the git host must accept
37 changes: 36 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ forge config delete # Delete all stored config
```bash
forge servers list # List all servers
forge servers get <id> # Get server details
forge servers public-key <id> # Print the public key of the forge user, for the git host
forge servers reboot <id> # Reboot a server
forge servers create --name <name> --ip-address <ip> [--php-version php84 --database mariadb1011]
# Create a server (Custom VPS by default)
Expand Down Expand Up @@ -155,6 +156,7 @@ forge sites apply --file site.json # Make the site match the file
},
"database": { "name": "example", "user": "example" },
"env": { "APP_ENV": "production" },
"nginx": "nginx.conf",
"workers": [{ "command": "php artisan queue:work database --queue=default", "processes": 4 }],
"schedule": [{ "command": "php artisan schedule:run", "frequency": "minutely" }]
}
Expand All @@ -165,11 +167,44 @@ forge sites apply --file site.json # Make the site match the file
| `server`, `domain` | Find the server and the site by exact name (never a partial match, with or without `--exact`). `server` can also be an ID |
| `type`, `php`, `webDirectory`, `repository` | Create the site when it is missing (`type` is then required) and wait until Forge installs it. On an existing site, update `php`, `webDirectory` and the branch |
| `aliases` | Add the missing aliases. Never remove one |
| `database` | Create the database and the user when they are missing. A new user gets a random password, written only into the site `.env` (`DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`) |
| `database` | Create the database and the user when they are missing. A new user gets a random password, written only into the site `.env` (`DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, see below) |
| `env` | Add the missing keys to the site `.env`. A key with another value on Forge is reported, never overwritten |
| `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) |
| `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 |
| `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 |

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`:

```json
{
"database": {
"name": "example",
"user": "example",
"env": { "name": "DB_NAME", "user": "DB_USER", "password": "DB_PASSWD" }
},
"env": { "DB_SERVER": "127.0.0.1" }
}
```

`nginx` points to an Nginx file that uses the variables of [Forge Nginx templates](https://forge.laravel.com/docs/servers/nginx-templates). `apply` renders the file locally with the values of the site, so one file works on any server, without a template on the server:

| Variable | Value |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `{{SITE}}`, `{{SITE_ID}}`, `{{USER}}` | Site name (`www.example.com`), site ID (`456`), site user (`forge`) |
| `{{DOMAINS}}` | Site name and aliases, separated by spaces |
| `{{ROOT_PATH}}`, `{{PATH}}`, `{{DIRECTORY}}` | Site root, web directory (`/home/forge/www.example.com/public`) and its relative path (`/public`) |
| `{{PROXY_PASS}}` | PHP-FPM socket, e.g. `unix:/var/run/php/php8.3-fpm.sock` (`php8.3-fpm-<user>.sock` for an isolated site) |
| `{{PORT}}`, `{{PORT_V6}}` | `80` and `[::]:80` (with `default_server` for the site named `default`) |
| `{{SERVER_PUBLIC_IP}}`, `{{SERVER_PRIVATE_IP}}` | IP addresses of the server (empty when there is no private IP) |

Spaces inside the braces are allowed (`{{ SITE_ID }}`). An unknown variable is an error (exit `3`). On a new site, `apply` writes the file after Forge installs the site. On an existing site, the plan shows the diff from Forge to the rendered file (CRLF and the final newline do not count), and `apply` writes it. When `apply` also changes the site or its aliases, it reads the site again and renders the file with the new values. Forge returns the full `server` block on old servers and only its content on recent ones: write the file in the same form as `forge nginx get` prints it.

Forge clones the repository when it creates the site, with the key of the `forge` user of the server. Add this key to the git host (for example as a deploy key) before `apply` creates the site:

```bash
forge servers public-key web-1 > server.pub # The key only; --format json gives { "public_key": "..." }
```

A worker or a scheduled job matches the file when its command, directory, processes (or frequency) and user are the same. Commands are compared after normalization: quotes (`'database'` and `database`), repeated spaces and a relative `artisan` (`php artisan` and `php /home/forge/www.example.com/artisan`) do not count as differences. Forge cannot update a worker: a changed worker is created again, then the old one is deleted.

`workers` and `schedule` delete what the file does not declare, so `apply` deletes only what surely belongs to the site, because a server often hosts several sites:
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/schemas/site.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,29 @@
"required": ["name", "user"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"user": { "type": "string", "minLength": 1 }
"user": { "type": "string", "minLength": 1 },
"env": {
"description": ".env keys that get the database name, user and password of a new user. The keys must be different.",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"default": "DB_DATABASE"
},
"user": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"default": "DB_USERNAME"
},
"password": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
"default": "DB_PASSWORD"
}
}
}
}
},
"env": {
Expand All @@ -77,6 +99,11 @@
"propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
"additionalProperties": { "type": ["string", "number", "boolean"] }
},
"nginx": {
"description": "Nginx file, relative to the site file, with the Forge template variables {{DIRECTORY}}, {{DOMAINS}}, {{PATH}}, {{PORT}}, {{PORT_V6}}, {{PROXY_PASS}}, {{ROOT_PATH}}, {{SERVER_PUBLIC_IP}}, {{SERVER_PRIVATE_IP}}, {{SITE}}, {{SITE_ID}} and {{USER}}. Rendered for the site and written when it differs from Forge. An unknown variable is an error.",
"type": "string",
"minLength": 1
},
"workers": {
"description": "Forge background processes of the site. [] deletes all the workers of the site.",
"type": "array",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ _forge_completions() {

# Subcommands for each command
local config_cmds="set get delete"
local servers_cmds="list ls get create reboot"
local servers_cmds="list ls get public-key create reboot"
local sites_cmds="list ls get apply"
local deployments_cmds="list ls deploy logs log script update-script"
local databases_cmds="list ls get"
Expand Down Expand Up @@ -235,6 +235,7 @@ _forge() {
'list:List all servers'
'ls:List all servers (alias)'
'get:Get server details'
'public-key:Print the public key of the forge user'
'create:Create a server'
'reboot:Reboot a server'
)
Expand Down Expand Up @@ -594,6 +595,7 @@ complete -c forge -f -n "__fish_seen_subcommand_from config" -a "delete" -d "Del
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "list" -d "List all servers"
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "ls" -d "List all servers (alias)"
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "get" -d "Get server details"
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "public-key" -d "Print the public key of the forge user"
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "create" -d "Create a server"
complete -c forge -f -n "__fish_seen_subcommand_from servers s" -a "reboot" -d "Reboot a server"

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/servers/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ vi.mock("./handlers.ts", () => ({
serversGet: vi.fn().mockResolvedValue(),
serversCreate: vi.fn().mockResolvedValue(),
serversReboot: vi.fn().mockResolvedValue(),
serversPublicKey: vi.fn().mockResolvedValue(),
}));

vi.mock("../../context.ts", () => ({
Expand Down Expand Up @@ -61,6 +62,12 @@ describe("handleServersCommand routing", () => {
expect(handlers.serversGet).toHaveBeenCalledWith(["123"], expect.anything());
});

it("should route public-key subcommand with args", async () => {
const handlers = await import("./handlers.ts");
await handleServersCommand("public-key", ["123"], {});
expect(handlers.serversPublicKey).toHaveBeenCalledWith(["123"], expect.anything());
});

it("should route create subcommand", async () => {
const handlers = await import("./handlers.ts");
await handleServersCommand("create", [], { name: "my-server" });
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/servers/command.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { createCommandRouter } from "../../utils/command-router.ts";
import { serversList, serversGet, serversCreate, serversReboot } from "./handlers.ts";
import {
serversList,
serversGet,
serversCreate,
serversReboot,
serversPublicKey,
} from "./handlers.ts";

export const handleServersCommand = createCommandRouter({
resource: "servers",
handlers: {
list: serversList,
ls: serversList,
get: [serversGet, "args"],
"public-key": [serversPublicKey, "args"],
create: serversCreate,
reboot: [serversReboot, "args"],
},
Expand Down
69 changes: 68 additions & 1 deletion packages/cli/src/commands/servers/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { ServerAttributes } from "@studiometa/forge-api";

import { createTestContext } from "../../context.ts";
import { serversList, serversGet, serversCreate, serversReboot } from "./handlers.ts";
import {
serversList,
serversGet,
serversCreate,
serversReboot,
serversPublicKey,
} from "./handlers.ts";

vi.mock("@studiometa/forge-core", () => ({
listServers: vi.fn(),
Expand Down Expand Up @@ -448,3 +454,64 @@ describe("serversCreate", () => {
expect(processExitSpy).toHaveBeenCalled();
});
});

describe("serversPublicKey", () => {
let processExitSpy: ReturnType<typeof vi.spyOn>;
const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample root@web-1";

beforeEach(() => {
processExitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
});

it("prints only the key in human format", async () => {
const { getServer } = await import("@studiometa/forge-core");
vi.mocked(getServer).mockResolvedValue({
data: { ...mockServer, local_public_key: `${key}\n` },
});
const ctx = createTestContext({
token: "test",
mockClient: {} as never,
options: { format: "human" },
});

await serversPublicKey(["1"], ctx);
expect(vi.mocked(console.log).mock.calls).toEqual([[key]]);
});

it("prints { public_key } in json format", async () => {
const { getServer } = await import("@studiometa/forge-core");
vi.mocked(getServer).mockResolvedValue({ data: { ...mockServer, local_public_key: key } });
const ctx = createTestContext({
token: "test",
mockClient: {} as never,
options: { format: "json" },
});

await serversPublicKey(["1"], ctx);
expect(JSON.parse(String(vi.mocked(console.log).mock.calls[0][0]))).toEqual({
public_key: key,
});
});

it("exits with not found when the server has no key yet", async () => {
const { getServer } = await import("@studiometa/forge-core");
vi.mocked(getServer).mockResolvedValue({ data: { ...mockServer, local_public_key: null } });
const ctx = createTestContext({ token: "test", mockClient: {} as never, options: {} });

await serversPublicKey(["1"], ctx);
expect(processExitSpy).toHaveBeenCalledWith(5);
});

it("exits with a validation error without a server", async () => {
const ctx = createTestContext({ token: "test", mockClient: {} as never, options: {} });

await serversPublicKey([], ctx).catch(() => {});
expect(processExitSpy).toHaveBeenCalledWith(3);
});
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/servers/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { listServers, getServer, createServer, rebootServer } from "@studiometa/
import type { CommandContext } from "../../context.ts";

import { exitWithValidationError, runCommand } from "../../error-handler.ts";
import { ApiError } from "../../errors.ts";
import { resolveServerId } from "../../utils/resolve.ts";

/**
Expand Down Expand Up @@ -57,6 +58,36 @@ export async function serversGet(args: string[], ctx: CommandContext): Promise<v
}, ctx.formatter);
}

/**
* Print the public key of the `forge` user of a server (`~/.ssh/id_rsa.pub`), the key that
* Forge uses to clone repositories: add it to the git host before you create a site.
*/
export async function serversPublicKey(args: string[], ctx: CommandContext): Promise<void> {
const [server] = args;

if (!server) {
exitWithValidationError("server_id", "forge servers public-key <server_id>", ctx.formatter);
}

await runCommand(async () => {
const token = ctx.getToken();
const execCtx = ctx.createExecutorContext(token);
const server_id = await resolveServerId(server, execCtx);
const result = await getServer({ server_id }, execCtx);
const key = result.data.local_public_key?.trim();
if (!key) {
throw new ApiError(`Server ${result.data.name} has no public key yet`, 404, [
"Wait until Forge provisions the server",
]);
}
if (ctx.formatter.isJson()) {
ctx.formatter.output({ public_key: key });
} else {
console.log(key);
}
}, ctx.formatter);
}

const CREATE_USAGE = "forge servers create --name <name> --ip-address <ip> [options]";

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/src/commands/servers/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ ${colors.bold("OPTIONS:")}
${colors.bold("EXAMPLES:")}
forge servers get 123
forge servers get 123 --format json
`);
} else if (subcommand === "public-key") {
console.log(`
${colors.bold("forge servers public-key")} - Print the public key of the forge user

${colors.bold("USAGE:")}
forge servers public-key <server_id> [options]

${colors.bold("ARGUMENTS:")}
<server_id> Server ID or name (required)

${colors.bold("OPTIONS:")}
-f, --format <fmt> Output format: json ({ "public_key": "..." }), human (the key only)

Forge clones repositories as the forge user, with this key (~/.ssh/id_rsa.pub).
Add it to the git host (e.g. as a deploy key) before you create a site with a
repository. It is not the key of GET /servers/{id}/key: that key lets Forge
connect to the server.

${colors.bold("EXAMPLES:")}
forge servers public-key 123
forge servers public-key 123 > server.pub
`);
} else if (subcommand === "create") {
console.log(`
Expand Down Expand Up @@ -91,6 +113,7 @@ ${colors.bold("ALIASES:")}
${colors.bold("SUBCOMMANDS:")}
list, ls List all servers
get <id> Get server details
public-key <id> Print the public key of the forge user
create Create a server (requires --name, and --ip-address for custom)
reboot <id> Reboot a server

Expand Down
Loading
Loading