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
42 changes: 27 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,19 @@ twice.
A local clone still works if you are developing the add-on itself:
`ddev add-on get ~/src/hostshift/ddev`.

Then, in the worktree, one command:
Then `ddev start`. A worktree configures itself: a pre-start hook derives
`.ddev/.env` before compose reads it, so there is nothing to run and nothing to
remember. It writes exactly one file and prints the map it resolved.

```
ddev hostshift init
```
`ddev hostshift init` still exists and is still the way to *change* that —
`--slug`, or re-deriving after a branch rename. It writes the same file and then
restarts to pick it up. `--no-restart` writes and stops, which is what the hook
runs.

It writes exactly one file, `.ddev/.env`, and nothing else — then restarts the
project to pick it up, and prints the URLs it is serving. That is the whole
required path.
The hook fires only in a linked worktree, and only when `.ddev/.env` carries no
`HOSTSHIFT_` line, so it is a no-op on every later start. One thing it cannot do
that `init` can: set the exit status. `ddev start` exits 0 whatever `check`
finds, so a script that wants a status must run `ddev hostshift check` itself.

### Worked example

Expand All @@ -131,15 +135,18 @@ DDEV project of its own with nothing configured.
$ git worktree add ../acme-wt-a -b wt-a
$ cd ../acme-wt-a
$ ddev add-on get https://github.com/generoi/hostshift/releases/latest/download/hostshift-ddev.tar.gz
$ ddev hostshift init
$ ddev start
hostshift: slug "wt-a", from the git branch wt-a
hostshift: canonical hostnames from /Users/you/Projects/acme, the checkout this was made from
hostshift: wrote .ddev/.env
map from --from/--to
site1 https://acme.ddev.site -> https://wt-a--acme.ddev.site
hostshift: restarting to pick it up
```

The hostshift lines come from a pre-start hook, which runs before compose reads
`.ddev/.env` — so the project comes up already serving the variant. There is no
second start.

Nothing was committed and no map was declared. The hostnames the database holds
are the **parent checkout's** — whatever pulled it search-replaced to
`acme.ddev.site`, not to the worktree's hostname — so the command reads them
Expand Down Expand Up @@ -167,8 +174,8 @@ You do not need to gitignore it. Installing the add-on adds its files to
the ignore travels with the machine rather than with the branch. Removing the
add-on takes the entry back out.

After `ddev restart`, `https://wt-a--acme.ddev.site` serves the worktree and
`https://acme.ddev.site` goes on serving the parent.
That first `ddev start` is the only one needed: `https://wt-a--acme.ddev.site`
serves the worktree and `https://acme.ddev.site` goes on serving the parent.

### What happens by itself

Expand All @@ -184,8 +191,10 @@ After `ddev restart`, `https://wt-a--acme.ddev.site` serves the worktree and
to the worktree's own hostnames. DDEV derives `name` from the directory but
not `additional_hostnames`, so a worktree inherits the parent's extra
hostnames verbatim and — traefik breaking the tie by rule length — silently
wins them from its first `ddev start` until the next restart. `init` says so
when it sees the overlap. Upstream: [ddev/ddev#5486][].
wins them from its first `ddev start` until the next restart. `check` says so
on every start while the overlap lasts — the pre-start hook deliberately does
not, because its remedy is "run `ddev restart`" and the hook is inside one.
Upstream: [ddev/ddev#5486][].
- **Staleness.** A `post-start` hook runs `ddev hostshift check` on every
`ddev start`, prints what is being served, and says so when `.ddev/.env` no
longer matches what the project resolves to — which happens on its own, since
Expand Down Expand Up @@ -261,7 +270,7 @@ $ git worktree add ../acme-wt-a -b wt-a
$ cd ../acme-wt-a
$ printf '#ddev-silent-no-warn\nname: acme-wt-a\n' > .ddev/config.hostshift-name.yaml
$ ddev add-on get https://github.com/generoi/hostshift/releases/latest/download/hostshift-ddev.tar.gz
$ ddev hostshift init
$ ddev start
```

The name only has to be unique; hostshift derives the preview hostnames from the
Expand Down Expand Up @@ -450,7 +459,10 @@ hostshift diff crawl a site two ways and compare, to verify a deployment
```

```
ddev hostshift init write .ddev/.env (required, per worktree)
ddev hostshift init write .ddev/.env (automatic on start; run it
to change --slug, or after a
branch rename)
--no-restart write it and stop (what the pre-start hook runs)
ddev hostshift check is the deployed map still current (also the post-start hook)
ddev hostshift copy-db copy the parent checkout's database into this worktree
(refuses to overwrite a non-empty one without --force)
Expand Down
32 changes: 30 additions & 2 deletions ddev/commands/host/hostshift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ usage: ddev hostshift <command> [flags]
--slug NAME name this worktree's hostnames; recorded, and it sticks
--slug-from-branch forget a recorded slug and follow the branch again
--dry-run, -n say what would happen, write nothing
--no-restart init: write the file and stop, for a pre-start hook
--force copy-db: overwrite a database that has tables
--quiet, -q say nothing when there is nothing to check
USAGE
Expand All @@ -77,6 +78,7 @@ slug_explicit=""
forget_slug=""
branch=""
dry_run=""
no_restart=""
force=""
quiet=""
claimed=""
Expand All @@ -96,6 +98,7 @@ while [ $# -gt 0 ]; do
# naming is to hand-edit .ddev/.env.
--slug-from-branch) forget_slug=1; shift ;;
--dry-run|-n) dry_run=1; shift ;;
--no-restart) no_restart=1; shift ;;
--quiet|-q) quiet=1; shift ;;
--force) force=1; shift ;;
# Silently discarding an unknown flag is how `--dry-run` came to write the
Expand Down Expand Up @@ -1284,7 +1287,17 @@ fi
# `loopback` printed it on an already-restarted, entirely healthy worktree,
# telling the developer their worktree was stealing a hostname it was not and
# pointing them at remedial action they should not take.
if [ -n "$parent_hosts" ] && [ "$cmd" = "init" ]; then
# ...and not from the pre-start hook either, for the same reason one step further
# on: the hook *is* a start. It would print "until `ddev restart`, those serve
# this worktree" from inside the restart that hands them back, and on a
# first-ever start nothing was ever serving them.
#
# HOSTSHIFT_HOOK, not `-z "$no_restart"`. The property is "I am inside a start",
# and --no-restart is a public flag: run by hand it restarts nothing, so the
# hostnames really do keep serving this worktree and the note is true. Keying on
# the flag suppressed it for the one caller it is correct for. The post-start
# hook already uses this variable for the same distinction.
if [ -n "$parent_hosts" ] && [ "$cmd" = "init" ] && [ -z "${HOSTSHIFT_HOOK:-}" ]; then
shared=""
while IFS= read -r h; do
[ -n "$h" ] || continue
Expand Down Expand Up @@ -2874,7 +2887,14 @@ touch .ddev/.env
# In .ddev/ so the mv is a rename on one filesystem rather than a copy+unlink
# across two, which is not atomic — a concurrent compose read must never catch
# this half-written and see an empty VIRTUAL_HOST.
tmp="$(mktemp .ddev/.env.XXXXXX)"
# Not `.ddev/.env.XXXXXX`: DDEV globs `.ddev/.env.*` in EnvFiles() and passes
# each one to compose as a *later* `--env-file`, so a leftover temp would
# override the real file rather than be ignored — and the exclude block names
# `.ddev/.env` exactly, so it would show up in `git status` too. This spelling
# dodges the glob and falls inside the `.ddev/**/*hostshift*` rule. The EXIT trap
# still removes it; the point is that a start killed mid-write leaves nothing
# that can outrank the file it was writing.
tmp="$(mktemp .ddev/.hostshift-env.XXXXXX)"
trap 'rm -f "$tmp"' EXIT
# cp -p first, then truncate: that gives the temp file the mode the real one
# already had, and redirection keeps it. .ddev/.env is where DDEV documents
Expand All @@ -2896,6 +2916,14 @@ hostshift map --slug "$slug" "${map_args[@]+"${map_args[@]}"}" >&2
# Only when actually invoked through `ddev hostshift` — DDEV sets DDEV_APPROOT
# for a host command, and nothing else does. Run directly, from a test or by
# hand, init stays what it says on the tin: it writes a file.
# --no-restart is for the pre-start hook, which is already inside the start it
# would otherwise ask for: restarting there either deadlocks or loops. Everything
# above this line has run, so the refusals and the warnings are the same ones a
# hand-run init prints.
if [ -n "$no_restart" ]; then
exit 0
fi

if [ -n "${DDEV_APPROOT:-}" ] && command -v ddev >/dev/null; then
echo "hostshift: restarting to pick it up" >&2
# Not `exec`: that hands the exit status to `ddev restart`, and DDEV does not
Expand Down
62 changes: 62 additions & 0 deletions ddev/config.hostshift.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,68 @@
# following DDEV's own advice arms the trap on a project that was fine. Nagging
# on every start until the command is replaced is the proportionate answer.
hooks:
# A worktree configures itself on its first start, so `ddev hostshift init` is
# not a step anyone has to know about. It ships here rather than in a file the
# project commits, for the reason the rest of this file is about: this one
# carries #ddev-generated and travels in the same tarball as the command it
# calls, so they upgrade together in the ordinary case. Not "cannot drift":
# DDEV will not replace the *command* on a project whose copy predates the
# marker, so a pre-marker worktree that was never configured runs this hook
# against a parser that exits 2 on `--no-restart`. The HOSTSHIFT_ guard covers
# every such project that was configured, because v0.1.0 wrote HOSTSHIFT_ lines
# too. A committed bootstrap would still be worse: it would be the one file
# `ddev add-on get` can never replace, in every client repo at once.
#
# `init --no-restart`, never `env`. Every refusal that makes init safe is gated
# on the subcommand being init: the one that stops a worktree writing a map
# built from its own hostnames when the parent checkout cannot be read, and the
# note about hostnames inherited from the parent. `env` prints past all of
# them, and `check` would then agree with the result, because it recomputes the
# same thing.
#
# Only in a linked worktree. `.git` is a file there and a directory in the
# parent, and that is the distinction the whole design rests on — running this
# in the parent would write it a map of its own hostnames and take the
# canonical name the database holds.
#
# The guard is a HOSTSHIFT_ line, not the file existing. init writes through a
# temp file and a rename, so a failure leaves no file at all — but an operator
# who ran `: > .ddev/.env`, or any future writer that is not init, leaves an
# empty one, and `[ -f ]` would then skip the derive on every start while the
# post-start check short-circuits silently on the same file.
pre-start:
- exec-host: |
if [ -f .git ] && grep -q '/worktrees/' .git 2>/dev/null; then
# `-r`, not `2>/dev/null` on the grep. Suppressing the error conflates
# "no HOSTSHIFT_ line" with "cannot read the file" — the exact
# distinction the command rejects `grep -qs` for. Unreadable means
# already-configured-but-broken, and `check` says so precisely a moment
# later; deriving over the top of it prints a raw `sed: Permission
# denied` first and re-derives a project that needs no deriving.
if [ -r .ddev/.env ] && grep -q '^HOSTSHIFT_' .ddev/.env; then
:
elif [ -e .ddev/.env ] && [ ! -r .ddev/.env ]; then
:
else
# The CLI is optional (install.yaml), and without it init cannot
# derive anything. Saying so once per start forever is noise on a
# project that may never want the CLI, so this is the one failure
# that stays quiet — `check` reports it a moment later either way.
command -v hostshift >/dev/null 2>&1 || exit 0
HOSTSHIFT_HOOK=1 .ddev/commands/host/hostshift init --no-restart || {
# Not `|| true`. DDEV prints `Task failed:` on a non-zero hook and
# only aborts under fail_on_hook_fail, so swallowing the status
# swallowed the one marker that said anything went wrong — and
# when the derive refuses there is no .ddev/.env, so the
# post-start check exits 0 without a word. Exit 0 deliberately: a
# project running fail_on_hook_fail must still be able to start
# when init legitimately refuses.
echo "hostshift: could not configure this worktree." >&2
echo " \`ddev hostshift init\` on its own says why." >&2
}
fi
fi

post-start:
- exec-host: |
head -5 .ddev/commands/host/hostshift 2>/dev/null | grep -q '#ddev-generated' || {
Expand Down
20 changes: 15 additions & 5 deletions ddev/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,24 @@ post_install_actions:
# writes into .ddev/, and an add-on that edits a project's configuration
# without being asked is how a surprise gets installed.
echo
echo "hostshift installed. One step remains:"
echo "hostshift installed."
echo
echo "In a git worktree, run:"
echo
echo " ddev restart"
echo
echo "A pre-start hook works the slug out from the git branch and writes the"
echo "HOSTSHIFT_* variables into .ddev/.env — the only file it writes —"
echo "before compose reads it, so one start is enough."
echo
echo "Anywhere else — the parent checkout, or a project mapping production"
echo "hostnames — the hook does not fire and there is one step:"
echo
echo " ddev hostshift init"
echo
echo "It works out the slug from the git branch and merges the HOSTSHIFT_*"
echo "variables into .ddev/.env — the only file it writes. Then"
echo "'ddev restart', which prints the URLs it is serving. Pass --dry-run"
echo "first to see what it would write, or --slug to name it yourself."
echo "That is also how you change a worktree's map afterwards: --slug to name"
echo "it yourself, --slug-from-branch after a rename, --dry-run to see what"
echo "it would write."
echo
echo "Two things it cannot do for you:"
echo
Expand Down
Loading
Loading