kitsune-kit 0.4.0 → 0.5.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +117 -2
  3. data/CONTRIBUTING.md +83 -0
  4. data/README.md +108 -136
  5. data/SECURITY.md +25 -0
  6. data/bin/kit +4 -1
  7. data/docs/architecture/decisions/0001-product-boundary.md +23 -0
  8. data/docs/architecture/decisions/0002-core-and-interfaces.md +23 -0
  9. data/docs/architecture/decisions/0003-configuration-state-and-secrets.md +23 -0
  10. data/docs/architecture/decisions/0004-supported-platforms.md +19 -0
  11. data/docs/architecture/decisions/0005-operation-semantics.md +19 -0
  12. data/docs/architecture/decisions/0006-tui-backend.md +21 -0
  13. data/docs/architecture/provider-adapters.md +49 -0
  14. data/docs/architecture.md +89 -0
  15. data/docs/commands.md +210 -0
  16. data/docs/configuration.md +210 -0
  17. data/docs/getting-started.md +133 -0
  18. data/docs/providers/digitalocean.md +52 -0
  19. data/docs/releasing.md +43 -0
  20. data/docs/roadmap.md +46 -0
  21. data/docs/security-audit.md +130 -0
  22. data/docs/security.md +116 -0
  23. data/docs/services/postgres.md +78 -0
  24. data/docs/services/redis.md +53 -0
  25. data/docs/testing.md +200 -0
  26. data/docs/troubleshooting.md +123 -0
  27. data/docs/tui.md +57 -0
  28. data/lib/kitsune/kit/adapters/confirming_host_key_verifier.rb +42 -0
  29. data/lib/kitsune/kit/adapters/digitalocean_provider.rb +217 -0
  30. data/lib/kitsune/kit/adapters/fake_clock.rb +34 -0
  31. data/lib/kitsune/kit/adapters/fake_provider.rb +107 -0
  32. data/lib/kitsune/kit/adapters/fake_reporter.rb +34 -0
  33. data/lib/kitsune/kit/adapters/fake_secret_store.rb +35 -0
  34. data/lib/kitsune/kit/adapters/fake_state_store.rb +91 -0
  35. data/lib/kitsune/kit/adapters/fake_transport.rb +56 -0
  36. data/lib/kitsune/kit/adapters/net_ssh_transport.rb +155 -0
  37. data/lib/kitsune/kit/adapters/provider.rb +34 -0
  38. data/lib/kitsune/kit/adapters/transport.rb +22 -0
  39. data/lib/kitsune/kit/adapters/transport_factory.rb +116 -0
  40. data/lib/kitsune/kit/application.rb +170 -0
  41. data/lib/kitsune/kit/cancellation.rb +21 -0
  42. data/lib/kitsune/kit/cli.rb +802 -64
  43. data/lib/kitsune/kit/clock.rb +11 -0
  44. data/lib/kitsune/kit/configuration.rb +493 -0
  45. data/lib/kitsune/kit/errors.rb +98 -0
  46. data/lib/kitsune/kit/events.rb +55 -0
  47. data/lib/kitsune/kit/operations/ensure_dns_records.rb +165 -0
  48. data/lib/kitsune/kit/operations/ensure_server.rb +135 -0
  49. data/lib/kitsune/kit/operations/ensure_service.rb +285 -0
  50. data/lib/kitsune/kit/operations/remote_script.rb +214 -0
  51. data/lib/kitsune/kit/operations/service_backup.rb +71 -0
  52. data/lib/kitsune/kit/operations/service_files.rb +127 -0
  53. data/lib/kitsune/kit/operations/service_firewall.rb +190 -0
  54. data/lib/kitsune/kit/operations/service_state.rb +59 -0
  55. data/lib/kitsune/kit/plan.rb +72 -0
  56. data/lib/kitsune/kit/reporters/human.rb +89 -0
  57. data/lib/kitsune/kit/reporters/json.rb +65 -0
  58. data/lib/kitsune/kit/reporters/reporter.rb +11 -0
  59. data/lib/kitsune/kit/result.rb +28 -0
  60. data/lib/kitsune/kit/run_journal.rb +102 -0
  61. data/lib/kitsune/kit/run_logger.rb +37 -0
  62. data/lib/kitsune/kit/scripts/docker.sh +137 -0
  63. data/lib/kitsune/kit/scripts/firewall.sh +205 -0
  64. data/lib/kitsune/kit/scripts/metrics.sh +54 -0
  65. data/lib/kitsune/kit/scripts/ssh.sh +95 -0
  66. data/lib/kitsune/kit/scripts/swap.sh +86 -0
  67. data/lib/kitsune/kit/scripts/unattended.sh +81 -0
  68. data/lib/kitsune/kit/scripts/user.sh +114 -0
  69. data/lib/kitsune/kit/secret_filter.rb +54 -0
  70. data/lib/kitsune/kit/secret_store.rb +32 -0
  71. data/lib/kitsune/kit/secret_stores/store.rb +12 -0
  72. data/lib/kitsune/kit/service_compose.rb +72 -0
  73. data/lib/kitsune/kit/state_store.rb +158 -0
  74. data/lib/kitsune/kit/state_stores/store.rb +15 -0
  75. data/lib/kitsune/kit/tui/actions.rb +72 -0
  76. data/lib/kitsune/kit/tui/application.rb +35 -0
  77. data/lib/kitsune/kit/tui/controller.rb +162 -0
  78. data/lib/kitsune/kit/tui/renderer.rb +134 -0
  79. data/lib/kitsune/kit/tui/state.rb +18 -0
  80. data/lib/kitsune/kit/tui/store.rb +88 -0
  81. data/lib/kitsune/kit/tui/terminal.rb +95 -0
  82. data/lib/kitsune/kit/version.rb +1 -1
  83. data/lib/kitsune/kit/workflows/apply_plan.rb +145 -0
  84. data/lib/kitsune/kit/workflows/base.rb +31 -0
  85. data/lib/kitsune/kit/workflows/build_plan.rb +33 -0
  86. data/lib/kitsune/kit/workflows/destroy_server.rb +84 -0
  87. data/lib/kitsune/kit/workflows/doctor.rb +225 -0
  88. data/lib/kitsune/kit/workflows/environment_selection.rb +70 -0
  89. data/lib/kitsune/kit/workflows/import_server.rb +100 -0
  90. data/lib/kitsune/kit/workflows/initialize_project.rb +121 -0
  91. data/lib/kitsune/kit/workflows/inspect_environment.rb +44 -0
  92. data/lib/kitsune/kit/workflows/rollback.rb +54 -0
  93. data/lib/kitsune/kit/workflows/support_bundle.rb +82 -0
  94. data/lib/kitsune/kit.rb +41 -2
  95. metadata +122 -79
  96. data/.rspec +0 -3
  97. data/Rakefile +0 -8
  98. data/kitsune-kit-logo.jpg +0 -0
  99. data/lib/kitsune/blueprints/.env.template +0 -31
  100. data/lib/kitsune/blueprints/docker/postgres.yml +0 -27
  101. data/lib/kitsune/blueprints/docker/redis.yml +0 -23
  102. data/lib/kitsune/blueprints/kit.env.template +0 -1
  103. data/lib/kitsune/kit/ansi_color.rb +0 -78
  104. data/lib/kitsune/kit/commands/bootstrap.rb +0 -134
  105. data/lib/kitsune/kit/commands/bootstrap_docker.rb +0 -66
  106. data/lib/kitsune/kit/commands/dns.rb +0 -112
  107. data/lib/kitsune/kit/commands/init.rb +0 -148
  108. data/lib/kitsune/kit/commands/install_docker_engine.rb +0 -146
  109. data/lib/kitsune/kit/commands/postinstall_docker.rb +0 -142
  110. data/lib/kitsune/kit/commands/provision.rb +0 -43
  111. data/lib/kitsune/kit/commands/setup_do_metrics.rb +0 -123
  112. data/lib/kitsune/kit/commands/setup_docker_prereqs.rb +0 -151
  113. data/lib/kitsune/kit/commands/setup_firewall.rb +0 -132
  114. data/lib/kitsune/kit/commands/setup_postgres_docker.rb +0 -246
  115. data/lib/kitsune/kit/commands/setup_redis_docker.rb +0 -241
  116. data/lib/kitsune/kit/commands/setup_swap.rb +0 -151
  117. data/lib/kitsune/kit/commands/setup_unattended.rb +0 -132
  118. data/lib/kitsune/kit/commands/setup_user.rb +0 -189
  119. data/lib/kitsune/kit/commands/ssh.rb +0 -46
  120. data/lib/kitsune/kit/commands/switch_env.rb +0 -42
  121. data/lib/kitsune/kit/defaults.rb +0 -91
  122. data/lib/kitsune/kit/env_loader.rb +0 -41
  123. data/lib/kitsune/kit/options_builder.rb +0 -26
  124. data/lib/kitsune/kit/provisioner.rb +0 -107
  125. data/sig/kitsune/kit.rbs +0 -6
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ class Change < Data.define(:resource, :action, :summary, :details, :destructive)
6
+ ACTIONS = %w[create update delete no_change].freeze
7
+
8
+ def initialize(resource:, action:, summary:, details: {}, destructive: false)
9
+ raise ArgumentError, "unknown action: #{action}" unless ACTIONS.include?(action.to_s)
10
+
11
+ super(
12
+ resource: resource.to_s,
13
+ action: action.to_s,
14
+ summary: summary.to_s,
15
+ details: details.freeze,
16
+ destructive: !!destructive
17
+ )
18
+ end
19
+
20
+ def changed? = action != "no_change"
21
+ def destructive? = destructive
22
+
23
+ def to_h
24
+ {
25
+ resource: resource,
26
+ action: action,
27
+ summary: summary,
28
+ details: details,
29
+ destructive: destructive
30
+ }
31
+ end
32
+
33
+ def self.from_h(value)
34
+ new(
35
+ resource: value.fetch("resource"),
36
+ action: value.fetch("action"),
37
+ summary: value.fetch("summary"),
38
+ details: value.fetch("details", {}),
39
+ destructive: value.fetch("destructive", false)
40
+ )
41
+ end
42
+ end
43
+
44
+ class Plan < Data.define(:environment, :changes)
45
+ def initialize(environment:, changes:)
46
+ super(environment: environment.to_s, changes: changes.freeze)
47
+ end
48
+
49
+ def changed? = changes.any?(&:changed?)
50
+ def destructive? = changes.any?(&:destructive)
51
+ def changed_count = changes.count(&:changed?)
52
+
53
+ def counts
54
+ changes.each_with_object(Hash.new(0)) { |change, total| total[change.action] += 1 }
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ environment: environment,
60
+ changed: changed?,
61
+ destructive: destructive?,
62
+ counts: counts,
63
+ changes: changes.map(&:to_h)
64
+ }
65
+ end
66
+
67
+ def self.from_h(environment:, changes:)
68
+ new(environment: environment, changes: changes.map { |change| Change.from_h(change) })
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "reporter"
4
+
5
+ module Kitsune
6
+ module Kit
7
+ module Reporters
8
+ class Human < Reporter
9
+ SYMBOLS = {
10
+ "run_started" => "START",
11
+ "plan_built" => "PLAN",
12
+ "operation_started" => "RUN",
13
+ "operation_progressed" => "PROGRESS",
14
+ "operation_succeeded" => "OK",
15
+ "operation_skipped" => "SKIP",
16
+ "operation_failed" => "FAIL",
17
+ "warning_emitted" => "WARN",
18
+ "run_finished" => "DONE"
19
+ }.freeze
20
+
21
+ def initialize(output: $stdout, error: $stderr, color: nil, quiet: false, verbose: false,
22
+ secret_filter: SecretFilter.new)
23
+ super()
24
+ @output = output
25
+ @error = error
26
+ @color = color.nil? ? output.tty? && !ENV.key?("NO_COLOR") : color
27
+ @secret_filter = secret_filter
28
+ @quiet = quiet
29
+ @verbose = verbose
30
+ end
31
+
32
+ def handle(event)
33
+ return if @quiet && !%w[operation_failed warning_emitted run_finished].include?(event.type)
34
+
35
+ data = @secret_filter.filter(event.data)
36
+ stream = %w[operation_failed warning_emitted].include?(event.type) ? @error : @output
37
+ message = message_for(event.type, data)
38
+ stream.puts("#{label(event.type)} #{message}") if message
39
+ end
40
+
41
+ private
42
+
43
+ def label(type)
44
+ value = "[#{SYMBOLS.fetch(type, type.upcase)}]"
45
+ return value unless @color
46
+
47
+ code = case type
48
+ when "operation_succeeded", "run_finished" then 32
49
+ when "operation_failed" then 31
50
+ when "warning_emitted" then 33
51
+ else 36
52
+ end
53
+ "\e[#{code}m#{value}\e[0m"
54
+ end
55
+
56
+ def message_for(type, data)
57
+ method = "message_#{type}"
58
+ send(method, data) if respond_to?(method, true)
59
+ end
60
+
61
+ def message_run_started(data) = "#{data[:command]} for #{data[:environment]}"
62
+ def message_plan_built(data) = "#{data[:changed_count]} change(s), #{data[:unchanged_count]} unchanged"
63
+
64
+ def message_operation_started(data)
65
+ progress = data[:index] && data[:total] ? "[#{data[:index]}/#{data[:total]}] " : ""
66
+ "#{progress}#{data[:summary]}"
67
+ end
68
+
69
+ def message_operation_succeeded(data)
70
+ duration = data[:duration_ms] ? " (#{data[:duration_ms]}ms)" : ""
71
+ "#{data[:summary]}#{duration}"
72
+ end
73
+
74
+ def message_operation_progressed(data)
75
+ "#{data[:summary]}: #{data[:percent]}%" if @verbose
76
+ end
77
+
78
+ def message_operation_failed(data) = "#{data[:summary]}: #{data[:message]}"
79
+
80
+ def message_operation_skipped(data)
81
+ "#{data[:summary]} (#{data[:reason]})" if @verbose
82
+ end
83
+
84
+ def message_warning_emitted(data) = data[:message]
85
+ def message_run_finished(data) = "#{data[:status]} in #{data[:duration_ms]}ms"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "reporter"
5
+
6
+ module Kitsune
7
+ module Kit
8
+ module Reporters
9
+ class Json < Reporter
10
+ attr_reader :events
11
+
12
+ def initialize(output: $stdout, secret_filter: SecretFilter.new)
13
+ super()
14
+ @output = output
15
+ @secret_filter = secret_filter
16
+ @events = []
17
+ end
18
+
19
+ def handle(event)
20
+ @events << @secret_filter.filter(event.to_h)
21
+ end
22
+
23
+ def flush(result: nil, command: nil, environment: nil)
24
+ started = event("run_started", reverse: true)
25
+ finished = event("run_finished", reverse: true)
26
+ payload = {
27
+ schema_version: 1,
28
+ command: command || started&.dig(:data, :command),
29
+ environment: environment || started&.dig(:data, :environment),
30
+ run_id: started&.dig(:run_id) || result&.metadata&.dig(:run_id),
31
+ status: (result&.status || status_from_events).to_s,
32
+ duration_ms: finished&.dig(:data, :duration_ms),
33
+ result: @secret_filter.filter(serialize(result&.value)),
34
+ warnings: @secret_filter.filter(result&.warnings || []),
35
+ events: @events
36
+ }
37
+ @output.puts(::JSON.generate(payload))
38
+ payload
39
+ end
40
+
41
+ private
42
+
43
+ def event(type, reverse: false)
44
+ source = reverse ? @events.reverse_each : @events.each
45
+ source.find { |item| item[:type] == type }
46
+ end
47
+
48
+ def status_from_events
49
+ @events.reverse_each do |event|
50
+ return event.dig(:data, :status) if event[:type] == "run_finished"
51
+ end
52
+ "unknown"
53
+ end
54
+
55
+ def serialize(value)
56
+ return value.map { |item| serialize(item) } if value.is_a?(Array)
57
+ return value.to_h { |key, item| [key, serialize(item)] } if value.is_a?(Hash)
58
+ return serialize(value.to_h) if value.respond_to?(:to_h)
59
+
60
+ value
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ module Reporters
6
+ class Reporter
7
+ def handle(event) = raise NotImplementedError
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kitsune
4
+ module Kit
5
+ class Result < Data.define(:status, :value, :warnings, :metadata)
6
+ STATUSES = %i[success failure cancelled].freeze
7
+
8
+ def initialize(status:, value: nil, warnings: [], metadata: {})
9
+ raise ArgumentError, "unknown status: #{status}" unless STATUSES.include?(status.to_sym)
10
+
11
+ super(
12
+ status: status.to_sym,
13
+ value: value,
14
+ warnings: warnings.freeze,
15
+ metadata: metadata.freeze
16
+ )
17
+ end
18
+
19
+ def success? = status == :success
20
+ def failure? = status == :failure
21
+ def cancelled? = status == :cancelled
22
+
23
+ def self.success(value = nil, warnings: [], metadata: {})
24
+ new(status: :success, value: value, warnings: warnings, metadata: metadata)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "errors"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ class RunJournal
10
+ RESUMABLE_STATUSES = %w[running failure cancelled].freeze
11
+
12
+ def initialize(state_store:, environment:, run_id:, clock: -> { Time.now.utc })
13
+ @state_store = state_store
14
+ @environment = environment
15
+ @run_id = run_id
16
+ @clock = clock
17
+ end
18
+
19
+ def start(changes:, resumed_from: nil)
20
+ update_run do |run|
21
+ run.merge!(
22
+ "status" => "running",
23
+ "started_at" => timestamp,
24
+ "finished_at" => nil,
25
+ "resumed_from" => resumed_from,
26
+ "changes" => normalize(changes),
27
+ "steps" => {}
28
+ )
29
+ end
30
+ end
31
+
32
+ def record_step(resource, status, error: nil)
33
+ update_run do |run|
34
+ run["steps"] ||= {}
35
+ run["steps"][resource] = {
36
+ "status" => status,
37
+ "error" => error,
38
+ "updated_at" => timestamp
39
+ }
40
+ end
41
+ end
42
+
43
+ def finish(status, error: nil)
44
+ update_run do |run|
45
+ run["status"] = status
46
+ run["error"] = error
47
+ run["finished_at"] = timestamp
48
+ end
49
+ end
50
+
51
+ def resumable(requested_id = nil)
52
+ runs = state_store.read(environment)["runs"]
53
+ selected_id = requested_id || runs.keys.reverse.find { |id| resumable_status?(runs.fetch(id)) }
54
+ raise no_resumable_run_error unless selected_id
55
+
56
+ run = runs[selected_id]
57
+ raise Errors::ConfigurationError, "run not found: #{selected_id}" unless run
58
+
59
+ validate_resumable!(selected_id, run)
60
+ [selected_id, run]
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :state_store, :environment, :run_id, :clock
66
+
67
+ def update_run
68
+ return unless state_store
69
+
70
+ state_store.update(environment) do |state|
71
+ run = state["runs"][run_id] ||= {}
72
+ yield(run)
73
+ state
74
+ end
75
+ end
76
+
77
+ def validate_resumable!(selected_id, run)
78
+ unless resumable_status?(run)
79
+ raise Errors::ConfigurationError.new(
80
+ "run #{selected_id} cannot be resumed because its status is #{run['status']}",
81
+ hint: "Resume a failed, cancelled, or interrupted run."
82
+ )
83
+ end
84
+ return if run["changes"].is_a?(Array) && run["steps"].is_a?(Hash)
85
+
86
+ raise Errors::ConfigurationError, "run #{selected_id} does not contain a resumable plan"
87
+ end
88
+
89
+ def resumable_status?(run) = RESUMABLE_STATUSES.include?(run["status"])
90
+
91
+ def no_resumable_run_error
92
+ Errors::ConfigurationError.new(
93
+ "there is no run available to resume",
94
+ hint: "Run `kit apply` first or provide the ID of a failed run."
95
+ )
96
+ end
97
+
98
+ def timestamp = clock.call.iso8601(6)
99
+ def normalize(value) = JSON.parse(JSON.generate(value))
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Kitsune
8
+ module Kit
9
+ class RunLogger
10
+ MAX_FILES = 20
11
+
12
+ attr_reader :path
13
+
14
+ def initialize(root:, run_id:, secret_filter: SecretFilter.new, clock: Clock.new)
15
+ directory = Pathname(root).expand_path.join(".kitsune/logs")
16
+ FileUtils.mkdir_p(directory, mode: 0o700)
17
+ @path = directory.join("#{clock.now.strftime('%Y%m%dT%H%M%S')}-#{run_id}.jsonl")
18
+ @secret_filter = secret_filter
19
+ FileUtils.touch(@path)
20
+ File.chmod(0o600, @path)
21
+ rotate(directory)
22
+ end
23
+
24
+ def handle(event)
25
+ File.open(path, "a", 0o600) do |file|
26
+ file.puts(JSON.generate(@secret_filter.filter(event.to_h)))
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def rotate(directory)
33
+ directory.glob("*.jsonl").sort_by(&:mtime).reverse.drop(MAX_FILES).each(&:delete)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ action="${1:?action is required}"
5
+ user="${2:?user is required}"
6
+ backup_dir="/var/lib/kitsune/backups/docker"
7
+ keyring="/etc/apt/keyrings/docker.asc"
8
+ repository="/etc/apt/sources.list.d/docker.sources"
9
+ packages=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin)
10
+ prerequisites=(ca-certificates curl)
11
+ conflicting_packages=(docker.io docker-compose docker-compose-v2 podman-docker containerd runc)
12
+
13
+ refuse_conflicting_packages() {
14
+ local installed=()
15
+ local package
16
+ for package in "${conflicting_packages[@]}"; do
17
+ if dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -q "ok installed"; then
18
+ installed+=("${package}")
19
+ fi
20
+ done
21
+ if (( ${#installed[@]} > 0 )); then
22
+ printf 'conflicting Docker packages are installed: %s\n' "${installed[*]}" >&2
23
+ echo "Remove or migrate them explicitly before Kitsune Kit installs Docker CE." >&2
24
+ exit 3
25
+ fi
26
+ }
27
+
28
+ snapshot() {
29
+ [[ -f "${backup_dir}/snapshot-complete" ]] && return
30
+ : > "${backup_dir}/packages-before"
31
+ for package in "${prerequisites[@]}" "${packages[@]}"; do
32
+ if dpkg-query -W -f='${Status}' "${package}" 2>/dev/null | grep -q "ok installed"; then
33
+ printf '%s\n' "${package}" >> "${backup_dir}/packages-before"
34
+ fi
35
+ done
36
+ systemctl is-enabled --quiet docker && touch "${backup_dir}/enabled-before"
37
+ systemctl is-active --quiet docker && touch "${backup_dir}/active-before"
38
+ touch "${backup_dir}/snapshot-complete"
39
+ }
40
+
41
+ apply() {
42
+ refuse_conflicting_packages
43
+ install -d -m 0700 "${backup_dir}"
44
+ snapshot
45
+ apt-get update
46
+ DEBIAN_FRONTEND=noninteractive apt-get install --yes "${prerequisites[@]}"
47
+ install -d -m 0755 /etc/apt/keyrings
48
+ if [[ ! -f "${keyring}" ]]; then
49
+ temporary_key="$(mktemp)"
50
+ trap 'rm -f "${temporary_key}"' EXIT
51
+ curl --fail --silent --show-error --location \
52
+ https://download.docker.com/linux/ubuntu/gpg --output "${temporary_key}"
53
+ test -s "${temporary_key}"
54
+ install -o root -g root -m 0644 "${temporary_key}" "${keyring}"
55
+ rm -f "${temporary_key}"
56
+ trap - EXIT
57
+ touch "${backup_dir}/key-created"
58
+ fi
59
+ if [[ ! -f "${repository}" ]]; then
60
+ codename="$(sed -n 's/^UBUNTU_CODENAME=//p' /etc/os-release | tr -d '\"')"
61
+ if [[ -z "${codename}" ]]; then
62
+ codename="$(sed -n 's/^VERSION_CODENAME=//p' /etc/os-release | tr -d '\"')"
63
+ fi
64
+ [[ -n "${codename}" ]] || { echo "cannot determine Ubuntu codename" >&2; exit 1; }
65
+ temporary_repository="$(mktemp)"
66
+ trap 'rm -f "${temporary_repository}"' EXIT
67
+ cat > "${temporary_repository}" <<EOF
68
+ Types: deb
69
+ URIs: https://download.docker.com/linux/ubuntu
70
+ Suites: ${codename}
71
+ Components: stable
72
+ Signed-By: ${keyring}
73
+ EOF
74
+ install -o root -g root -m 0644 "${temporary_repository}" "${repository}"
75
+ rm -f "${temporary_repository}"
76
+ trap - EXIT
77
+ touch "${backup_dir}/repository-created"
78
+ fi
79
+ apt-get update
80
+ DEBIAN_FRONTEND=noninteractive apt-get install --yes "${packages[@]}"
81
+ systemctl enable --now docker
82
+ if ! id -nG "${user}" | tr ' ' '\n' | grep -Fxq docker; then
83
+ usermod -aG docker "${user}"
84
+ touch "${backup_dir}/group-added"
85
+ fi
86
+ if ! docker network inspect kitsune-private >/dev/null 2>&1; then
87
+ docker network create --driver bridge kitsune-private >/dev/null
88
+ touch "${backup_dir}/network-created"
89
+ fi
90
+ }
91
+
92
+ verify() {
93
+ systemctl is-active --quiet docker
94
+ docker version >/dev/null
95
+ docker compose version >/dev/null
96
+ id -nG "${user}" | tr ' ' '\n' | grep -Fxq docker
97
+ docker network inspect kitsune-private >/dev/null
98
+ }
99
+
100
+ rollback() {
101
+ if [[ -f "${backup_dir}/group-added" ]]; then
102
+ gpasswd -d "${user}" docker || true
103
+ fi
104
+ if [[ -f "${backup_dir}/network-created" ]]; then
105
+ docker network rm kitsune-private || true
106
+ fi
107
+ for package in "${packages[@]}"; do
108
+ if ! grep -Fxq "${package}" "${backup_dir}/packages-before" 2>/dev/null; then
109
+ apt-get remove --yes "${package}" || true
110
+ fi
111
+ done
112
+ for package in "${prerequisites[@]}"; do
113
+ if ! grep -Fxq "${package}" "${backup_dir}/packages-before" 2>/dev/null; then
114
+ apt-get remove --yes "${package}" || true
115
+ fi
116
+ done
117
+ if [[ -f "${backup_dir}/enabled-before" ]]; then
118
+ systemctl enable docker || true
119
+ else
120
+ systemctl disable docker || true
121
+ fi
122
+ if [[ -f "${backup_dir}/active-before" ]]; then
123
+ systemctl start docker || true
124
+ else
125
+ systemctl stop docker || true
126
+ fi
127
+ [[ -f "${backup_dir}/repository-created" ]] && rm -f "${repository}"
128
+ [[ -f "${backup_dir}/key-created" ]] && rm -f "${keyring}"
129
+ rm -rf "${backup_dir}"
130
+ }
131
+
132
+ case "${action}" in
133
+ apply) apply ;;
134
+ verify) verify ;;
135
+ rollback) rollback ;;
136
+ *) echo "unknown action: ${action}" >&2; exit 2 ;;
137
+ esac