kozo 0.1.0 → 0.2.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -2
  3. data/.kzignore +1 -0
  4. data/.overcommit.yml +1 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +44 -5
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +3 -1
  9. data/Gemfile.lock +156 -48
  10. data/LICENSE.md +1 -1
  11. data/README.md +28 -0
  12. data/Rakefile +3 -7
  13. data/bin/kozo +6 -2
  14. data/config/application.rb +3 -0
  15. data/config/dependencies.rb +4 -25
  16. data/kozo.gemspec +20 -5
  17. data/lib/core_ext/boolean.rb +12 -0
  18. data/lib/core_ext/nil_class.rb +11 -0
  19. data/lib/core_ext/string.rb +25 -0
  20. data/lib/kozo/backend.rb +81 -0
  21. data/lib/kozo/backends/git.rb +50 -0
  22. data/lib/kozo/backends/local.rb +41 -15
  23. data/lib/kozo/backends/memory.rb +26 -0
  24. data/lib/kozo/cli.rb +30 -9
  25. data/lib/kozo/command.rb +32 -0
  26. data/lib/kozo/commands/apply.rb +44 -0
  27. data/lib/kozo/commands/console.rb +15 -0
  28. data/lib/kozo/commands/import.rb +47 -0
  29. data/lib/kozo/commands/init.rb +15 -0
  30. data/lib/kozo/commands/plan.rb +29 -3
  31. data/lib/kozo/commands/refresh.rb +21 -0
  32. data/lib/kozo/commands/show.rb +15 -0
  33. data/lib/kozo/commands/state.rb +64 -0
  34. data/lib/kozo/commands/validate.rb +13 -0
  35. data/lib/kozo/commands/version.rb +13 -0
  36. data/lib/kozo/concerns/assignment.rb +17 -0
  37. data/lib/kozo/concerns/attributes.rb +99 -0
  38. data/lib/kozo/concerns/mark.rb +25 -0
  39. data/lib/kozo/concerns/track.rb +47 -0
  40. data/lib/kozo/configuration.rb +34 -7
  41. data/lib/kozo/dsl.rb +18 -12
  42. data/lib/kozo/error.rb +13 -0
  43. data/lib/kozo/logger.rb +8 -4
  44. data/lib/kozo/operation.rb +42 -0
  45. data/lib/kozo/operations/create.rb +29 -0
  46. data/lib/kozo/operations/destroy.rb +29 -0
  47. data/lib/kozo/operations/show.rb +12 -0
  48. data/lib/kozo/operations/update.rb +19 -0
  49. data/lib/kozo/options.rb +9 -1
  50. data/lib/kozo/parser.rb +35 -0
  51. data/lib/kozo/provider.rb +7 -1
  52. data/lib/kozo/providers/dummy/dependencies.rb +9 -0
  53. data/lib/kozo/providers/{null → dummy}/provider.rb +2 -6
  54. data/lib/kozo/providers/dummy/resource.rb +19 -0
  55. data/lib/kozo/providers/dummy/resources/dummy.rb +16 -0
  56. data/lib/kozo/providers/hcloud/dependencies.rb +13 -0
  57. data/lib/kozo/providers/hcloud/provider.rb +7 -1
  58. data/lib/kozo/providers/hcloud/resource.rb +46 -1
  59. data/lib/kozo/providers/hcloud/resources/server.rb +29 -0
  60. data/lib/kozo/providers/hcloud/resources/ssh_key.rb +6 -2
  61. data/lib/kozo/resource.rb +110 -3
  62. data/lib/kozo/state.rb +25 -0
  63. data/lib/kozo/type.rb +15 -0
  64. data/lib/kozo/types/boolean.rb +30 -0
  65. data/lib/kozo/types/date.rb +20 -0
  66. data/lib/kozo/types/float.rb +17 -0
  67. data/lib/kozo/types/hash.rb +20 -0
  68. data/lib/kozo/types/integer.rb +17 -0
  69. data/lib/kozo/types/string.rb +17 -0
  70. data/lib/kozo/types/time.rb +18 -0
  71. data/lib/kozo/version.rb +1 -1
  72. data/lib/kozo.rb +13 -7
  73. metadata +195 -31
  74. data/.github/dependabot.yml +0 -14
  75. data/.github/workflows/ci.yml +0 -81
  76. data/bin/bundle +0 -118
  77. data/bin/console +0 -7
  78. data/bin/rspec +0 -28
  79. data/bin/version +0 -62
  80. data/lib/kozo/backends/base.rb +0 -31
  81. data/lib/kozo/commands/base.rb +0 -21
  82. data/lib/kozo/container.rb +0 -35
  83. data/lib/kozo/environment.rb +0 -27
  84. data/lib/kozo/providers/null/resource.rb +0 -11
  85. data/lib/kozo/providers/null/resources/null.rb +0 -13
  86. data/log/.keep +0 -0
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ class Backend
5
+ attr_accessor :configuration, :directory
6
+
7
+ def initialize(configuration, directory)
8
+ @configuration = configuration
9
+ @directory = directory
10
+ end
11
+
12
+ def state
13
+ @state ||= State
14
+ .new(resources)
15
+ end
16
+
17
+ def state=(value)
18
+ @state = value
19
+
20
+ self.data = value.to_h
21
+ end
22
+
23
+ def validate!
24
+ state_version = data.fetch(:version)
25
+ kozo_version = data.fetch(:kozo_version)
26
+
27
+ raise StateError, "invalid version in state: got #{state_version}, expected #{State::VERSION}" unless state_version == State::VERSION
28
+
29
+ return if kozo_version == Kozo::VERSION
30
+
31
+ raise StateError, "invalid kozo version in state: got #{kozo_version}, expected #{Kozo::VERSION}"
32
+ end
33
+
34
+ ##
35
+ # Create necessary backend files/structures if they do not exist
36
+ #
37
+ def initialize!
38
+ raise NotImplementedError
39
+ end
40
+
41
+ def ==(other)
42
+ directory == other.directory
43
+ end
44
+
45
+ protected
46
+
47
+ ##
48
+ # Read state from backend
49
+ #
50
+ # @return Hash
51
+ #
52
+ def data
53
+ raise NotImplementedError
54
+ end
55
+
56
+ ##
57
+ # Write state to backend
58
+ #
59
+ # @param value Hash
60
+ #
61
+ def data=(value)
62
+ raise NotImplementedError
63
+ end
64
+
65
+ private
66
+
67
+ def resources
68
+ data
69
+ .fetch(:resources, [])
70
+ .map do |hash|
71
+ provider = configuration.providers[hash.dig(:meta, :provider)]
72
+
73
+ raise StateError, "provider #{hash.dig(:meta, :provider)} not configured" unless provider
74
+
75
+ Resource
76
+ .from_h(hash)
77
+ .tap { |r| r.provider = provider }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "git"
4
+
5
+ module Kozo
6
+ module Backends
7
+ class Git < Local
8
+ attr_writer :repository
9
+ attr_accessor :remote
10
+
11
+ def initialize!
12
+ Kozo.logger.debug "Initializing local state in #{repository_path}"
13
+
14
+ return if Dir.exist?(repository_path)
15
+
16
+ # Initialize git repository
17
+ @git = ::Git.init(repository_path)
18
+ git.add_remote("origin", remote) if remote
19
+
20
+ super
21
+ end
22
+
23
+ def data=(value)
24
+ super
25
+
26
+ git.add(file)
27
+ git.commit("Update Kozo resource state")
28
+ git.push(git.remote("origin")) if remote
29
+ end
30
+
31
+ def repository
32
+ @repository ||= "kozo.git"
33
+ end
34
+
35
+ private
36
+
37
+ def repository_path
38
+ @repository_path ||= File.join(directory, repository)
39
+ end
40
+
41
+ def path
42
+ @path ||= File.join(directory, repository, file)
43
+ end
44
+
45
+ def git
46
+ @git ||= Git.open(path)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,33 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require "fileutils"
4
+ require "yaml"
4
5
 
5
6
  module Kozo
6
7
  module Backends
7
- class Local < Base
8
- def state
9
- Kozo.logger.debug "Reading local state in #{file}"
8
+ class Local < Kozo::Backend
9
+ attr_writer :file
10
+ attr_accessor :backups
10
11
 
11
- File.write(file, {}.to_json) unless File.exist?(file)
12
+ def initialize(configuration, directory)
13
+ super
12
14
 
13
- JSON.parse(File.read(file), symbolize_names: true)
14
- rescue JSON::ParserError => e
15
- Kozo.logger.fatal "Could not read state file: #{e.message}"
15
+ @directory ||= Kozo.options.directory
16
16
  end
17
17
 
18
- def state=(value)
19
- Kozo.logger.debug "Writing local state in #{file}"
20
- File.write(file, value.to_json)
18
+ def initialize!
19
+ Kozo.logger.debug "Initializing local state in #{path}"
20
+
21
+ return if File.exist?(path)
22
+
23
+ # Initialize empty local state
24
+ self.state = State.new
21
25
  end
22
26
 
23
- def ==(other)
24
- directory == other.directory
27
+ def data
28
+ Kozo.logger.debug "Reading local state in #{path}"
29
+
30
+ YAML
31
+ .safe_load(File.read(path), [Time, Date])
32
+ .deep_symbolize_keys
33
+ rescue Errno::ENOENT, Errno::ENOTDIR
34
+ raise StateError, "local state at #{path} not initialized"
35
+ rescue Psych::SyntaxError => e
36
+ raise StateError, "could not read state file: #{e.message}"
25
37
  end
26
38
 
27
- private
39
+ def data=(value)
40
+ Kozo.logger.debug "Writing local state in #{path}"
41
+
42
+ @data = value
43
+
44
+ # Write backup state file
45
+ FileUtils.mv(path, "#{path}.#{DateTime.current.to_i}.kzbackup") if backups
46
+
47
+ # Write local state file
48
+ File.write(path, value.deep_stringify_keys.to_yaml)
49
+ end
28
50
 
29
51
  def file
30
- @file ||= File.join(directory, "kozo.kzstate")
52
+ @file ||= "kozo.kzstate"
53
+ end
54
+
55
+ def path
56
+ @path ||= File.join(directory, file)
31
57
  end
32
58
  end
33
59
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Backends
5
+ class Memory < Kozo::Backend
6
+ def initialize!
7
+ Kozo.logger.debug "Initializing local state in memory"
8
+
9
+ # Initialize empty local state
10
+ self.state = State.new
11
+ end
12
+
13
+ def data
14
+ Kozo.logger.debug "Reading local state in memory"
15
+
16
+ @data
17
+ end
18
+
19
+ def data=(value)
20
+ Kozo.logger.debug "Writing local state in memory"
21
+
22
+ @data = value
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/kozo/cli.rb CHANGED
@@ -1,19 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "English"
4
5
 
5
6
  module Kozo
6
7
  class CLI
7
8
  attr_reader :parser, :args, :command_args
8
9
 
9
10
  def initialize(args)
10
- @parser = OptionParser.new("#{File.basename($PROGRAM_NAME)} [global options] command [command options]") do |o|
11
+ @parser = OptionParser.new("#{File.basename($PROGRAM_NAME)} [global options] command [subcommand] [command options]") do |o|
11
12
  o.on("Global options:")
13
+ o.on("-d", "--directory=DIRECTORY", "Set working directory")
12
14
  o.on("-v", "--verbose", "Turn on verbose logging")
13
15
  o.on("-h", "--help", "Display this message") { usage }
14
16
  o.separator("\n")
15
17
  o.on("Commands:")
16
- commands.each { |(name, description)| o.on(" #{name}#{description.rjust(48)}") }
18
+ commands.each do |(name, description, subcommands)|
19
+ o.on(" #{name.ljust(33)}#{description}")
20
+
21
+ subcommands.each do |(sb_name, sb_description)|
22
+ o.on(" #{sb_name.ljust(31)}#{sb_description}")
23
+ end
24
+ end
17
25
  o.separator("\n")
18
26
  end
19
27
 
@@ -37,28 +45,41 @@ module Kozo
37
45
  def start
38
46
  command = command_args.shift
39
47
 
40
- return usage unless command
48
+ raise UsageError, "no command specified" unless command
41
49
 
42
50
  klass = "Kozo::Commands::#{command.camelize}".safe_constantize
43
51
 
44
- return usage(tail: "#{File.basename($PROGRAM_NAME)}: unknown command: #{command}") unless klass
52
+ raise UsageError, "unknown command: #{command}" unless klass
45
53
 
46
54
  klass
47
- .new(command_args)
55
+ .new(*command_args)
48
56
  .start
57
+ rescue UsageError => e
58
+ # Don't print tail if no message was passed
59
+ return usage if e.message == e.class.name
60
+
61
+ usage(tail: "#{File.basename($PROGRAM_NAME)}: #{e.message}")
62
+ rescue Error => e
63
+ Kozo.logger.fatal e.message
49
64
  end
50
65
 
51
66
  private
52
67
 
53
68
  def usage(code: 1, tail: nil)
54
- puts parser.to_s
55
- puts tail if tail
69
+ Kozo.logger.info parser.to_s
70
+ Kozo.logger.info tail if tail
56
71
 
57
- exit code
72
+ raise ExitError, code
58
73
  end
59
74
 
60
75
  def commands
61
- Commands::Base.descendants.map { |k| [k.name.demodulize.underscore, k.description] }
76
+ Command.subclasses.sort_by(&:name).map do |k|
77
+ [
78
+ k.name.demodulize.underscore,
79
+ k.description,
80
+ k.descendants.sort_by(&:name).map { |s| [s.name.demodulize.underscore, s.description] },
81
+ ]
82
+ end
62
83
  end
63
84
  end
64
85
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ class Command
5
+ class_attribute :description
6
+
7
+ attr_reader :args
8
+ attr_writer :state, :configuration
9
+
10
+ def initialize(*args)
11
+ @args = args
12
+ end
13
+
14
+ def start
15
+ raise NotImplementedError
16
+ end
17
+
18
+ protected
19
+
20
+ def configuration
21
+ @configuration ||= Parser
22
+ .new(Kozo.options.directory)
23
+ .call
24
+ end
25
+
26
+ def state
27
+ @state ||= configuration
28
+ .backend
29
+ .state
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Apply < Plan
6
+ self.description = "Execute a plan"
7
+
8
+ attr_reader :parser, :options
9
+
10
+ def initialize(*args)
11
+ super()
12
+
13
+ @options = {
14
+ yes: false,
15
+ }
16
+
17
+ @parser = OptionParser.new do |o|
18
+ o.on("-y", "--yes", "Answer yes to all prompts")
19
+ end.parse!(args, into: options)
20
+ end
21
+
22
+ def start
23
+ super
24
+
25
+ return if operations.empty?
26
+
27
+ unless options[:yes]
28
+ print "\nContinue executing these actions (yes/no)? ".bold
29
+
30
+ abort("\nApply cancelled") unless gets.chomp == "yes"
31
+ end
32
+
33
+ # Apply operations to in-memory state and remote infrastructure
34
+ operations
35
+ .each { |o| o.apply(state) }
36
+
37
+ # Write state
38
+ configuration
39
+ .backend
40
+ .state = state
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Console < Kozo::Command
6
+ self.description = "Open an interactive Ruby console"
7
+
8
+ def start
9
+ # rubocop:disable Lint/Debugger
10
+ binding.irb
11
+ # rubocop:enable Lint/Debugger
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Import < Kozo::Command
6
+ self.description = "Import existing resources into the state"
7
+
8
+ attr_reader :address, :id
9
+
10
+ def initialize(*args)
11
+ address = args.shift
12
+
13
+ raise UsageError, "address not specified" unless address
14
+
15
+ id = args.shift
16
+
17
+ raise UsageError, "id not specified" unless id
18
+
19
+ @address = address
20
+ @id = id
21
+ end
22
+
23
+ def start
24
+ # Find resource in configuration
25
+ resource = configuration
26
+ .resources
27
+ .find { |r| r.address == address }
28
+
29
+ raise StateError, "no such resource address: #{address}" unless resource
30
+
31
+ # Set ID and fetch attributes
32
+ resource
33
+ .tap { |r| r.id = id }
34
+ .refresh!
35
+
36
+ # Add or replace resource to in-memory state
37
+ state
38
+ .resources << resource
39
+
40
+ # Write state
41
+ configuration
42
+ .backend
43
+ .state = state
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Init < Kozo::Command
6
+ self.description = "Prepare your working directory for other commands"
7
+
8
+ def start
9
+ configuration.backend.initialize!
10
+
11
+ Kozo.logger.info "Kozo initialized in #{configuration.directory}"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -2,11 +2,37 @@
2
2
 
3
3
  module Kozo
4
4
  module Commands
5
- class Plan < Base
6
- self.description = "Show execution plan"
5
+ class Plan < Kozo::Command
6
+ self.description = "Create and show an execution plan"
7
+
8
+ attr_reader :operations
9
+
10
+ def initialize(*_args)
11
+ @operations = []
12
+ end
7
13
 
8
14
  def start
9
- configuration.parse!
15
+ @operations += configuration.changes.filter_map do |resource|
16
+ if resource.marked_for_creation?
17
+ Operations::Create.new(resource)
18
+ elsif resource.marked_for_deletion?
19
+ Operations::Destroy.new(resource)
20
+ elsif resource.changed?
21
+ Operations::Update.new(resource)
22
+ end
23
+ end
24
+
25
+ Kozo.logger.info "Kozo analyzed the state and created the following execution plan. Actions are indicated by the following symbols:"
26
+
27
+ [:create, :update, :destroy]
28
+ .map { |c| "Kozo::Operations::#{c.to_s.camelize}".constantize }
29
+ .each { |o| Kozo.logger.info " #{o.display_symbol} #{o.name.demodulize.downcase}" }
30
+
31
+ return Kozo.logger.info "\nNo actions have to be performed." if operations.empty?
32
+
33
+ Kozo.logger.info "\nKozo will perform the following actions:"
34
+
35
+ operations.each { |o| Kozo.logger.info o.to_s }
10
36
  end
11
37
  end
12
38
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Refresh < Kozo::Command
6
+ self.description = "Update the state to match remote infrastructure"
7
+
8
+ def start
9
+ # Refresh resources in-memory
10
+ state
11
+ .resources
12
+ .each(&:refresh!)
13
+
14
+ # Write state
15
+ configuration
16
+ .backend
17
+ .state = state
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Show < Kozo::Command
6
+ self.description = "Show all resources in the state"
7
+
8
+ def start
9
+ state
10
+ .resources
11
+ .each { |r| Kozo.logger.info Operations::Show.new(r).to_s }
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class State < Kozo::Command
6
+ self.description = "Manage and manipulate state"
7
+
8
+ attr_reader :subcommand
9
+
10
+ def initialize(*args)
11
+ subcommand = args.shift
12
+
13
+ raise UsageError unless subcommand
14
+
15
+ klass = "Kozo::Commands::State::#{subcommand.camelize}".safe_constantize
16
+
17
+ raise UsageError, "unknown subcommand: state #{subcommand}" unless klass
18
+
19
+ @subcommand = klass.new(*args)
20
+ end
21
+
22
+ def start
23
+ subcommand
24
+ .start
25
+ end
26
+
27
+ class List < State
28
+ self.description = "List resources in the state"
29
+
30
+ def initialize(*_args); end
31
+
32
+ def start
33
+ state
34
+ .resources
35
+ .each { |r| Kozo.logger.info r.address }
36
+ end
37
+ end
38
+
39
+ class Show < State
40
+ self.description = "Show a resource in the state"
41
+
42
+ attr_reader :address
43
+
44
+ def initialize(*args)
45
+ address = args.shift
46
+
47
+ raise UsageError, "address not specified" unless address
48
+
49
+ @address = address
50
+ end
51
+
52
+ def start
53
+ resource = state
54
+ .resources
55
+ .find { |r| r.address == address }
56
+
57
+ raise StateError, "no such resource address: #{address}" unless resource
58
+
59
+ Kozo.logger.info Operations::Show.new(resource).to_s
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Validate < Kozo::Command
6
+ self.description = "Check whether configuration is valid"
7
+
8
+ def start
9
+ Kozo.logger.info "The configuration in #{configuration.directory} is valid"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Commands
5
+ class Version < Kozo::Command
6
+ self.description = "Show the current application version"
7
+
8
+ def start
9
+ Kozo.logger.info "kozo #{Kozo::VERSION}"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozo
4
+ module Assignment
5
+ def initialize(attributes = {})
6
+ super()
7
+
8
+ assign_attributes(attributes) if attributes
9
+ end
10
+
11
+ def assign_attributes(attributes)
12
+ attributes.each do |k, v|
13
+ send(:"#{k}=", v)
14
+ end
15
+ end
16
+ end
17
+ end