Class: GDKBox::Provisioner

Inherits:
Object
  • Object
show all
Defined in:
lib/gdkbox/provisioner.rb

Overview

Runs the in-container setup steps: enabling SSH access and (optionally) installing Claude Code. All steps are idempotent so they can be re-run safely, for example after a gdkbox start.

Constant Summary collapse

SSH_SETUP =

Installs and starts the OpenSSH server, then authorizes the gdkbox public key for the GDK user. Values are passed via the environment to dodge shell quoting.

The official GDK image ships an sshd_config that listens on a non-default port (2022) and runs GitLab's own gitlab-sshd for git-over-SSH, so we cannot assume a vanilla sshd on port 22. We drop in our own config (loaded via the Include near the top of sshd_config, so it overrides the image's later Port line) pinning the listen port to the container port gdkbox publishes, ensure host keys exist, and restart sshd so it rebinds. This is idempotent and also re-run by gdkbox start.

<<~'BASH'
  set -e
  if [ ! -x /usr/sbin/sshd ] && ! command -v sshd >/dev/null 2>&1; then
    apt-get update -qq
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server >/dev/null
  fi
  mkdir -p /run/sshd
  ssh-keygen -A >/dev/null 2>&1 || true
  mkdir -p /etc/ssh/sshd_config.d
  printf 'Port %s\nPasswordAuthentication no\n' "$GDKBOX_SSH_PORT" \
    > /etc/ssh/sshd_config.d/00-gdkbox.conf
  # The stock ~/.bash_logout runs `clear_console -q`, which fails (no TTY)
  # under `docker exec ... bash -lc` and clobbers the command's exit
  # status with 1. We run agents through a login shell, so neutralize it
  # to keep `gdkbox dispatch` exit codes (and the rest) honest.
  : > "/home/$GDKBOX_USER/.bash_logout" 2>/dev/null || true
  # The GDK image manages its toolchain (ruby, node, bundle, ...) with mise
  # but only activates it in ~/.bashrc (interactive shells). `gdkbox
  # dispatch` and `gdkbox ssh <box> <cmd>` use non-interactive login shells,
  # which source ~/.profile, not ~/.bashrc. Put mise's shims on PATH there
  # so dispatched agents can actually run the GDK toolchain. Shims resolve
  # the correct tool version from the directory's mise/.tool-versions config.
  profile="/home/$GDKBOX_USER/.profile"
  touch "$profile"
  if ! grep -q 'gdkbox:mise-shims' "$profile" 2>/dev/null; then
    {
      echo '# gdkbox:mise-shims'
      echo 'if [ -d "$HOME/.local/share/mise/shims" ]; then PATH="$HOME/.local/share/mise/shims:$PATH"; fi'
    } >> "$profile"
  fi
  chown "$GDKBOX_USER:$GDKBOX_USER" "$profile"
  install -d -m 700 -o "$GDKBOX_USER" -g "$GDKBOX_USER" "/home/$GDKBOX_USER/.ssh"
  printf '%s\n' "$GDKBOX_PUBKEY" > "/home/$GDKBOX_USER/.ssh/authorized_keys"
  chown "$GDKBOX_USER:$GDKBOX_USER" "/home/$GDKBOX_USER/.ssh/authorized_keys"
  chmod 600 "/home/$GDKBOX_USER/.ssh/authorized_keys"
  /usr/sbin/sshd -t
  # Restart our sshd so it binds the configured port (gitlab-sshd and any
  # pre-existing sshd may be on other ports). Match only the OpenSSH daemon.
  pkill -x sshd >/dev/null 2>&1 || true
  sleep 1
  /usr/sbin/sshd
BASH
AGENT_SETUP =

Installs an agent harness CLI (an npm package) for the GDK user.

The official GDK image manages Node with mise, so node/npm are not on PATH in a plain shell. We install into ~/.local (which is on PATH for every shell) using mise's npm when a direct npm is unavailable, and symlink the mise-managed node so the agent's env node shebang can find an interpreter. Both npm and the plain-npm case are handled so this works on the GDK image and on simpler images alike. The binary name and package come from the harness via the environment.

<<~'BASH'
  set -e
  mkdir -p "$HOME/.local/bin"
  # Pin a concrete node: the agent CLIs are node scripts with `env node`
  # shebangs, and the mise *shim* for node errors outside a directory that
  # configures a node version. Symlinking the real mise install makes node
  # resolvable everywhere (and gets linked into /usr/local/bin afterwards).
  # Done before the early-exit so re-provisioning fixes the PATH too.
  if command -v mise >/dev/null 2>&1 && mise which node >/dev/null 2>&1; then
    ln -sf "$(mise which node)" "$HOME/.local/bin/node"
  fi
  if command -v "$GDKBOX_AGENT_BIN" >/dev/null 2>&1; then
    echo "$GDKBOX_AGENT_BIN already installed: $("$GDKBOX_AGENT_BIN" --version 2>/dev/null || echo unknown)"
    exit 0
  fi
  if command -v npm >/dev/null 2>&1; then
    npm install -g --prefix "$HOME/.local" $GDKBOX_NPM_FLAGS "$GDKBOX_NPM_PKG"
  elif command -v mise >/dev/null 2>&1 && mise which npm >/dev/null 2>&1; then
    mise exec -- npm install -g --prefix "$HOME/.local" $GDKBOX_NPM_FLAGS "$GDKBOX_NPM_PKG"
  else
    echo "Neither npm nor mise-managed node found in box; cannot install $GDKBOX_AGENT_BIN" >&2
    exit 1
  fi
  "$HOME/.local/bin/$GDKBOX_AGENT_BIN" --version || true
BASH
<<~'BASH'
  set -e
  home="/home/$GDKBOX_USER"
  if [ -e "$home/.local/bin/$GDKBOX_AGENT_BIN" ]; then
    ln -sf "$home/.local/bin/$GDKBOX_AGENT_BIN" "/usr/local/bin/$GDKBOX_AGENT_BIN"
  fi
  if [ -e "$home/.local/bin/node" ]; then
    ln -sf "$home/.local/bin/node" /usr/local/bin/node
  fi
BASH
API_KEY_SETUP =

Writes the harness's API key into the box so dispatched agents can authenticate without a human. The key is exported under the env var the harness's provider reads (e.g. ANTHROPIC_API_KEY, OPENAI_API_KEY). It is stored only inside the container, at a 0600 file owned by the GDK user, sourced explicitly by gdkbox dispatch and wired into interactive shells. The key never touches host-side metadata.

<<~'BASH'
  set -e
  home="/home/$GDKBOX_USER"
  install -d -m 700 -o "$GDKBOX_USER" -g "$GDKBOX_USER" "$home/.gdkbox"
  umask 077
  printf "export %s='%s'\n" "$GDKBOX_API_KEY_ENV" "$GDKBOX_API_KEY" > "$home/.gdkbox/env"
  chown "$GDKBOX_USER:$GDKBOX_USER" "$home/.gdkbox/env"
  chmod 600 "$home/.gdkbox/env"
  line='[ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"'
  for f in "$home/.bashrc" "$home/.profile"; do
    touch "$f"
    grep -qF "$line" "$f" || printf '%s\n' "$line" >> "$f"
    chown "$GDKBOX_USER:$GDKBOX_USER" "$f"
  done
BASH
GIT_IDENTITY_SETUP =

Seeds the GDK user's global git identity so git commit works inside the box. Values arrive via the environment (dodging shell quoting) and blanks are skipped, so a partial host config seeds what it can.

<<~'BASH'
  set -e
  [ -n "$GDKBOX_GIT_NAME" ] && git config --global user.name "$GDKBOX_GIT_NAME"
  [ -n "$GDKBOX_GIT_EMAIL" ] && git config --global user.email "$GDKBOX_GIT_EMAIL"
  true
BASH
GIT_SSH_SETUP =

Prepares the box's SSH client for real git traffic against a remote host (issue #6, hit when a gitlab_remote is configured):

  • StrictHostKeyChecking accept-new: a fresh box has an empty known_hosts, so the first fetch would die on host key verification.
  • Connection multiplexing: lazy fetches from the treeless clone open SSH connections in bursts; gitlab.com throttles that, which surfaces as a misleading "Permission denied (publickey)". One shared control connection absorbs the burst.

The stanza is tagged per host, so re-runs are no-ops and different remotes each get their own block.

<<~'BASH'
  set -e
  mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
  config="$HOME/.ssh/config"
  touch "$config" && chmod 600 "$config"
  if ! grep -qF "gdkbox:git-ssh $GDKBOX_GIT_HOST" "$config"; then
    cat >> "$config" <<EOF

  # gdkbox:git-ssh $GDKBOX_GIT_HOST
  Host $GDKBOX_GIT_HOST
    StrictHostKeyChecking accept-new
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h-%p
    ControlPersist 20m
    ServerAliveInterval 30
  EOF
  fi
BASH
GITLAB_REMOTE_SETUP =

Points the GitLab checkout's origin at a different repository. The image hardcodes the community mirror (gitlab-community/gitlab-org/gitlab); team members typically want the canonical gitlab-org/gitlab. The mirror shares its history, so a set-url plus fetch is all it takes (the checkout is treeless, keeping the fetch cheap).

<<~'BASH'
  set -e
  git remote set-url origin "$GDKBOX_GITLAB_REMOTE"
  git fetch --quiet origin
BASH
VITE_PORT_SETUP =

Makes GDK serve vite on the box's published vite port. Asset URLs embed this port (http://gdk.local:/assets/vite/...), so the in-container listen port must equal the host-published one. The reconfigure is skipped when the configured port already matches (the common case for the first box, which gets vite's default port); gdk reconfigure is what makes it slow otherwise.

The restart must cover all services, not just vite: rails boots with the container and holds the old ViteRuby config, so with only vite restarted it probes the old port, decides the dev server is down, and every page 500s with ViteRuby::MissingEntrypointError. Services may not exist yet during first boot, hence the trailing || true.

<<~'BASH'
  set -e
  current=$(gdk config get vite.port 2>/dev/null || echo "")
  if [ "$current" != "$GDKBOX_VITE_PORT" ]; then
    gdk config set vite.port "$GDKBOX_VITE_PORT"
    gdk reconfigure
    gdk restart || true
  fi
BASH

Instance Method Summary collapse

Constructor Details

#initialize(docker:, config:) ⇒ Provisioner

Returns a new instance of Provisioner.



208
209
210
211
# File 'lib/gdkbox/provisioner.rb', line 208

def initialize(docker:, config:)
  @docker = docker
  @config = config
end

Instance Method Details

#setup_agent(container_name, harness) ⇒ Object

Install the given harness (an npm-installable agent CLI) into the box.



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/gdkbox/provisioner.rb', line 226

def setup_agent(container_name, harness)
  unless harness.installable?
    raise Error, "gdkbox cannot auto-install the '#{harness.id}' harness yet."
  end

  @docker.exec(
    container_name, AGENT_SETUP, user: @config.ssh_user,
    env: {
      "GDKBOX_AGENT_BIN" => harness.bin,
      "GDKBOX_NPM_PKG" => harness.npm_package,
      "GDKBOX_NPM_FLAGS" => harness.npm_flags.to_s
    }
  )
  @docker.exec(
    container_name, AGENT_LINK, user: "root",
    env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_AGENT_BIN" => harness.bin }
  )
end

#setup_api_key(container_name, api_key, key_env) ⇒ Object



282
283
284
285
286
287
288
289
290
291
292
# File 'lib/gdkbox/provisioner.rb', line 282

def setup_api_key(container_name, api_key, key_env)
  @docker.exec(
    container_name, API_KEY_SETUP,
    user: "root",
    env: {
      "GDKBOX_USER" => @config.ssh_user,
      "GDKBOX_API_KEY" => api_key,
      "GDKBOX_API_KEY_ENV" => key_env
    }
  )
end

#setup_git_identity(container_name, name:, email:) ⇒ Object



254
255
256
257
258
259
260
261
262
263
# File 'lib/gdkbox/provisioner.rb', line 254

def setup_git_identity(container_name, name:, email:)
  @docker.exec(
    container_name, GIT_IDENTITY_SETUP,
    user: @config.ssh_user,
    env: {
      "GDKBOX_GIT_NAME" => name.to_s,
      "GDKBOX_GIT_EMAIL" => email.to_s
    }
  )
end

#setup_git_ssh(container_name, host) ⇒ Object



265
266
267
268
269
270
271
# File 'lib/gdkbox/provisioner.rb', line 265

def setup_git_ssh(container_name, host)
  @docker.exec(
    container_name, GIT_SSH_SETUP,
    user: @config.ssh_user,
    env: { "GDKBOX_GIT_HOST" => host }
  )
end

#setup_gitlab_remote(container_name, url) ⇒ Object



273
274
275
276
277
278
279
280
# File 'lib/gdkbox/provisioner.rb', line 273

def setup_gitlab_remote(container_name, url)
  @docker.exec(
    container_name, GITLAB_REMOTE_SETUP,
    user: @config.ssh_user,
    workdir: @config.gitlab_checkout_path,
    env: { "GDKBOX_GITLAB_REMOTE" => url }
  )
end

#setup_ssh(container_name, public_key) ⇒ Object



213
214
215
216
217
218
219
220
221
222
223
# File 'lib/gdkbox/provisioner.rb', line 213

def setup_ssh(container_name, public_key)
  @docker.exec(
    container_name, SSH_SETUP,
    user: "root",
    env: {
      "GDKBOX_USER" => @config.ssh_user,
      "GDKBOX_PUBKEY" => public_key,
      "GDKBOX_SSH_PORT" => Config::SSH_CONTAINER_PORT.to_s
    }
  )
end

#setup_vite_port(container_name, port) ⇒ Object



245
246
247
248
249
250
251
252
# File 'lib/gdkbox/provisioner.rb', line 245

def setup_vite_port(container_name, port)
  @docker.exec(
    container_name, VITE_PORT_SETUP,
    user: @config.ssh_user,
    workdir: @config.remote_path,
    env: { "GDKBOX_VITE_PORT" => port.to_s }
  )
end