kozo 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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