-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·411 lines (385 loc) · 18.1 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·411 lines (385 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
#!/usr/bin/env bash
# Teploy Ship — clean VM to a working Ship, one command.
#
# ./install.sh --host 203.0.113.10 --user root mybox
#
# In order: provisions the server (teploy setup), collects secrets — generating
# the ones it can, asking for the two it cannot — builds Ship and deploys.
# Sandbox images are built only when a daemon is installed. See QUICKSTART
# for measured timings; the latest minimal path still needs a clean timed pass.
#
# Moving to a SECOND host — the thing that was impossible before this script,
# because Ship's secrets lived only in teploy's age store on one server and
# there was no way to read them back out:
#
# ./install.sh --export-secrets ship.env --host <old-ip> --user <u> oldbox
# ./install.sh --secrets-file ship.env --host <new-ip> --user <u> newbox
#
# The bundle is a plain env file with real values in it. It is chmod 600 and
# gitignored, and it is yours to delete once the new box is up. Nothing else in
# this repo ever writes a secret to disk.
#
# --export-secrets writes a COMPLETE worker environment: the secret store plus
# the non-secret settings read back off the running worker container, because a
# bundle of secrets alone was never startable (NUCLEUS_URL and friends live in
# teploy.yml, not in the secret store). That is what makes the second command
# below possible — a box that joins an EXISTING fleet as another worker rather
# than standing up a whole second Ship:
#
# teploy-ship join http://<old-ip>:7460 --secrets ship.env \
# --nucleus-url postgres://nucleus:<pw>@<old-ip>:5432/nucleus --start
#
# Options:
# --host <addr> server address (required)
# --user <name> ssh user (default: root)
# --secrets-file <path> read/write the secret bundle (default: ./ship-secrets.env)
# --export-secrets <path> read every secret off the server, write the bundle,
# and STOP. Nothing is built and nothing is deployed.
# --git-token <t> forge deploy token (or $SHIP_GIT_TOKEN)
# --github-token <t> github PAT (or $SHIP_GITHUB_TOKEN)
# --anthropic-key <k> model key (or $ANTHROPIC_API_KEY)
# --model-url <url> an Anthropic-COMPATIBLE endpoint instead of
# Anthropic (e.g. https://api.z.ai/api/anthropic);
# the key becomes AI_GATEWAY_KEY and --model is
# sent verbatim (e.g. --model glm-5.3)
# --allow <origins> SHIP_REPO_ALLOWLIST, comma separated
# --model <id> SHIP_MODEL (default anthropic/claude-sonnet-5)
# --skip-images do not build the sandbox images
# --skip-setup the server is already provisioned for teploy
# --yes never prompt; fail instead
set -euo pipefail
repo="$(cd "$(dirname "$0")" && pwd -P)"
cd "${repo}"
say() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
note() { printf ' %s\n' "$*"; }
die() { printf '\ninstall.sh: %s\n' "$*" >&2; exit 1; }
server=""
host=""
ssh_user="root"
secrets_file="${repo}/ship-secrets.env"
export_only=""
git_token="${SHIP_GIT_TOKEN:-}"
github_token="${SHIP_GITHUB_TOKEN:-}"
model_key="${ANTHROPIC_API_KEY:-}"
model_url=""
allowlist="${SHIP_REPO_ALLOWLIST:-}"
model="${SHIP_MODEL:-anthropic/claude-sonnet-5}"
skip_images=0
skip_setup=0
assume_yes=0
while [ $# -gt 0 ]; do
case "$1" in
--host) host="${2:?--host needs a value}"; shift 2 ;;
--user) ssh_user="${2:?--user needs a value}"; shift 2 ;;
--secrets-file) secrets_file="${2:?}"; shift 2 ;;
--export-secrets) export_only="${2:?}"; shift 2 ;;
--git-token) git_token="${2:?}"; shift 2 ;;
--github-token) github_token="${2:?}"; shift 2 ;;
--anthropic-key) model_key="${2:?}"; shift 2 ;;
--model-url) model_url="${2:?}"; shift 2 ;;
--allow) allowlist="${2:?}"; shift 2 ;;
--model) model="${2:?}"; shift 2 ;;
--skip-images) skip_images=1; shift ;;
--skip-setup) skip_setup=1; shift ;;
-y|--yes) assume_yes=1; shift ;;
-h|--help) sed -n '2,46p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
-*) die "unknown option: $1" ;;
*) server="$1"; shift ;;
esac
done
[ -n "${server}" ] || die "name the server: ./install.sh --host <addr> --user <user> <name>"
[ -n "${host}" ] || die "--host <addr> is required"
for tool in teploy ssh openssl; do
command -v "${tool}" >/dev/null 2>&1 || die "${tool} is not on PATH (teploy: brew install useteploy/tap/teploy)"
done
# teploy's global --host/--user override servers.yml, so every call below names
# the target without this script ever editing a tracked config file.
install_stage=""
tp() {
if [ -n "${install_stage}" ]; then
teploy --project-dir "${install_stage}" --host "${host}" --user "${ssh_user}" "$@"
else
teploy --host "${host}" --user "${ssh_user}" "$@"
fi
}
remote() { ssh -o BatchMode=yes "${ssh_user}@${host}" "$@"; }
# --------------------------------------------------------------------------
# Secrets.
#
# The bundle is the whole portability story. Ship's secrets live in teploy's
# age store ON THE SERVER — `teploy secret list` opens an ssh connection to
# read them — so an install had exactly one copy of its own credentials and no
# way to stand up a second host from them. Everything below is
# generate-or-accept; the only two values a human must supply are the two only
# they can have.
#
# Bash 3.2 is what /bin/bash is on macOS, so: no associative arrays. Secrets
# are "KEY=VALUE" strings in a plain array.
# --------------------------------------------------------------------------
secrets=()
sec_get() {
local prefix="$1="
local entry
for entry in ${secrets[@]+"${secrets[@]}"}; do
case "${entry}" in "${prefix}"*) printf '%s' "${entry#"${prefix}"}"; return 0 ;; esac
done
return 1
}
sec_put() {
local key="$1" value="$2" out=() entry
for entry in ${secrets[@]+"${secrets[@]}"}; do
case "${entry}" in "${key}="*) ;; *) out+=("${entry}") ;; esac
done
out+=("${key}=${value}")
secrets=(${out[@]+"${out[@]}"})
}
# Read every secret off the target server into a bundle file, then stop.
if [ -n "${export_only}" ]; then
say "reading secrets from ${host}"
umask 077
{
echo "# Teploy Ship secrets exported from ${host} on $(date -u +%Y-%m-%dT%H:%M:%SZ)."
echo "# REAL VALUES. chmod 600. Delete once the new host is up."
} > "${export_only}"
keys="$(tp secret list | sed -n 's/^\([A-Z][A-Z0-9_]*\)=.*/\1/p')"
[ -n "${keys}" ] || die "no secrets found on ${host} — is that the right server?"
for key in ${keys}; do
printf '%s=%s\n' "${key}" "$(tp secret get "${key}" | tail -n 1)" >> "${export_only}"
note "${key}"
done
# The NON-secret half of the worker's environment.
#
# `teploy secret list` holds credentials only, so a bundle of secrets alone
# was never a startable configuration: NUCLEUS_URL, AI_GATEWAY_URL, SHIP_MODEL
# and SHIP_REPO_ALLOWLIST all live in teploy.yml's env block and reach the
# container that way. `teploy-ship join` needs the whole environment or it
# cannot verify anything, so read it back off the running worker — which is
# also the only copy that reflects what the box is ACTUALLY running, rather
# than what a tracked yml says it should be.
#
# Read from the container rather than from teploy.yml on purpose: a value
# edited on the server, or defaulted by a deploy overlay, is in one place and
# it is here.
say "reading the worker's environment from ${host}"
worker="$(remote 'docker ps --format "{{.Names}}" | grep "^ship-worker-" | head -n 1' || true)"
if [ -z "${worker}" ]; then
note "no ship-worker container is running — the bundle carries secrets only."
note "teploy-ship join will tell you exactly which settings are missing."
else
{
echo ""
echo "# Non-secret worker settings, read from container ${worker}."
echo "# NUCLEUS_URL below is almost certainly a docker network alias, which"
echo "# only THIS box can resolve. teploy-ship join refuses it and says so;"
echo "# pass --nucleus-url with the controller's tailnet address."
} >> "${export_only}"
# Only the families Ship reads. A blanket dump would carry PATH, NODE_VERSION
# and the container's own TEPLOY_SHIP_STATE=/data into a bundle that is then
# sourced on a host where none of them are true.
remote "docker inspect ${worker} --format '{{range .Config.Env}}{{println .}}{{end}}'" \
| grep -E '^(NUCLEUS_URL|AI_GATEWAY_URL|OBSERVE_[A-Z_]*|SHIP_[A-Z0-9_]*|ANTHROPIC_BASE_URL)=' \
| while IFS= read -r line; do
key="${line%%=*}"
# Anything already written above came from the secret store, which is
# authoritative; never write a key twice into a file that gets sourced.
if grep -q "^${key}=" "${export_only}"; then continue; fi
printf '%s\n' "${line}" >> "${export_only}"
note "${key}"
done
fi
chmod 600 "${export_only}"
say "wrote ${export_only}"
note "install elsewhere with:"
note " ./install.sh --secrets-file ${export_only} --host <new-ip> --user <user> <name>"
note "or join an EXISTING fleet as a second worker, without deploying a dashboard:"
note " teploy-ship join http://${host}:7460 --secrets ${export_only} \\"
note " --nucleus-url postgres://nucleus:<pw>@${host}:5432/nucleus"
exit 0
fi
if [ -f "${secrets_file}" ]; then
say "reusing ${secrets_file}"
while IFS= read -r line; do
case "${line}" in ''|'#'*) continue ;; esac
case "${line}" in *=*) sec_put "${line%%=*}" "${line#*=}" ;; esac
done < "${secrets_file}"
fi
ask() {
# $1 name, $2 prompt. Never echoes what is typed.
if sec_get "$1" >/dev/null; then return 0; fi
[ "${assume_yes}" -eq 1 ] && die "$1 is not set and --yes forbids prompting"
printf ' %s: ' "$2" >&2
local value=""
read -r -s value
printf '\n' >&2
[ -n "${value}" ] || die "$1 is required"
sec_put "$1" "${value}"
}
say "secrets"
# Generated, never copied between installs: a second Ship has no business
# sharing the first one's dashboard token or webhook HMAC.
for key in SHIP_WEB_TOKEN SHIP_SESSION_SECRET SHIP_WEBHOOK_SECRET; do
if sec_get "${key}" >/dev/null; then
note "${key}: from bundle"
else
sec_put "${key}" "$(openssl rand -hex 32)"
note "${key}: generated"
fi
done
if [ -n "${git_token}" ]; then sec_put SHIP_GIT_TOKEN "${git_token}"; fi
if [ -n "${github_token}" ]; then sec_put SHIP_GITHUB_TOKEN "${github_token}"; fi
# An Anthropic-compatible endpoint takes the key as AI_GATEWAY_KEY: Ship's
# direct Anthropic path always calls api.anthropic.com (ANTHROPIC_BASE_URL is
# not read), so the endpoint is wired the gateway way — verified against z.ai
# by the fresh-machine passes (2026-09-23/24).
model_secret="ANTHROPIC_API_KEY"
if [ -n "${model_url}" ]; then model_secret="AI_GATEWAY_KEY"; fi
if [ -n "${model_key}" ]; then sec_put "${model_secret}" "${model_key}"; fi
ask SHIP_GIT_TOKEN "forge deploy token (Forgejo/Gitea access token, or a GitHub PAT)"
ask "${model_secret}" "model API key"
umask 077
{
echo "# Teploy Ship secrets. REAL VALUES — chmod 600, gitignored, never committed."
for entry in ${secrets[@]+"${secrets[@]}"}; do printf '%s\n' "${entry}"; done
} > "${secrets_file}"
chmod 600 "${secrets_file}"
note "bundle: ${secrets_file}"
# --------------------------------------------------------------------------
# Server.
# --------------------------------------------------------------------------
if [ "${skip_setup}" -eq 0 ]; then
say "provisioning ${server} (${ssh_user}@${host})"
# Idempotent: teploy setup re-runs cleanly on a box it already provisioned.
teploy setup "${host}" --name "${server}" --user "${ssh_user}" --yes
fi
# --------------------------------------------------------------------------
# Sandbox daemon + images.
#
# The daemon is what gives a run its own container with default-deny egress.
# Without it Ship still works for tasks an operator typed, but REFUSES tasks
# that arrived from a webhook — an untrusted task on a non-isolated executor is
# the one thing Ship will not do. That refusal is a feature, so this reports
# the state plainly rather than quietly installing a daemon nobody asked for.
# --------------------------------------------------------------------------
sandbox_url=""
sandbox_token=""
if remote 'systemctl is-active --quiet teploy-sandbox' 2>/dev/null; then
sandbox_url="http://172.18.0.1:7439"
sandbox_token="$(remote 'sudo -n cat /var/lib/teploy-sandbox/token 2>/dev/null || cat /var/lib/teploy-sandbox/token' | tr -d '\r\n')"
say "sandbox daemon: running"
[ -n "${sandbox_token}" ] || note "WARNING: could not read /var/lib/teploy-sandbox/token — set SHIP_SANDBOX_TOKEN yourself"
else
say "sandbox daemon: NOT installed"
note "Ship will run operator-typed tasks inside the worker container and REFUSE"
note "tasks arriving from webhooks/Slack/issues, which need an isolated executor."
note "docs/DEPLOY.md section 3 installs teploy-sandbox; re-run this afterwards."
fi
image="node:22"
# The images are what the sandbox DAEMON boots; without one nothing uses them,
# and on a cold box they are most of the install's wall clock (the 2026-09-24
# fresh-machine rerun measured it). Built when the daemon is there; re-run this
# after installing it, exactly as the note above says.
if [ -z "${sandbox_url}" ] && [ "${skip_images}" -eq 0 ]; then
note "skipping the sandbox images: no daemon to boot them (re-run after installing it)"
skip_images=1
fi
if [ "${skip_images}" -eq 0 ]; then
say "building sandbox images on ${host}"
# The images must exist on the machine whose docker daemon creates run
# containers — the server, not this laptop. The build context goes over ssh
# rather than anyone copying a Dockerfile by hand, which is exactly how the
# old images came to exist on one box and nowhere else.
remote 'rm -rf ~/.teploy-ship-images && mkdir -p ~/.teploy-ship-images'
tar -cf - -C "${repo}" images | remote 'tar -xf - -C ~/.teploy-ship-images'
remote 'bash ~/.teploy-ship-images/images/build.sh'
image="ship-sandbox-go:dev"
note "SHIP_SANDBOX_IMAGE=${image}; ship-sandbox-node:dev is there too — set it"
note "per repo on the Projects page for a pnpm project."
fi
# --------------------------------------------------------------------------
# Build and deploy.
# --------------------------------------------------------------------------
say "building ship"
for tool in pnpm node git; do
command -v "${tool}" >/dev/null 2>&1 || die "${tool} is not on PATH (needed to build the deploy artefacts)"
done
pnpm install --silent
pnpm run build
(cd web && pnpm install --silent && pnpm run build)
# The destination overlay is applied to the minimal example in a private
# staging directory, never to the maintainer's production teploy.yml.
say "writing teploy.install.yml"
{
echo "# Generated by install.sh. Machine-specific, gitignored, safe to delete."
echo "server: ${server}"
echo "user: ${ssh_user}"
echo "env:"
# A blank gateway selects the direct provider. Optional telemetry and
# delivery stay disabled until this installation configures them.
if [ -n "${model_url}" ]; then
echo " AI_GATEWAY_URL: \"${model_url}\""
# The endpoint receives the id verbatim; this prefix makes Ship speak
# Anthropic's wire to it (src/model-id.ts usesAnthropicWire).
echo " SHIP_ANTHROPIC_WIRE_PREFIXES: \"${model}\""
else
echo " AI_GATEWAY_URL: \"\""
fi
echo " SHIP_REPO_ALLOWLIST: \"${allowlist}\""
echo " SHIP_EMBED_MODEL: \"\""
echo " OBSERVE_URL: \"\""
echo " OBSERVE_SERVICE: \"\""
echo " OBSERVE_REPO: \"\""
echo " SHIP_TELEMETRY: \"\""
# Detection reads the repo's own tree at enqueue (src/test-detect.ts), so a
# worker-wide command that is wrong for every repo but one is no longer the
# default. An explicit per-repo entry still wins over both.
echo " SHIP_TEST_COMMAND: \"\""
# Trusted delivery is opt-in for a new installation.
echo " SHIP_DELIVERY_DIR: \"\""
echo " SHIP_TESTS: \"1\""
echo " SHIP_MODEL: \"${model}\""
echo " SHIP_SANDBOX_IMAGE: \"${image}\""
if [ -n "${sandbox_url}" ]; then
echo " SHIP_SANDBOX_URL: \"${sandbox_url}\""
echo " SHIP_SANDBOX_NETWORK: \"egress\""
else
echo " SHIP_SANDBOX_URL: \"\""
fi
} > "${repo}/teploy.install.yml"
# Keep the caller's checkout/config untouched. Only image inputs enter this
# temporary context; no secret bundles, node_modules, or production mounts.
install_stage=$(mktemp -d "${TMPDIR:-/tmp}/ship-install.XXXXXXXX")
trap 'rm -rf -- "${install_stage}"' EXIT
bash "${repo}/deploy/stage-install.sh" "${repo}" "${install_stage}"
install_version=$(git -C "${repo}" rev-parse --short HEAD)
say "setting secrets on ${server}"
args=()
for entry in ${secrets[@]+"${secrets[@]}"}; do args+=("${entry}"); done
if [ -n "${sandbox_token}" ]; then args+=("SHIP_SANDBOX_TOKEN=${sandbox_token}"); fi
if [ -n "${allowlist}" ]; then args+=("SHIP_REPO_ALLOWLIST=${allowlist}"); fi
if [ -n "${SHIP_PUBLIC_URL:-}" ]; then args+=("SHIP_PUBLIC_URL=${SHIP_PUBLIC_URL}"); fi
tp secret set "${args[@]}" > /dev/null
note "${#args[@]} secrets set"
# teploy creates the `ship-data` volume's host directory owned by root, and
# the image runs as uid 1000 (node): without a sandbox daemon the worker's run
# workspaces live there, and every run died at its first step with EACCES
# (fresh-machine rerun, 2026-09-24). Create it with the right owner first.
remote 'd=/deployments/ship/volumes/ship-data; if [ "$(id -u)" = 0 ]; then mkdir -p "$d" && chown 1000:1000 "$d"; else sudo -n mkdir -p "$d" && sudo -n chown 1000:1000 "$d"; fi' \
|| note "WARNING: could not chown /deployments/ship/volumes/ship-data to 1000:1000 — do it yourself before the first run"
say "deploying"
tp deploy -d install --version "${install_version}"
say "Ship is up"
note "dashboard http://${host}:7460"
note "login the SHIP_WEB_TOKEN in ${secrets_file}"
note ""
note "Firewall it — ingress: host publishes 7460 with no TLS:"
note " ufw allow from 100.64.0.0/10 to any port 7460"
note ""
if [ -z "${allowlist}" ]; then
note "SHIP_REPO_ALLOWLIST is unset, so a repo URL is REFUSED until you add the"
note "project on the dashboard's Projects page (or pass --allow next time)."
note ""
fi
note "First run:"
note " teploy-ship enqueue \"fix the failing test\" --repo <clone-url>"
note "The test command is detected from the repo's own tree; override it per repo"
note "on the Projects page when the guess is wrong."