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
2 changes: 1 addition & 1 deletion .github/workflows/launcher.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ jobs:
run: |
set -euo pipefail
site=$(mktemp -d)
curl -fsSL "https://$DOMAIN/install.sh" | bash -s -- --dir "$site" install --local --no-prompt
curl -fsSL "https://$DOMAIN/install.sh" | bash -s -- --dir "$site" --local --no-prompt
version=$(jq -r .stack.version "$site/.ghost-docker.json")
channel=$(jq -r .channel "$site/.ghost-docker.json")
[[ $version == "$TAG" && $channel == beta ]] || {
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Configuration to run Ghost and its services with Docker Compose.

```sh
# Linux, macOS, and Windows through WSL2; or --domain example.com --email ops@example.com
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local
./ghost-docker check
./ghost-docker self-update
```
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ use native clients; bulk database operations use version-matched MySQL tools.
would refer to a different machine.
- External import bundles and restore backups are mounted read-only at their
own paths. Prompts use the terminal even when the launcher is piped from curl.
- Piped from curl with no command word, the launcher runs `install`.
- `doctor` writes a file and reads it through a sibling container mounting the
same host path. Seeing the directory inside the manager alone cannot establish
that the daemon sees the same files.
Expand Down
34 changes: 19 additions & 15 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

```bash
mkdir ~/my-site && cd ~/my-site
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local # or: --domain example.com
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local # or: --domain example.com
```

`install.sh` is the launcher, `ghost-docker`, from the newest release. Piped
into bash it installs the newest release on the `beta` channel; `install`
writes a copy of it into the site, and every later command is
`./ghost-docker ...` from there.
into bash with no command, it runs `install` with the options given, from the
newest release on the `beta` channel. As with Ghost-CLI's `ghost install`, a
site is a production one unless `--local` is given: plain `curl -fsSL
https://docker.ghost.org/install.sh | bash` asks for its domain. `install`
writes a copy of the launcher into the site, and every later command is
`./ghost-docker ...` from there. Other commands can still be piped by name:
`bash -s -- self-update`.

## Releases

Expand All @@ -26,7 +30,7 @@ a beta.
| The development image | `GD_CHANNEL=edge` | `edge`, built from every push to `next-docker`. Not a release. |

These go to the launcher with the command: `curl -fsSL
https://docker.ghost.org/install.sh | bash -s -- install --local --release
https://docker.ghost.org/install.sh | bash -s -- --local --release
v0.1.0-beta.1`. A site always runs one image digest, never a channel: `install`
pins the site's launcher to the digest it ran as, and records the channel the
site follows, which `self-update` uses.
Expand Down Expand Up @@ -61,7 +65,7 @@ ghost-docker install --import BUNDLE [--domain DOMAIN] [--admin-domain DOMAIN] [
| Option | Meaning |
| --- | --- |
| `--local` | Ghost and MySQL, published on `127.0.0.1:PORT`. `NODE_ENV=development`, `RESTART_POLICY=no`. |
| `--domain DOMAIN` | Production: Ghost, MySQL and Caddy with HTTPS on that domain. |
| `--domain DOMAIN` | The production site's domain: Ghost, MySQL and Caddy with HTTPS on it. Production is the default; asked for at a terminal when omitted. |
| `--admin-domain DOMAIN` | A separate Ghost Admin domain. Production only. |
| `--email EMAIL` | The ACME account email. Production only; see below. |
| `--port PORT` | The loopback port Ghost is published on, in both modes. Omitted: the first at or above 2368 that is free (see [Ports](#ports-and-your-existing-proxy)). |
Expand All @@ -73,11 +77,11 @@ ghost-docker install --import BUNDLE [--domain DOMAIN] [--admin-domain DOMAIN] [
| `--no-start` | Write the configuration and routes; create no containers. |
| `--import BUNDLE` | Import a local or production Ghost-CLI site from the bundle `ghost migrate-export` made; see [Importing a Ghost-CLI site](#importing-a-ghost-cli-site). |

With neither `--local` nor `--domain`, `install` asks which kind of site, and
for a production site its domain. It asks only at a terminal, including when
the launcher was piped from curl. Without one, or with `--no-prompt`, a missing
answer is a usage error naming the option that supplies it; nothing has a
silent default. Every question has an option, so a script never needs to answer
Without `--local`, the site is a production one, as with Ghost-CLI's
`ghost install`, and `install` asks for the domain `--domain` would give. It
asks only at a terminal, including when the launcher was piped from curl.
Without one, or with `--no-prompt`, a missing answer is a usage error naming
the option that supplies it. Every question has an option, so a script never needs to answer
one.

Exit statuses: `0` installed, `1` failed, `2` a usage error. Options the plan
Expand Down Expand Up @@ -341,7 +345,7 @@ cd ~/sites/my-blog
ghost stop
ghost migrate-export --output ../my-blog-bundle
mkdir ../my-blog-docker && cd ../my-blog-docker
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ../my-blog-bundle --port 2368
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ../my-blog-bundle --port 2368
```

- **Stop first.** The exporter stops a running site while it copies, and
Expand Down Expand Up @@ -399,7 +403,7 @@ ghost stop
ghost migrate-export --output ~/my-site-bundle
sudo systemctl stop nginx
mkdir ~/my-site && cd ~/my-site
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle
```

- **Stop nginx yourself.** Nothing in the import stops it; it may serve other
Expand All @@ -426,7 +430,7 @@ scp ~/my-site-bundle.tgz new-host:

# On the new host
mkdir ~/my-site && cd ~/my-site
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle.tgz
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle.tgz
```

Then point the domain's DNS at the new host. Until it moves, `check` reports
Expand All @@ -448,7 +452,7 @@ domain of its own (the source's admin domain is not carried to it; name the
copy's own with `--admin-domain` if it needs one):

```bash
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ~/my-site-bundle.tgz --domain staging.example.com --no-start
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ~/my-site-bundle.tgz --domain staging.example.com --no-start
```

**A copied database sends real mail.** Before starting the copy, remove the
Expand Down
25 changes: 19 additions & 6 deletions ghost-docker
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
#
# ghost-docker [--dir PATH] <command> [options]
# ghost-docker help
# curl -fsSL https://docker.ghost.org/install.sh | bash -s -- [install options]
#
# Piped into bash with no command, the command is `install`.
#
# Everything ghost-docker does happens in a container, the manager image. This
# script is the only part that runs on the host, and it does three things:
Expand Down Expand Up @@ -139,6 +142,22 @@ while (($#)); do
shift
done

# Empty when this script is being read from a pipe (`curl ... | bash`): there is
# no file, so there is no checkout to find beside it.
script_dir=""
if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then
script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
fi

# Piped with no command, it installs: `curl ... | bash` and
# `curl ... | bash -s -- --local` mean `install`, the only command that has no
# site's own copy of this script to run instead.
if [[ -z $script_dir && -z $command_word ]]; then
command_word=install
args=(install ${args[@]+"${args[@]}"})
[[ -z $bundle_at ]] || bundle_at=$((bundle_at + 1))
fi

# Only the release formats that are published; the manager checks them again.
case $channel in
'' | stable | beta) ;;
Expand Down Expand Up @@ -241,12 +260,6 @@ fi

# --- Which image ------------------------------------------------------------

# Empty when this script is being read from a pipe (`curl ... | bash`): there is
# no file, so there is no checkout to find beside it.
script_dir=""
if [[ -n ${BASH_SOURCE[0]:-} && -f ${BASH_SOURCE[0]} ]]; then
script_dir=$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
fi
source_kind=image
# A channel is a tag that moves, and `docker run` would otherwise keep using
# whichever copy of it this host pulled first. Docker's progress goes to
Expand Down
31 changes: 14 additions & 17 deletions manager/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,14 @@ const hostname = z

const options = z
.object({
local: flag('A local site: Ghost and MySQL on 127.0.0.1:PORT.'),
local: flag(
'A local site: Ghost and MySQL on 127.0.0.1:PORT. Without it, the site is a production one.',
),
domain: hostname
.optional()
.describe('A production site on this domain: Ghost, MySQL and Caddy with HTTPS.'),
.describe(
"The production site's domain: Ghost, MySQL and Caddy with HTTPS. Asked for at a terminal.",
),
adminDomain: hostname
.optional()
.describe('Serve Ghost Admin on a separate domain. Production only.'),
Expand Down Expand Up @@ -202,24 +206,17 @@ interface Plan {
/** What the options ask for, and at a terminal, what they leave out. */
async function plan(flags: Flags, prompt: Prompter | null): Promise<Plan> {
const ask = flags.noPrompt ? null : prompt;
let mode: SiteMode | null = flags.local
? 'local'
: flags.domain !== undefined
? 'production'
: null;
if (mode === null) {
if (ask === null) {
throw new UsageError('choose a site mode: --local, or --domain example.com');
}
mode = await ask.choose<SiteMode>('What kind of site?', [
{ name: 'Local: for themes and trying Ghost, on 127.0.0.1', value: 'local' },
{ name: 'Production: on a domain, with HTTPS', value: 'production' },
]);
}
// As Ghost-CLI's `ghost install`: production unless --local says otherwise.
const mode: SiteMode = flags.local ? 'local' : 'production';
let domain = flags.domain ?? '';
if (mode === 'production' && domain === '') {
if (ask === null) {
throw new UsageError(
'a production site needs --domain example.com; for a local site, use --local',
);
}
domain = (
await ask!.text('Its domain (example.com):', (answer) =>
await ask.text('Its domain (example.com):', (answer) =>
isHostname(answer) ? null : 'a hostname such as example.com, not a URL',
)
).toLowerCase();
Expand Down
12 changes: 6 additions & 6 deletions manager/test/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const siteFiles = () =>
describe('refusals that change nothing', () => {
test('usage errors exit 2', async () => {
for (const [args, message] of [
[[], /choose a site mode: --local, or --domain example.com/],
[[], /a production site needs --domain example\.com; for a local site, use --local/],
[['--local', '--domain', 'example.com'], /not both/],
[['--local', '--with', 'analytics'], /set up after installation.*\n.*TINYBIRD\.md/],
[['--local', '--without', 'redis'], /without/],
Expand Down Expand Up @@ -86,21 +86,21 @@ describe('refusals that change nothing', () => {
assert.deepEqual(siteFiles(), []);
});

test('at a terminal, the mode and the domain are asked for', async () => {
h.answers = ['production', 'example.com'];
test('without --local, it is a production site, and at a terminal the domain is asked for', async () => {
h.answers = ['example.com'];
assert.equal((await install('--no-start')).code, 0);
assert.deepEqual(h.asked, ['What kind of site?', 'Its domain (example.com):']);
assert.deepEqual(h.asked, ['Its domain (example.com):']);
assert.equal(
env.get(readFileSync(join(h.dir, '.env'), 'utf8'), 'URL'),
'https://example.com',
);
});

test('--no-prompt never asks, even at a terminal', async () => {
h.answers = ['local'];
h.answers = ['example.com'];
const result = await install('--no-prompt');
assert.equal(result.code, 2);
assert.match(result.stderr, /choose a site mode/);
assert.match(result.stderr, /a production site needs --domain/);
assert.deepEqual(h.asked, []);
});

Expand Down
6 changes: 3 additions & 3 deletions pages/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ <h2>Requirements</h2>

<h2>A production site</h2>
<p>Ports 80 and 443 must be free. Make an empty directory for the site and run, from inside it:</p>
<pre><code>curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --domain example.com --email you@example.com</code></pre>
<pre><code>curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --domain example.com --email you@example.com</code></pre>
<p>Then point the domain's DNS at this host. Caddy obtains a certificate once it does, and <code>./ghost-docker check</code> reports it.</p>

<h2>A local site</h2>
<p>For themes, and for trying Ghost, on <code>127.0.0.1</code>:</p>
<pre><code>mkdir ~/my-site &amp;&amp; cd ~/my-site
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --local</code></pre>
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --local</code></pre>

<h2>Moving a Ghost-CLI site</h2>
<p>A local Ghost-CLI site (one made with <code>ghost install local</code>) moves to Docker with its posts, members, staff accounts, settings, themes, images and configuration. You need Ghost-CLI 1.33.4 or later, and Ghost 6.61.0 or later in the site: the import runs the exact version the site did, and 6.61.0 is the first with a Docker image the import can use. Older sites are refused, so update first.</p>
Expand All @@ -93,7 +93,7 @@ <h2>Moving a Ghost-CLI site</h2>
ghost stop
ghost migrate-export --output ../my-blog-bundle
mkdir ../my-blog-docker &amp;&amp; cd ../my-blog-docker
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- install --import ../my-blog-bundle --port 2368</code></pre>
curl -fsSL https://docker.ghost.org/install.sh | bash -s -- --import ../my-blog-bundle --port 2368</code></pre>
<ul>
<li>The Docker site runs the exact Ghost version the source did, at the same <code>http://localhost:2368</code>. Use the source's port: <code>server.port</code> in its <code>config.development.json</code>.</li>
<li>Staff sign in with their existing passwords.</li>
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,25 @@ expect_status 0
expect_run_arg "ghcr.io/tryghost/ghost-docker:beta"
ok "runs with no file of its own, and uses the published image"

mkdir -p "$WORK/piped"
: >"$WORK/piped/piped.tgz"
set +e
OUT=$(cd "$WORK" && FAKE_DOCKER=ok PATH="$FAKE:$PATH" env ${fake_socket_env[@]+"${fake_socket_env[@]}"} \
"$BASH_BIN" -s -- --dir "$SITE" --channel stable --local --import piped/piped.tgz <"$LAUNCHER" 2>&1)
RC=$?
set -e
expect_status 0
[[ $(run_args | tail -7) == $'ghcr.io/tryghost/ghost-docker:stable\ninstall\n--channel\nstable\n--local\n--import\n'"$WORK/piped/piped.tgz" ]] ||
fail "a piped launcher with no command did not run install" "$(run_args)"
expect_run_arg "$WORK/piped/piped.tgz:$WORK/piped/piped.tgz:ro"
rm -rf "$WORK/piped"
cp "$LAUNCHER" "$WORK/no-command-launcher"
with_fake ok env ${fake_socket_env[@]+"${fake_socket_env[@]}"} GD_IMAGE=example/manager:1 \
"$BASH_BIN" "$WORK/no-command-launcher" --dir "$SITE" --local
[[ $(run_args | tail -2) == $'example/manager:1\n--local' ]] ||
fail "a launcher run from a file was given a command" "$(run_args)"
ok "with no command, piped means install, and the bundle is still mounted; a file is not given one"

step "The manager's exit status"
for status in 0 1 2 3; do
: >"$FAKE_CALLS"
Expand Down
Loading