Skip to content

Commit f74076b

Browse files
committed
update procedure
1 parent 116002d commit f74076b

12 files changed

Lines changed: 346 additions & 8 deletions

docs/operations/reconfiguration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ A backup, human-readable copy of your settings is always kept at `/etc/terrarium
1010

1111
Whenever you run a `terrariumctl set ...` command, Terrarium updates the database, updates the text file, and then seamlessly applies the changes to your system. (It's smart enough to skip the heavy OS-hardening steps during routine updates, making reconfigurations very fast).
1212

13+
If you want to update Terrarium itself, use:
14+
15+
```bash
16+
terrariumctl update
17+
```
18+
19+
That refreshes the installed Terrarium release under `/opt/terrarium`, installs any updated Ansible collection requirements, and reapplies the saved configuration without asking the initial install questions again. If you run the interactive installer on a host that already has `/etc/terrarium/config.yaml`, it will ask whether you want to update the existing installation or intentionally reinstall from scratch.
20+
1321
## The Main Reconfiguration Commands
1422

1523
Here are the commands you'll use to change how Terrarium behaves:
@@ -103,4 +111,4 @@ Terrarium is designed to be non-disruptive. When you change a setting:
103111
- Traefik routing changes trigger a graceful Traefik restart.
104112
- IDP changes re-render and restart the `oauth2-proxy` without dropping active container traffic.
105113
- ZITADEL settings are updated inside the `terrarium-idp` container.
106-
- Terrarium automatically runs `terrariumctl proxy sync` to ensure all your published apps reflect the new domains and authentication rules.
114+
- Terrarium automatically runs `terrariumctl proxy sync` to ensure all your published apps reflect the new domains and authentication rules.

docs/reference/terrariumctl.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
| `terrariumctl backup export` | none | n/a | Uploads the current incremental ZFS backup chain to configured S3 storage. |
1414
| `terrariumctl backup restore` | required: `--instance`; optional: `--source`, `--at`, `--as-new` | `--source local`, latest restore point, in-place restore | Restores an instance either in place by default or as a new instance when `--as-new` is provided. |
1515
| `terrariumctl reconfigure` | none | n/a | Re-runs the local Ansible reconciliation using the saved config. |
16+
| `terrariumctl update` | optional: `--ref`, `--skip-reconfigure` | latest release, reconfigure after update | Updates installed Terrarium code/assets, refreshes Ansible collections, and re-runs reconciliation using the saved config. |
1617
| `terrariumctl config import` | none | n/a | Imports `/etc/terrarium/config.yaml` into the LXD dqlite-backed config store. |
1718
| `terrariumctl config export` | none | n/a | Recreates `/etc/terrarium/config.yaml` from the LXD dqlite-backed config store. |
1819
| `terrariumctl cluster status` | none | n/a | Shows LXD cluster state and the Terrarium OVN workload network. |
@@ -105,6 +106,24 @@ Use `terrariumctl config import` to copy the local export into the dqlite-backed
105106

106107
Use `terrariumctl config export` to recreate the local export from the dqlite-backed store. `terrariumctl reconfigure` does this automatically before invoking Ansible when the dqlite-backed store exists.
107108

109+
## update
110+
111+
Use `terrariumctl update` on an existing Terrarium host when you want newer Terrarium code, Ansible roles, managed LXD profiles, systemd units, or package lists without going through the installer again.
112+
113+
```bash
114+
terrariumctl update
115+
terrariumctl update --ref 0.0.21
116+
terrariumctl update --skip-reconfigure
117+
```
118+
119+
The command updates `/opt/terrarium`, refreshes Ansible collections, and then runs `terrariumctl reconfigure` with OS hardening skipped. It reuses the saved configuration from LXD's config store or `/etc/terrarium/config.yaml`; it does not ask storage, domain, IDP, S3, or syncoid setup questions.
120+
121+
When using the bootstrap installer, pass `--update` to get the same behavior from a release bundle:
122+
123+
```bash
124+
curl -fsSL https://github.com/terion-name/terrarium/releases/latest/download/install.sh | sudo bash -s -- --update
125+
```
126+
108127
## exec
109128

110129
`terrariumctl exec` is Terrarium's safer wrapper around `lxc exec`.

install.sh

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ set -Eeuo pipefail
44
REPO_URL="${TERRARIUM_REPO_URL:-https://github.com/terion-name/terrarium.git}"
55
GITHUB_REPO="${TERRARIUM_GITHUB_REPO:-terion-name/terrarium}"
66
REF=""
7+
UPDATE=false
78
EMBEDDED_BOOTSTRAP_REF="" # TERRARIUM_RELEASE_REF
89
BOOTSTRAP_REF="${TERRARIUM_BOOTSTRAP_REF:-}"
910
TMPDIR_PATH=""
@@ -17,15 +18,17 @@ usage() {
1718
Usage: install.sh [options]
1819
1920
--ref REF
21+
--update
2022
--help
2123
22-
All other flags are forwarded to `terrariumctl install`.
24+
All other flags are forwarded to `terrariumctl install` or `terrariumctl update`.
2325
2426
Behavior:
2527
- without --ref, the bootstrap downloads the bundled release when the installer is release-pinned
2628
- otherwise without --ref, it downloads the latest Terrarium release bundle
2729
- with a tag-like --ref, it downloads that release bundle
2830
- with a branch-like --ref (for example main), it falls back to a source build
31+
- with --update, it updates an existing Terrarium install instead of starting the install wizard
2932
EOF
3033
}
3134

@@ -118,6 +121,10 @@ parse_args() {
118121
REF="${1#--ref=}"
119122
shift
120123
;;
124+
--update)
125+
UPDATE=true
126+
shift
127+
;;
121128
--)
122129
shift
123130
while [[ $# -gt 0 ]]; do
@@ -155,7 +162,7 @@ install_release_bundle() {
155162
local resolved_ref="$3"
156163

157164
download_release_bundle "${bundle_dir}" "${arch}" "${resolved_ref}" || die "failed to download Terrarium release bundle ${resolved_ref}"
158-
run_terrariumctl_install "${bundle_dir}" "${bundle_dir}/dist/terrariumctl" "${resolved_ref}" "${FORWARD_ARGS[@]}"
165+
run_terrariumctl "${bundle_dir}" "${bundle_dir}/dist/terrariumctl" "${resolved_ref}" "${FORWARD_ARGS[@]}"
159166
}
160167

161168
build_from_source() {
@@ -178,7 +185,7 @@ build_from_source() {
178185
/opt/bun/bin/bun install --frozen-lockfile || /opt/bun/bin/bun install --no-progress
179186
/opt/bun/bin/bun scripts/build.ts
180187
)
181-
run_terrariumctl_install "${build_dir}/repo" "${build_dir}/repo/dist/terrariumctl" "${source_ref}" "${FORWARD_ARGS[@]}"
188+
run_terrariumctl "${build_dir}/repo" "${build_dir}/repo/dist/terrariumctl" "${source_ref}" "${FORWARD_ARGS[@]}"
182189
}
183190

184191
is_non_interactive_install() {
@@ -193,12 +200,17 @@ is_non_interactive_install() {
193200
return 1
194201
}
195202

196-
run_terrariumctl_install() {
203+
run_terrariumctl() {
197204
local bundle_dir="$1"
198205
local terrariumctl="$2"
199206
local ref="$3"
200207
shift 3
201208

209+
if [[ "${UPDATE}" == "true" ]]; then
210+
TERRARIUM_BUNDLE_DIR="${bundle_dir}" TERRARIUM_REPO_URL="${REPO_URL}" "${terrariumctl}" update --ref "${ref}" "$@"
211+
return
212+
fi
213+
202214
if [[ -r /dev/tty ]]; then
203215
TERRARIUM_BUNDLE_DIR="${bundle_dir}" TERRARIUM_REPO_URL="${REPO_URL}" "${terrariumctl}" install --ref "${ref}" "$@" </dev/tty
204216
return

scripts/ctl/completion.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ describe("terrariumctl completion", () => {
99
expect(script).toContain("complete -F _terrariumctl_complete trm");
1010
expect(script).toContain("backup) COMPREPLY");
1111
expect(script).toContain("list export restore");
12-
expect(script).toContain("--storage-source");
12+
expect(script).toContain("update");
13+
expect(script).toContain("update) COMPREPLY");
14+
expect(script).toContain("--ref --skip-reconfigure --non-interactive");
1315
expect(script).toContain("--skip-reconfigure");
16+
expect(script).toContain("--storage-source");
1417
expect(script).toContain("local oidc");
1518
});
1619

@@ -19,6 +22,7 @@ describe("terrariumctl completion", () => {
1922
const fish = completionScript("fish");
2023

2124
expect(zsh).toContain("#compdef terrariumctl trm");
25+
expect(zsh).toContain("update) opts=(--ref --skip-reconfigure --non-interactive)");
2226
expect(zsh).toContain("compadd local oidc");
2327
expect(fish).toContain("complete -c terrariumctl");
2428
expect(fish).toContain("complete -c trm");

scripts/ctl/completion.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const commands = [
55
"status",
66
"backup",
77
"reconfigure",
8+
"update",
89
"exec",
910
"config",
1011
"cluster",
@@ -67,6 +68,7 @@ const optionGroups: Record<string, string[]> = {
6768
"--syncoid-ssh-key"
6869
],
6970
backup: ["--source", "--instance", "--at", "--as-new"],
71+
update: ["--ref", "--skip-reconfigure", "--non-interactive"],
7072
exec: ["--root", "--user"],
7173
cluster: [
7274
"--member",
@@ -161,6 +163,7 @@ _terrariumctl_complete() {
161163
case "\${command}" in
162164
install) COMPREPLY=( $(compgen -W "${words(optionGroups.install)} --help" -- "\${cur}") ) ;;
163165
backup) COMPREPLY=( $(compgen -W "${words(optionGroups.backup)} --help" -- "\${cur}") ) ;;
166+
update) COMPREPLY=( $(compgen -W "${words(optionGroups.update)} --help" -- "\${cur}") ) ;;
164167
exec) COMPREPLY=( $(compgen -W "${words(optionGroups.exec)} --help" -- "\${cur}") ) ;;
165168
cluster) COMPREPLY=( $(compgen -W "${words(optionGroups.cluster)} --help" -- "\${cur}") ) ;;
166169
mount) COMPREPLY=( $(compgen -W "${words(optionGroups.mount)} --help" -- "\${cur}") ) ;;
@@ -226,6 +229,7 @@ _terrariumctl() {
226229
set) actions=(${words(actions.set)}); opts=(${words(optionGroups.set)}) ;;
227230
completion) actions=(${words(actions.completion)}) ;;
228231
install) opts=(${words(optionGroups.install)}) ;;
232+
update) opts=(${words(optionGroups.update)}) ;;
229233
exec) opts=(${words(optionGroups.exec)}) ;;
230234
esac
231235

scripts/ctl/update.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join, resolve } from "node:path";
4+
import { runText } from "../lib/common";
5+
import { reconfigureCmd } from "./system";
6+
7+
const PREFIX = "terrariumctl update";
8+
const REPO_DIR = process.env.TERRARIUM_REPO_DIR ?? "/opt/terrarium";
9+
const BUNDLE_DIR = process.env.TERRARIUM_BUNDLE_DIR ?? "";
10+
const REPO_URL = process.env.TERRARIUM_REPO_URL ?? "https://github.com/terion-name/terrarium.git";
11+
const GITHUB_REPO = process.env.TERRARIUM_GITHUB_REPO ?? "terion-name/terrarium";
12+
const ANSIBLE_GALAXY_ATTEMPTS = 4;
13+
14+
export type UpdateOptions = {
15+
ref?: string;
16+
reconfigure?: boolean;
17+
};
18+
19+
function requireRoot(): void {
20+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
21+
throw new Error("run as root");
22+
}
23+
}
24+
25+
function releaseArch(): string {
26+
if (process.arch === "x64") {
27+
return "x64";
28+
}
29+
if (process.arch === "arm64") {
30+
return "arm64";
31+
}
32+
throw new Error(`unsupported architecture: ${process.arch}`);
33+
}
34+
35+
function isReleaseRef(ref: string): boolean {
36+
return /^v?[0-9]+(\.[0-9]+)*([.-][A-Za-z0-9]+)?$/.test(ref);
37+
}
38+
39+
function localSourcePath(repoUrl: string): string {
40+
if (repoUrl.startsWith("file://")) {
41+
return repoUrl.slice("file://".length);
42+
}
43+
if (repoUrl.startsWith("/")) {
44+
return repoUrl;
45+
}
46+
return "";
47+
}
48+
49+
function syncTree(sourceDir: string, targetDir: string): void {
50+
if (resolve(sourceDir) === resolve(targetDir)) {
51+
throw new Error(`refusing to sync Terrarium source onto itself: ${sourceDir}`);
52+
}
53+
if (!existsSync(join(sourceDir, "ansible", "site.yml"))) {
54+
throw new Error(`Terrarium bundle is missing ansible/site.yml: ${sourceDir}`);
55+
}
56+
if (!existsSync(join(sourceDir, "dist", "terrariumctl"))) {
57+
throw new Error(`Terrarium bundle is missing dist/terrariumctl: ${sourceDir}`);
58+
}
59+
60+
rmSync(targetDir, { recursive: true, force: true });
61+
mkdirSync(targetDir, { recursive: true });
62+
cpSync(sourceDir, targetDir, {
63+
recursive: true,
64+
force: true,
65+
filter: (source) => {
66+
const base = source.split("/").at(-1) ?? "";
67+
return ![".git", "node_modules"].includes(base);
68+
}
69+
});
70+
chmodSync(join(targetDir, "dist", "terrariumctl"), 0o755);
71+
}
72+
73+
async function resolveLatestReleaseRef(arch: string): Promise<string> {
74+
const script = `
75+
import json
76+
import os
77+
import sys
78+
79+
asset = os.environ["TERRARIUM_ASSET"]
80+
for release in json.load(sys.stdin):
81+
if release.get("draft") or release.get("prerelease"):
82+
continue
83+
if any(item.get("name") == asset for item in release.get("assets", [])):
84+
print(release.get("tag_name", ""))
85+
break
86+
`;
87+
const releases = await runText(["curl", "-fsSL", `https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=50`], PREFIX);
88+
const resolved = await runText(["python3", "-c", script], PREFIX, {
89+
stdin: releases,
90+
env: { TERRARIUM_ASSET: `terrarium-linux-${arch}.zip` }
91+
});
92+
const ref = resolved.trim();
93+
if (!ref) {
94+
throw new Error("failed to resolve latest Terrarium release tag");
95+
}
96+
return ref;
97+
}
98+
99+
async function downloadReleaseBundle(ref: string): Promise<string> {
100+
const arch = releaseArch();
101+
const resolvedRef = ref ? ref : await resolveLatestReleaseRef(arch);
102+
const workDir = mkdtempSync(join(tmpdir(), "terrarium-update-"));
103+
const assetUrl = `https://github.com/${GITHUB_REPO}/releases/download/${resolvedRef}/terrarium-linux-${arch}.zip`;
104+
105+
try {
106+
await runText(["curl", "-fsSL", assetUrl, "-o", join(workDir, "terrarium.zip")], PREFIX);
107+
await runText(["unzip", "-q", join(workDir, "terrarium.zip"), "-d", workDir], PREFIX);
108+
return workDir;
109+
} catch (error) {
110+
rmSync(workDir, { recursive: true, force: true });
111+
throw error;
112+
}
113+
}
114+
115+
async function syncSourceCheckout(ref: string): Promise<void> {
116+
if (!existsSync(join(REPO_DIR, ".git"))) {
117+
throw new Error("source update requires an existing git checkout in /opt/terrarium; use install.sh --update for release-bundle installs");
118+
}
119+
await runText(["git", "-C", REPO_DIR, "fetch", "--tags", "origin"], PREFIX);
120+
await runText(["git", "-C", REPO_DIR, "checkout", ref], PREFIX);
121+
await runText(["git", "-C", REPO_DIR, "pull", "--ff-only", "origin", ref], PREFIX);
122+
const bun = existsSync("/opt/bun/bin/bun") ? "/opt/bun/bin/bun" : "bun";
123+
await runText([bun, "install", "--frozen-lockfile"], PREFIX, { cwd: REPO_DIR });
124+
await runText([bun, "scripts/build.ts"], PREFIX, { cwd: REPO_DIR });
125+
}
126+
127+
async function installAnsibleCollections(): Promise<void> {
128+
let lastOutput = "";
129+
for (let attempt = 1; attempt <= ANSIBLE_GALAXY_ATTEMPTS; attempt += 1) {
130+
const result = Bun.spawn({
131+
cmd: ["ansible-galaxy", "collection", "install", "-r", "requirements.yml"],
132+
cwd: join(REPO_DIR, "ansible"),
133+
stdout: "pipe",
134+
stderr: "pipe"
135+
});
136+
const [exitCode, stdout, stderr] = await Promise.all([
137+
result.exited,
138+
result.stdout ? new Response(result.stdout).text() : Promise.resolve(""),
139+
result.stderr ? new Response(result.stderr).text() : Promise.resolve("")
140+
]);
141+
if (exitCode === 0) {
142+
return;
143+
}
144+
145+
lastOutput = `${stdout}\n${stderr}`.trim();
146+
if (attempt < ANSIBLE_GALAXY_ATTEMPTS) {
147+
console.warn(`${PREFIX}: ansible-galaxy collection install failed on attempt ${attempt}/${ANSIBLE_GALAXY_ATTEMPTS}; retrying`);
148+
await Bun.sleep(attempt * 5000);
149+
}
150+
}
151+
152+
throw new Error(`ansible-galaxy collection install failed after ${ANSIBLE_GALAXY_ATTEMPTS} attempts${lastOutput ? `\n${lastOutput}` : ""}`);
153+
}
154+
155+
async function ensureUpdateDependencies(): Promise<void> {
156+
await runText(["apt-get", "-o", "DPkg::Lock::Timeout=900", "update", "-y"], PREFIX);
157+
await runText(["apt-get", "-o", "DPkg::Lock::Timeout=900", "install", "-y", "ca-certificates", "curl", "git", "ansible", "python3", "jq", "unzip"], PREFIX);
158+
}
159+
160+
export async function updateCmd(options: UpdateOptions = {}): Promise<void> {
161+
requireRoot();
162+
await ensureUpdateDependencies();
163+
164+
const requestedRef = options.ref ?? "";
165+
const sourcePath = localSourcePath(REPO_URL);
166+
let downloadedBundle = "";
167+
168+
try {
169+
if (BUNDLE_DIR && existsSync(join(BUNDLE_DIR, "ansible", "site.yml"))) {
170+
console.log(`${PREFIX}: installing Terrarium release bundle into ${REPO_DIR}`);
171+
syncTree(BUNDLE_DIR, REPO_DIR);
172+
} else if (sourcePath && existsSync(join(sourcePath, "ansible", "site.yml"))) {
173+
console.log(`${PREFIX}: syncing local Terrarium source from ${sourcePath}`);
174+
syncTree(sourcePath, REPO_DIR);
175+
} else if (requestedRef && !isReleaseRef(requestedRef)) {
176+
await syncSourceCheckout(requestedRef);
177+
} else {
178+
downloadedBundle = await downloadReleaseBundle(requestedRef);
179+
console.log(`${PREFIX}: installing Terrarium release bundle into ${REPO_DIR}`);
180+
syncTree(downloadedBundle, REPO_DIR);
181+
}
182+
183+
await installAnsibleCollections();
184+
185+
if (options.reconfigure !== false) {
186+
await reconfigureCmd({ applyHardening: false });
187+
}
188+
} finally {
189+
if (downloadedBundle) {
190+
rmSync(downloadedBundle, { recursive: true, force: true });
191+
}
192+
}
193+
}

scripts/terrarium-install.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ describe("terrarium install CLI parsing", () => {
110110
expect(source).toContain('cd ${join(REPO_DIR, "ansible")}; ansible-playbook -i inventory.ini site.yml');
111111
});
112112

113+
test("interactive install offers update when an existing Terrarium config is present", () => {
114+
const source = readFileSync(join(repoRoot, "scripts/terrarium-install.ts"), "utf8");
115+
116+
expect(source).toContain("handleExistingInteractiveInstall");
117+
expect(source).toContain("Existing Terrarium configuration found at ${CONFIG_PATH}");
118+
expect(source).toContain("Update existing installation");
119+
expect(source).toContain("Reinstall / reconfigure from scratch");
120+
expect(source).toContain("await updateCmd({ ref: options.ref })");
121+
});
122+
113123
test("shows concrete external OIDC setup requirements before provider prompts", () => {
114124
const instructions = externalOidcSetupInstructions({
115125
adminGroup: "admin",

0 commit comments

Comments
 (0)