workspaces 0.1.0
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 +7 -0
- data/DESIGN.md +67 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/exe/workspaces +5 -0
- data/lib/workspaces/backend.rb +108 -0
- data/lib/workspaces/cli.rb +189 -0
- data/lib/workspaces/command_runner.rb +49 -0
- data/lib/workspaces/config.rb +116 -0
- data/lib/workspaces/coordinator.rb +203 -0
- data/lib/workspaces/dashboard/app.js +132 -0
- data/lib/workspaces/dashboard/checkout_authentication_error.html.erb +22 -0
- data/lib/workspaces/dashboard/environment.js +120 -0
- data/lib/workspaces/dashboard/pull_request.html.erb +37 -0
- data/lib/workspaces/dashboard/styles.css +461 -0
- data/lib/workspaces/dashboard/template.html.erb +82 -0
- data/lib/workspaces/dashboard_view.rb +96 -0
- data/lib/workspaces/environment_overrides.rb +177 -0
- data/lib/workspaces/front_door.rb +275 -0
- data/lib/workspaces/paths.rb +63 -0
- data/lib/workspaces/port_allocator.rb +35 -0
- data/lib/workspaces/presentation.rb +68 -0
- data/lib/workspaces/recipe.rb +135 -0
- data/lib/workspaces/registry.rb +55 -0
- data/lib/workspaces/state_store.rb +84 -0
- data/lib/workspaces/tee_log.rb +39 -0
- data/lib/workspaces/tls.rb +112 -0
- data/lib/workspaces/version.rb +3 -0
- data/lib/workspaces/workflow.rb +78 -0
- data/lib/workspaces/worktree.rb +218 -0
- data/lib/workspaces.rb +5 -0
- metadata +121 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require_relative 'config'
|
|
3
|
+
|
|
4
|
+
module Workspaces
|
|
5
|
+
class Recipe
|
|
6
|
+
class Invalid < ArgumentError; end
|
|
7
|
+
RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
|
|
8
|
+
WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
|
|
9
|
+
attr_reader :steps, :environment
|
|
10
|
+
|
|
11
|
+
def initialize(root: Config.repo_root)
|
|
12
|
+
@root = root
|
|
13
|
+
document = YAML.safe_load(root.join('.workspaces.yml').read, aliases: false)
|
|
14
|
+
validate_document!(document)
|
|
15
|
+
@environment = validate_env!(document.fetch('env', {}))
|
|
16
|
+
@steps = document.fetch('steps').map { |step| validate_step!(step) }
|
|
17
|
+
validate_sequence!
|
|
18
|
+
rescue Errno::ENOENT
|
|
19
|
+
raise Invalid, 'Add .workspaces.yml to the source repository before starting a workspace'
|
|
20
|
+
rescue Psych::Exception => e
|
|
21
|
+
raise Invalid, "Invalid .workspaces.yml: #{e.message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def for_operation(restart: false)
|
|
25
|
+
restart ? steps.drop(steps.index { |step| step['background'] }) : steps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def command(step, context)
|
|
29
|
+
command = step.fetch('run')
|
|
30
|
+
return ['sh', '-c', command] if command.is_a?(String)
|
|
31
|
+
|
|
32
|
+
command.map { |argument| expand(argument, context) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def env(step, context, overrides: {})
|
|
36
|
+
expanded = environment.merge(step.fetch('env', {})).transform_values do |value|
|
|
37
|
+
value.nil? ? nil : expand(value, context)
|
|
38
|
+
end
|
|
39
|
+
expanded.merge(overrides).merge(context)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def validate_document!(document)
|
|
45
|
+
unless document.is_a?(Hash) && document['version'] == 1
|
|
46
|
+
raise Invalid,
|
|
47
|
+
'Configuration must be a mapping with version: 1'
|
|
48
|
+
end
|
|
49
|
+
unknown_keys!(document, %w(version env steps))
|
|
50
|
+
raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_step!(step)
|
|
54
|
+
raise Invalid, 'Each step must be a mapping' unless step.is_a?(Hash)
|
|
55
|
+
unknown_keys!(step, %w(id name run env timeout background))
|
|
56
|
+
validate_step_identity!(step)
|
|
57
|
+
validate_command!(step['run'])
|
|
58
|
+
validate_background!(step)
|
|
59
|
+
timeout = step.fetch('timeout', 600)
|
|
60
|
+
unless timeout.is_a?(Integer) && timeout.positive?
|
|
61
|
+
raise Invalid,
|
|
62
|
+
'Step timeout must be a positive integer (seconds)'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
step.merge('timeout' => timeout, 'env' => validate_env!(step.fetch('env', {})))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_step_identity!(step)
|
|
69
|
+
unless step['id'].is_a?(String) && step['id'].match?(/\A[a-z][a-z0-9_-]*\z/)
|
|
70
|
+
raise Invalid,
|
|
71
|
+
'Step id must be a lowercase slug'
|
|
72
|
+
end
|
|
73
|
+
return if step['name'].is_a?(String) && !step['name'].strip.empty?
|
|
74
|
+
raise Invalid,
|
|
75
|
+
'Step name must be a nonempty string'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_command!(command)
|
|
79
|
+
valid = command.is_a?(String) ? (!command.strip.empty? && valid_argument?(command)) : valid_argv?(command)
|
|
80
|
+
raise Invalid, 'run must be a nonempty shell string or an array of string arguments' unless valid
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def valid_argv?(command)
|
|
84
|
+
command.is_a?(Array) && !command.empty? && command.first != '' && command.all? { |arg| valid_argument?(arg) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def valid_argument?(argument)
|
|
88
|
+
argument.is_a?(String) && argument.index("\0").nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_background!(step)
|
|
92
|
+
return unless step.key?('background')
|
|
93
|
+
raise Invalid, 'background must be true or false' unless [true, false].include?(step['background'])
|
|
94
|
+
return unless step['background'] && step.key?('timeout')
|
|
95
|
+
raise Invalid,
|
|
96
|
+
'timeout applies to foreground steps, not the server lifetime'
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_env!(values)
|
|
100
|
+
raise Invalid, 'env must be a mapping' unless values.is_a?(Hash)
|
|
101
|
+
values.each do |key, value|
|
|
102
|
+
validate_env_name!(key)
|
|
103
|
+
raise Invalid, "#{key} is supplied by the workspace runner" if RESERVED_ENV.include?(key)
|
|
104
|
+
raise Invalid, 'Environment values must be strings or null' unless value.nil? || value.is_a?(String)
|
|
105
|
+
end
|
|
106
|
+
values
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_env_name!(key)
|
|
110
|
+
return if key.is_a?(String) && key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
111
|
+
|
|
112
|
+
raise Invalid, 'Environment names must be strings'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_sequence!
|
|
116
|
+
raise Invalid, 'Step IDs must be unique' unless steps.uniq { |step| step['id'] }.length == steps.length
|
|
117
|
+
unless steps.one? { |step| step['background'] }
|
|
118
|
+
raise Invalid, 'Configure exactly one background run step for the application'
|
|
119
|
+
end
|
|
120
|
+
raise Invalid, 'Add a foreground readiness command after the background step' if steps.last['background']
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def unknown_keys!(mapping, allowed)
|
|
124
|
+
unknown = mapping.keys - allowed
|
|
125
|
+
raise Invalid, "Unknown configuration keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def expand(value, context)
|
|
129
|
+
value.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) do
|
|
130
|
+
name = Regexp.last_match(1)
|
|
131
|
+
context.fetch(name) { ENV.fetch(name) { raise Invalid, "Missing environment variable: #{name}" } }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative 'worktree'
|
|
2
|
+
require_relative 'state_store'
|
|
3
|
+
|
|
4
|
+
module Workspaces
|
|
5
|
+
class Registry
|
|
6
|
+
def find(id)
|
|
7
|
+
Worktree.new(id).require_owned!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list(**filters)
|
|
11
|
+
root = Config.worktrees_dir(create: false)
|
|
12
|
+
return [] unless root.directory?
|
|
13
|
+
|
|
14
|
+
root.children.sort.filter_map do |directory|
|
|
15
|
+
describe_directory(directory, filters[:pr])
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(**options)
|
|
20
|
+
workspace = Worktree.create(**options)
|
|
21
|
+
StateStore.new(Config.state_file).set(workspace.id, 'status' => 'idle', 'related_pr' => workspace.related_pr)
|
|
22
|
+
workspace
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_lock(id)
|
|
26
|
+
workspace = find(id)
|
|
27
|
+
Config.locks_dir.mkpath
|
|
28
|
+
File.open(Config.prepare_lock_path(id), File::RDWR | File::CREAT, 0o600) do |lock|
|
|
29
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
'Workspace is busy preparing, restarting, or running a command'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
yield workspace
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def describe_directory(directory, number)
|
|
41
|
+
return unless managed_directory?(directory)
|
|
42
|
+
return unless directory.basename.to_s.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/)
|
|
43
|
+
|
|
44
|
+
workspace = Worktree.new(directory.basename.to_s)
|
|
45
|
+
return unless workspace.exists?
|
|
46
|
+
return if number && workspace.related_pr&.fetch('number', nil).to_s != number.to_s
|
|
47
|
+
|
|
48
|
+
workspace.describe
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def managed_directory?(directory)
|
|
52
|
+
directory.directory? && !directory.symlink?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Workspaces
|
|
7
|
+
# Tiny JSON-file-backed key/value store, keyed by PR number (as a string), guarded by an
|
|
8
|
+
# exclusive flock so the front door and CLI commands can safely read/modify it concurrently.
|
|
9
|
+
class StateStore
|
|
10
|
+
def initialize(path)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Yields the full state hash (String pr_number => attrs Hash) inside an exclusive lock and
|
|
15
|
+
# persists whatever the block leaves it as. Returns the block's return value.
|
|
16
|
+
def transaction
|
|
17
|
+
with_lock(File::LOCK_EX, create: true) do |file|
|
|
18
|
+
data = read_data(file)
|
|
19
|
+
result = yield(data)
|
|
20
|
+
write_data(file, data)
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def all
|
|
26
|
+
with_lock(File::LOCK_SH, default: {}) { |file| deep_copy(read_data(file)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(pr_number)
|
|
30
|
+
with_lock(File::LOCK_SH, default: nil) { |file| deep_copy(read_data(file)[pr_number.to_s]) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set(pr_number, attrs)
|
|
34
|
+
transaction { |data| data[pr_number.to_s] = (data[pr_number.to_s] || {}).merge(attrs) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(pr_number)
|
|
38
|
+
transaction { |data| data.delete(pr_number.to_s) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :path
|
|
44
|
+
|
|
45
|
+
def with_lock(lock_type, create: false, default: nil)
|
|
46
|
+
ensure_parent_dir! if create
|
|
47
|
+
mode = create ? (File::RDWR | File::CREAT) : File::RDONLY
|
|
48
|
+
File.open(path, mode) do |file|
|
|
49
|
+
file.flock(lock_type)
|
|
50
|
+
yield(file)
|
|
51
|
+
end
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
default
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ensure_parent_dir!
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_data(file)
|
|
61
|
+
file.rewind
|
|
62
|
+
raw = file.read
|
|
63
|
+
return {} if raw.to_s.strip.empty?
|
|
64
|
+
|
|
65
|
+
parsed = JSON.parse(raw)
|
|
66
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_data(file, data)
|
|
72
|
+
file.rewind
|
|
73
|
+
file.truncate(0)
|
|
74
|
+
file.write(JSON.pretty_generate(data))
|
|
75
|
+
file.flush
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def deep_copy(value)
|
|
79
|
+
return nil if value.nil?
|
|
80
|
+
|
|
81
|
+
JSON.parse(JSON.generate(value))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'environment_overrides'
|
|
4
|
+
|
|
5
|
+
module Workspaces
|
|
6
|
+
# Duplicates every log line to both the persistent per-PR log file AND the front door
|
|
7
|
+
# process's own stdout - so progress during a (sometimes 1-2 minute) first-time prepare shows
|
|
8
|
+
# up live in the terminal running `bin/pr-preview`, not just in a file nobody's tailing.
|
|
9
|
+
# Prefixed with the PR number since multiple previews can be preparing concurrently.
|
|
10
|
+
class TeeLog
|
|
11
|
+
def initialize(workspace_id, file)
|
|
12
|
+
@workspace_id = workspace_id
|
|
13
|
+
@file = file
|
|
14
|
+
@overrides = EnvironmentOverrides.new(workspace_id)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def puts(message = '')
|
|
18
|
+
scrubbed = scrub(message)
|
|
19
|
+
@file.puts(scrubbed)
|
|
20
|
+
$stdout.puts(prefixed(scrubbed))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def print(message)
|
|
24
|
+
scrubbed = scrub(message)
|
|
25
|
+
@file.print(scrubbed)
|
|
26
|
+
$stdout.print(prefixed(scrubbed))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def scrub(message)
|
|
32
|
+
@overrides.scrub(message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def prefixed(message)
|
|
36
|
+
"[Workspace #{@workspace_id}] #{message}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'config'
|
|
6
|
+
|
|
7
|
+
module Workspaces
|
|
8
|
+
class Tls
|
|
9
|
+
DEFAULT_CERT_PATH = '~/.workspaces/certs/workspaces.pem'
|
|
10
|
+
DEFAULT_KEY_PATH = '~/.workspaces/certs/workspaces-key.pem'
|
|
11
|
+
DEFAULT_PUBLIC_ORIGIN_HOST = 'workspaces.localhost'
|
|
12
|
+
|
|
13
|
+
attr_reader :cert_path, :key_path, :shortcut
|
|
14
|
+
|
|
15
|
+
def self.from_serve_options(options)
|
|
16
|
+
cert = options[:tls_cert]
|
|
17
|
+
key = options[:tls_key]
|
|
18
|
+
raise ArgumentError, '--tls-cert and --tls-key must be provided together' if cert.nil? ^ key.nil?
|
|
19
|
+
return nil unless options[:secure] || cert
|
|
20
|
+
|
|
21
|
+
new(tls_cert: cert || default_cert_path, tls_key: key || default_key_path,
|
|
22
|
+
shortcut: cert.nil?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.default_cert_path
|
|
26
|
+
File.expand_path(DEFAULT_CERT_PATH)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.default_key_path
|
|
30
|
+
File.expand_path(DEFAULT_KEY_PATH)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(tls_cert:, tls_key:, shortcut: false)
|
|
34
|
+
@cert_path = File.expand_path(tls_cert.to_s)
|
|
35
|
+
@key_path = File.expand_path(tls_key.to_s)
|
|
36
|
+
@shortcut = shortcut
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate!
|
|
40
|
+
validate_public_origin!
|
|
41
|
+
validate_file!(:certificate, cert_path)
|
|
42
|
+
validate_file!(:private_key, key_path)
|
|
43
|
+
|
|
44
|
+
certificate = OpenSSL::X509::Certificate.new(File.read(cert_path))
|
|
45
|
+
private_key = OpenSSL::PKey.read(File.read(key_path))
|
|
46
|
+
|
|
47
|
+
unless certificate.check_private_key(private_key)
|
|
48
|
+
raise ArgumentError, 'TLS certificate does not match the TLS private key'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
self
|
|
52
|
+
rescue OpenSSL::OpenSSLError, ArgumentError => e
|
|
53
|
+
raise e if e.is_a?(ArgumentError)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, 'TLS certificate or private key is invalid'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bind_uri(host:, port:)
|
|
59
|
+
query = URI.encode_www_form(cert: cert_path, key: key_path, verify_mode: 'none')
|
|
60
|
+
"ssl://#{host}:#{port}?#{query}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def env_exports
|
|
64
|
+
origin = default_public_origin
|
|
65
|
+
domain = default_base_domain
|
|
66
|
+
[
|
|
67
|
+
"export WORKSPACES_PUBLIC_ORIGIN=#{origin}",
|
|
68
|
+
"export WORKSPACES_BASE_DOMAIN=#{domain}"
|
|
69
|
+
]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def apply_env_defaults
|
|
73
|
+
defaults = { 'WORKSPACES_PUBLIC_ORIGIN' => default_public_origin,
|
|
74
|
+
'WORKSPACES_BASE_DOMAIN' => default_base_domain }
|
|
75
|
+
temporary = defaults.select { |name, _| ENV.fetch(name, '').empty? }
|
|
76
|
+
previous = temporary.keys.to_h { |name| [name, ENV.fetch(name, nil)] }
|
|
77
|
+
temporary.each { |name, value| ENV[name] = value }
|
|
78
|
+
yield
|
|
79
|
+
ensure
|
|
80
|
+
previous&.each { |name, value| value.nil? ? ENV.delete(name) : ENV[name] = value }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def effective_public_origin
|
|
86
|
+
return default_public_origin if ENV['WORKSPACES_PUBLIC_ORIGIN'].to_s.empty?
|
|
87
|
+
|
|
88
|
+
Config.public_origin
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def default_public_origin
|
|
92
|
+
"https://#{DEFAULT_PUBLIC_ORIGIN_HOST}:#{Config.front_door_port}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def default_base_domain
|
|
96
|
+
DEFAULT_PUBLIC_ORIGIN_HOST
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_public_origin!
|
|
100
|
+
return if URI.parse(effective_public_origin).scheme == 'https'
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, 'TLS requires an HTTPS WORKSPACES_PUBLIC_ORIGIN'
|
|
103
|
+
rescue URI::InvalidURIError
|
|
104
|
+
raise ArgumentError, 'Invalid WORKSPACES_PUBLIC_ORIGIN'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_file!(label, path)
|
|
108
|
+
raise ArgumentError, "TLS #{label} file does not exist: #{path}" unless File.file?(path)
|
|
109
|
+
raise ArgumentError, "TLS #{label} file is not readable: #{path}" unless File.readable?(path)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
require_relative 'recipe'
|
|
3
|
+
require_relative 'command_runner'
|
|
4
|
+
require_relative 'backend'
|
|
5
|
+
require_relative 'environment_overrides'
|
|
6
|
+
|
|
7
|
+
module Workspaces
|
|
8
|
+
class Workflow
|
|
9
|
+
def initialize(workspace, log, state: StateStore.new(Config.state_file))
|
|
10
|
+
@workspace = workspace
|
|
11
|
+
@log = log
|
|
12
|
+
@state = state
|
|
13
|
+
@backend = Backend.new(workspace.id)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run!(restart: false)
|
|
17
|
+
recipe = Recipe.new
|
|
18
|
+
overrides = EnvironmentOverrides.new(@workspace.id).load
|
|
19
|
+
steps = recipe.for_operation(restart: restart)
|
|
20
|
+
publish_steps(steps)
|
|
21
|
+
@backend.stop!
|
|
22
|
+
context = build_context(@backend.reserve!)
|
|
23
|
+
runner = CommandRunner.new(@workspace.path, @log)
|
|
24
|
+
steps.each { |step| execute(step, recipe, context, runner, overrides) }
|
|
25
|
+
@backend.ensure_alive!
|
|
26
|
+
@backend.port
|
|
27
|
+
rescue StandardError
|
|
28
|
+
@backend.stop!
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def publish_steps(steps)
|
|
35
|
+
@state.set(@workspace.id, 'steps' => steps.map do |step|
|
|
36
|
+
{ 'key' => step['id'], 'label' => step['name'], 'state' => 'pending' }
|
|
37
|
+
end)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_context(port)
|
|
41
|
+
{
|
|
42
|
+
'WORKSPACE_ID' => @workspace.id, 'WORKSPACE_PATH' => @workspace.path.to_s,
|
|
43
|
+
'WORKSPACE_REPO_ROOT' => Config.repo_root.to_s, 'WORKSPACE_PORT' => port.to_s,
|
|
44
|
+
'WORKSPACE_URL' => Config.preview_url(@workspace.id).delete_suffix('/'),
|
|
45
|
+
'WORKSPACE_HOST' => Config.preview_host(@workspace.id),
|
|
46
|
+
'WORKSPACE_CACHE_DIR' => Config.home.to_s, 'WORKSPACE_RUN_ID' => SecureRandom.hex(24)
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execute(step, recipe, context, runner, overrides)
|
|
51
|
+
progress(step, 'active')
|
|
52
|
+
@log.puts "==> #{step.fetch('name')}"
|
|
53
|
+
command = recipe.command(step, context)
|
|
54
|
+
env = recipe.env(step, context, overrides: overrides)
|
|
55
|
+
if step['background']
|
|
56
|
+
@backend.start!(@workspace, command, env: env)
|
|
57
|
+
else
|
|
58
|
+
runner.run!(command, env: env, timeout: step.fetch('timeout'))
|
|
59
|
+
@backend.ensure_alive! if @server_started
|
|
60
|
+
end
|
|
61
|
+
@server_started = true if step['background']
|
|
62
|
+
progress(step, 'complete')
|
|
63
|
+
rescue StandardError
|
|
64
|
+
progress(step, 'failed')
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def progress(step, status)
|
|
69
|
+
@state.transaction do |data|
|
|
70
|
+
entry = data.fetch(@workspace.id)
|
|
71
|
+
entry['steps'].find { |item| item['key'] == step['id'] }['state'] = status
|
|
72
|
+
entry['message'] = step['name']
|
|
73
|
+
entry['current_step'] = step['id']
|
|
74
|
+
entry['updated_at'] = Time.now.utc.iso8601
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|