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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: daf648a63c80b783c7701b1543ba59903618e531b5560a484b6274d3dde81bb1
4
- data.tar.gz: d02a328b6ace511f14c478d3928f79fd5be132624c9b23ee649c9e77f2e3b0ef
3
+ metadata.gz: fa120cedebd032e6487ad85157b0a7c98721af37fbd656d3b61c7cd1d521af0c
4
+ data.tar.gz: 3dc1adfaddc9e08ca5579d151e4d61b1d6c4cf98aef4cab25e347728be4980e4
5
5
  SHA512:
6
- metadata.gz: d5e4289f26441de35cc62b9bbd10a354a4ed5b70ef541e90eab7fc9292402986a0e0e99fbc5f34220d4721a08fa1020f41d263f2c97b4b3bd338aac96b7ad91d
7
- data.tar.gz: 892d324fd210063705b9722cff702c7680d4b972f6e8529ccbe27c4a84ae9f66cb6ce5acf1e21bc7769ff19ac9adc46fea1d6f0892589ae631dd69a99b0a2410
6
+ metadata.gz: f48cc1ddd4444fdb469dfb0d111a3a4af1e55ad36168b1003598c94695831eb89f08c1d50903afb81683956bc0109602db49f2421eeae28bfcd84e960d7562fb
7
+ data.tar.gz: 7fa9c5990e393c28900d04978f4ce66b9cd52458d51a7598de769114b16f9fbe5df9fa3fdaec1e48f02d311593fcf96b8b786274b8d9296a417edf68e37e4f2a
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rexec
4
+ VERSION = "1.0.1"
5
+ 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.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