pipeops-rexec 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/rexec/client.rb +103 -0
- data/lib/rexec/container.rb +101 -0
- data/lib/rexec/error.rb +45 -0
- data/lib/rexec/file_service.rb +72 -0
- data/lib/rexec/terminal.rb +129 -0
- data/lib/rexec/version.rb +5 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa120cedebd032e6487ad85157b0a7c98721af37fbd656d3b61c7cd1d521af0c
|
|
4
|
+
data.tar.gz: 3dc1adfaddc9e08ca5579d151e4d61b1d6c4cf98aef4cab25e347728be4980e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f48cc1ddd4444fdb469dfb0d111a3a4af1e55ad36168b1003598c94695831eb89f08c1d50903afb81683956bc0109602db49f2421eeae28bfcd84e960d7562fb
|
|
7
|
+
data.tar.gz: 7fa9c5990e393c28900d04978f4ce66b9cd52458d51a7598de769114b16f9fbe5df9fa3fdaec1e48f02d311593fcf96b8b786274b8d9296a417edf68e37e4f2a
|
data/lib/rexec/client.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rexec
|
|
7
|
+
# Main client for interacting with Rexec API.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# client = Rexec::Client.new("https://your-instance.com", "your-token")
|
|
11
|
+
# containers = client.containers.list
|
|
12
|
+
#
|
|
13
|
+
class Client
|
|
14
|
+
attr_reader :base_url, :containers, :files, :terminal
|
|
15
|
+
|
|
16
|
+
# Initialize a new Rexec client.
|
|
17
|
+
#
|
|
18
|
+
# @param base_url [String] Base URL of your Rexec instance
|
|
19
|
+
# @param token [String] API token for authentication
|
|
20
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
21
|
+
def initialize(base_url, token, timeout: 30)
|
|
22
|
+
@base_url = base_url.chomp("/")
|
|
23
|
+
@token = token
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
|
|
26
|
+
@http = Faraday.new(url: @base_url) do |f|
|
|
27
|
+
f.request :json
|
|
28
|
+
f.response :json, content_type: /\bjson$/
|
|
29
|
+
f.adapter Faraday.default_adapter
|
|
30
|
+
f.options.timeout = timeout
|
|
31
|
+
f.headers["Authorization"] = "Bearer #{token}"
|
|
32
|
+
f.headers["Accept"] = "application/json"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@containers = ContainerService.new(self)
|
|
36
|
+
@files = FileService.new(self)
|
|
37
|
+
@terminal = TerminalService.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Make an API request.
|
|
41
|
+
# @api private
|
|
42
|
+
def request(method, path, body: nil, params: nil)
|
|
43
|
+
response = @http.run_request(method, path, body, nil) do |req|
|
|
44
|
+
req.params = params if params
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
handle_response(response)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Make a raw request and return bytes.
|
|
51
|
+
# @api private
|
|
52
|
+
def request_bytes(method, path)
|
|
53
|
+
raw_http = Faraday.new(url: @base_url) do |f|
|
|
54
|
+
f.adapter Faraday.default_adapter
|
|
55
|
+
f.options.timeout = @timeout
|
|
56
|
+
f.headers["Authorization"] = "Bearer #{@token}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
response = raw_http.run_request(method, path, nil, nil)
|
|
60
|
+
|
|
61
|
+
if response.status >= 400
|
|
62
|
+
raise APIError.new(response.status, "Request failed")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
response.body
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get WebSocket URL.
|
|
69
|
+
# @api private
|
|
70
|
+
def ws_url(path)
|
|
71
|
+
uri = URI.parse(@base_url)
|
|
72
|
+
ws_scheme = uri.scheme == "https" ? "wss" : "ws"
|
|
73
|
+
"#{ws_scheme}://#{uri.host}:#{uri.port || (uri.scheme == 'https' ? 443 : 80)}#{path}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get the API token.
|
|
77
|
+
# @api private
|
|
78
|
+
attr_reader :token
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def handle_response(response)
|
|
83
|
+
case response.status
|
|
84
|
+
when 200..299
|
|
85
|
+
response.body
|
|
86
|
+
when 401, 403
|
|
87
|
+
raise AuthError.new(extract_error_message(response))
|
|
88
|
+
when 404
|
|
89
|
+
raise APIError.new(404, extract_error_message(response))
|
|
90
|
+
else
|
|
91
|
+
raise APIError.new(response.status, extract_error_message(response), response.body)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_error_message(response)
|
|
96
|
+
if response.body.is_a?(Hash)
|
|
97
|
+
response.body["error"] || response.body["message"] || "Unknown error"
|
|
98
|
+
else
|
|
99
|
+
"Unknown error"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rexec
|
|
4
|
+
# Represents a Rexec container/sandbox.
|
|
5
|
+
class Container
|
|
6
|
+
attr_reader :id, :name, :image, :status, :created_at, :started_at, :labels, :environment
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@id = data["id"]
|
|
10
|
+
@name = data["name"]
|
|
11
|
+
@image = data["image"]
|
|
12
|
+
@status = data["status"]
|
|
13
|
+
@created_at = data["created_at"]
|
|
14
|
+
@started_at = data["started_at"]
|
|
15
|
+
@labels = data["labels"] || {}
|
|
16
|
+
@environment = data["environment"] || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def running?
|
|
20
|
+
status == "running"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def stopped?
|
|
24
|
+
status == "stopped"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Service for managing containers.
|
|
29
|
+
class ContainerService
|
|
30
|
+
def initialize(client)
|
|
31
|
+
@client = client
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# List all containers.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Container>]
|
|
37
|
+
def list
|
|
38
|
+
data = @client.request(:get, "/api/containers")
|
|
39
|
+
# API returns { "containers" => [...], "count" => N, "limit" => M }
|
|
40
|
+
items = data.is_a?(Array) ? data : (data && data["containers"]) || []
|
|
41
|
+
items.map { |c| Container.new(c) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get a container by ID.
|
|
45
|
+
#
|
|
46
|
+
# @param id [String] Container ID
|
|
47
|
+
# @return [Container]
|
|
48
|
+
def get(id)
|
|
49
|
+
data = @client.request(:get, "/api/containers/#{id}")
|
|
50
|
+
Container.new(data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create a new container.
|
|
54
|
+
#
|
|
55
|
+
# @param image [String] Docker image to use
|
|
56
|
+
# @param name [String, nil] Optional container name
|
|
57
|
+
# @param environment [Hash] Environment variables
|
|
58
|
+
# @param labels [Hash] Container labels
|
|
59
|
+
# @return [Container]
|
|
60
|
+
#
|
|
61
|
+
# @example
|
|
62
|
+
# container = client.containers.create(
|
|
63
|
+
# image: "ubuntu:24.04",
|
|
64
|
+
# name: "my-sandbox",
|
|
65
|
+
# environment: { "MY_VAR" => "value" }
|
|
66
|
+
# )
|
|
67
|
+
def create(image:, name: nil, environment: {}, labels: {})
|
|
68
|
+
body = { image: image }
|
|
69
|
+
body[:name] = name if name
|
|
70
|
+
body[:environment] = environment unless environment.empty?
|
|
71
|
+
body[:labels] = labels unless labels.empty?
|
|
72
|
+
|
|
73
|
+
data = @client.request(:post, "/api/containers", body: body)
|
|
74
|
+
Container.new(data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Delete a container.
|
|
78
|
+
#
|
|
79
|
+
# @param id [String] Container ID
|
|
80
|
+
def delete(id)
|
|
81
|
+
@client.request(:delete, "/api/containers/#{id}")
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Start a container.
|
|
86
|
+
#
|
|
87
|
+
# @param id [String] Container ID
|
|
88
|
+
def start(id)
|
|
89
|
+
@client.request(:post, "/api/containers/#{id}/start")
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Stop a container.
|
|
94
|
+
#
|
|
95
|
+
# @param id [String] Container ID
|
|
96
|
+
def stop(id)
|
|
97
|
+
@client.request(:post, "/api/containers/#{id}/stop")
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/rexec/error.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rexec
|
|
4
|
+
# Base error class for all Rexec errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# API error with status code.
|
|
8
|
+
class APIError < Error
|
|
9
|
+
attr_reader :status_code, :response_body
|
|
10
|
+
|
|
11
|
+
def initialize(status_code, message, response_body = nil)
|
|
12
|
+
@status_code = status_code
|
|
13
|
+
@response_body = response_body
|
|
14
|
+
super("API error #{status_code}: #{message}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Authentication error (401/403).
|
|
19
|
+
class AuthError < APIError
|
|
20
|
+
def initialize(message = "Authentication failed")
|
|
21
|
+
super(401, message)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resource not found (404).
|
|
26
|
+
class NotFoundError < APIError
|
|
27
|
+
attr_reader :resource, :resource_id
|
|
28
|
+
|
|
29
|
+
def initialize(resource, resource_id)
|
|
30
|
+
@resource = resource
|
|
31
|
+
@resource_id = resource_id
|
|
32
|
+
super(404, "#{resource} '#{resource_id}' not found")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Connection error.
|
|
37
|
+
class ConnectionError < Error; end
|
|
38
|
+
|
|
39
|
+
# Terminal connection closed.
|
|
40
|
+
class TerminalClosedError < Error
|
|
41
|
+
def initialize
|
|
42
|
+
super("Terminal connection closed")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Rexec
|
|
6
|
+
# Represents file metadata.
|
|
7
|
+
class FileInfo
|
|
8
|
+
attr_reader :name, :path, :size, :mode, :mod_time, :is_dir
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@name = data["name"]
|
|
12
|
+
@path = data["path"]
|
|
13
|
+
@size = data["size"] || 0
|
|
14
|
+
@mode = data["mode"]
|
|
15
|
+
@mod_time = data["mod_time"]
|
|
16
|
+
@is_dir = data["is_dir"] || false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
alias directory? is_dir
|
|
20
|
+
|
|
21
|
+
def file?
|
|
22
|
+
!is_dir
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Service for file operations in containers.
|
|
27
|
+
class FileService
|
|
28
|
+
def initialize(client)
|
|
29
|
+
@client = client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# List files in a directory.
|
|
33
|
+
#
|
|
34
|
+
# @param container_id [String] Container ID
|
|
35
|
+
# @param path [String] Directory path
|
|
36
|
+
# @return [Array<FileInfo>]
|
|
37
|
+
def list(container_id, path = "/")
|
|
38
|
+
encoded_path = URI.encode_www_form_component(path)
|
|
39
|
+
data = @client.request(:get, "/api/containers/#{container_id}/files/list?path=#{encoded_path}")
|
|
40
|
+
data.map { |f| FileInfo.new(f) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Download a file.
|
|
44
|
+
#
|
|
45
|
+
# @param container_id [String] Container ID
|
|
46
|
+
# @param path [String] File path
|
|
47
|
+
# @return [String] File contents
|
|
48
|
+
def download(container_id, path)
|
|
49
|
+
encoded_path = URI.encode_www_form_component(path)
|
|
50
|
+
@client.request_bytes(:get, "/api/containers/#{container_id}/files?path=#{encoded_path}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Create a directory.
|
|
54
|
+
#
|
|
55
|
+
# @param container_id [String] Container ID
|
|
56
|
+
# @param path [String] Directory path
|
|
57
|
+
def mkdir(container_id, path)
|
|
58
|
+
@client.request(:post, "/api/containers/#{container_id}/files/mkdir", body: { path: path })
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Delete a file or directory.
|
|
63
|
+
#
|
|
64
|
+
# @param container_id [String] Container ID
|
|
65
|
+
# @param path [String] Path to delete
|
|
66
|
+
def delete(container_id, path)
|
|
67
|
+
encoded_path = URI.encode_www_form_component(path)
|
|
68
|
+
@client.request(:delete, "/api/containers/#{container_id}/files?path=#{encoded_path}")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rexec
|
|
6
|
+
# WebSocket terminal connection.
|
|
7
|
+
class Terminal
|
|
8
|
+
attr_reader :closed
|
|
9
|
+
|
|
10
|
+
def initialize(ws)
|
|
11
|
+
@ws = ws
|
|
12
|
+
@closed = false
|
|
13
|
+
@data_handlers = []
|
|
14
|
+
@close_handlers = []
|
|
15
|
+
@error_handlers = []
|
|
16
|
+
|
|
17
|
+
setup_handlers
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Send data to the terminal.
|
|
21
|
+
#
|
|
22
|
+
# @param data [String] Data to send
|
|
23
|
+
def write(data)
|
|
24
|
+
raise TerminalClosedError if @closed
|
|
25
|
+
|
|
26
|
+
@ws.send(data)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resize the terminal.
|
|
30
|
+
#
|
|
31
|
+
# @param cols [Integer] Number of columns
|
|
32
|
+
# @param rows [Integer] Number of rows
|
|
33
|
+
def resize(cols, rows)
|
|
34
|
+
raise TerminalClosedError if @closed
|
|
35
|
+
|
|
36
|
+
msg = { type: "resize", cols: cols, rows: rows }.to_json
|
|
37
|
+
@ws.send(msg)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Register a handler for incoming data.
|
|
41
|
+
#
|
|
42
|
+
# @yield [data] Block called with each chunk of data
|
|
43
|
+
def on_data(&block)
|
|
44
|
+
@data_handlers << block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Register a handler for connection close.
|
|
48
|
+
#
|
|
49
|
+
# @yield Block called when connection closes
|
|
50
|
+
def on_close(&block)
|
|
51
|
+
@close_handlers << block
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Register a handler for errors.
|
|
55
|
+
#
|
|
56
|
+
# @yield [error] Block called with error
|
|
57
|
+
def on_error(&block)
|
|
58
|
+
@error_handlers << block
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Close the terminal connection.
|
|
62
|
+
def close
|
|
63
|
+
return if @closed
|
|
64
|
+
|
|
65
|
+
@closed = true
|
|
66
|
+
@ws.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
alias closed? closed
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def setup_handlers
|
|
74
|
+
@ws.on :message do |msg|
|
|
75
|
+
@data_handlers.each { |h| h.call(msg.data) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@ws.on :close do
|
|
79
|
+
@closed = true
|
|
80
|
+
@close_handlers.each(&:call)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@ws.on :error do |e|
|
|
84
|
+
@error_handlers.each { |h| h.call(e) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Service for terminal WebSocket connections.
|
|
90
|
+
class TerminalService
|
|
91
|
+
def initialize(client)
|
|
92
|
+
@client = client
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Connect to a container's terminal.
|
|
96
|
+
#
|
|
97
|
+
# @param container_id [String] Container ID
|
|
98
|
+
# @param cols [Integer] Terminal width (default: 80)
|
|
99
|
+
# @param rows [Integer] Terminal height (default: 24)
|
|
100
|
+
# @return [Terminal]
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# terminal = client.terminal.connect(container.id)
|
|
104
|
+
# terminal.on_data { |data| print data }
|
|
105
|
+
# terminal.write("ls -la\n")
|
|
106
|
+
def connect(container_id, cols: 80, rows: 24)
|
|
107
|
+
begin
|
|
108
|
+
require "websocket-client-simple"
|
|
109
|
+
rescue LoadError
|
|
110
|
+
raise LoadError, "websocket-client-simple is required for terminal connections (gem install websocket-client-simple)"
|
|
111
|
+
end
|
|
112
|
+
ws_url = @client.ws_url("/ws/terminal/#{container_id}")
|
|
113
|
+
|
|
114
|
+
ws = WebSocket::Client::Simple.connect(ws_url, headers: {
|
|
115
|
+
"Authorization" => "Bearer #{@client.token}"
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
terminal = Terminal.new(ws)
|
|
119
|
+
|
|
120
|
+
# Wait for connection
|
|
121
|
+
sleep 0.1 until ws.open?
|
|
122
|
+
|
|
123
|
+
# Set initial size
|
|
124
|
+
terminal.resize(cols, rows)
|
|
125
|
+
|
|
126
|
+
terminal
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pipeops-rexec
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- PipeOpsHQ
|
|
@@ -105,6 +105,12 @@ files:
|
|
|
105
105
|
- LICENSE
|
|
106
106
|
- README.md
|
|
107
107
|
- lib/rexec.rb
|
|
108
|
+
- lib/rexec/client.rb
|
|
109
|
+
- lib/rexec/container.rb
|
|
110
|
+
- lib/rexec/error.rb
|
|
111
|
+
- lib/rexec/file_service.rb
|
|
112
|
+
- lib/rexec/terminal.rb
|
|
113
|
+
- lib/rexec/version.rb
|
|
108
114
|
homepage: https://github.com/PipeOpsHQ/rexec
|
|
109
115
|
licenses:
|
|
110
116
|
- MIT
|