slipway 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/.yardopts +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +1013 -0
- data/exe/slipway +10 -0
- data/lib/slipway/cli/builtins.rb +241 -0
- data/lib/slipway/cli/completer.rb +158 -0
- data/lib/slipway/cli/completion_scripts.rb +163 -0
- data/lib/slipway/cli/context.rb +67 -0
- data/lib/slipway/cli/errors.rb +19 -0
- data/lib/slipway/cli/globals.rb +27 -0
- data/lib/slipway/cli/help_renderer.rb +135 -0
- data/lib/slipway/cli/manpage.rb +226 -0
- data/lib/slipway/cli/parser.rb +45 -0
- data/lib/slipway/cli/registry.rb +191 -0
- data/lib/slipway/cli/runner.rb +186 -0
- data/lib/slipway/cli/style.rb +82 -0
- data/lib/slipway/cli/theme.rb +85 -0
- data/lib/slipway/cli/validator.rb +61 -0
- data/lib/slipway/cli.rb +22 -0
- data/lib/slipway/command_line.rb +22 -0
- data/lib/slipway/commands/api_resources.rb +82 -0
- data/lib/slipway/commands/apply.rb +172 -0
- data/lib/slipway/commands/base.rb +50 -0
- data/lib/slipway/commands/config.rb +73 -0
- data/lib/slipway/commands/create.rb +218 -0
- data/lib/slipway/commands/delete.rb +82 -0
- data/lib/slipway/commands/describe.rb +74 -0
- data/lib/slipway/commands/diff.rb +122 -0
- data/lib/slipway/commands/edit.rb +130 -0
- data/lib/slipway/commands/explain.rb +97 -0
- data/lib/slipway/commands/fetch.rb +112 -0
- data/lib/slipway/commands/from_dir.rb +141 -0
- data/lib/slipway/commands/get.rb +167 -0
- data/lib/slipway/commands/label.rb +114 -0
- data/lib/slipway/commands/manual.rb +67 -0
- data/lib/slipway/commands/options.rb +73 -0
- data/lib/slipway/commands/results.rb +57 -0
- data/lib/slipway/commands/rollout.rb +114 -0
- data/lib/slipway/commands/rollout_spec.rb +99 -0
- data/lib/slipway/commands/rollout_undo.rb +126 -0
- data/lib/slipway/commands/scope.rb +156 -0
- data/lib/slipway/commands/sync.rb +140 -0
- data/lib/slipway/commands.rb +54 -0
- data/lib/slipway/drift.rb +87 -0
- data/lib/slipway/editor.rb +71 -0
- data/lib/slipway/error.rb +27 -0
- data/lib/slipway/fetcher.rb +99 -0
- data/lib/slipway/field_selector.rb +86 -0
- data/lib/slipway/git/branch_name.rb +32 -0
- data/lib/slipway/git/commit.rb +13 -0
- data/lib/slipway/git/distance.rb +13 -0
- data/lib/slipway/git/errors.rb +125 -0
- data/lib/slipway/git/fake.rb +147 -0
- data/lib/slipway/git/fast_forward.rb +12 -0
- data/lib/slipway/git/fast_forwarding.rb +148 -0
- data/lib/slipway/git/fetch_result.rb +22 -0
- data/lib/slipway/git/move_back.rb +12 -0
- data/lib/slipway/git/reflog.rb +25 -0
- data/lib/slipway/git/repository.rb +288 -0
- data/lib/slipway/git/rolling_back.rb +98 -0
- data/lib/slipway/git/runner.rb +175 -0
- data/lib/slipway/git/status.rb +110 -0
- data/lib/slipway/git/url.rb +95 -0
- data/lib/slipway/git.rb +20 -0
- data/lib/slipway/inspector.rb +103 -0
- data/lib/slipway/labels.rb +126 -0
- data/lib/slipway/manifest.rb +265 -0
- data/lib/slipway/names.rb +22 -0
- data/lib/slipway/outcome.rb +45 -0
- data/lib/slipway/output/age.rb +70 -0
- data/lib/slipway/output/describe.rb +71 -0
- data/lib/slipway/output/explain.rb +75 -0
- data/lib/slipway/output/serializer.rb +35 -0
- data/lib/slipway/output/table.rb +67 -0
- data/lib/slipway/output.rb +28 -0
- data/lib/slipway/paths.rb +65 -0
- data/lib/slipway/plan.rb +227 -0
- data/lib/slipway/pool.rb +94 -0
- data/lib/slipway/resources.rb +91 -0
- data/lib/slipway/rollback.rb +236 -0
- data/lib/slipway/rollout_history.rb +69 -0
- data/lib/slipway/runtime.rb +65 -0
- data/lib/slipway/scanner.rb +54 -0
- data/lib/slipway/schema.rb +128 -0
- data/lib/slipway/selector.rb +146 -0
- data/lib/slipway/settings.rb +174 -0
- data/lib/slipway/state.rb +82 -0
- data/lib/slipway/store.rb +170 -0
- data/lib/slipway/syncer.rb +139 -0
- data/lib/slipway/version.rb +5 -0
- data/lib/slipway/views/group.rb +35 -0
- data/lib/slipway/views/project.rb +148 -0
- data/lib/slipway/views.rb +10 -0
- data/lib/slipway/yaml.rb +14 -0
- data/lib/slipway.rb +32 -0
- data/man/man1/slipway-api-resources.1 +53 -0
- data/man/man1/slipway-apply.1 +45 -0
- data/man/man1/slipway-completion.1 +29 -0
- data/man/man1/slipway-config-path.1 +20 -0
- data/man/man1/slipway-config-view.1 +25 -0
- data/man/man1/slipway-config.1 +22 -0
- data/man/man1/slipway-create.1 +89 -0
- data/man/man1/slipway-delete.1 +46 -0
- data/man/man1/slipway-describe.1 +95 -0
- data/man/man1/slipway-diff.1 +120 -0
- data/man/man1/slipway-edit.1 +34 -0
- data/man/man1/slipway-explain.1 +36 -0
- data/man/man1/slipway-fetch.1 +77 -0
- data/man/man1/slipway-get.1 +155 -0
- data/man/man1/slipway-help.1 +19 -0
- data/man/man1/slipway-label.1 +53 -0
- data/man/man1/slipway-man.1 +36 -0
- data/man/man1/slipway-rollout-history.1 +28 -0
- data/man/man1/slipway-rollout-pause.1 +21 -0
- data/man/man1/slipway-rollout-resume.1 +21 -0
- data/man/man1/slipway-rollout-undo.1 +73 -0
- data/man/man1/slipway-rollout-unpin.1 +21 -0
- data/man/man1/slipway-rollout.1 +36 -0
- data/man/man1/slipway-sync.1 +90 -0
- data/man/man1/slipway-version.1 +17 -0
- data/man/man1/slipway.1 +243 -0
- metadata +173 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
# Follows the XDG Base Directory Specification. Reads only the environment Hash it is
|
|
5
|
+
# given, so tests never touch ENV.
|
|
6
|
+
class Paths
|
|
7
|
+
CONFIG_VARIABLE = 'SLIPWAY_CONFIG'
|
|
8
|
+
DATA_HOME_VARIABLE = 'SLIPWAY_DATA_HOME'
|
|
9
|
+
HOME_VARIABLE = 'HOME'
|
|
10
|
+
XDG_CONFIG_HOME_VARIABLE = 'XDG_CONFIG_HOME'
|
|
11
|
+
XDG_DATA_HOME_VARIABLE = 'XDG_DATA_HOME'
|
|
12
|
+
APP = 'slipway'
|
|
13
|
+
CONFIG_FILE = 'config.yaml'
|
|
14
|
+
MAN_SECTION = File.join('man', 'man1').freeze
|
|
15
|
+
TILDE = %r{\A~(?=/|\z)}
|
|
16
|
+
|
|
17
|
+
XDG_DEFAULTS = {
|
|
18
|
+
XDG_CONFIG_HOME_VARIABLE => '.config',
|
|
19
|
+
XDG_DATA_HOME_VARIABLE => File.join('.local', 'share')
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Only a bare `~` or `~/` is expanded; `~user` forms and relative paths stay as written.
|
|
23
|
+
def self.expand(value, home:) = value&.sub(TILDE, home)
|
|
24
|
+
|
|
25
|
+
def initialize(env, config: nil)
|
|
26
|
+
@env = env
|
|
27
|
+
@config_flag = present(config)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def home = present(@env[HOME_VARIABLE]) || Dir.home
|
|
31
|
+
|
|
32
|
+
def config_file
|
|
33
|
+
@config_flag || expand(present(@env[CONFIG_VARIABLE])) ||
|
|
34
|
+
File.join(xdg(XDG_CONFIG_HOME_VARIABLE), APP, CONFIG_FILE)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A missing explicit file is an error, a missing default is not.
|
|
38
|
+
def config_explicit? = !(@config_flag || present(@env[CONFIG_VARIABLE])).nil?
|
|
39
|
+
|
|
40
|
+
def data_home = expand(present(@env[DATA_HOME_VARIABLE])) || File.join(xdg(XDG_DATA_HOME_VARIABLE), APP)
|
|
41
|
+
|
|
42
|
+
def man_install_dir = File.join(xdg(XDG_DATA_HOME_VARIABLE), MAN_SECTION)
|
|
43
|
+
|
|
44
|
+
# The one user directory man-db searches on its own, when ~/.local/bin is on PATH.
|
|
45
|
+
def man_db_dir = File.join(home, XDG_DEFAULTS.fetch(XDG_DATA_HOME_VARIABLE), MAN_SECTION)
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The specification says an unset or empty variable takes its default and a relative
|
|
50
|
+
# path is invalid and ignored.
|
|
51
|
+
def xdg(name)
|
|
52
|
+
value = present(@env[name])
|
|
53
|
+
return value if value && File.absolute_path?(value)
|
|
54
|
+
|
|
55
|
+
File.join(home, XDG_DEFAULTS.fetch(name))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def present(value)
|
|
59
|
+
value unless value.nil? || value.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A leading ~ refers to the HOME the environment names, not the one Ruby started with.
|
|
63
|
+
def expand(value) = self.class.expand(value, home:)
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/slipway/plan.rb
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require_relative 'command_line'
|
|
5
|
+
require_relative 'drift'
|
|
6
|
+
require_relative 'git'
|
|
7
|
+
require_relative 'resources'
|
|
8
|
+
|
|
9
|
+
module Slipway
|
|
10
|
+
# What differs between a project and its manifest, and what sync would do about it, read from
|
|
11
|
+
# one Inspection. It runs no git and reads no file, so every verb that shows drift reads the
|
|
12
|
+
# same answer.
|
|
13
|
+
module Plan
|
|
14
|
+
# `actions` is the drift sync resolves, `reports` the drift it leaves as it is, and `skips`
|
|
15
|
+
# the blockers that stop it. A Behind action moves the branch onto its upstream and a
|
|
16
|
+
# Revision action onto the pinned revision.
|
|
17
|
+
Result = Data.define(:actions, :skips, :reports) do
|
|
18
|
+
def items = [*(actions + reports).sort_by { Drift::TYPES.index(it.type) }, *skips]
|
|
19
|
+
|
|
20
|
+
def fast_forward? = actions.any? { Drift::MOVES.include?(it.type) }
|
|
21
|
+
|
|
22
|
+
def to_revision? = actions.any? { it.type == Drift::REVISION }
|
|
23
|
+
|
|
24
|
+
def converged? = items.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.for(inspection) = Planner.new(inspection).result
|
|
28
|
+
|
|
29
|
+
class Planner
|
|
30
|
+
ABBREVIATION = Git::Porcelain::ABBREVIATION
|
|
31
|
+
FAST_FORWARD = 'sync will fast-forward'
|
|
32
|
+
FETCH_ONLY = "syncPolicy is #{SyncPolicy::FETCH_ONLY}".freeze
|
|
33
|
+
PAUSED = 'the project is paused'
|
|
34
|
+
KEEPS_REMOTE = 'sync never changes a remote'
|
|
35
|
+
|
|
36
|
+
def initialize(inspection)
|
|
37
|
+
@inspection = inspection
|
|
38
|
+
@project = inspection.project
|
|
39
|
+
@status = inspection.status
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def result
|
|
43
|
+
return unreadable(@inspection.error) if @inspection.error
|
|
44
|
+
|
|
45
|
+
declared = [remote, branch].compact
|
|
46
|
+
@project.revision ? pinned(declared) : following(declared)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def outcome(actions: [], skips: [], reports: []) = Result.new(actions:, skips:, reports:)
|
|
52
|
+
|
|
53
|
+
# A paused or FetchOnly project is never moved, so nothing can block it.
|
|
54
|
+
def following(declared)
|
|
55
|
+
held = hold
|
|
56
|
+
return outcome(reports: [*declared, *behind(held)]) if held
|
|
57
|
+
|
|
58
|
+
position = unfollowable
|
|
59
|
+
return outcome(reports: declared, skips: [position]) if position
|
|
60
|
+
return outcome(reports: declared) unless behind?
|
|
61
|
+
|
|
62
|
+
blocker = upstream_obstacle
|
|
63
|
+
return outcome(reports: [*declared, *behind], skips: [blocker]) if blocker
|
|
64
|
+
|
|
65
|
+
outcome(actions: behind(FAST_FORWARD), reports: declared)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The pinned revision replaces the upstream as where the branch should be. Sync moves the
|
|
69
|
+
# branch forward to it and never back: a move back is not a fast-forward.
|
|
70
|
+
def pinned(declared)
|
|
71
|
+
return outcome(reports: declared) if @inspection.at_pin?
|
|
72
|
+
|
|
73
|
+
held = hold
|
|
74
|
+
return outcome(reports: [*declared, revision(held)]) if held
|
|
75
|
+
|
|
76
|
+
blocker = unfollowable || pin_obstacle
|
|
77
|
+
return outcome(reports: [*declared, revision], skips: [blocker]) if blocker
|
|
78
|
+
|
|
79
|
+
outcome(actions: [revision(FAST_FORWARD)], reports: declared)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def hold
|
|
83
|
+
return PAUSED if @project.paused
|
|
84
|
+
|
|
85
|
+
FETCH_ONLY if @project.sync_policy == SyncPolicy::FETCH_ONLY
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def behind? = @status.behind.to_i.positive?
|
|
89
|
+
|
|
90
|
+
def behind(consequence = nil)
|
|
91
|
+
return [] unless behind?
|
|
92
|
+
|
|
93
|
+
message = "#{count(@status.behind, 'commit')} behind #{@status.upstream}"
|
|
94
|
+
[Drift::Item.new(type: Drift::BEHIND, message: [message, consequence].compact.join('; '))]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Without a branch that tracks an upstream that still exists, nothing says where the branch
|
|
98
|
+
# should be; a pinned branch needs one too, since the fast-forward checks it the same way.
|
|
99
|
+
def unfollowable
|
|
100
|
+
upstream = @status.upstream
|
|
101
|
+
return Drift.blocker('Detached', head: @status.head, command: git('status')) if @status.detached?
|
|
102
|
+
return Drift.blocker('Unborn') if @status.unborn?
|
|
103
|
+
return Drift.blocker('Gone', upstream:, command: git('branch', '-vv')) if @status.upstream_gone?
|
|
104
|
+
|
|
105
|
+
no_upstream if upstream.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def upstream_obstacle = local_changes || (diverged if @status.ahead.to_i.positive?) || in_progress
|
|
109
|
+
|
|
110
|
+
def pin_obstacle = unreachable_pin || local_changes || in_progress
|
|
111
|
+
|
|
112
|
+
# HEAD past the pin, or on another line of history, would have to move back to reach it. A
|
|
113
|
+
# pin the upstream lacks, or was not compared with, could be on any branch or fork the
|
|
114
|
+
# repository has fetched, and a manifest must not move a branch there.
|
|
115
|
+
def unreachable_pin
|
|
116
|
+
pin = short(@project.revision)
|
|
117
|
+
distance = @inspection.pin
|
|
118
|
+
return Drift.blocker('RevisionNotFound', revision: pin) if distance.nil?
|
|
119
|
+
return past_revision(pin) if distance.ahead.positive?
|
|
120
|
+
return if distance.off_upstream&.zero?
|
|
121
|
+
|
|
122
|
+
Drift.blocker('OffUpstream', revision: pin, upstream: @status.upstream,
|
|
123
|
+
command: git('log', '--oneline', "@{upstream}..#{pin}"))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def past_revision(pin)
|
|
127
|
+
Drift.blocker('PastRevision', branch: @status.branch, command: git('log', '--oneline', "#{pin}..HEAD"))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def local_changes
|
|
131
|
+
return conflicted if @status.conflicted.positive?
|
|
132
|
+
|
|
133
|
+
dirty if @status.staged.positive? || @status.unstaged.positive?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The operation in progress is known only for a project that would otherwise fast-forward,
|
|
137
|
+
# so it is checked last.
|
|
138
|
+
def in_progress
|
|
139
|
+
operation = @inspection.operation
|
|
140
|
+
Drift.blocker('InProgress', operation:, command: git('status')) if operation
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unreadable(error)
|
|
144
|
+
case error
|
|
145
|
+
when Git::RelativePath then outcome(reports: [missing(reason(error), clone: false)])
|
|
146
|
+
when Git::MissingPath then outcome(reports: [missing("no directory at #{@project.path}")])
|
|
147
|
+
when Git::NotARepository then outcome(skips: [Drift.blocker('NotARepo', path: @project.path)])
|
|
148
|
+
when Git::UnsafeRepository
|
|
149
|
+
outcome(skips: [Drift.blocker('Unsafe', reason: reason(error), command: error.command)])
|
|
150
|
+
else outcome(skips: [Drift.blocker('Unknown', reason: reason(error))])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def missing(message, clone: true)
|
|
155
|
+
url = @project.remote
|
|
156
|
+
command = "git clone -- #{Shellwords.escape(url)} #{CommandLine.path(@project.path)}" if clone && url
|
|
157
|
+
Drift::Item.new(type: Drift::MISSING, message:, command:)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def remote
|
|
161
|
+
wanted = @project.remote
|
|
162
|
+
actual = @inspection.remote
|
|
163
|
+
return if wanted.nil? || actual == wanted
|
|
164
|
+
|
|
165
|
+
url = Shellwords.escape(wanted)
|
|
166
|
+
if actual.nil?
|
|
167
|
+
return Drift::Item.new(type: Drift::REMOTE, message: "no origin, manifest says #{wanted}; #{KEEPS_REMOTE}",
|
|
168
|
+
command: git('remote', 'add', 'origin', url))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Drift::Item.new(type: Drift::REMOTE, message: "origin is #{actual}, manifest says #{wanted}; #{KEEPS_REMOTE}",
|
|
172
|
+
command: git('remote', 'set-url', 'origin', url))
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def branch
|
|
176
|
+
wanted = @project.branch
|
|
177
|
+
actual = @status.branch
|
|
178
|
+
return if wanted.nil? || actual == wanted
|
|
179
|
+
|
|
180
|
+
head = actual ? "HEAD is on #{actual}" : 'HEAD is detached'
|
|
181
|
+
Drift::Item.new(type: Drift::BRANCH, message: "#{head}, manifest says #{wanted}; sync never switches branches",
|
|
182
|
+
command: git('switch', Shellwords.escape(wanted)))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def revision(consequence = nil)
|
|
186
|
+
sha = @inspection.commit&.sha
|
|
187
|
+
head = sha ? "HEAD is at #{short(sha)}" : 'HEAD has no commits'
|
|
188
|
+
message = ["#{head}, manifest pins #{short(@project.revision)}", consequence].compact.join('; ')
|
|
189
|
+
Drift::Item.new(type: Drift::REVISION, message:)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The command names origin, so it is offered only when origin exists, and only for a branch
|
|
193
|
+
# name that cannot reach the shell as anything but a word.
|
|
194
|
+
def no_upstream
|
|
195
|
+
name = @status.branch
|
|
196
|
+
if @inspection.remote && Git::BranchName.valid?(name)
|
|
197
|
+
command = git('branch', "--set-upstream-to=origin/#{name}")
|
|
198
|
+
end
|
|
199
|
+
Drift.blocker('NoUpstream', branch: name, command:)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def conflicted
|
|
203
|
+
Drift.blocker('Conflicted', paths: count(@status.conflicted, 'unmerged path'), command: git('status'))
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def dirty
|
|
207
|
+
changes = { 'staged' => @status.staged, 'unstaged' => @status.unstaged }
|
|
208
|
+
.filter_map { |kind, number| "#{number} #{kind}" if number.positive? }.join(', ')
|
|
209
|
+
Drift.blocker('Dirty', changes:, command: git('status'))
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def diverged
|
|
213
|
+
Drift.blocker('Diverged', ahead: @status.ahead, behind: @status.behind, upstream: @status.upstream,
|
|
214
|
+
command: git('log', '--oneline', '--left-right', 'HEAD...@{upstream}'))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def count(number, noun) = "#{number} #{noun}#{'s' unless number == 1}"
|
|
218
|
+
|
|
219
|
+
def short(sha) = sha[0, ABBREVIATION]
|
|
220
|
+
|
|
221
|
+
def reason(error) = error.message.delete_prefix("#{error.path}: ")
|
|
222
|
+
|
|
223
|
+
def git(*words) = CommandLine.git(@project, *words)
|
|
224
|
+
end
|
|
225
|
+
private_constant :Planner
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/slipway/pool.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slipway
|
|
4
|
+
# Runs one block per item on at most `workers` threads; results come back in input order,
|
|
5
|
+
# whatever order the items finish in.
|
|
6
|
+
#
|
|
7
|
+
# However a call ends early (an exception from an item, an interrupt, a consumer that raises),
|
|
8
|
+
# the workers are killed and waited for before the exception leaves it: killing a thread runs
|
|
9
|
+
# its ensure blocks, which is how Git::Runner sends TERM to a git still running. An exception
|
|
10
|
+
# from an item is re-raised on the calling thread as soon as it is seen, without waiting for
|
|
11
|
+
# the other items, because callers turn the failures they expect into results and anything
|
|
12
|
+
# else is a bug or a fatal condition.
|
|
13
|
+
class Pool
|
|
14
|
+
attr_reader :workers
|
|
15
|
+
|
|
16
|
+
def initialize(workers:)
|
|
17
|
+
raise ArgumentError, "workers must be a positive Integer, not #{workers.inspect}" unless positive?(workers)
|
|
18
|
+
|
|
19
|
+
@workers = workers
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def map(items, &work)
|
|
23
|
+
results = []
|
|
24
|
+
each_ordered(items, work) { results << it }
|
|
25
|
+
results
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Yields each result on the calling thread as soon as every earlier item is done, so a
|
|
29
|
+
# caller that prints one line per item streams them in input order.
|
|
30
|
+
def each_ordered(items, work, &)
|
|
31
|
+
threads = []
|
|
32
|
+
begin
|
|
33
|
+
jobs = Queue.new
|
|
34
|
+
items.each_with_index { |item, index| jobs << [index, item] }
|
|
35
|
+
jobs.close
|
|
36
|
+
done = Queue.new
|
|
37
|
+
# An interrupt between Thread.new and the append would leave a worker that stop never sees.
|
|
38
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
39
|
+
[@workers, items.size].min.times { threads << worker(jobs, done, work) }
|
|
40
|
+
end
|
|
41
|
+
deliver(items.size, done, &)
|
|
42
|
+
ensure
|
|
43
|
+
stop(threads)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def positive?(workers) = workers.is_a?(Integer) && workers.positive?
|
|
50
|
+
|
|
51
|
+
# A worker pushes itself when it ends, so the caller joins one that died of an exception,
|
|
52
|
+
# which re-raises it once, instead of waiting for a result that never comes.
|
|
53
|
+
def worker(jobs, done, work)
|
|
54
|
+
Thread.new do
|
|
55
|
+
Thread.current.report_on_exception = false
|
|
56
|
+
# A thread inherits its creator's interrupt mask; without lifting it, an exception sent
|
|
57
|
+
# with Thread#raise would wait until the worker ends.
|
|
58
|
+
Thread.handle_interrupt(Exception => :immediate) do
|
|
59
|
+
while (job = jobs.pop)
|
|
60
|
+
index, item = job
|
|
61
|
+
done << [index, work.call(item)]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
ensure
|
|
65
|
+
done << Thread.current
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def deliver(count, done)
|
|
70
|
+
pending = {}
|
|
71
|
+
delivered = 0
|
|
72
|
+
while delivered < count
|
|
73
|
+
message = done.pop
|
|
74
|
+
if message.is_a?(Thread)
|
|
75
|
+
message.join
|
|
76
|
+
else
|
|
77
|
+
pending.store(*message)
|
|
78
|
+
end
|
|
79
|
+
while pending.key?(delivered)
|
|
80
|
+
yield pending.delete(delivered)
|
|
81
|
+
delivered += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A worker that died of an exception has nothing left to wait for, and joining it would
|
|
87
|
+
# raise that exception again over the one already leaving the call. An interrupt between
|
|
88
|
+
# two kills would leave the rest running, so the kills are masked.
|
|
89
|
+
def stop(threads)
|
|
90
|
+
Thread.handle_interrupt(Exception => :never) { threads.each(&:kill) }
|
|
91
|
+
threads.each { it.join unless it.status.nil? }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Slipway
|
|
6
|
+
# The values of spec.syncPolicy, capitalized as Kubernetes enums are (imagePullPolicy: IfNotPresent).
|
|
7
|
+
module SyncPolicy
|
|
8
|
+
FAST_FORWARD = 'FastForward'
|
|
9
|
+
FETCH_ONLY = 'FetchOnly'
|
|
10
|
+
ALL = [FAST_FORWARD, FETCH_ONLY].freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A registered git repository.
|
|
14
|
+
Project = Data.define(:name, :group, :labels, :created_at, :path, :description, :remote, :branch, :revision,
|
|
15
|
+
:sync_policy, :paused) do
|
|
16
|
+
def initialize(name:, path:, group: 'default', labels: {}, created_at: nil, description: nil, remote: nil,
|
|
17
|
+
branch: nil, revision: nil, sync_policy: SyncPolicy::FAST_FORWARD, paused: false)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def kind = 'Project'
|
|
22
|
+
|
|
23
|
+
# Key order here is the order written to disk.
|
|
24
|
+
def to_manifest
|
|
25
|
+
metadata = { 'name' => name, 'group' => group, 'labels' => labels.sort.to_h,
|
|
26
|
+
'creationTimestamp' => Resources.timestamp(created_at) }.compact
|
|
27
|
+
{ 'kind' => kind, 'metadata' => metadata, 'spec' => spec }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_labels(labels) = with(labels:)
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# A field at its default is left out, so a manifest written before the field existed is
|
|
35
|
+
# written back byte for byte and apply reads it as unchanged.
|
|
36
|
+
def spec
|
|
37
|
+
{ 'path' => path, 'description' => description, 'remote' => remote, 'branch' => branch,
|
|
38
|
+
'revision' => revision, 'syncPolicy' => (sync_policy unless sync_policy == SyncPolicy::FAST_FORWARD),
|
|
39
|
+
'paused' => (true if paused) }.compact
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Group = Data.define(:name, :labels, :created_at, :description) do
|
|
44
|
+
def initialize(name:, labels: {}, created_at: nil, description: nil)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def kind = 'Group'
|
|
49
|
+
|
|
50
|
+
# Key order here is the order written to disk.
|
|
51
|
+
def to_manifest
|
|
52
|
+
metadata = { 'name' => name, 'labels' => labels.sort.to_h,
|
|
53
|
+
'creationTimestamp' => Resources.timestamp(created_at) }.compact
|
|
54
|
+
{ 'kind' => kind, 'metadata' => metadata, 'spec' => { 'description' => description }.compact }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def with_labels(labels) = with(labels:)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module Resources
|
|
61
|
+
Kind = Data.define(:plural, :singular, :aliases, :klass) do
|
|
62
|
+
def names = [plural, singular, *aliases]
|
|
63
|
+
|
|
64
|
+
# Case-insensitive, like kubectl.
|
|
65
|
+
def match?(word) = names.include?(word.downcase)
|
|
66
|
+
|
|
67
|
+
# Projects live inside a group the way pods live inside a namespace.
|
|
68
|
+
def namespaced? = klass == Project
|
|
69
|
+
|
|
70
|
+
def title = singular.capitalize
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
PROJECTS = Kind.new(plural: 'projects', singular: 'project', aliases: %w[proj], klass: Project)
|
|
74
|
+
GROUPS = Kind.new(plural: 'groups', singular: 'group', aliases: [], klass: Group)
|
|
75
|
+
KINDS = [PROJECTS, GROUPS].freeze
|
|
76
|
+
# Created on first use.
|
|
77
|
+
DEFAULT_GROUP = 'default'
|
|
78
|
+
|
|
79
|
+
def self.resolve(word)
|
|
80
|
+
KINDS.find { it.match?(word) } ||
|
|
81
|
+
raise(Error, "unknown resource type #{word.inspect} (known types: #{KINDS.map(&:plural).join(', ')})")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.of(resource)
|
|
85
|
+
KINDS.find { resource.is_a?(it.klass) } || raise(ArgumentError, "not a resource: #{resource.inspect}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# RFC 3339 in UTC with second precision, the form creationTimestamp takes on disk.
|
|
89
|
+
def self.timestamp(time) = time&.getutc&.iso8601
|
|
90
|
+
end
|
|
91
|
+
end
|