kitsune-kit 0.5.0 → 0.6.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.
@@ -44,8 +44,27 @@ module Kitsune
44
44
  end
45
45
  end
46
46
 
47
+ class Compose < Data.define(:mode, :file, :allow_unsafe)
48
+ MODES = %w[generated overlay custom].freeze
49
+
50
+ def initialize(mode: "generated", file: nil, allow_unsafe: false, root: Dir.pwd)
51
+ expanded_file = file.to_s.empty? ? nil : Pathname(file.to_s).expand_path(root).to_s
52
+ super(mode: mode.to_s, file: expanded_file, allow_unsafe: boolean(allow_unsafe))
53
+ end
54
+
55
+ private
56
+
57
+ def boolean(value)
58
+ return value if [true, false].include?(value)
59
+ return true if value.to_s.casecmp("true").zero?
60
+ return false if value.to_s.casecmp("false").zero?
61
+
62
+ raise Errors::ConfigurationError, "expected a boolean, got #{value.inspect}"
63
+ end
64
+ end
65
+
47
66
  class Service < Data.define(:enabled, :mode, :host, :image, :publish, :bind, :allowed_cidrs, :port,
48
- :password_env)
67
+ :password_env, :compose)
49
68
  def initialize(**attributes)
50
69
  super(
51
70
  enabled: boolean(attributes.fetch(:enabled)),
@@ -56,7 +75,8 @@ module Kitsune
56
75
  bind: attributes.fetch(:bind).to_s,
57
76
  allowed_cidrs: Array(attributes.fetch(:allowed_cidrs)).map(&:to_s).freeze,
58
77
  port: Integer(attributes.fetch(:port)),
59
- password_env: attributes.fetch(:password_env).to_s
78
+ password_env: attributes.fetch(:password_env).to_s,
79
+ compose: attributes.fetch(:compose, Compose.new)
60
80
  )
61
81
  rescue ArgumentError, TypeError
62
82
  raise Errors::ConfigurationError, "service port must be an integer"
@@ -142,7 +162,8 @@ module Kitsune
142
162
  "bind" => "127.0.0.1",
143
163
  "allowed_cidrs" => [],
144
164
  "port" => 5432,
145
- "password_env" => "POSTGRES_PASSWORD"
165
+ "password_env" => "POSTGRES_PASSWORD",
166
+ "compose" => { "mode" => "generated", "file" => nil, "allow_unsafe" => false }
146
167
  },
147
168
  "redis" => {
148
169
  "enabled" => false,
@@ -153,7 +174,8 @@ module Kitsune
153
174
  "bind" => "127.0.0.1",
154
175
  "allowed_cidrs" => [],
155
176
  "port" => 6379,
156
- "password_env" => "REDIS_PASSWORD"
177
+ "password_env" => "REDIS_PASSWORD",
178
+ "compose" => { "mode" => "generated", "file" => nil, "allow_unsafe" => false }
157
179
  }
158
180
  },
159
181
  "system" => {
@@ -185,17 +207,20 @@ module Kitsune
185
207
  %w[server] => %w[name region size image ssh_key_id tags],
186
208
  %w[ssh] => %w[user port key_path allowed_cidrs],
187
209
  %w[services] => %w[postgres redis],
188
- %w[services postgres] => %w[enabled mode host image publish bind allowed_cidrs port password_env],
189
- %w[services redis] => %w[enabled mode host image publish bind allowed_cidrs port password_env],
210
+ %w[services postgres] => %w[enabled mode host image publish bind allowed_cidrs port password_env compose],
211
+ %w[services redis] => %w[enabled mode host image publish bind allowed_cidrs port password_env compose],
212
+ %w[services postgres compose] => %w[mode file allow_unsafe],
213
+ %w[services redis compose] => %w[mode file allow_unsafe],
190
214
  %w[system] => %w[swap_size_gb swap_swappiness unattended_upgrades metrics metrics_installer_sha256],
191
215
  %w[dns] => %w[domains ttl]
192
216
  }.freeze
193
217
 
194
218
  class Loader
195
- def initialize(root: Dir.pwd, env: ENV, config_path: nil)
219
+ def initialize(root: Dir.pwd, env: ENV, config_path: nil, validate_secrets: true)
196
220
  @root = Pathname(root).expand_path
197
221
  @env = env
198
222
  @config_path = config_path ? Pathname(config_path).expand_path : @root.join(".kitsune/config.yml")
223
+ @validate_secrets = validate_secrets
199
224
  end
200
225
 
201
226
  def load(environment: nil, overrides: {})
@@ -260,13 +285,13 @@ module Kitsune
260
285
  server: Server.new(**symbolize(data.fetch("server"))),
261
286
  ssh: Ssh.new(**symbolize(data.fetch("ssh"))),
262
287
  services: Services.new(
263
- postgres: Service.new(**symbolize(data.dig("services", "postgres"))),
264
- redis: Service.new(**symbolize(data.dig("services", "redis")))
288
+ postgres: build_service(data.dig("services", "postgres")),
289
+ redis: build_service(data.dig("services", "redis"))
265
290
  ),
266
291
  system: System.new(**symbolize(data.fetch("system"))),
267
292
  dns: Dns.new(**symbolize(data.fetch("dns")))
268
293
  )
269
- Validator.new(config, env: @env).validate!
294
+ Validator.new(config, env: @env, root: @root, validate_secrets: @validate_secrets).validate!
270
295
  config
271
296
  rescue KeyError => e
272
297
  raise Errors::ConfigurationError.new("missing configuration value: #{e.key}",
@@ -293,6 +318,12 @@ module Kitsune
293
318
  )
294
319
  end
295
320
 
321
+ def build_service(data)
322
+ attributes = symbolize(data)
323
+ compose = Compose.new(**symbolize(data.fetch("compose")), root: @root)
324
+ Service.new(**attributes, compose: compose)
325
+ end
326
+
296
327
  def validate_schema_version!(version)
297
328
  return if version == SCHEMA_VERSION
298
329
 
@@ -335,9 +366,11 @@ module Kitsune
335
366
  IMAGE = %r{\A[a-z0-9]+(?:[._/-][a-z0-9]+)*(?::[a-zA-Z0-9][a-zA-Z0-9._-]*)?(?:@sha256:[a-f0-9]{64})?\z}
336
367
  INSECURE_SECRETS = %w[password postgres redis changeme change-me secret admin].freeze
337
368
 
338
- def initialize(config, env: ENV)
369
+ def initialize(config, env: ENV, root: Dir.pwd, validate_secrets: true)
339
370
  @config = config
340
371
  @env = env
372
+ @root = Pathname(root).expand_path
373
+ @validate_secrets = validate_secrets
341
374
  @errors = []
342
375
  end
343
376
 
@@ -410,7 +443,58 @@ module Kitsune
410
443
  else
411
444
  validate_managed_service(name, service)
412
445
  end
413
- validate_service_secret(name, service) if service.enabled
446
+ validate_compose(name, service)
447
+ validate_service_secret(name, service) if service.enabled && @validate_secrets
448
+ end
449
+
450
+ def validate_compose(name, service)
451
+ compose = service.compose
452
+ return invalid_compose_mode(name) unless Compose::MODES.include?(compose.mode)
453
+
454
+ validate_compose_ownership(name, service)
455
+ return validate_generated_compose(name, compose) if compose.mode == "generated"
456
+ return missing_compose_file(name, compose) unless compose.file
457
+
458
+ validate_compose_file(name, Pathname(compose.file))
459
+ rescue SystemCallError => e
460
+ @errors << "services.#{name}.compose.file cannot be inspected: #{e.class}"
461
+ end
462
+
463
+ def invalid_compose_mode(name)
464
+ @errors << "services.#{name}.compose.mode must be generated, overlay or custom"
465
+ end
466
+
467
+ def validate_compose_ownership(name, service)
468
+ return unless service.mode == "external" && service.compose.mode != "generated"
469
+
470
+ @errors << "services.#{name}.compose is only available in managed mode"
471
+ end
472
+
473
+ def validate_generated_compose(name, compose)
474
+ @errors << "services.#{name}.compose.file must be empty in generated mode" if compose.file
475
+ return unless compose.allow_unsafe
476
+
477
+ @errors << "services.#{name}.compose.allow_unsafe must be false in generated mode"
478
+ end
479
+
480
+ def missing_compose_file(name, compose)
481
+ @errors << "services.#{name}.compose.file is required in #{compose.mode} mode"
482
+ end
483
+
484
+ def validate_compose_file(name, path)
485
+ root_prefix = "#{@root}#{File::SEPARATOR}"
486
+ return @errors << "services.#{name}.compose.file must stay inside the project root" unless
487
+ path.to_s.start_with?(root_prefix)
488
+
489
+ if path.symlink?
490
+ @errors << "services.#{name}.compose.file must not be a symbolic link"
491
+ elsif !path.file?
492
+ @errors << "services.#{name}.compose.file does not exist or is not a regular file: #{path}"
493
+ elsif !path.realpath.to_s.start_with?("#{@root.realpath}#{File::SEPARATOR}")
494
+ @errors << "services.#{name}.compose.file must not resolve outside the project root"
495
+ elsif path.size > 262_144
496
+ @errors << "services.#{name}.compose.file must not exceed 256 KiB"
497
+ end
414
498
  end
415
499
 
416
500
  def validate_managed_service(name, service)
@@ -12,7 +12,8 @@ require_relative "service_state"
12
12
  module Kitsune
13
13
  module Kit
14
14
  module Operations
15
- class EnsureService
15
+ # Service lifecycle coordination intentionally lives together so apply and rollback share one transaction model.
16
+ class EnsureService # rubocop:disable Metrics/ClassLength
16
17
  TYPES = %w[postgres redis].freeze
17
18
  MARKER_DIRECTORY = "/var/lib/kitsune/state"
18
19
 
@@ -62,7 +63,11 @@ module Kitsune
62
63
  transport.execute("mkdir", arguments: ["-p", service_directory], timeout: 10).then do |result|
63
64
  raise_remote!(result, "create service directory")
64
65
  end
65
- transport.upload(content: compose_content, remote_path: compose_path, mode: "0600")
66
+ @compose.documents.each do |document|
67
+ transport.upload(
68
+ content: document.content, remote_path: files.compose_path(document.filename), mode: "0600"
69
+ )
70
+ end
66
71
  transport.upload(content: env_content(password), remote_path: env_path, mode: "0600")
67
72
  validate_compose
68
73
  start_service
@@ -79,7 +84,7 @@ module Kitsune
79
84
  def remove
80
85
  return false unless managed?
81
86
 
82
- compose("down", "--remove-orphans")
87
+ compose("down", "--remove-orphans", filenames: installed_compose_files)
83
88
  firewall.remove
84
89
  remove_marker
85
90
  @managed_state.record_remove
@@ -97,7 +102,8 @@ module Kitsune
97
102
  remove
98
103
  if files.restore_previous(previous, marker_path: marker_path)
99
104
  firewall.restore(previous["previous_state"] || {})
100
- start_service if previous.dig("previous_state", "installed") != false
105
+ start_service(filenames: previous.dig("previous_state", "compose_files")) if
106
+ previous.dig("previous_state", "installed") != false
101
107
  end
102
108
  @managed_state.record_rollback(previous)
103
109
  true
@@ -107,7 +113,7 @@ module Kitsune
107
113
  previous = managed_state
108
114
  return false unless previous
109
115
 
110
- compose("down", "--volumes", "--remove-orphans")
116
+ compose("down", "--volumes", "--remove-orphans", filenames: installed_compose_files)
111
117
  files.destroy(previous, marker_path: marker_path)
112
118
  @managed_state.delete("destroy_data")
113
119
  true
@@ -120,13 +126,12 @@ module Kitsune
120
126
  def transport = @transport ||= transport_factory.deploy
121
127
  def project_name = "kitsune-#{config.environment}-#{@type}"
122
128
  def service_directory = files.directory
123
- def compose_path = files.compose_path
124
129
  def env_path = files.env_path
125
130
  def marker_path = "#{MARKER_DIRECTORY}/service-#{@type}.sha256"
126
131
 
127
132
  def fingerprint
128
133
  @fingerprint ||= Digest::SHA256.hexdigest(
129
- [compose_content, service.password_env, secret_digest, service.publish, service.bind,
134
+ [@compose.fingerprint, service.password_env, secret_digest, service.publish, service.bind,
130
135
  *service.allowed_cidrs].join("\0")
131
136
  )
132
137
  end
@@ -147,21 +152,24 @@ module Kitsune
147
152
  value
148
153
  end
149
154
 
150
- def compose_content = @compose.content
151
155
  def env_content(password) = @compose.env_content(password)
152
156
 
153
157
  def validate_compose = compose("config", "--quiet")
154
- def start_service = compose("up", "--detach", "--wait", "--wait-timeout", "120")
158
+
159
+ def start_service(filenames: nil)
160
+ compose("up", "--detach", "--wait", "--wait-timeout", "120", filenames: filenames)
161
+ end
155
162
 
156
163
  def verify_service
157
164
  result = compose("ps", "--status", "running", "--quiet")
158
165
  raise Errors::VerificationError, "#{@type} did not reach running state" if result.stdout.strip.empty?
159
166
  end
160
167
 
161
- def compose(*arguments)
168
+ def compose(*arguments, filenames: nil)
162
169
  result = transport.execute(
163
170
  "docker",
164
- arguments: ["compose", "--project-directory", service_directory, "--file", compose_path, *arguments],
171
+ arguments: ["compose", "--project-directory", service_directory,
172
+ *compose_file_arguments(filenames), *arguments],
165
173
  timeout: 180
166
174
  )
167
175
  raise_remote!(result, "docker compose #{arguments.first}")
@@ -176,7 +184,7 @@ module Kitsune
176
184
  firewall.restore_after_failure(previous_state) if @firewall_changed
177
185
  safe_compose_down
178
186
  files.restore_after_failure(@backed_up_files)
179
- start_service unless @backed_up_files.empty?
187
+ start_service(filenames: previous_state&.fetch("compose_files", nil)) unless @backed_up_files.empty?
180
188
  end
181
189
 
182
190
  def recover_failed_apply(previous_state, original_error)
@@ -194,7 +202,7 @@ module Kitsune
194
202
  def safe_compose_down
195
203
  transport.execute(
196
204
  "docker",
197
- arguments: ["compose", "--project-directory", service_directory, "--file", compose_path,
205
+ arguments: ["compose", "--project-directory", service_directory, *compose_file_arguments,
198
206
  "down", "--remove-orphans"],
199
207
  timeout: 180
200
208
  )
@@ -219,6 +227,8 @@ module Kitsune
219
227
  {
220
228
  "managed" => true, "installed" => true, "fingerprint" => fingerprint, "project" => project_name,
221
229
  "directory" => service_directory, "published" => service.publish, "port" => service.port,
230
+ "compose_mode" => @compose.mode, "compose_files" => @compose.filenames,
231
+ "compose_fingerprint" => @compose.fingerprint,
222
232
  "firewall_rules_added" => firewall.owned_rules, "firewall_drop_added" => firewall.drop_owned,
223
233
  "volume" => "#{project_name}_data",
224
234
  "previous_fingerprint" => change.details[:actual_fingerprint] || change.details["actual_fingerprint"],
@@ -229,11 +239,12 @@ module Kitsune
229
239
 
230
240
  def managed? = @managed_state.managed?
231
241
  def managed_state = @managed_state.current
242
+ def installed_compose_files = managed_state&.fetch("compose_files", nil)
232
243
 
233
244
  def files
234
245
  @files ||= ServiceFiles.new(
235
246
  config: config, type: @type, transport: transport, state_store: state_store,
236
- resource: resource, fingerprint: -> { fingerprint }
247
+ resource: resource, fingerprint: -> { fingerprint }, compose_filenames: -> { @compose.filenames }
237
248
  )
238
249
  end
239
250
 
@@ -246,7 +257,8 @@ module Kitsune
246
257
 
247
258
  def backup_operation
248
259
  @backup_operation ||= ServiceBackup.new(
249
- config: config, type: @type, transport: transport, compose: method(:compose), clock: @clock
260
+ config: config, type: @type, transport: transport,
261
+ compose: ->(*arguments) { compose(*arguments, filenames: installed_compose_files) }, clock: @clock
250
262
  )
251
263
  end
252
264
 
@@ -274,10 +286,15 @@ module Kitsune
274
286
  image: service.image, published: service.publish,
275
287
  bind: service.publish ? service.bind : nil, port: service.publish ? service.port : nil,
276
288
  fingerprint: fingerprint,
277
- actual_fingerprint: actual.empty? ? nil : actual
289
+ actual_fingerprint: actual.empty? ? nil : actual,
290
+ compose: @compose.metadata
278
291
  }
279
292
  end
280
293
 
294
+ def compose_file_arguments(filenames = nil)
295
+ Array(filenames || @compose.filenames).flat_map { |filename| ["--file", files.compose_path(filename)] }
296
+ end
297
+
281
298
  def error_name(error) = error.respond_to?(:code) ? error.code : error.class.name
282
299
  end
283
300
  end
@@ -6,23 +6,25 @@ module Kitsune
6
6
  module Kit
7
7
  module Operations
8
8
  class ServiceFiles
9
- def initialize(config:, type:, transport:, state_store:, resource:, fingerprint:)
9
+ def initialize(config:, type:, transport:, state_store:, resource:, fingerprint:, compose_filenames:)
10
10
  @config = config
11
11
  @type = type
12
12
  @transport = transport
13
13
  @state_store = state_store
14
14
  @resource = resource
15
15
  @fingerprint = fingerprint
16
+ @compose_filenames = compose_filenames
16
17
  end
17
18
 
18
19
  def directory = "/home/#{config.ssh.user}/.local/share/kitsune/services/#{type}"
19
- def compose_path = "#{directory}/compose.yml"
20
+ def compose_path(filename = "compose.yml") = "#{directory}/#{filename}"
21
+ def compose_paths = compose_filenames.call.map { |filename| compose_path(filename) }
20
22
  def env_path = "#{directory}/.env"
21
23
  def backup_directory = "/var/lib/kitsune/backups/service-#{type}-#{fingerprint.call[0, 12]}"
22
24
 
23
25
  def ensure_managed_or_absent!(actual_fingerprint)
24
26
  return if managed?
25
- return if actual_fingerprint.empty? && !exists?(compose_path) && !exists?(env_path)
27
+ return if actual_fingerprint.empty? && known_paths.none? { |path| exists?(path) }
26
28
 
27
29
  raise Errors::UnsafeOperationError.new(
28
30
  "refusing to replace unmanaged #{type} service files",
@@ -39,17 +41,14 @@ module Kitsune
39
41
  timeout: 20
40
42
  )
41
43
  raise_remote!(result, "create service backup directory")
42
- [compose_path, env_path].select { |path| exists?(path) }.tap do |paths|
44
+ known_paths.select { |path| exists?(path) }.tap do |paths|
43
45
  paths.each { |path| copy(path, backup_directory) }
44
46
  end
45
47
  end
46
48
 
47
49
  def restore_after_failure(backed_up_files)
48
- if backed_up_files.empty?
49
- transport.execute("rm", arguments: ["-f", compose_path, env_path], timeout: 20)
50
- else
51
- backed_up_files.each { |path| copy("#{backup_directory}/#{File.basename(path)}", File.dirname(path)) }
52
- end
50
+ transport.execute("rm", arguments: ["-f", *known_paths], timeout: 20)
51
+ backed_up_files.each { |path| copy("#{backup_directory}/#{File.basename(path)}", File.dirname(path)) }
53
52
  end
54
53
 
55
54
  def restore_previous(previous, marker_path:)
@@ -75,7 +74,12 @@ module Kitsune
75
74
 
76
75
  private
77
76
 
78
- attr_reader :config, :type, :transport, :state_store, :resource, :fingerprint
77
+ attr_reader :config, :type, :transport, :state_store, :resource, :fingerprint, :compose_filenames
78
+
79
+ def known_paths
80
+ previous = state_store.read(config.environment).dig("resources", resource, "compose_files") || []
81
+ (compose_paths + previous.map { |filename| compose_path(filename) } + [env_path]).uniq
82
+ end
79
83
 
80
84
  def managed? = !!state_store.read(config.environment).dig("resources", resource, "managed")
81
85
  def exists?(path) = transport.execute("test", arguments: ["-e", path], timeout: 10).success?
@@ -9,6 +9,11 @@ repository="/etc/apt/sources.list.d/docker.sources"
9
9
  packages=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin)
10
10
  prerequisites=(ca-certificates curl)
11
11
  conflicting_packages=(docker.io docker-compose docker-compose-v2 podman-docker containerd runc)
12
+ apt_options=(-o DPkg::Lock::Timeout=120 -o Acquire::Retries=3)
13
+
14
+ apt_get() {
15
+ DEBIAN_FRONTEND=noninteractive apt-get "${apt_options[@]}" "$@"
16
+ }
12
17
 
13
18
  refuse_conflicting_packages() {
14
19
  local installed=()
@@ -42,8 +47,8 @@ apply() {
42
47
  refuse_conflicting_packages
43
48
  install -d -m 0700 "${backup_dir}"
44
49
  snapshot
45
- apt-get update
46
- DEBIAN_FRONTEND=noninteractive apt-get install --yes "${prerequisites[@]}"
50
+ apt_get update
51
+ apt_get install --yes "${prerequisites[@]}"
47
52
  install -d -m 0755 /etc/apt/keyrings
48
53
  if [[ ! -f "${keyring}" ]]; then
49
54
  temporary_key="$(mktemp)"
@@ -76,8 +81,8 @@ EOF
76
81
  trap - EXIT
77
82
  touch "${backup_dir}/repository-created"
78
83
  fi
79
- apt-get update
80
- DEBIAN_FRONTEND=noninteractive apt-get install --yes "${packages[@]}"
84
+ apt_get update
85
+ apt_get install --yes "${packages[@]}"
81
86
  systemctl enable --now docker
82
87
  if ! id -nG "${user}" | tr ' ' '\n' | grep -Fxq docker; then
83
88
  usermod -aG docker "${user}"
@@ -106,12 +111,12 @@ rollback() {
106
111
  fi
107
112
  for package in "${packages[@]}"; do
108
113
  if ! grep -Fxq "${package}" "${backup_dir}/packages-before" 2>/dev/null; then
109
- apt-get remove --yes "${package}" || true
114
+ apt_get remove --yes "${package}" || true
110
115
  fi
111
116
  done
112
117
  for package in "${prerequisites[@]}"; do
113
118
  if ! grep -Fxq "${package}" "${backup_dir}/packages-before" 2>/dev/null; then
114
- apt-get remove --yes "${package}" || true
119
+ apt_get remove --yes "${package}" || true
115
120
  fi
116
121
  done
117
122
  if [[ -f "${backup_dir}/enabled-before" ]]; then
@@ -8,6 +8,11 @@ allowed_cidrs=("$@")
8
8
  backup_dir="/var/lib/kitsune/backups/firewall"
9
9
  added_file="${backup_dir}/rules-added"
10
10
  pending_file="${backup_dir}/rules-pending"
11
+ apt_options=(-o DPkg::Lock::Timeout=120 -o Acquire::Retries=3)
12
+
13
+ apt_get() {
14
+ DEBIAN_FRONTEND=noninteractive apt-get "${apt_options[@]}" "$@"
15
+ }
11
16
 
12
17
  validate() {
13
18
  [[ "${ssh_port}" =~ ^[0-9]+$ ]] && (( ssh_port >= 1 && ssh_port <= 65535 ))
@@ -127,8 +132,8 @@ apply() {
127
132
  touch "${backup_dir}/snapshot-complete"
128
133
  fi
129
134
  if ! dpkg-query -W -f='${Status}' ufw 2>/dev/null | grep -q "ok installed"; then
130
- apt-get update
131
- DEBIAN_FRONTEND=noninteractive apt-get install --yes ufw
135
+ apt_get update
136
+ apt_get install --yes ufw
132
137
  touch "${backup_dir}/package-created"
133
138
  fi
134
139
 
@@ -190,7 +195,7 @@ rollback() {
190
195
  ufw --force disable || true
191
196
  fi
192
197
  if [[ -f "${backup_dir}/package-created" ]]; then
193
- apt-get remove --yes ufw || true
198
+ apt_get remove --yes ufw || true
194
199
  fi
195
200
  rm -rf "${backup_dir}"
196
201
  }
@@ -4,6 +4,11 @@ set -Eeuo pipefail
4
4
  action="${1:?action is required}"
5
5
  expected_sha256="${2:?installer SHA-256 is required}"
6
6
  backup_dir="/var/lib/kitsune/backups/metrics"
7
+ apt_options=(-o DPkg::Lock::Timeout=120 -o Acquire::Retries=3)
8
+
9
+ apt_get() {
10
+ DEBIAN_FRONTEND=noninteractive apt-get "${apt_options[@]}" "$@"
11
+ }
7
12
 
8
13
  validate() {
9
14
  [[ "${expected_sha256}" =~ ^[a-f0-9]{64}$ ]] || {
@@ -41,7 +46,7 @@ verify() {
41
46
  rollback() {
42
47
  if [[ -f "${backup_dir}/package-created" ]]; then
43
48
  systemctl disable --now do-agent || true
44
- apt-get remove --purge --yes do-agent || true
49
+ apt_get remove --purge --yes do-agent || true
45
50
  fi
46
51
  rm -rf "${backup_dir}"
47
52
  }
@@ -6,6 +6,11 @@ config_file="/etc/apt/apt.conf.d/20auto-upgrades"
6
6
  backup_dir="/var/lib/kitsune/backups/unattended"
7
7
  backup_file="${backup_dir}/20auto-upgrades.before"
8
8
  packages=(unattended-upgrades apt-listchanges)
9
+ apt_options=(-o DPkg::Lock::Timeout=120 -o Acquire::Retries=3)
10
+
11
+ apt_get() {
12
+ DEBIAN_FRONTEND=noninteractive apt-get "${apt_options[@]}" "$@"
13
+ }
9
14
 
10
15
  snapshot() {
11
16
  [[ -f "${backup_dir}/snapshot-complete" ]] && return
@@ -28,8 +33,8 @@ snapshot() {
28
33
  apply() {
29
34
  install -d -m 0700 "${backup_dir}"
30
35
  snapshot
31
- apt-get update
32
- DEBIAN_FRONTEND=noninteractive apt-get install --yes "${packages[@]}"
36
+ apt_get update
37
+ apt_get install --yes "${packages[@]}"
33
38
  cat > "${config_file}" <<'EOF'
34
39
  APT::Periodic::Update-Package-Lists "1";
35
40
  APT::Periodic::Download-Upgradeable-Packages "1";
@@ -67,7 +72,7 @@ rollback() {
67
72
  restore_service_state
68
73
  for package in "${packages[@]}"; do
69
74
  if ! grep -Fxq "${package}" "${backup_dir}/packages-before" 2>/dev/null; then
70
- apt-get remove --yes "${package}" || true
75
+ apt_get remove --yes "${package}" || true
71
76
  fi
72
77
  done
73
78
  rm -rf "${backup_dir}"