Class: Clacky::DeployApiClient

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/deploy_api_client.rb

Overview

DeployApiClient - Encapsulates all Deploy API calls for the Railway deployment flow.

All endpoints use Workspace API Key (clacky_ak_*) authentication, as the backend supports both clacky_ak_* and clacky_dk_* for all deploy endpoints.

Usage:

client = DeployApiClient.new("clacky_ak_xxx", base_url: "https://api.clacky.ai")

# Check payment status
result = client.payment_status(project_id: "proj_abc")
# => { success: true, is_paid: true }

# Create a deploy task
result = client.create_task(project_id: "proj_abc")
# => { success: true, deploy_task_id: "...", platform_token: "...", ... }

# Poll services until DB is ready
result = client.services(deploy_task_id: "task_abc")
# => { success: true, services: [...], domain_name: "..." }

# Poll deploy status
result = client.deploy_status(deploy_task_id: "task_abc")
# => { success: true, status: "SUCCESS", url: "https://..." }

# Bind domain
result = client.bind_domain(deploy_task_id: "task_abc")
# => { success: true, domain: "my-app.example.com" }

# Notify backend of deploy outcome
client.notify(project_id: "...", deploy_task_id: "...", status: "success")

Constant Summary collapse

BASE_PATH =
"/openclacky/v1"
REQUEST_TIMEOUT =

seconds for normal requests

30
OPEN_TIMEOUT =

seconds for connection

10

Instance Method Summary collapse

Constructor Details

#initialize(workspace_key, base_url:) ⇒ DeployApiClient

Returns a new instance of DeployApiClient.



42
43
44
45
# File 'lib/clacky/deploy_api_client.rb', line 42

def initialize(workspace_key, base_url:)
  @workspace_key = workspace_key.to_s.strip
  @base_url      = base_url.to_s.strip.sub(%r{/+$}, "")
end

Instance Method Details

#bind_domain(deploy_task_id:) ⇒ Hash

Bind a custom domain to the deploy task.

Parameters:

  • deploy_task_id (String)

Returns:

  • (Hash)

    { success: true, domain: String } or { success: false, error: String }



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/clacky/deploy_api_client.rb', line 280

def bind_domain(deploy_task_id:)
  response = connection.post("#{BASE_PATH}/deploy/bind-domain") do |req|
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate({ deploy_task_id: deploy_task_id })
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  { success: true, domain: data["domain"].to_s }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#build_logs(deploy_task_id:, service_id: nil, level: "INFO", lines: 100) ⇒ Hash

Fetch build logs for a deploy task (synchronous, not SSE).

} or { success: false, error: String }

Parameters:

  • deploy_task_id (String)
  • service_id (String, nil) (defaults to: nil)

    optional service ID filter

  • level (String) (defaults to: "INFO")

    log level filter (“INFO”, “ERROR”, “WARN”, etc.)

  • lines (Integer) (defaults to: 100)

    maximum number of lines to return (default: 100)

Returns:

  • (Hash)

    { success: true, logs: Array<Hash> # [{ “timestamp” => …, “level” => “INFO”, “message” => “…” }, …]



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
# File 'lib/clacky/deploy_api_client.rb', line 313

def build_logs(deploy_task_id:, service_id: nil, level: "INFO", lines: 100)
  body_params = {
    deploy_task_id: deploy_task_id,
    level: level,
    lines: lines
  }
  body_params[:service_id] = service_id if service_id

  response = connection.post("#{BASE_PATH}/tasks/logs") do |req|
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate(body_params)
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  logs = data["logs"] || []
  { success: true, logs: logs }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#create_task(project_id:, backup_db: false, env_vars: {}, region: nil) ⇒ Hash

Create a new deployment task on the backend. Returns Railway credentials.

}

Parameters:

  • project_id (String)
  • backup_db (Boolean) (defaults to: false)

    default false

  • env_vars (Hash) (defaults to: {})

    extra env vars to pass at task creation time

  • region (String) (defaults to: nil)

    optional Railway region slug

Returns:

  • (Hash)

    { success: true, deploy_task_id: String, deploy_service_id: String, platform_token: String, # RAILWAY_TOKEN platform_project_id: String, platform_environment_id: String



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
# File 'lib/clacky/deploy_api_client.rb', line 122

def create_task(project_id:, backup_db: false, env_vars: {}, region: nil)
  body_params = { project_id: project_id, backup_db: backup_db }
  body_params[:env_vars] = env_vars unless env_vars.empty?
  body_params[:region]   = region   if region

  response = connection.post("#{BASE_PATH}/deploy/create-task") do |req|
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate(body_params)
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  {
    success:                 true,
    deploy_task_id:          data["deploy_task_id"],
    deploy_service_id:       data["deploy_service_id"],
    platform_token:          data["platform_token"],
    platform_project_id:     data["platform_project_id"],
    platform_environment_id: data["platform_environment_id"]
  }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#deploy_status(deploy_task_id:) ⇒ Hash

Query the real-time deployment status for a task.

}

Parameters:

  • deploy_task_id (String)

Returns:

  • (Hash)

    { success: true, status: String, # SUCCESS / FAILED / CRASHED / DEPLOYING / WAITING url: String



249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/clacky/deploy_api_client.rb', line 249

def deploy_status(deploy_task_id:)
  response = connection.get("#{BASE_PATH}/deploy/status") do |req|
    req.params["deploy_task_id"] = deploy_task_id
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  {
    success: true,
    status:  data["status"].to_s.upcase,
    url:     data["url"].to_s,
    deploy_service_id: data["deploy_service_id"].to_s
  }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#notify(project_id:, deploy_task_id:, status:, deploy_service_id: nil, message: nil, target_port: nil) ⇒ Hash

Notify the backend of the current deployment outcome. Fire-and-forget — failures are logged but do not raise.

Parameters:

  • project_id (String)
  • deploy_task_id (String)
  • deploy_service_id (String) (defaults to: nil)

    optional

  • status (String)

    “deploying” | “success” | “failed”

  • message (String) (defaults to: nil)

    optional description

  • target_port (Integer) (defaults to: nil)

    default 3000

Returns:

  • (Hash)

    { success: true } or { success: false, error: String }



409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/clacky/deploy_api_client.rb', line 409

def notify(project_id:, deploy_task_id:, status:,
           deploy_service_id: nil, message: nil, target_port: nil)
  payload = {
    project_id:     project_id,
    deploy_task_id: deploy_task_id,
    status:         status
  }
  payload[:deploy_service_id] = deploy_service_id if deploy_service_id
  payload[:message]           = message           if message
  payload[:target_port]       = target_port       if target_port

  response = connection.post("#{BASE_PATH}/deploy/notify") do |req|
    req.headers["Content-Type"] = "application/json"
    req.body = JSON.generate(payload)
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  { success: true }
rescue => e
  # Notify failures are non-fatal — log and move on
  warn "[deploy_api] notify failed: #{e.message}"
  { success: false, error: e.message }
end

#payment_status(project_id:) ⇒ Hash

Query whether the project has an active paid subscription.

Parameters:

  • project_id (String)

Returns:

  • (Hash)

    { success: true, is_paid: Boolean } or { success: false, error: String }



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/clacky/deploy_api_client.rb', line 55

def payment_status(project_id:)
  response = connection.get("#{BASE_PATH}/deploy/payment") do |req|
    req.params["project_id"] = project_id
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  { success: true, is_paid: data["is_paid"] == true }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#regions(project_id:) ⇒ Hash

Fetch the list of supported deployment regions.

} or { success: false, error: String }

Parameters:

  • project_id (String)

    Required by the backend to scope region availability.

Returns:

  • (Hash)

    { success: true, regions: Array<Hash> # e.g. [{ “id” => “us-west”, “name” => “US West”, “label” => “US West (Oregon)” }, …]



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/clacky/deploy_api_client.rb', line 84

def regions(project_id:)
  response = connection.get("#{BASE_PATH}/deploy/regions") do |req|
    req.params["project_id"] = project_id
  end

  return http_error(response) unless response.status == 200

  body = parse_body(response)
  return body_error(body) unless success_code?(body)

  data = body["data"] || {}
  list = data["regions"] || data || []
  list = list.values if list.is_a?(Hash)
  { success: true, regions: Array(list) }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#services(deploy_task_id:) ⇒ Hash

Query all services under a deploy task. Used to wait for the PostgreSQL middleware to reach status SUCCESS before injecting the DATABASE_URL reference.

}

Parameters:

  • deploy_task_id (String)

Returns:

  • (Hash)

    { success: true, services: Array<Hash>, # full service objects from API domain_name: String, # assigned domain (may be empty on first call) db_service: Hash | nil # first middleware service with status SUCCESS



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
# File 'lib/clacky/deploy_api_client.rb', line 167

def services(deploy_task_id:)
  url = "#{BASE_PATH}/deploy/services?deploy_task_id=#{deploy_task_id}"
  puts "  [DEBUG API] GET #{@base_url}#{url}"
  
  response = connection.get("#{BASE_PATH}/deploy/services") do |req|
    req.params["deploy_task_id"] = deploy_task_id
  end

  puts "  [DEBUG API] Response status: #{response.status}"
  
  return http_error(response) unless response.status == 200

  body = parse_body(response)
  puts "  [DEBUG API] Response body: #{body.inspect[0..500]}..." if body
  
  return body_error(body) unless success_code?(body)

  data     = body["data"] || {}
  svcs     = data["services"] || []
  domain   = data["domain_name"].to_s

  # Debug: print detailed service info
  puts "  [DEBUG] Total services returned: #{svcs.size}"
  svcs.each_with_index do |s, idx|
    puts "  [DEBUG]   Service[#{idx}]: name=#{s['service_name']}, type=#{s['type']}, status=#{s['status']}"
    if s["type"] == "middleware"
      env_vars = s["env_vars"] || {}
      puts "  [DEBUG]     - env_vars keys: #{env_vars.keys.join(', ')}"
      puts "  [DEBUG]     - has DATABASE_URL: #{env_vars.key?('DATABASE_URL')}"
      puts "  [DEBUG]     - has DATABASE_PUBLIC_URL: #{env_vars.key?('DATABASE_PUBLIC_URL')}"
    end
  end

  # Find first middleware (DB) that is fully provisioned
  db_svc = svcs.find do |s|
    s["type"] == "middleware" && s["status"]&.upcase == "SUCCESS"
  end
  
  puts "  [DEBUG] db_svc found: #{!db_svc.nil?}"
  if db_svc
    puts "  [DEBUG]   - db_svc name: #{db_svc['service_name']}"
    puts "  [DEBUG]   - db_svc status: #{db_svc['status']}"
  end

  # middleware_support: { supported: Boolean, supported_types: Array }
  # When supported == false, no DB middleware will be provisioned by Clacky.
  # The deploy script uses this to skip the DB polling loop entirely.
  middleware_support = data["middleware_support"] || {}
  puts "  [DEBUG] middleware_support: #{middleware_support.inspect}"

  # platform_bucket_credentials contains S3-compatible storage credentials.
  # Passed through so the deploy script can inject STORAGE_BUCKET_* env vars.
  bucket_credentials = data["platform_bucket_credentials"]
  bucket_name        = data["platform_bucket_name"].to_s

  {
    success:              true,
    services:             svcs,
    domain_name:          domain,
    db_service:           db_svc,
    middleware_support:   middleware_support,
    bucket_credentials:   bucket_credentials,
    bucket_name:          bucket_name
  }
rescue Faraday::Error => e
  { success: false, error: "Network error: #{e.message}" }
rescue => e
  { success: false, error: "Unexpected error: #{e.message}" }
end

#stream_build_logs(deploy_task_id:, service_id: nil, level: "INFO") {|Hash| ... } ⇒ Hash

Stream build logs using SSE (Server-Sent Events). This method yields each log line as it arrives.

Parameters:

  • deploy_task_id (String)
  • service_id (String, nil) (defaults to: nil)

    optional service ID filter

  • level (String) (defaults to: "INFO")

    log level filter (“INFO”, “ERROR”, “WARN”, etc.)

Yields:

  • (Hash)

    each log event: { “type” => “log”, “timestamp” => …, “message” => “…” }

Returns:

  • (Hash)

    { success: true } or { success: false, error: String }



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
# File 'lib/clacky/deploy_api_client.rb', line 348

def stream_build_logs(deploy_task_id:, service_id: nil, level: "INFO", &block)
  require "net/http"
  require "openssl"

  body_params = {
    deploy_task_id: deploy_task_id,
    level: level
  }
  body_params[:service_id] = service_id if service_id

  url = "#{@base_url}#{BASE_PATH}/tasks/stream/build-logs"
  uri = URI.parse(url)

  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
    request = Net::HTTP::Post.new(uri.path)
    request["Authorization"] = "Bearer #{@workspace_key}"
    request["Accept"] = "text/event-stream"
    request["Content-Type"] = "application/json"
    request.body = JSON.generate(body_params)

    http.request(request) do |response|
      return { success: false, error: "HTTP #{response.code}: #{response.message}" } unless response.code.to_i == 200

      buffer = ""
      response.read_body do |chunk|
        buffer << chunk
        while (line_end = buffer.index("\n"))
          line = buffer.slice!(0..line_end).strip
          next if line.empty? || !line.start_with?("data:")

          json_str = line.sub(/^data:\s*/, "")
          begin
            event = JSON.parse(json_str)
            block.call(event) if block
          rescue JSON::ParserError
            # Ignore malformed JSON
          end
        end
      end
    end
  end

  { success: true }
rescue => e
  { success: false, error: "Stream error: #{e.message}" }
end