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.
@@ -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,3 @@
1
+ module Workspaces
2
+ VERSION = '0.1.0'
3
+ 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