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.
Files changed (124) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +7 -0
  3. data/CHANGELOG.md +45 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1013 -0
  6. data/exe/slipway +10 -0
  7. data/lib/slipway/cli/builtins.rb +241 -0
  8. data/lib/slipway/cli/completer.rb +158 -0
  9. data/lib/slipway/cli/completion_scripts.rb +163 -0
  10. data/lib/slipway/cli/context.rb +67 -0
  11. data/lib/slipway/cli/errors.rb +19 -0
  12. data/lib/slipway/cli/globals.rb +27 -0
  13. data/lib/slipway/cli/help_renderer.rb +135 -0
  14. data/lib/slipway/cli/manpage.rb +226 -0
  15. data/lib/slipway/cli/parser.rb +45 -0
  16. data/lib/slipway/cli/registry.rb +191 -0
  17. data/lib/slipway/cli/runner.rb +186 -0
  18. data/lib/slipway/cli/style.rb +82 -0
  19. data/lib/slipway/cli/theme.rb +85 -0
  20. data/lib/slipway/cli/validator.rb +61 -0
  21. data/lib/slipway/cli.rb +22 -0
  22. data/lib/slipway/command_line.rb +22 -0
  23. data/lib/slipway/commands/api_resources.rb +82 -0
  24. data/lib/slipway/commands/apply.rb +172 -0
  25. data/lib/slipway/commands/base.rb +50 -0
  26. data/lib/slipway/commands/config.rb +73 -0
  27. data/lib/slipway/commands/create.rb +218 -0
  28. data/lib/slipway/commands/delete.rb +82 -0
  29. data/lib/slipway/commands/describe.rb +74 -0
  30. data/lib/slipway/commands/diff.rb +122 -0
  31. data/lib/slipway/commands/edit.rb +130 -0
  32. data/lib/slipway/commands/explain.rb +97 -0
  33. data/lib/slipway/commands/fetch.rb +112 -0
  34. data/lib/slipway/commands/from_dir.rb +141 -0
  35. data/lib/slipway/commands/get.rb +167 -0
  36. data/lib/slipway/commands/label.rb +114 -0
  37. data/lib/slipway/commands/manual.rb +67 -0
  38. data/lib/slipway/commands/options.rb +73 -0
  39. data/lib/slipway/commands/results.rb +57 -0
  40. data/lib/slipway/commands/rollout.rb +114 -0
  41. data/lib/slipway/commands/rollout_spec.rb +99 -0
  42. data/lib/slipway/commands/rollout_undo.rb +126 -0
  43. data/lib/slipway/commands/scope.rb +156 -0
  44. data/lib/slipway/commands/sync.rb +140 -0
  45. data/lib/slipway/commands.rb +54 -0
  46. data/lib/slipway/drift.rb +87 -0
  47. data/lib/slipway/editor.rb +71 -0
  48. data/lib/slipway/error.rb +27 -0
  49. data/lib/slipway/fetcher.rb +99 -0
  50. data/lib/slipway/field_selector.rb +86 -0
  51. data/lib/slipway/git/branch_name.rb +32 -0
  52. data/lib/slipway/git/commit.rb +13 -0
  53. data/lib/slipway/git/distance.rb +13 -0
  54. data/lib/slipway/git/errors.rb +125 -0
  55. data/lib/slipway/git/fake.rb +147 -0
  56. data/lib/slipway/git/fast_forward.rb +12 -0
  57. data/lib/slipway/git/fast_forwarding.rb +148 -0
  58. data/lib/slipway/git/fetch_result.rb +22 -0
  59. data/lib/slipway/git/move_back.rb +12 -0
  60. data/lib/slipway/git/reflog.rb +25 -0
  61. data/lib/slipway/git/repository.rb +288 -0
  62. data/lib/slipway/git/rolling_back.rb +98 -0
  63. data/lib/slipway/git/runner.rb +175 -0
  64. data/lib/slipway/git/status.rb +110 -0
  65. data/lib/slipway/git/url.rb +95 -0
  66. data/lib/slipway/git.rb +20 -0
  67. data/lib/slipway/inspector.rb +103 -0
  68. data/lib/slipway/labels.rb +126 -0
  69. data/lib/slipway/manifest.rb +265 -0
  70. data/lib/slipway/names.rb +22 -0
  71. data/lib/slipway/outcome.rb +45 -0
  72. data/lib/slipway/output/age.rb +70 -0
  73. data/lib/slipway/output/describe.rb +71 -0
  74. data/lib/slipway/output/explain.rb +75 -0
  75. data/lib/slipway/output/serializer.rb +35 -0
  76. data/lib/slipway/output/table.rb +67 -0
  77. data/lib/slipway/output.rb +28 -0
  78. data/lib/slipway/paths.rb +65 -0
  79. data/lib/slipway/plan.rb +227 -0
  80. data/lib/slipway/pool.rb +94 -0
  81. data/lib/slipway/resources.rb +91 -0
  82. data/lib/slipway/rollback.rb +236 -0
  83. data/lib/slipway/rollout_history.rb +69 -0
  84. data/lib/slipway/runtime.rb +65 -0
  85. data/lib/slipway/scanner.rb +54 -0
  86. data/lib/slipway/schema.rb +128 -0
  87. data/lib/slipway/selector.rb +146 -0
  88. data/lib/slipway/settings.rb +174 -0
  89. data/lib/slipway/state.rb +82 -0
  90. data/lib/slipway/store.rb +170 -0
  91. data/lib/slipway/syncer.rb +139 -0
  92. data/lib/slipway/version.rb +5 -0
  93. data/lib/slipway/views/group.rb +35 -0
  94. data/lib/slipway/views/project.rb +148 -0
  95. data/lib/slipway/views.rb +10 -0
  96. data/lib/slipway/yaml.rb +14 -0
  97. data/lib/slipway.rb +32 -0
  98. data/man/man1/slipway-api-resources.1 +53 -0
  99. data/man/man1/slipway-apply.1 +45 -0
  100. data/man/man1/slipway-completion.1 +29 -0
  101. data/man/man1/slipway-config-path.1 +20 -0
  102. data/man/man1/slipway-config-view.1 +25 -0
  103. data/man/man1/slipway-config.1 +22 -0
  104. data/man/man1/slipway-create.1 +89 -0
  105. data/man/man1/slipway-delete.1 +46 -0
  106. data/man/man1/slipway-describe.1 +95 -0
  107. data/man/man1/slipway-diff.1 +120 -0
  108. data/man/man1/slipway-edit.1 +34 -0
  109. data/man/man1/slipway-explain.1 +36 -0
  110. data/man/man1/slipway-fetch.1 +77 -0
  111. data/man/man1/slipway-get.1 +155 -0
  112. data/man/man1/slipway-help.1 +19 -0
  113. data/man/man1/slipway-label.1 +53 -0
  114. data/man/man1/slipway-man.1 +36 -0
  115. data/man/man1/slipway-rollout-history.1 +28 -0
  116. data/man/man1/slipway-rollout-pause.1 +21 -0
  117. data/man/man1/slipway-rollout-resume.1 +21 -0
  118. data/man/man1/slipway-rollout-undo.1 +73 -0
  119. data/man/man1/slipway-rollout-unpin.1 +21 -0
  120. data/man/man1/slipway-rollout.1 +36 -0
  121. data/man/man1/slipway-sync.1 +90 -0
  122. data/man/man1/slipway-version.1 +17 -0
  123. data/man/man1/slipway.1 +243 -0
  124. 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
@@ -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
@@ -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