Skip to content

feat(install): unattended installation mode - #408

Merged
alex-clickhouse merged 2 commits into
ClickHouse:mainfrom
nikita-vanyasin:feat/unattended-install
Aug 25, 2026
Merged

feat(install): unattended installation mode#408
alex-clickhouse merged 2 commits into
ClickHouse:mainfrom
nikita-vanyasin:feat/unattended-install

Conversation

@nikita-vanyasin

Copy link
Copy Markdown
Member

Problem

install.sh cannot complete without a terminal, so it can't be used from cloud-init, Ansible, a Dockerfile or an image build. Two separate things block it.

1. The tty reclaim is fatal without a controlling terminal.

if [ !-t 0 ] && [ -e /dev/tty ];thenexec< /dev/tty
fi

/dev/tty exists on any normal system, but a process started by a daemon has no controlling terminal, so the open fails with ENXIO. Under set -e that ends the install before the first dependency check. -e /dev/tty tests that the device node is there, which is not the same question.

2. There is no way to skip the wizard.

Past the dependency phase run_init always runs the interactive nerve init. NERVE_YES=1 does not help — it makes confirm "Run the setup wizard now?" return true, so the wizard runs with no one to answer it and the install hangs. offer_start then does the same for "Start Nerve now?".

nerve init --non-interactive already exists and reads everything from the environment; the installer just had no way to reach it.

Change

Adds --non-interactive / NERVE_NON_INTERACTIVE=1:

  • implies NERVE_YES=1 and skips the tty reclaim entirely
  • run_init calls nerve init --non-interactive and fails loudly rather than leaving a half-configured install
  • offer_start leaves the daemon stopped, since these installs are normally followed by a service manager that owns the process. NERVE_START=1 starts it from the installer instead.
  • an existing install still takes the upgrade path and keeps its configuration

The tty reclaim is now probed in a subshell before it is used, so an interactive run behaves as before and a run without a terminal continues instead of dying.

curl -fsSL https://raw.githubusercontent.com/ClickHouse/nerve/main/install.sh \
| NERVE_NON_INTERACTIVE=1 \
NERVE_INSTALL_DIR=/home/agent/nerve \
NERVE_MODE=worker \
NERVE_PROVIDER=bedrock \
NERVE_AWS_REGION=eu-central-1 \
bash

docs/setup.md gets an "Unattended installation" section listing the variables the wizard would otherwise ask for.

Notes

  • No behaviour change for an interactive install: NON_INTERACTIVE defaults to 0 and every new branch is behind it.
  • NERVE_PASSWORD is read once by nerve init and stored only as a bcrypt hash, so an unattended caller can pass it in and drop it immediately. Called out in the docs.

Testing

  • bash -n install.sh
  • Verified NERVE_NON_INTERACTIVE=1 sets AUTO_YES=1 and that confirm then returns without reading stdin.
  • Verified the subshell probe swallows an unopenable path and lets the script continue, where a bare exec redirection does not.
  • Not yet run as a full unattended install on a clean host.

🤖 Generated with Claude Code

install.sh could not complete without a terminal. Two things blocked it:
`exec < /dev/tty` ran whenever stdin was not a tty, which fails with ENXIO
under cloud-init and ends the script; and past the dependency phase the
installer always ran the interactive `nerve init` wizard, with NERVE_YES=1
auto-confirming it rather than skipping it.
Adds --non-interactive / NERVE_NON_INTERACTIVE=1, which implies NERVE_YES,
never prompts, and runs `nerve init --non-interactive`. The daemon is left
stopped for a service manager to own unless NERVE_START=1. The tty reclaim
is now probed in a subshell and skipped when it cannot succeed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikita-vanyasin
nikita-vanyasin marked this pull request as ready for review August 24, 2026 12:10
@alex-clickhousealex-clickhouse self-assigned this Aug 24, 2026
Comment threadinstall.sh
PREFERRED_PYTHON_MINOR=13
# Vite 7 (see web/package.json) requires Node 20.19+ or 22.12+.
MIN_NODE_VERSION="20.19.0"
NON_INTERACTIVE="${NERVE_NON_INTERACTIVE:-0}"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make dependency elevation honor unattended mode too? The official ubuntu:24.04 image runs as root and has apt-get, but has neither sudo nor git, so the current HAS_SUDO=0 guard aborts even though it can install packages directly. In a container with sudo added, sudo apt-get -y also allowed tzdata to emit its Geographic area: debconf prompt because DEBIAN_FRONTEND was stripped; -y only answers apt's own confirmation.

One way to centralize both concerns:

run_as_root() {
if [ "$(id -u)"-eq 0 ];then"$@"elif! command_exists sudo;then
error "Root privileges are required, but sudo is not available."return 1
elif [ "$NON_INTERACTIVE"="1" ];then# Fail instead of waiting for a sudo password in automation.
sudo -n "$@"else
sudo "$@"fi
}
run_debian_command() {
if [ "$NON_INTERACTIVE"="1" ];then# Set this after the sudo boundary so sudo cannot strip it.
run_as_root env DEBIAN_FRONTEND=noninteractive "$@"else
run_as_root "$@"fi
}

The apt paths can then use:

run_debian_command apt-get update -qq
run_debian_command apt-get install -y -qq git
curl -fsSL https://deb.nodesource.com/setup_lts.x \
| run_debian_command bash -
run_debian_command apt-get install -y -qq nodejs

The Python apt path should use the same wrapper, while dnf/pacman/zypper can use run_as_root. This keeps interactive sudo behavior unchanged, lets root images install directly, and guarantees unattended sudo never prompts.

@nikita-vanyasinnikita-vanyasinAug 25, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

run_as_root runs directly when id -u is 0, errors when there is no sudo and no root, and uses sudo -n under --non-interactive so a missing password rule fails immediately rather than waiting. run_debian_command wraps it with DEBIAN_FRONTEND=noninteractive past the sudo boundary.

Every apt path now goes through run_debian_command.

Comment threadinstall.sh Outdated
step "Running Nerve setup (non-interactive)"
cd "$INSTALL_DIR" || exit 1
# Fail loudly: a half-configured unattended install is worse than none.
"$nerve_bin" -c "$INSTALL_DIR" init --non-interactive

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we resolve the config directory once, for example CONFIG_DIR="${NERVE_CONFIG_DIR:-$INSTALL_DIR}", and use it consistently? This explicit -c "$INSTALL_DIR" ignores NERVE_CONFIG_DIR during init, but offer_start later calls nerve start without -c, where the same environment variable wins and may select a different config. The upgrade check also only looks at $INSTALL_DIR/config.local.yaml.

I do not think the config directory needs to be mandatory—the install directory is a safe default—but upgrade detection, init, start, and the summary should all use the same resolved value.

@nikita-vanyasinnikita-vanyasinAug 25, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. CONFIG_DIR="${NERVE_CONFIG_DIR:-$INSTALL_DIR}" is resolved once next to INSTALL_DIR.

…g dir
Review feedback on two gaps.
Dependency installation assumed a non-root user with sudo. A root container
has apt-get but neither sudo nor git, and the old guard aborted anyway. Adds
run_as_root, which runs directly as root, uses sudo -n under
--non-interactive so a missing password rule fails immediately, and
run_debian_command, which sets DEBIAN_FRONTEND=noninteractive past the sudo
boundary so debconf cannot prompt behind apt's -y.
The config directory was resolved inconsistently: init was pinned to
INSTALL_DIR while start resolved NERVE_CONFIG_DIR itself, so the two could
act on different configurations. Resolves it once as CONFIG_DIR and uses it
for upgrade detection, init, start and the summary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alex-clickhouse

Copy link
Copy Markdown
Collaborator

LGTM, thank you for the PR!

@alex-clickhouse
alex-clickhouse merged commit 5357668 into ClickHouse:mainAug 25, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@nikita-vanyasin@alex-clickhouse