Class: GDKBox::Box

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

Overview

A single GDK-in-a-box instance: a Docker container plus its persisted metadata. This is the orchestration layer the CLI talks to.

Defined Under Namespace

Classes: AgentResult

Constant Summary collapse

HYDRATE_SCRIPT =

The hydrate fetch, run inside the box. The filter is passed to git fetch directly and only persisted to config after the fetch succeeds (and, for a full hydrate, the previous filter is put back on failure), so an interrupted hydrate cannot leave the config claiming objects the store does not have (issue #8).

<<~'BASH'
  set -e
  if [ -n "$GDKBOX_HYDRATE_FILTER" ]; then
    git fetch --refetch --progress --filter="$GDKBOX_HYDRATE_FILTER" origin
    git config remote.origin.partialclonefilter "$GDKBOX_HYDRATE_FILTER"
  else
    prev=$(git config --get remote.origin.partialclonefilter || true)
    git config --unset-all remote.origin.partialclonefilter || true
    if ! git fetch --refetch --progress origin; then
      if [ -n "$prev" ]; then git config remote.origin.partialclonefilter "$prev"; fi
      echo "hydrate: fetch failed; previous filter config restored" >&2
      exit 1
    fi
    git config --unset remote.origin.promisor || true
  fi
BASH

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil) ⇒ Box

Returns a new instance of Box.



19
20
21
22
23
24
25
26
# File 'lib/gdkbox/box.rb', line 19

def initialize(name, config:, shell: Shell.new, docker: nil, store: nil, ssh_key: nil)
  @name = name
  @config = config
  @shell = shell
  @docker = docker || Docker.new(shell: shell)
  @store = store || Store.new(config: config)
  @ssh_key = ssh_key || SSHKey.new(config: config, shell: shell)
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



17
18
19
# File 'lib/gdkbox/box.rb', line 17

def config
  @config
end

#nameObject (readonly)

Returns the value of attribute name.



17
18
19
# File 'lib/gdkbox/box.rb', line 17

def name
  @name
end

Class Method Details

.all(config:, store: nil, **kwargs) ⇒ Object



28
29
30
31
# File 'lib/gdkbox/box.rb', line 28

def self.all(config:, store: nil, **kwargs)
  store ||= Store.new(config: config)
  store.all.map { |data| from_data(data, config: config, store: store, **kwargs) }
end

.claim_any(config:, owner:, ttl: nil, **kwargs) ⇒ Object

Atomically claim any free box (unclaimed, or with an expired lease) for owner, returning it. Each attempt is itself atomic, so racing orchestrators simply end up with different boxes. Raises Error when every box is claimed.

Raises:



241
242
243
244
245
246
247
248
# File 'lib/gdkbox/box.rb', line 241

def self.claim_any(config:, owner:, ttl: nil, **kwargs)
  all(config: config, **kwargs).sort_by(&:name).each do |box|
    return box.claim!(owner: owner, ttl: ttl)
  rescue Error
    next # claimed by someone else (possibly since we listed) — try the next
  end
  raise Error, "No free box to claim. See `gdkbox ls` for current claims."
end

.expand_remote(token) ⇒ Object

The full git URL for a remote given as a URL or a "namespace/project" shorthand (expanded against gitlab.com over SSH, so pushes ride the forwarded agent).



253
254
255
256
257
# File 'lib/gdkbox/box.rb', line 253

def self.expand_remote(token)
  return token if token.include?("://") || token.include?(":")

  "git@gitlab.com:#{token}.git"
end

.from_data(data, config:, **kwargs) ⇒ Object



33
34
35
36
37
# File 'lib/gdkbox/box.rb', line 33

def self.from_data(data, config:, **kwargs)
  box = new(data["name"], config: config, **kwargs)
  box.instance_variable_set(:@data, data)
  box
end

.remote_ssh_host(url) ⇒ Object

The host an SSH-style git URL connects to, or nil for non-SSH remotes (https needs no SSH client preparation).



261
262
263
264
265
266
267
# File 'lib/gdkbox/box.rb', line 261

def self.remote_ssh_host(url)
  if (m = url.match(%r{\Assh://(?:[^@/]+@)?([^:/]+)}))
    m[1]
  elsif (m = url.match(/\A(?:[^@:\/]+@)([^:]+):/))
    m[1]
  end
end

Instance Method Details

#claim!(owner:, ttl: nil) ⇒ Object

Claim this box for exclusive use by owner — an advisory lock for fleet orchestrators, so two agents cannot pick the same box. Runs under the create lock, so concurrent claims serialize; exactly one wins. Re-claiming with the same owner renews (and can extend a --ttl lease). Raises Error when another owner holds an unexpired claim.

Raises:



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/gdkbox/box.rb', line 184

def claim!(owner:, ttl: nil)
  raise Error, "Box '#{name}' does not exist" unless exists?

  with_create_lock do
    @data = @store.load(name) # fresh read under the lock
    holder = claimed_by
    if holder && holder != owner
      raise Error, "Box '#{name}' is claimed by '#{holder}'. " \
        "Pick another box or use `gdkbox release #{name} --force`."
    end

    @data["claimed_by"] = owner
    @data["claimed_at"] = Time.now.utc.iso8601
    if ttl
      @data["claim_expires_at"] = (Time.now.utc + ttl).iso8601
    else
      @data.delete("claim_expires_at")
    end
    @store.save(@data)
  end
  self
end

#claimed_byObject

The owner of the active claim, or nil when unclaimed or the claim's lease has expired (an expired claim counts as free).



228
229
230
231
232
233
234
235
# File 'lib/gdkbox/box.rb', line 228

def claimed_by
  return nil unless data && data["claimed_by"]

  expires = data["claim_expires_at"]
  return nil if expires && Time.parse(expires) <= Time.now.utc

  data["claimed_by"]
end

#container_nameObject



47
48
49
# File 'lib/gdkbox/box.rb', line 47

def container_name
  data ? data["container_name"] : @config.container_name(name)
end

#create!(image: nil, ssh_port: nil, web_port: nil, harness: Harness.default, install_agent: true, api_key: nil) ⇒ Object

Provision a brand new box end to end: pull image, run container, enable SSH, optionally install the agent harness, then persist metadata.

Raises:



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
# File 'lib/gdkbox/box.rb', line 83

def create!(image: nil, ssh_port: nil, web_port: nil, harness: Harness.default,
  install_agent: true, api_key: nil)
  raise Error, "Box '#{name}' already exists" if exists?

  harness = Harness[harness]
  image ||= @config.default_image
  public_key = @ssh_key.ensure!
  cname = @config.container_name(name)

  # Only pull when the image is missing locally, so `gdkbox up` on a warm
  # machine does not hit the registry; `gdkbox update-image` is the
  # explicit way to refresh.
  @docker.pull(image) unless @docker.image_exists?(image)

  # Reserve the host ports atomically. `next_port` reads the store, so
  # parallel `gdkbox up` runs would otherwise all pick the same "next free"
  # port and collide at `docker run`. Holding an exclusive lock while we
  # choose ports *and* persist a preliminary record makes each sibling see
  # the others' reservations.
  ssh_port, web_port = reserve_ports!(cname, ssh_port, web_port)

  begin
    @docker.run_container(
      name: cname,
      image: image,
      # GDK's services resolve and redirect to this hostname; without it
      # set inside the container, workhorse and the http-router crash-loop
      # on DNS timeouts.
      hostname: Config::GDK_HOSTNAME,
      publish: [
        "127.0.0.1:#{ssh_port}:#{Config::SSH_CONTAINER_PORT}",
        "127.0.0.1:#{web_port}:#{Config::GDK_WEB_CONTAINER_PORT}"
      ],
      labels: { "gdkbox" => "true", "gdkbox.name" => name }
    )
  rescue StandardError
    # The container never started, so release the reserved ports rather
    # than stranding a record that points at nothing.
    @store.delete(name)
    raise
  end

  provisioner = Provisioner.new(docker: @docker, config: @config)
  provisioner.setup_ssh(cname, public_key)

  # Enrich the reserved record now that the box is reachable over SSH,
  # before the optional Claude/API-key steps. That way a failure in those
  # steps leaves a box that `gdkbox ls`/`rm` can still see and clean up,
  # rather than an orphan container with no record.
  @data = @data.merge(
    "image" => image,
    "ssh_user" => @config.ssh_user,
    "remote_path" => @config.remote_path,
    "harness" => harness.id,
    "created_at" => Time.now.utc.iso8601
  )
  @store.save(@data)

  # Best-effort: a host without a git identity (or a transient exec
  # failure) should not abort the box; `gdkbox set-git` can seed it later.
  begin
    git_name, git_email = host_git_identity
    provisioner.setup_git_identity(cname, name: git_name, email: git_email) if git_name || git_email
  rescue StandardError
    nil
  end

  provisioner.setup_agent(cname, harness) if install_agent
  @data["agent_installed"] = install_agent

  if api_key && !api_key.strip.empty?
    provisioner.setup_api_key(cname, api_key, harness.key_env)
    @data["api_key_set"] = true
  end

  @store.save(@data)
  @data
end

#dataObject



39
40
41
# File 'lib/gdkbox/box.rb', line 39

def data
  @data ||= @store.load(name)
end

#destroy!Object



174
175
176
177
# File 'lib/gdkbox/box.rb', line 174

def destroy!
  @docker.rm(container_name, force: true)
  @store.delete(name)
end

#exists?Boolean

Returns:

  • (Boolean)


43
44
45
# File 'lib/gdkbox/box.rb', line 43

def exists?
  @store.exists?(name)
end

#harnessObject

The agent harness this box runs (defaults to Claude for boxes created before harness support existed).



73
74
75
# File 'lib/gdkbox/box.rb', line 73

def harness
  Harness[(data && data["harness"]) || Harness.default]
end

#hydrate!(trees: false, &progress) ⇒ Object

Backfill the box's treeless GitLab clone so deep-history operations (old rebases, blame, bisect) work. trees: true fetches only the missing trees (much smaller; file contents stay lazy).

The fetch runs over SSH (ssh -F <generated config> <alias>), not docker exec: the remote is typically git@gitlab.com, and only an SSH session carries the user's forwarded agent — docker exec has no SSH_AUTH_SOCK, so it cannot authenticate at all (issue #8). The SSH client stanza (host key + multiplexing) is seeded first for SSH origins. Streams git's progress lines to the block; raises CommandError when the fetch fails.

Raises:



317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/gdkbox/box.rb', line 317

def hydrate!(trees: false, &progress)
  origin = @docker.exec(
    container_name, "git remote get-url origin",
    user: @config.ssh_user, workdir: @config.gitlab_checkout_path
  ).stdout.strip
  if (host = self.class.remote_ssh_host(origin))
    Provisioner.new(docker: @docker, config: @config)
      .setup_git_ssh(container_name, host)
  end

  filter = trees ? "blob:none" : ""
  remote_cmd = "cd #{Shellwords.escape(@config.gitlab_checkout_path)} && " \
    "GDKBOX_HYDRATE_FILTER=#{Shellwords.escape(filter)} " \
    "bash -c #{Shellwords.escape(HYDRATE_SCRIPT)}"
  argv = ["ssh", "-F", @config.ssh_config_path, ssh_host_alias, remote_cmd]
  result = @shell.stream_tty(*argv, &progress)
  raise CommandError.new(argv, result.status, "") unless result.success?

  result
end

#install_agent!Object

(Re)install this box's agent harness inside the container.



356
357
358
359
360
361
362
# File 'lib/gdkbox/box.rb', line 356

def install_agent!
  Provisioner.new(docker: @docker, config: @config).setup_agent(container_name, harness)
  return unless data

  @data = data.merge("agent_installed" => true)
  @store.save(@data)
end

#install_skill(token, project_dir: Dir.pwd, force: false) ⇒ Object

Install an agent skill into this box's harness skills directory so agents dispatched into the box can use it. token is a discovered skill name (bundled, ~/.claude/skills, or ./.claude/skills) or a path to a skill directory. Skills use the same SKILL.md format across harnesses; only the destination directory differs. Returns the resolved Skill.

Raises:



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
# File 'lib/gdkbox/box.rb', line 369

def install_skill(token, project_dir: Dir.pwd, force: false)
  raise Error, "Box '#{name}' does not exist" unless exists?

  skill = Skills.resolve(token, project_dir: project_dir)
  skills_dir = "/home/#{@config.ssh_user}/#{harness.skills_subdir}"
  target = "#{skills_dir}/#{skill.name}"

  @docker.exec(container_name, 'mkdir -p "$GDKBOX_SKILLS_DIR"',
    user: @config.ssh_user, env: { "GDKBOX_SKILLS_DIR" => skills_dir })

  present = @docker.exec(
    container_name, '[ -e "$GDKBOX_TARGET" ] && echo yes || echo no',
    user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }, check: false
  ).stdout.strip == "yes"
  raise Error, "Skill '#{skill.name}' is already in box '#{name}'. Use --force to overwrite." if present && !force

  @docker.exec(container_name, 'rm -rf "$GDKBOX_TARGET"',
    user: @config.ssh_user, env: { "GDKBOX_TARGET" => target }) if present

  @docker.cp_into(container_name, skill.path, target)
  @docker.exec(container_name, 'chown -R "$GDKBOX_USER:$GDKBOX_USER" "$GDKBOX_TARGET"',
    user: "root", env: { "GDKBOX_USER" => @config.ssh_user, "GDKBOX_TARGET" => target })

  skill
end

#release!(owner: nil, force: false) ⇒ Object

Release this box's claim. Only the claiming owner may (force: true overrides — the janitor path). Returns :released, or :unclaimed when there was nothing to release.



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/gdkbox/box.rb', line 210

def release!(owner: nil, force: false)
  with_create_lock do
    @data = @store.load(name)
    holder = @data && @data["claimed_by"]
    return :unclaimed unless holder

    if !force && holder != owner
      raise Error, "Box '#{name}' is claimed by '#{holder}', not " \
        "'#{owner}'. Use --force to override."
    end
    %w[claimed_by claimed_at claim_expires_at].each { |k| @data.delete(k) }
    @store.save(@data)
    :released
  end
end

#remote_pathObject



67
68
69
# File 'lib/gdkbox/box.rb', line 67

def remote_path
  data ? data["remote_path"] : @config.remote_path
end

#run_agent(task:, json: false, yolo: true, timeout: nil) ⇒ Object

Run this box's agent harness non-interactively and capture its output. This is the primitive an orchestrator uses to dispatch a task to a box. The task text is passed through the environment so arbitrary prompts cannot break out of the shell command.

Raises:



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
# File 'lib/gdkbox/box.rb', line 412

def run_agent(task:, json: false, yolo: true, timeout: nil)
  raise Error, "Box '#{name}' does not exist" unless exists?

  agent = harness.agent_command(json: json, yolo: yolo)
  agent = "timeout #{Integer(timeout)} #{agent}" if timeout
  # Source the seeded API key (if any) so the agent authenticates unattended.
  command = %([ -f "$HOME/.gdkbox/env" ] && . "$HOME/.gdkbox/env"; #{agent})

  result = @docker.exec(
    container_name, command,
    user: @config.ssh_user,
    workdir: remote_path,
    env: { "GDKBOX_TASK" => task },
    check: false
  )
  AgentResult.new(result.stdout, result.stderr, result.status)
end

#set_api_key!(api_key) ⇒ Object

Seed or rotate the agent API key inside an existing box so dispatched agents can authenticate unattended. The key is exported under the env var this box's harness reads.

Raises:



398
399
400
401
402
403
404
405
406
# File 'lib/gdkbox/box.rb', line 398

def set_api_key!(api_key)
  raise Error, "Box '#{name}' does not exist" unless exists?
  raise Error, "An API key is required" if api_key.nil? || api_key.strip.empty?

  Provisioner.new(docker: @docker, config: @config)
    .setup_api_key(container_name, api_key, harness.key_env)
  @data = data.merge("api_key_set" => true)
  @store.save(@data)
end

#set_git_identity!(git_name: nil, git_email: nil) ⇒ Object

Seed a git identity into the box so git commit works there. Falls back to the host's git config when name/email are not given; raises when neither source has anything to seed. Returns the [name, email] seeded.



341
342
343
344
345
346
347
348
349
350
351
352
353
# File 'lib/gdkbox/box.rb', line 341

def set_git_identity!(git_name: nil, git_email: nil)
  host_name, host_email = host_git_identity
  git_name ||= host_name
  git_email ||= host_email
  if git_name.nil? && git_email.nil?
    raise Error, "No git identity found. Pass --name/--email or set " \
      "`git config --global user.name/user.email` on the host."
  end

  Provisioner.new(docker: @docker, config: @config)
    .setup_git_identity(container_name, name: git_name, email: git_email)
  [git_name, git_email]
end

#set_gitlab_remote!(remote) ⇒ Object

Point the box's GitLab checkout at a different remote (URL or "namespace/project"). SSH remotes get the box's SSH client prepared first (host key acceptance + connection multiplexing) so the follow-up fetch — and later lazy fetches from the treeless clone — succeed. Returns the URL that origin now uses.



274
275
276
277
278
279
280
281
282
# File 'lib/gdkbox/box.rb', line 274

def set_gitlab_remote!(remote)
  url = self.class.expand_remote(remote)
  provisioner = Provisioner.new(docker: @docker, config: @config)
  if (host = self.class.remote_ssh_host(url))
    provisioner.setup_git_ssh(container_name, host)
  end
  provisioner.setup_gitlab_remote(container_name, url)
  url
end

#ssh_commandObject

argv to open an interactive SSH session using the generated config.



453
454
455
# File 'lib/gdkbox/box.rb', line 453

def ssh_command
  ["ssh", ssh_host_alias]
end

#ssh_host_aliasObject



63
64
65
# File 'lib/gdkbox/box.rb', line 63

def ssh_host_alias
  @config.ssh_host_alias(name)
end

#ssh_portObject



51
52
53
# File 'lib/gdkbox/box.rb', line 51

def ssh_port
  data && data["ssh_port"]
end

#start!Object

Start a stopped box and make sure sshd is running again (processes started via docker exec do not survive a container restart).



164
165
166
167
168
# File 'lib/gdkbox/box.rb', line 164

def start!
  @docker.start(container_name)
  Provisioner.new(docker: @docker, config: @config)
    .setup_ssh(container_name, @ssh_key.ensure!)
end

#stateObject



77
78
79
# File 'lib/gdkbox/box.rb', line 77

def state
  @docker.state(container_name)
end

#stop!Object



170
171
172
# File 'lib/gdkbox/box.rb', line 170

def stop!
  @docker.stop(container_name)
end

#summary(state: nil) ⇒ Object

A machine-readable summary for orchestrators (gdkbox ls --json).



431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/gdkbox/box.rb', line 431

def summary(state: nil)
  {
    "name" => name,
    "state" => (state || self.state).to_s,
    "container_name" => container_name,
    "ssh_host" => ssh_host_alias,
    "ssh_port" => ssh_port,
    "web_port" => web_port,
    "web_url" => web_url,
    "remote_path" => remote_path,
    "harness" => harness.id,
    "agent_installed" => (data && data["agent_installed"]) || false,
    # Back-compat: orchestrators predating multi-harness check this field.
    "claude_installed" => (harness.id == "claude" && (data && data["agent_installed"])) || false,
    "api_key_set" => (data && data["api_key_set"]) || false,
    # Active claim (nil when free; an expired lease counts as free).
    "claimed_by" => claimed_by,
    "claim_expires_at" => (claimed_by ? data["claim_expires_at"] : nil)
  }
end

#web_portObject



55
56
57
# File 'lib/gdkbox/box.rb', line 55

def web_port
  data && data["web_port"]
end

#web_urlObject



59
60
61
# File 'lib/gdkbox/box.rb', line 59

def web_url
  "http://127.0.0.1:#{web_port}"
end