sprites-ruby 0.1.0 → 0.2.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/CHANGELOG.md +19 -0
- data/README.md +173 -2
- data/lib/sprites/client.rb +44 -1
- data/lib/sprites/collection.rb +9 -1
- data/lib/sprites/resources/checkpoints.rb +22 -0
- data/lib/sprites/resources/exec.rb +293 -0
- data/lib/sprites/resources/policies.rb +10 -0
- data/lib/sprites/resources/sprites.rb +44 -1
- data/lib/sprites/sprite.rb +31 -2
- data/lib/sprites/version.rb +1 -1
- data/lib/sprites.rb +1 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5da75311f037ca5acfc55a350835e258e792f06221d4f9fab135e7ba567676b0
|
|
4
|
+
data.tar.gz: c8ef0d1914539b8e618dea6aad2bf9641269d5396397950d1048a9295fc399f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd6283f8bee73c8a9adcb7a039cef763d2109e980da95fd4ec9eccd852af89a89b4b894b5299c2d76afa87edb2bbb61edbe3c4bab5d17dab8169c0d9b037d523
|
|
7
|
+
data.tar.gz: 9e96fe5d15b8f974fce96a1ced6740aa83ba6a77f78abb7e2a682668b29c03b49c4cd7b4f5f7938c1c3073b1e84e5be9b37ce44864be736d1442f3bf9a887e14
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- Fix parsing error for empty API responses
|
|
6
|
+
|
|
7
|
+
## 0.2.0
|
|
8
|
+
|
|
9
|
+
- Command execution via HTTP (`exec.create`)
|
|
10
|
+
- WebSocket exec for interactive sessions (`run`, `interactive`, `connect`)
|
|
11
|
+
- Session management (`exec.list`, `exec.attach`, `exec.kill`)
|
|
12
|
+
- `wait_until_warm` option for sprite creation
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
- Initial release
|
|
17
|
+
- Sprites CRUD operations
|
|
18
|
+
- Checkpoints (create, list, retrieve, restore)
|
|
19
|
+
- Network policies (retrieve, update)
|
data/README.md
CHANGED
|
@@ -1,13 +1,184 @@
|
|
|
1
|
-
# Sprites
|
|
1
|
+
# Sprites
|
|
2
2
|
|
|
3
|
-
Ruby client for the [Sprites](https://sprites.dev) API.
|
|
3
|
+
Ruby client for the [Sprites](https://sprites.dev) API - stateful sandbox environments.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
|
+
Add this line to your application's Gemfile:
|
|
8
|
+
|
|
7
9
|
```ruby
|
|
8
10
|
gem "sprites-ruby"
|
|
9
11
|
```
|
|
10
12
|
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
Create a client
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
client = Sprites::Client.new(token: ENV["SPRITES_TOKEN"])
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or configure globally
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Sprites.configure do |config|
|
|
25
|
+
config.token = ENV["SPRITES_TOKEN"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
client = Sprites::Client.new
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Sprites
|
|
32
|
+
|
|
33
|
+
Create a sprite
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
sprite = client.sprites.create(name: "my-sprite")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Create and wait until ready
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
sprite = client.sprites.create(name: "my-sprite", wait: true)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
List sprites
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
collection = client.sprites.list
|
|
49
|
+
collection.sprites.each { |s| puts s.name }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Get a sprite
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
sprite = client.sprites.retrieve("my-sprite")
|
|
56
|
+
sprite.status # => "warm"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Update a sprite
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
sprite = client.sprites.update("my-sprite", url_settings: { auth: "public" })
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Delete a sprite
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
client.sprites.delete("my-sprite")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Command Execution
|
|
72
|
+
|
|
73
|
+
Run a command (HTTP, simple)
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result = client.exec.create("my-sprite", command: "echo hello")
|
|
77
|
+
result[:output] # => "hello\n"
|
|
78
|
+
result[:exit_code] # => 0
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Run a command (WebSocket, streaming)
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
result = client.exec.run("my-sprite", ["ls", "-la"])
|
|
85
|
+
result.stdout
|
|
86
|
+
result.stderr
|
|
87
|
+
result.exit_code
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Interactive terminal session
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
client.exec.interactive("my-sprite", ["bash"])
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
With custom I/O
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
client.exec.interactive("my-sprite", ["bash"], input: $stdin, output: $stdout)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Sessions
|
|
103
|
+
|
|
104
|
+
List active sessions
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
sessions = client.exec.list("my-sprite")
|
|
108
|
+
sessions.each { |s| puts "#{s[:id]}: #{s[:command]}" }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Attach to a session
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
client.exec.attach("my-sprite", session_id)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Kill a session
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
client.exec.kill("my-sprite", session_id)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
With a specific signal
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
client.exec.kill("my-sprite", session_id, signal: "SIGKILL")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Checkpoints
|
|
130
|
+
|
|
131
|
+
Create a checkpoint
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
client.checkpoints.create("my-sprite", comment: "before deploy")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
List checkpoints
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
checkpoints = client.checkpoints.list("my-sprite")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Restore a checkpoint
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
client.checkpoints.restore("my-sprite", checkpoint_id)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Network Policies
|
|
150
|
+
|
|
151
|
+
Get current policy
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
policy = client.policies.retrieve("my-sprite")
|
|
155
|
+
policy[:egress][:policy] # => "allow-all"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Update policy
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
client.policies.update("my-sprite", egress: { policy: "block-all" })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Low-Level WebSocket API
|
|
165
|
+
|
|
166
|
+
For advanced use cases, use `connect` directly
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
client.exec.connect("my-sprite", command: ["bash"], tty: true) do |task, session|
|
|
170
|
+
session.on_stdout { |data| print data }
|
|
171
|
+
session.on_stderr { |data| $stderr.print data }
|
|
172
|
+
session.on_exit { |code| puts "Exit: #{code}" }
|
|
173
|
+
|
|
174
|
+
task.async do
|
|
175
|
+
while (line = $stdin.gets)
|
|
176
|
+
session.write(line)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
11
182
|
## Development
|
|
12
183
|
|
|
13
184
|
```sh
|
data/lib/sprites/client.rb
CHANGED
|
@@ -4,24 +4,63 @@ require "faraday"
|
|
|
4
4
|
require "json"
|
|
5
5
|
|
|
6
6
|
module Sprites
|
|
7
|
+
# HTTP client for the Sprites API.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# client = Sprites::Client.new(token: "your-token")
|
|
11
|
+
# sprite = client.sprites.create(name: "my-sprite")
|
|
12
|
+
#
|
|
7
13
|
class Client
|
|
14
|
+
# @return [String] the API token
|
|
15
|
+
attr_reader :token
|
|
16
|
+
|
|
17
|
+
# Create a new client.
|
|
18
|
+
#
|
|
19
|
+
# @param token [String, nil] API token (defaults to Sprites.configuration.token)
|
|
20
|
+
# @param base_url [String, nil] API base URL (defaults to https://api.sprites.dev)
|
|
8
21
|
def initialize(token: nil, base_url: nil)
|
|
9
22
|
@token = token || Sprites.configuration.token
|
|
10
23
|
@base_url = base_url || Sprites.configuration.base_url
|
|
11
24
|
end
|
|
12
25
|
|
|
26
|
+
# Access sprite operations.
|
|
27
|
+
#
|
|
28
|
+
# @return [Resources::Sprites]
|
|
13
29
|
def sprites
|
|
14
30
|
Resources::Sprites.new(self)
|
|
15
31
|
end
|
|
16
32
|
|
|
33
|
+
# Access checkpoint operations.
|
|
34
|
+
#
|
|
35
|
+
# @return [Resources::Checkpoints]
|
|
17
36
|
def checkpoints
|
|
18
37
|
Resources::Checkpoints.new(self)
|
|
19
38
|
end
|
|
20
39
|
|
|
40
|
+
# Access network policy operations.
|
|
41
|
+
#
|
|
42
|
+
# @return [Resources::Policies]
|
|
21
43
|
def policies
|
|
22
44
|
Resources::Policies.new(self)
|
|
23
45
|
end
|
|
24
46
|
|
|
47
|
+
# Access command execution operations.
|
|
48
|
+
#
|
|
49
|
+
# @return [Resources::Exec]
|
|
50
|
+
def exec
|
|
51
|
+
Resources::Exec.new(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] WebSocket URL derived from base URL
|
|
55
|
+
def websocket_url
|
|
56
|
+
@base_url.sub(/^http(s?)/) { "ws#{$1}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Array<Array<String>>] authorization headers for WebSocket connections
|
|
60
|
+
def auth_headers
|
|
61
|
+
[["authorization", "Bearer #{@token}"]]
|
|
62
|
+
end
|
|
63
|
+
|
|
25
64
|
def get(path) = handle_response(connection.get(path))
|
|
26
65
|
|
|
27
66
|
def post(path, body) = handle_response(connection.post(path, body.to_json, "Content-Type" => "application/json"))
|
|
@@ -65,7 +104,11 @@ module Sprites
|
|
|
65
104
|
raise Error, body.strip
|
|
66
105
|
end
|
|
67
106
|
|
|
68
|
-
def parse_json(json)
|
|
107
|
+
def parse_json(json)
|
|
108
|
+
return nil if json.to_s.empty?
|
|
109
|
+
|
|
110
|
+
JSON.parse(json, symbolize_names: true)
|
|
111
|
+
end
|
|
69
112
|
|
|
70
113
|
def parse_ndjson(body) = body.each_line.map { parse_json(it) }
|
|
71
114
|
|
data/lib/sprites/collection.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Sprites
|
|
4
|
+
# Paginated collection of sprites.
|
|
4
5
|
class Collection
|
|
5
|
-
|
|
6
|
+
# @return [Array<Sprite>] sprites in this page
|
|
7
|
+
attr_reader :sprites
|
|
8
|
+
|
|
9
|
+
# @return [String, nil] token for fetching the next page
|
|
10
|
+
attr_reader :next_continuation_token
|
|
6
11
|
|
|
7
12
|
def initialize(sprites:, has_more:, next_continuation_token:)
|
|
8
13
|
@sprites = sprites
|
|
@@ -10,6 +15,9 @@ module Sprites
|
|
|
10
15
|
@next_continuation_token = next_continuation_token
|
|
11
16
|
end
|
|
12
17
|
|
|
18
|
+
# Check if more pages are available.
|
|
19
|
+
#
|
|
20
|
+
# @return [Boolean]
|
|
13
21
|
def has_more?
|
|
14
22
|
@has_more && !@next_continuation_token.nil?
|
|
15
23
|
end
|
|
@@ -2,23 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
module Sprites
|
|
4
4
|
module Resources
|
|
5
|
+
# Checkpoint operations for sprite snapshots.
|
|
5
6
|
class Checkpoints
|
|
6
7
|
def initialize(client)
|
|
7
8
|
@client = client
|
|
8
9
|
end
|
|
9
10
|
|
|
11
|
+
# List all checkpoints for a sprite.
|
|
12
|
+
#
|
|
13
|
+
# @param sprite_name [String] sprite name
|
|
14
|
+
# @return [Array<Hash>] checkpoints
|
|
10
15
|
def list(sprite_name)
|
|
11
16
|
@client.get("/v1/sprites/#{sprite_name}/checkpoints")
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# Retrieve a checkpoint by ID.
|
|
20
|
+
#
|
|
21
|
+
# @param sprite_name [String] sprite name
|
|
22
|
+
# @param checkpoint_id [String] checkpoint ID
|
|
23
|
+
# @return [Hash] checkpoint details
|
|
14
24
|
def retrieve(sprite_name, checkpoint_id)
|
|
15
25
|
@client.get("/v1/sprites/#{sprite_name}/checkpoints/#{checkpoint_id}")
|
|
16
26
|
end
|
|
17
27
|
|
|
28
|
+
# Create a new checkpoint.
|
|
29
|
+
#
|
|
30
|
+
# @param sprite_name [String] sprite name
|
|
31
|
+
# @param comment [String, nil] optional comment
|
|
32
|
+
# @yield [Hash] streaming NDJSON events
|
|
33
|
+
# @return [Array<Hash>] all events if no block given
|
|
18
34
|
def create(sprite_name, comment: nil, &block)
|
|
19
35
|
@client.post_stream("/v1/sprites/#{sprite_name}/checkpoint", { comment: }, &block)
|
|
20
36
|
end
|
|
21
37
|
|
|
38
|
+
# Restore a sprite to a checkpoint.
|
|
39
|
+
#
|
|
40
|
+
# @param sprite_name [String] sprite name
|
|
41
|
+
# @param checkpoint_id [String] checkpoint ID
|
|
42
|
+
# @yield [Hash] streaming NDJSON events
|
|
43
|
+
# @return [Array<Hash>] all events if no block given
|
|
22
44
|
def restore(sprite_name, checkpoint_id, &block)
|
|
23
45
|
@client.post_stream("/v1/sprites/#{sprite_name}/checkpoints/#{checkpoint_id}/restore", {}, &block)
|
|
24
46
|
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/http/endpoint"
|
|
5
|
+
require "async/websocket/client"
|
|
6
|
+
require "io/console"
|
|
7
|
+
require "io/stream"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Sprites
|
|
11
|
+
module Resources
|
|
12
|
+
# Command execution on sprites via HTTP and WebSocket.
|
|
13
|
+
class Exec
|
|
14
|
+
STREAM_STDIN = 0
|
|
15
|
+
STREAM_STDOUT = 1
|
|
16
|
+
STREAM_STDERR = 2
|
|
17
|
+
STREAM_EXIT = 3
|
|
18
|
+
STREAM_STDIN_EOF = 4
|
|
19
|
+
|
|
20
|
+
def initialize(client)
|
|
21
|
+
@client = client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Execute a command via HTTP POST (non-streaming).
|
|
25
|
+
#
|
|
26
|
+
# @param sprite_name [String] sprite name
|
|
27
|
+
# @param command [String] command to execute
|
|
28
|
+
# @return [Hash] result with :exit_code and :output
|
|
29
|
+
def create(sprite_name, command:)
|
|
30
|
+
@client.post("/v1/sprites/#{sprite_name}/exec", { command: })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# List active exec sessions.
|
|
34
|
+
#
|
|
35
|
+
# @param sprite_name [String] sprite name
|
|
36
|
+
# @return [Array<Hash>] sessions with :id, :command, :tty, :is_active, etc.
|
|
37
|
+
def list(sprite_name)
|
|
38
|
+
@client.get("/v1/sprites/#{sprite_name}/exec")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run a command via WebSocket and return the result (blocking).
|
|
42
|
+
#
|
|
43
|
+
# @param sprite_name [String] sprite name
|
|
44
|
+
# @param command [Array<String>] command and arguments
|
|
45
|
+
# @param options [Hash] WebSocket options (:cols, :rows, :path)
|
|
46
|
+
# @return [Result] with stdout, stderr, and exit_code
|
|
47
|
+
def run(sprite_name, command, **options)
|
|
48
|
+
stdout = +""
|
|
49
|
+
stderr = +""
|
|
50
|
+
exit_code = nil
|
|
51
|
+
|
|
52
|
+
connect(sprite_name, command: command, **options) do |_task, session|
|
|
53
|
+
session.on_stdout { |data| stdout << data }
|
|
54
|
+
session.on_stderr { |data| stderr << data }
|
|
55
|
+
session.on_exit { |code| exit_code = code }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Result.new(stdout: stdout, stderr: stderr, exit_code: exit_code)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Start an interactive terminal session wired to stdin/stdout.
|
|
62
|
+
#
|
|
63
|
+
# @param sprite_name [String] sprite name
|
|
64
|
+
# @param command [Array<String>] command and arguments
|
|
65
|
+
# @param input [IO] input stream (default: $stdin)
|
|
66
|
+
# @param output [IO] output stream (default: $stdout)
|
|
67
|
+
# @param options [Hash] WebSocket options (:cols, :rows)
|
|
68
|
+
def interactive(sprite_name, command, input: $stdin, output: $stdout, **options)
|
|
69
|
+
run_interactive(input: input, output: output) do |block|
|
|
70
|
+
connect(sprite_name, command: command, tty: true, **options, &block)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Attach to an existing exec session.
|
|
75
|
+
#
|
|
76
|
+
# @param sprite_name [String] sprite name
|
|
77
|
+
# @param session_id [Integer] session ID from #list
|
|
78
|
+
# @param input [IO] input stream (default: $stdin)
|
|
79
|
+
# @param output [IO] output stream (default: $stdout)
|
|
80
|
+
def attach(sprite_name, session_id, input: $stdin, output: $stdout)
|
|
81
|
+
run_interactive(input: input, output: output) do |block|
|
|
82
|
+
connect(sprite_name, session_id: session_id, tty: true, &block)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Kill an exec session.
|
|
87
|
+
#
|
|
88
|
+
# @param sprite_name [String] sprite name
|
|
89
|
+
# @param session_id [Integer] session ID from #list
|
|
90
|
+
# @param signal [String, nil] signal to send (default: SIGTERM)
|
|
91
|
+
# @param timeout [String, nil] timeout waiting for exit (default: 10s)
|
|
92
|
+
# @yield [Hash] streaming NDJSON events (signal, exited, complete)
|
|
93
|
+
# @return [Array<Hash>] all events if no block given
|
|
94
|
+
def kill(sprite_name, session_id, signal: nil, timeout: nil, &block)
|
|
95
|
+
body = {}
|
|
96
|
+
body[:signal] = signal if signal
|
|
97
|
+
body[:timeout] = timeout if timeout
|
|
98
|
+
@client.post_stream("/v1/sprites/#{sprite_name}/exec/#{session_id}/kill", body, &block)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Connect to a WebSocket exec session.
|
|
102
|
+
#
|
|
103
|
+
# Yields the Async task and session for concurrent I/O.
|
|
104
|
+
#
|
|
105
|
+
# @param sprite_name [String] sprite name
|
|
106
|
+
# @param command [Array<String>, nil] command and arguments
|
|
107
|
+
# @param tty [Boolean] enable TTY mode (default: false)
|
|
108
|
+
# @param options [Hash] WebSocket options (:session_id, :cols, :rows, :path, :stdin)
|
|
109
|
+
# @yield [Async::Task, Session] task and session for callbacks
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# client.exec.connect(sprite.name, command: ["bash"], tty: true) do |task, session|
|
|
113
|
+
# session.on_stdout { |data| print data }
|
|
114
|
+
# task.async do
|
|
115
|
+
# while (line = $stdin.gets)
|
|
116
|
+
# session.write(line)
|
|
117
|
+
# end
|
|
118
|
+
# end
|
|
119
|
+
# end
|
|
120
|
+
def connect(sprite_name, command: nil, tty: false, **options, &block)
|
|
121
|
+
url = build_websocket_url(sprite_name, command, tty: tty, **options)
|
|
122
|
+
endpoint = Async::HTTP::Endpoint.parse(url, alpn_protocols: ["http/1.1"])
|
|
123
|
+
headers = Protocol::HTTP::Headers.new
|
|
124
|
+
headers["authorization"] = "Bearer #{@client.token}"
|
|
125
|
+
|
|
126
|
+
Async do |task|
|
|
127
|
+
Async::WebSocket::Client.connect(endpoint, headers: headers) do |connection|
|
|
128
|
+
session = Session.new(connection, tty: tty)
|
|
129
|
+
|
|
130
|
+
# Run user's block and read loop concurrently
|
|
131
|
+
task.async { block.call(task, session) }
|
|
132
|
+
session.read_loop
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def run_interactive(input:, output:)
|
|
140
|
+
input.raw!
|
|
141
|
+
stdin = IO::Stream::Buffered.wrap(input)
|
|
142
|
+
|
|
143
|
+
yield proc { |task, session|
|
|
144
|
+
session.on_stdout do |data|
|
|
145
|
+
output.write(data)
|
|
146
|
+
output.flush
|
|
147
|
+
end
|
|
148
|
+
session.on_stderr do |data|
|
|
149
|
+
output.write(data)
|
|
150
|
+
output.flush
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
input_task = task.async do
|
|
154
|
+
while (char = stdin.read(1))
|
|
155
|
+
session.write(char)
|
|
156
|
+
end
|
|
157
|
+
rescue IOError
|
|
158
|
+
# Connection closed
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
session.on_exit { |_code| input_task.stop }
|
|
162
|
+
}
|
|
163
|
+
ensure
|
|
164
|
+
input.cooked!
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_websocket_url(sprite_name, command, tty:, session_id: nil, **options)
|
|
168
|
+
base = "#{@client.websocket_url}/v1/sprites/#{sprite_name}/exec"
|
|
169
|
+
base = "#{base}/#{session_id}" if session_id
|
|
170
|
+
|
|
171
|
+
params = []
|
|
172
|
+
Array(command).each { |arg| params << ["cmd", arg] } unless session_id
|
|
173
|
+
params << ["tty", "true"] if tty
|
|
174
|
+
params << ["stdin", "true"] if options.fetch(:stdin, true)
|
|
175
|
+
params << ["cols", options[:cols].to_s] if options[:cols]
|
|
176
|
+
params << ["rows", options[:rows].to_s] if options[:rows]
|
|
177
|
+
params << ["path", options[:path]] if options[:path]
|
|
178
|
+
|
|
179
|
+
query = params.empty? ? "" : "?#{URI.encode_www_form(params)}"
|
|
180
|
+
"#{base}#{query}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Result of a blocking command execution.
|
|
184
|
+
# @!attribute [r] stdout
|
|
185
|
+
# @return [String] standard output
|
|
186
|
+
# @!attribute [r] stderr
|
|
187
|
+
# @return [String] standard error
|
|
188
|
+
# @!attribute [r] exit_code
|
|
189
|
+
# @return [Integer] process exit code
|
|
190
|
+
Result = Data.define(:stdout, :stderr, :exit_code)
|
|
191
|
+
|
|
192
|
+
# WebSocket session for interactive command execution.
|
|
193
|
+
class Session
|
|
194
|
+
def initialize(connection, tty: false)
|
|
195
|
+
@connection = connection
|
|
196
|
+
@tty = tty
|
|
197
|
+
@callbacks = { stdout: [], stderr: [], exit: [] }
|
|
198
|
+
@exit_code = nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [Integer, nil] exit code once process exits
|
|
202
|
+
attr_reader :exit_code
|
|
203
|
+
|
|
204
|
+
# Register a callback for stdout data.
|
|
205
|
+
# @yield [String] stdout data
|
|
206
|
+
def on_stdout(&block) = @callbacks[:stdout] << block
|
|
207
|
+
|
|
208
|
+
# Register a callback for stderr data.
|
|
209
|
+
# @yield [String] stderr data
|
|
210
|
+
def on_stderr(&block) = @callbacks[:stderr] << block
|
|
211
|
+
|
|
212
|
+
# Register a callback for process exit.
|
|
213
|
+
# @yield [Integer] exit code
|
|
214
|
+
def on_exit(&block) = @callbacks[:exit] << block
|
|
215
|
+
|
|
216
|
+
# Write data to stdin.
|
|
217
|
+
# @param data [String] data to write
|
|
218
|
+
def write(data)
|
|
219
|
+
if @tty
|
|
220
|
+
@connection.write(Protocol::WebSocket::BinaryMessage.new(data))
|
|
221
|
+
else
|
|
222
|
+
message = [STREAM_STDIN].pack("C") + data
|
|
223
|
+
@connection.write(Protocol::WebSocket::BinaryMessage.new(message))
|
|
224
|
+
end
|
|
225
|
+
@connection.flush
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Signal end of stdin (non-TTY mode only).
|
|
229
|
+
def send_eof
|
|
230
|
+
return if @tty
|
|
231
|
+
|
|
232
|
+
message = [STREAM_STDIN_EOF].pack("C")
|
|
233
|
+
@connection.write(Protocol::WebSocket::BinaryMessage.new(message))
|
|
234
|
+
@connection.flush
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Close the WebSocket connection.
|
|
238
|
+
def close
|
|
239
|
+
@connection.close
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def read_loop
|
|
243
|
+
loop do
|
|
244
|
+
message = @connection.read
|
|
245
|
+
break unless message
|
|
246
|
+
|
|
247
|
+
handle_message(message)
|
|
248
|
+
break if @exit_code
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
def handle_message(message)
|
|
255
|
+
data = message.respond_to?(:buffer) ? message.buffer : message.to_s
|
|
256
|
+
|
|
257
|
+
if data.start_with?("{")
|
|
258
|
+
handle_json_message(JSON.parse(data, symbolize_names: true))
|
|
259
|
+
elsif @tty
|
|
260
|
+
@callbacks[:stdout].each { |cb| cb.call(data) }
|
|
261
|
+
else
|
|
262
|
+
handle_binary_message(data)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def handle_json_message(parsed)
|
|
267
|
+
case parsed[:type]
|
|
268
|
+
when "exit"
|
|
269
|
+
@exit_code = parsed[:exit_code]
|
|
270
|
+
@callbacks[:exit].each { |cb| cb.call(@exit_code) }
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def handle_binary_message(data)
|
|
275
|
+
return if data.nil? || data.empty?
|
|
276
|
+
|
|
277
|
+
stream_id = data.bytes[0]
|
|
278
|
+
payload = data[1..]
|
|
279
|
+
|
|
280
|
+
case stream_id
|
|
281
|
+
when STREAM_STDOUT
|
|
282
|
+
@callbacks[:stdout].each { |cb| cb.call(payload) }
|
|
283
|
+
when STREAM_STDERR
|
|
284
|
+
@callbacks[:stderr].each { |cb| cb.call(payload) }
|
|
285
|
+
when STREAM_EXIT
|
|
286
|
+
@exit_code = payload.unpack1("C")
|
|
287
|
+
@callbacks[:exit].each { |cb| cb.call(@exit_code) }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -2,15 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module Sprites
|
|
4
4
|
module Resources
|
|
5
|
+
# Network policy operations.
|
|
5
6
|
class Policies
|
|
6
7
|
def initialize(client)
|
|
7
8
|
@client = client
|
|
8
9
|
end
|
|
9
10
|
|
|
11
|
+
# Get current network policy for a sprite.
|
|
12
|
+
#
|
|
13
|
+
# @param sprite_name [String] sprite name
|
|
14
|
+
# @return [Hash] policy with :egress settings
|
|
10
15
|
def retrieve(sprite_name)
|
|
11
16
|
@client.get("/v1/sprites/#{sprite_name}/policies")
|
|
12
17
|
end
|
|
13
18
|
|
|
19
|
+
# Update network policy for a sprite.
|
|
20
|
+
#
|
|
21
|
+
# @param sprite_name [String] sprite name
|
|
22
|
+
# @param attrs [Hash] policy attributes (:egress)
|
|
23
|
+
# @return [Hash] updated policy
|
|
14
24
|
def update(sprite_name, **attrs)
|
|
15
25
|
@client.post("/v1/sprites/#{sprite_name}/policies", attrs)
|
|
16
26
|
end
|
|
@@ -2,23 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
module Sprites
|
|
4
4
|
module Resources
|
|
5
|
+
# Sprite CRUD operations.
|
|
5
6
|
class Sprites
|
|
6
7
|
def initialize(client)
|
|
7
8
|
@client = client
|
|
8
9
|
end
|
|
9
10
|
|
|
11
|
+
# List all sprites.
|
|
12
|
+
#
|
|
13
|
+
# @return [Collection] paginated collection of sprites
|
|
10
14
|
def list
|
|
11
15
|
@client.get("/v1/sprites") => { sprites:, has_more:, next_continuation_token: }
|
|
12
16
|
sprites = sprites.map(&Sprite)
|
|
13
17
|
Collection.new(sprites:, has_more:, next_continuation_token:)
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# Retrieve a sprite by name.
|
|
21
|
+
#
|
|
22
|
+
# @param name [String] sprite name
|
|
23
|
+
# @return [Sprite]
|
|
16
24
|
def retrieve(name) = @client.get("/v1/sprites/#{name}").then(&Sprite)
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
# Create a new sprite.
|
|
27
|
+
#
|
|
28
|
+
# @param name [String] sprite name
|
|
29
|
+
# @param wait [Boolean] block until sprite is warm (default: false)
|
|
30
|
+
# @param timeout [Integer] seconds to wait for warm status (default: 60)
|
|
31
|
+
# @return [Sprite]
|
|
32
|
+
def create(name:, wait: false, timeout: 60)
|
|
33
|
+
sprite = @client.post("/v1/sprites", { name: }).then(&Sprite)
|
|
34
|
+
wait ? wait_until_warm(sprite.name, timeout:) : sprite
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Poll until a sprite becomes warm.
|
|
38
|
+
#
|
|
39
|
+
# @param name [String] sprite name
|
|
40
|
+
# @param timeout [Integer] seconds to wait (default: 60)
|
|
41
|
+
# @return [Sprite]
|
|
42
|
+
# @raise [Error] if timeout is reached
|
|
43
|
+
def wait_until_warm(name, timeout: 60)
|
|
44
|
+
deadline = Time.now + timeout
|
|
45
|
+
loop do
|
|
46
|
+
sprite = retrieve(name)
|
|
47
|
+
return sprite if sprite.status == "warm"
|
|
48
|
+
raise Error, "Timed out waiting for sprite to become warm" if Time.now > deadline
|
|
49
|
+
|
|
50
|
+
sleep 0.5
|
|
51
|
+
end
|
|
52
|
+
end
|
|
19
53
|
|
|
54
|
+
# Update a sprite.
|
|
55
|
+
#
|
|
56
|
+
# @param name [String] sprite name
|
|
57
|
+
# @param attrs [Hash] attributes to update
|
|
58
|
+
# @return [Sprite]
|
|
20
59
|
def update(name, **attrs) = @client.put("/v1/sprites/#{name}", attrs).then(&Sprite)
|
|
21
60
|
|
|
61
|
+
# Delete a sprite.
|
|
62
|
+
#
|
|
63
|
+
# @param name [String] sprite name
|
|
64
|
+
# @return [nil]
|
|
22
65
|
def delete(name) = @client.delete("/v1/sprites/#{name}")
|
|
23
66
|
end
|
|
24
67
|
end
|
data/lib/sprites/sprite.rb
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Sprites
|
|
4
|
+
# A sprite instance.
|
|
4
5
|
class Sprite
|
|
6
|
+
# @api private
|
|
5
7
|
def self.to_proc = ->(attrs) { new(**attrs) }
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
# @return [String] unique sprite ID
|
|
10
|
+
attr_reader :id
|
|
11
|
+
|
|
12
|
+
# @return [String] sprite name
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
# @return [String] status ("cold", "warm")
|
|
16
|
+
attr_reader :status
|
|
17
|
+
|
|
18
|
+
# @return [String, nil] sprite version
|
|
19
|
+
attr_reader :version
|
|
20
|
+
|
|
21
|
+
# @return [String] public URL for the sprite
|
|
22
|
+
attr_reader :url
|
|
23
|
+
|
|
24
|
+
# @return [String] creation timestamp
|
|
25
|
+
attr_reader :created_at
|
|
26
|
+
|
|
27
|
+
# @return [String] last update timestamp
|
|
28
|
+
attr_reader :updated_at
|
|
29
|
+
|
|
30
|
+
# @return [String] organization slug
|
|
31
|
+
attr_reader :organization
|
|
32
|
+
|
|
33
|
+
# @return [Hash] URL authentication settings
|
|
34
|
+
attr_reader :url_settings
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] environment version
|
|
37
|
+
attr_reader :environment_version
|
|
9
38
|
|
|
10
39
|
def initialize(id:, name:, status:, version:, url:, created_at:, updated_at:,
|
|
11
40
|
organization:, url_settings:, environment_version:)
|
data/lib/sprites/version.rb
CHANGED
data/lib/sprites.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "sprites/sprite"
|
|
|
7
7
|
require_relative "sprites/resources/sprites"
|
|
8
8
|
require_relative "sprites/resources/checkpoints"
|
|
9
9
|
require_relative "sprites/resources/policies"
|
|
10
|
+
require_relative "sprites/resources/exec"
|
|
10
11
|
require_relative "sprites/client"
|
|
11
12
|
|
|
12
13
|
module Sprites
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sprites-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ara Hacopian
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async-websocket
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.30'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.30'
|
|
26
40
|
description: Ruby client for the Sprites API - stateful sandbox environments
|
|
27
41
|
email:
|
|
28
42
|
- ara@hacopian.de
|
|
@@ -30,6 +44,7 @@ executables: []
|
|
|
30
44
|
extensions: []
|
|
31
45
|
extra_rdoc_files: []
|
|
32
46
|
files:
|
|
47
|
+
- CHANGELOG.md
|
|
33
48
|
- LICENSE
|
|
34
49
|
- README.md
|
|
35
50
|
- lib/sprites.rb
|
|
@@ -37,6 +52,7 @@ files:
|
|
|
37
52
|
- lib/sprites/collection.rb
|
|
38
53
|
- lib/sprites/configuration.rb
|
|
39
54
|
- lib/sprites/resources/checkpoints.rb
|
|
55
|
+
- lib/sprites/resources/exec.rb
|
|
40
56
|
- lib/sprites/resources/policies.rb
|
|
41
57
|
- lib/sprites/resources/sprites.rb
|
|
42
58
|
- lib/sprites/sprite.rb
|