kubernetes_template_rendering 0.1.0.pre.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd6a61ce9b0278d451c6e402ffab894a703846d04704061be05b1bdd30ddfef1
4
+ data.tar.gz: '079c1784c5357f79fa1e6d6e5a15ef00ea6f2bdf72f37b1ff3949283d3d328ef'
5
+ SHA512:
6
+ metadata.gz: 2eee87d74893f113a7b999c312457001cc5a8df0fed38b3827b7e773b5f243f2f73625b69ceb7e330576a4438671aa7f7c6491bf80b1b3e82b2167ab31d272ed
7
+ data.tar.gz: 17698c156cc055c8d0e42c1194f88d1ba99b1152712c2ee212393222ac5a93c75ca1029f424cf01f86993f95113fec39183546f4aef04a3d46f3f02de8e6190c
@@ -0,0 +1,13 @@
1
+ ---
2
+ common_config: &common_config
3
+ agents:
4
+ queue: "ruby-3-1"
5
+ timeout_in_minutes: 5
6
+ steps:
7
+ - label: ":ruby: Render Test Matrix"
8
+ timeout_in_minutes: 5
9
+ plugins:
10
+ - ssh://git@github.com/Invoca/invoca-ruby-test-matrix-buildkite-plugin.git#main:
11
+ min_ruby_version: '3.1'
12
+ slack_notification_channel: '#dev-team-release-backend-tools-octothorpe'
13
+ test_command: "bundle exec rspec"
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,6 @@
1
+ # Inherit from the Invoca style guide
2
+ inherit_from: https://raw.githubusercontent.com/Invoca/style-guide/master/ruby/.rubocop.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 3.1
6
+ Gemspec/RequireMFA: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.1.4
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # CHANGELOG for `kubernetes_template_rendering`
2
+
3
+ Inspired by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
4
+
5
+ Note: this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # KubernetesTemplateRendering
2
+
3
+ The `invoca-kubernetes_template` gem is a thin wrapper around `jsonnet` and `erb` to allow for the generation of
4
+ Kubernetes manifests from a set of templates combined with a `definitions.yaml` file which stores environmental
5
+ configuration for various deployment environments.
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+ ```
11
+ bundle add kubernetes_template_rendering
12
+ ```
13
+
14
+ If bundler is not being used to manage dependencies, install the gem by executing:
15
+ ```
16
+ gem install kubernetes_template_rendering
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ This gem is meant to be used as a command line tool for rendering templates that are either written in `jsonnet` or `erb`.
22
+ To use this gem you can either install it, and use the `render_kubernetes_template` executable directly, or you can use
23
+ `gem exec` to execute the command without first installing the gem.
24
+
25
+ ### Example Usage
26
+ ```bash
27
+ gem exec -g kubernetes_template_rendering render_kubernetes_templates \
28
+ --jsonnet-library-path deployment/vendor \
29
+ --rendered_directory path/to/resources \
30
+ deployment/templates
31
+ ```
32
+
33
+ ### Options
34
+
35
+ To see a full list of options and how to use them, run the following command:
36
+ ```bash
37
+ gem exec -g kubernetes_template_rendering render_kubernetes_templates --help
38
+ ```
39
+
40
+ ## Development
41
+
42
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
43
+
44
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
45
+
46
+ ## Contributing
47
+
48
+ Bug reports and pull requests are welcome on GitHub at https://github.com/invoca/kubernetes_template_rendering.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "erb"
5
+ require "fileutils"
6
+ require "optparse"
7
+ require "ostruct"
8
+ require "pathname"
9
+ require "yaml"
10
+ require_relative "../lib/kubernetes_template_rendering/cli"
11
+
12
+ KubernetesTemplateRendering::CLI.parse_and_render(ARGV)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_directory_renderer"
4
+ require_relative "cli_arguments"
5
+
6
+ module KubernetesTemplateRendering
7
+ class CLI
8
+ DEFINITIONS_FILENAME = "definitions.yaml"
9
+
10
+ Arguments = CLIArguments
11
+
12
+ class << self
13
+ def parse_and_render(options)
14
+ renderer, args = parse(options)
15
+
16
+ renderer.render(args)
17
+ end
18
+
19
+ private
20
+
21
+ def parse(options)
22
+ args = Arguments.new
23
+
24
+ parser = OptionParser.new do |op|
25
+ op.banner = "Usage: #{$PROGRAM_NAME} --rendered-directory=<directory> <template directory>"
26
+
27
+ op.on("--rendered-directory=RENDERED_DIRECTORY", "set the directory where rendered output is written") { args.rendered_directory = _1 }
28
+ op.on("--[no-]fork", "disable/enable fork") { |fork| args.fork = fork }
29
+ op.on("--makeflags=MAKEFLAGS", "pass through makeflags so that we can infer fork preference from -j") { args.makeflags = _1 }
30
+ op.on("--jsonnet_library_path=JSONNET_LIBRARY_PATH", "set the jsonnet library path") { args.jsonnet_library_path = _1 }
31
+ op.on("--cluster_type=CLUSTER_TYPE", "set the specific cluster type to render") { args.cluster_type = _1 }
32
+ op.on("--region=REGION", "set the specific region to render") { args.region = _1 }
33
+ op.on("--color=COLOR", "set the specific color to render") { args.color = _1 }
34
+
35
+ op.on("--variable-override=KEY:VALUE", "override a variable value set within definitions.yaml") do |override|
36
+ args.variable_overrides ||= {}
37
+ args.variable_overrides.merge!(Hash[[override.split(":", 2)]])
38
+ end
39
+
40
+ op.on("-h", "--help") do
41
+ puts op
42
+ exit
43
+ end
44
+ end
45
+
46
+ parser.parse!(options)
47
+ args.template_directory = options.first
48
+
49
+ unless args.valid?
50
+ STDERR.puts(parser)
51
+ exit(1)
52
+ end
53
+
54
+ [renderer_from_args(args), args]
55
+ end
56
+
57
+ def renderer_from_args(args)
58
+ directories = template_directories(args.template_directory, DEFINITIONS_FILENAME)
59
+
60
+ TemplateDirectoryRenderer.new(
61
+ directories: directories,
62
+ rendered_directory: args.rendered_directory,
63
+ cluster_type: args.cluster_type,
64
+ region: args.region,
65
+ color: args.color,
66
+ variable_overrides: args.variable_overrides
67
+ )
68
+ end
69
+
70
+ def template_directories(template_directory, definitions_file)
71
+ File.directory?(template_directory) or raise ArgumentError, "template directory not found--make sure to include templates/ prefix: #{template_directory}"
72
+ directories = Dir["#{template_directory}/**/#{definitions_file}"]
73
+ directories.map { |directory| File.dirname(directory) }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ CLIArguments =
5
+ Struct.new(
6
+ :rendered_directory,
7
+ :template_directory,
8
+ :fork,
9
+ :makeflags,
10
+ :jsonnet_library_path,
11
+ :cluster_type,
12
+ :region,
13
+ :color,
14
+ :variable_overrides
15
+ ) do
16
+ def valid?
17
+ rendered_directory && template_directory
18
+ end
19
+
20
+ def fork?
21
+ if fork.nil?
22
+ makeflags&.include?('-j')
23
+ else
24
+ fork
25
+ end
26
+ end
27
+
28
+ def render_files?
29
+ true
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ module Color
5
+ class << self
6
+ def black(str)
7
+ "\e[30m#{str}\e[0m"
8
+ end
9
+
10
+ def red(str)
11
+ "\e[31m#{str}\e[0m"
12
+ end
13
+
14
+ def green(str)
15
+ "\e[32m#{str}\e[0m"
16
+ end
17
+
18
+ def brown(str)
19
+ "\e[33m#{str}\e[0m"
20
+ end
21
+
22
+ def blue(str)
23
+ "\e[34m#{str}\e[0m"
24
+ end
25
+
26
+ def magenta(str)
27
+ "\e[35m#{str}\e[0m"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+
5
+ module KubernetesTemplateRendering
6
+ class DeployGroupedResource
7
+ DEFAULT_GROUP_VARIABLE_NAME = "deploy_group"
8
+
9
+ attr_reader :groups_to_render, :variables, :template_path, :output_directory, :template_path_exclusions, :group_variable_name
10
+
11
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, groups_to_render:, template_path_exclusions:, group_variable_name: nil)
12
+ @template_path = template_path
13
+ @definitions_path = definitions_path
14
+ @variables = variables
15
+ @output_directory = output_directory
16
+ @groups_to_render = groups_to_render
17
+ @template_path_exclusions = template_path_exclusions || {}
18
+ @group_variable_name = group_variable_name || DEFAULT_GROUP_VARIABLE_NAME
19
+ end
20
+
21
+ def render(args)
22
+ @resources =
23
+ groups_to_render.map do |deploy_group|
24
+ if template_is_excluded?(deploy_group)
25
+ puts "Skipping #{Color.magenta(template_path_basename)} for #{deploy_group} deploy group due to it being " \
26
+ "excluded within the deploy group config\n\n"
27
+ nil
28
+ else
29
+ vars = variables.merge(group_variable_name => deploy_group)
30
+ filename = filename_for_deploy_group(deploy_group)
31
+ Resource.new(template_path: template_path, definitions_path: @definitions_path, variables: vars, output_directory: output_directory, output_filename: filename).tap do |resource|
32
+ resource.render(args) if args.render_files?
33
+ end
34
+ end
35
+ end.compact
36
+ end
37
+
38
+ private
39
+
40
+ def template_path_basename
41
+ @template_path_basename ||= File.basename(template_path)
42
+ end
43
+
44
+ def template_is_excluded?(deploy_group)
45
+ @template_path_exclusions[deploy_group]&.include?(template_path_basename)
46
+ end
47
+
48
+ def filename_for_deploy_group(group)
49
+ case @template_path
50
+ when /.erb/
51
+ File.basename(template_path, ".erb").sub(/([^-.]+)\.yaml$/, "#{group}-\\1.yaml")
52
+ when /.jsonnet/
53
+ File.basename(template_path).sub(/([^-.]+)\.jsonnet$/, "#{group}-\\1.yaml")
54
+ else
55
+ raise "unexpected template_path #{template_path.inspect}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template"
4
+
5
+ module KubernetesTemplateRendering
6
+ class ErbTemplate < Template
7
+ module Snippet
8
+ def snippet(file)
9
+ snippet = "#{File.dirname(@template_path)}/#{file}"
10
+ content = File.read(snippet)
11
+ erb = ERB.new(content, trim_mode: "-")
12
+ erb.filename = snippet
13
+ erb.result(variables_object._binding)
14
+ end
15
+ end
16
+
17
+ include Snippet
18
+
19
+ class VariablesClass < BasicObject
20
+ include Snippet
21
+
22
+ def initialize(template_path, variables)
23
+ @template_path = template_path
24
+ @variables = variables
25
+ end
26
+
27
+ def _binding
28
+ ::Kernel.binding
29
+ end
30
+
31
+ def keys
32
+ @variables.keys
33
+ end
34
+
35
+ def method_missing(sym, *args, &block)
36
+ @variables.fetch(sym.to_s) # will raise KeyError if not in @variables hash
37
+ end
38
+
39
+ private
40
+
41
+ def variables_object
42
+ self
43
+ end
44
+ end
45
+
46
+ def render(erb_binding: nil, jsonnet_library_path: nil)
47
+ rendered_erb = render_erb(erb_binding)
48
+ if template_path.end_with?("yaml.erb")
49
+ with_auto_generated_yaml_comment(sort_yaml(rendered_erb))
50
+ else
51
+ rendered_erb
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def variables_object
58
+ VariablesClass.new(template_path, variables)
59
+ end
60
+
61
+ def render_erb(erb_binding)
62
+ content = File.read(template_path)
63
+ erb = ERB.new(content, trim_mode: "-")
64
+ erb.filename = template_path
65
+ erb_binding.nil? ? erb.result(variables_object._binding) : erb.result(erb_binding) # here is where we eval the template
66
+ end
67
+
68
+ def sort_yaml(erb_yaml)
69
+ if (yaml_docs = YAML.load_stream(erb_yaml)).any?
70
+ yaml_docs.map { |yaml_doc| sort_keys(yaml_doc).to_yaml }.join("\n")
71
+ else
72
+ erb_yaml
73
+ end
74
+ end
75
+
76
+ def sort_keys(json_doc)
77
+ case json_doc
78
+ when Array
79
+ json_doc.map { |v| sort_keys(v) }
80
+ when Hash
81
+ with_sorted_values = json_doc.transform_values { |v| sort_keys(v) }
82
+ with_sorted_values.sort.to_h
83
+ else
84
+ json_doc
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jsonnet"
4
+ require_relative "template"
5
+
6
+ module KubernetesTemplateRendering
7
+ class JsonnetTemplate < Template
8
+ MULTI_FILE_RENDER_KEY = "MULTI_FILE_RENDER"
9
+ MULTI_FILE_RENDER_FILE_NAME_KEY = "MULTI_FILE_RENDER_NAME"
10
+
11
+ class MultiFileJsonnetRenderError < StandardError; end
12
+
13
+ def render(erb_binding: nil, jsonnet_library_path: nil)
14
+ json_doc = render_json_doc_from_template(jsonnet_library_path)
15
+ if multi_file_jsonnet_doc?(json_doc)
16
+ render_multi_file_jsonnet!(json_doc)
17
+ else
18
+ with_auto_generated_yaml_comment(json_doc.to_yaml)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_json_doc_from_template(jsonnet_library_path)
25
+ vm = Jsonnet::VM.new
26
+ vm.tla_code("vars", variables.to_json)
27
+ if jsonnet_library_path.nil?
28
+ vm.jpath_add(File.expand_path('../../../vendor-jb', __dir__))
29
+ else
30
+ vm.jpath_add(jsonnet_library_path)
31
+ end
32
+ JSON.parse(vm.evaluate_file(template_path))
33
+ end
34
+
35
+ def multi_file_jsonnet_doc?(json_doc)
36
+ json_doc[MULTI_FILE_RENDER_KEY]
37
+ end
38
+
39
+ # Multi-File JSONNET Template Structuring:
40
+ # Top Level - Multi File Hash
41
+ # Keys map to Either Hash or Array
42
+ # If Array, the values of the array must be Hashs.
43
+ # Hash can be either a normal Hash or another Multi-File Hash
44
+
45
+ def render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash = {})
46
+ json_doc.delete(MULTI_FILE_RENDER_KEY)
47
+ json_doc.each do |key, value|
48
+ case value
49
+ when Hash
50
+ render_json_doc(value, key, file_name_to_yaml_hash)
51
+ when NilClass
52
+ next
53
+ else
54
+ raise ArgumentError, "must be a Hash or NilClass, was #{value.inspect}"
55
+ end
56
+ end
57
+ file_name_to_yaml_hash
58
+ end
59
+
60
+ def render_json_doc(json_doc, default_file_name, file_name_to_yaml_hash)
61
+ if multi_file_jsonnet_doc?(json_doc)
62
+ render_multi_file_jsonnet!(json_doc, file_name_to_yaml_hash)
63
+ else
64
+ file_name = file_name_from_object(json_doc, default: default_file_name)
65
+ file_name_to_yaml_hash["#{file_name}.yaml"] = with_auto_generated_yaml_comment(json_doc.to_yaml)
66
+ end
67
+ end
68
+
69
+ def validate_json_doc!(json_doc, context)
70
+ json_doc.is_a?(Hash) or raise MultiFileJsonnetRenderError, context
71
+ end
72
+
73
+ def file_name_from_object(object, default: nil)
74
+ if (base_name = object.delete(MULTI_FILE_RENDER_FILE_NAME_KEY))
75
+ base_name
76
+ else
77
+ default
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require_relative "color"
5
+ require_relative "erb_template"
6
+ require_relative "jsonnet_template"
7
+
8
+ module KubernetesTemplateRendering
9
+ class Resource
10
+ class UnexpectedFileTypeError < StandardError; end
11
+
12
+ attr_reader :variables, :template_path, :output_directory
13
+
14
+ def initialize(template_path:, definitions_path:, variables:, output_directory:, output_filename: nil)
15
+ @template_path = template_path
16
+ @definitions_path = definitions_path
17
+ @variables = variables
18
+ @output_directory = output_directory
19
+ @output_filename = output_filename || template_filename(template_path)
20
+ end
21
+
22
+ def render(args)
23
+ write_template(args)
24
+ end
25
+
26
+ private
27
+
28
+ def rendered_template(args)
29
+ @rendered_template ||= template_klass.render(@template_path, variables, jsonnet_library_path: args.jsonnet_library_path)
30
+ end
31
+
32
+ def write_template(args)
33
+ print_status
34
+
35
+ # If a Hash is returned, that means this is a multi-file template, meaning we need to iterate over the hash.
36
+ # Else a String is returned and we can write it directly to the output file.
37
+ rt = rendered_template(args)
38
+
39
+ if args.render_files?
40
+ if rt.is_a?(Hash)
41
+ rt.each do |filename, contents|
42
+ File.write(output_path(filename), contents)
43
+ end
44
+ else
45
+ File.write(output_path(@output_filename), rt)
46
+ end
47
+ end
48
+ end
49
+
50
+ def template_klass
51
+ case @template_path
52
+ when /\.erb\z/
53
+ ErbTemplate
54
+ when /\.jsonnet\z/
55
+ JsonnetTemplate
56
+ else
57
+ raise UnexpectedFileTypeError, "Unexpected file type #{@template_path}"
58
+ end
59
+ end
60
+
61
+ def print_status
62
+ variable_output = variables.map { |k, v| "#{Color.magenta(k)}=#{Color.blue(v)}" }.join(', ')
63
+ puts "Writing #{Color.magenta(File.basename(output_path(@output_filename)))} with variables #{variable_output}\n\n"
64
+ end
65
+
66
+ def output_path(filename)
67
+ File.join(@output_directory, filename)
68
+ end
69
+
70
+ def template_filename(template_path)
71
+ if template_path.match(/\.erb\z/)
72
+ File.basename(template_path, '.erb')
73
+
74
+ elsif template_path.match(/\.jsonnet\z/)
75
+ Pathname.new(template_path).basename.sub_ext('.yaml')
76
+
77
+ else
78
+ raise "Unexpected template_path format: #{template_path}"
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource"
4
+ require_relative "deploy_grouped_resource"
5
+
6
+ # This class points to the resources in a given template_directory for a given kubernetes_cluster_type like 'ops' or 'prod' or 'ci'.
7
+ # `config` contains the definitions found in `definitions_path`.
8
+ # The most important config is "directory" which is a pattern like "%{region}/%{type}/%{color}/staging-ops".
9
+ # The config "regions", "colors" are the sets of regions and colors to render for.
10
+ # It renders into `rendered_directory`.
11
+ module KubernetesTemplateRendering
12
+ class ResourceSet
13
+ attr_reader :variables, :output_directory, :deploy_group_config, :omitted_resources,
14
+ :template_directory, :target_output_directory, :regions, :colors,
15
+ :definitions_path, :kubernetes_cluster_type
16
+
17
+ def initialize(config:, template_directory:, rendered_directory:, definitions_path:, kubernetes_cluster_type:)
18
+ @variables = config["variables"] || {}
19
+ @deploy_group_config = config["deploy_groups"]
20
+ @omitted_resources = config["omitted_resources"]
21
+ @template_directory = template_directory
22
+ @target_output_directory = config["directory"] or raise ArgumentError, "missing 'directory:' in #{config.inspect}"
23
+ @regions = config["regions"] || []
24
+ @colors = config["colors"] || []
25
+ @rendered_directory = rendered_directory
26
+ @definitions_path = definitions_path
27
+ @kubernetes_cluster_type = kubernetes_cluster_type
28
+ @resources = {}
29
+
30
+ if @kubernetes_cluster_type != "kube-platform"
31
+ @target_output_directory.include?("%{plain_region}") or raise "#{@template_directory}: target_output_directory #{@target_output_directory} needs %{plain_region}"
32
+ end
33
+ end
34
+
35
+ def normal_render(args)
36
+ dynamic_output_directory? and raise "Directory must not be dynamic: #{target_output_directory}"
37
+
38
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
39
+
40
+ if (plain_region = variables["plain_region"])
41
+ default_region_vars(plain_region)
42
+ end
43
+
44
+ output_directory = File.join(@rendered_directory, target_output_directory)
45
+ render_create_directory(args, output_directory)
46
+ end
47
+
48
+ def render(args)
49
+ @regions.any? or raise "#{template_directory}: must have at least one region"
50
+ @colors.any? or raise "#{template_directory}: must have at least one color"
51
+
52
+ variables["kubernetes_cluster_type"] = @kubernetes_cluster_type
53
+
54
+ @regions.each do |plain_region|
55
+ default_region_vars(plain_region)
56
+
57
+ @colors.each do |c|
58
+ variables["color"] = c
59
+ output_directory = File.join(@rendered_directory, format(@target_output_directory, plain_region: plain_region, color: c, type: @kubernetes_cluster_type))
60
+ render_create_directory(args, output_directory)
61
+ end
62
+ end
63
+ end
64
+
65
+ def render_create_directory(args, output_directory)
66
+ create_directory(output_directory)
67
+ puts "Rendering templates to: #{Color.magenta(output_directory)}"
68
+ puts "Variable assignments:"
69
+ variables.each { |k, v| puts "\t#{Color.magenta(k)}=#{Color.blue(v)}" }
70
+ puts
71
+ if omitted_resources
72
+ puts "Omitted resources:"
73
+ omitted_resources.each { |ot| puts "\t#{ot}" }
74
+ end
75
+ puts
76
+ resources(output_directory).each do |resource|
77
+ resource.render(args)
78
+ end
79
+ puts
80
+ end
81
+
82
+ private
83
+
84
+ CLOUD_REGION_TO_PROVIDER_AND_DATACENTER = {
85
+ # Note: The names below should match RegionDiscovery from process_settings-production.
86
+ # https://github.com/Invoca/process_settings-production/blob/main/settings/region_discovery/production_regions.yml
87
+ "us-east-1" => ['aws', "AWS-us-east-1"],
88
+ "us-east-2" => ['aws', "AWS-us-east-2"],
89
+ "us-central1" => ['gcp', "GCE-us-central1"],
90
+ "us-west2" => ['gcp', "GCE-us-west2"],
91
+ "eu-central-1" => ['aws', "AWS-eu-central-1"],
92
+ "eu-west-1" => ['aws', "AWS-eu-west-1"],
93
+ "europe-west4" => ['gcp', "GCE-europe-west4"],
94
+
95
+ # other regions
96
+ "us-east1" => ['gcp', "GCE-us-east1"],
97
+ "us-west1" => ['gcp', "GCE-us-west1"],
98
+ "local" => ['', "local"]
99
+ }.freeze
100
+
101
+ # The zone to use for failure-domain.beta.kubernetes.io/zone or topology.kubernetes.io/zone by DeployGroup
102
+ AVAILABILITY_ZONE_FOR_DEPLOY_GROUP = {
103
+ "us-east-1" => {
104
+ "primary" => "us-east-1c",
105
+ "secondary" => "us-east-1d",
106
+ "tertiary" => "us-east-1b"
107
+ },
108
+ "us-east-2" => {
109
+ "primary" => "us-east-2a",
110
+ "secondary" => "us-east-2b",
111
+ "tertiary" => "us-east-2c"
112
+ },
113
+ "us-central1" => {
114
+ "primary" => "us-central1-a",
115
+ "secondary" => "us-central1-b",
116
+ "tertiary" => "us-central1-c"
117
+ },
118
+ "us-west2" => {
119
+ "primary" => "us-west2-a",
120
+ "secondary" => "us-west2-b",
121
+ "tertiary" => "us-west2-c"
122
+ },
123
+ "eu-central-1" => {
124
+ "primary" => "eu-central-1a",
125
+ "secondary" => "eu-central-1b",
126
+ "tertiary" => "eu-central-1c"
127
+ },
128
+ "eu-west-1" => {
129
+ "primary" => "eu-west-1a",
130
+ "secondary" => "eu-west-1b",
131
+ "tertiary" => "eu-west-1c"
132
+ },
133
+ "europe-west4" => {
134
+ "primary" => "europe-west4-a",
135
+ "secondary" => "europe-west4-b",
136
+ "tertiary" => "europe-west4-c"
137
+ },
138
+ "local" => {},
139
+ "us-west1" => {},
140
+ "us-east1" => {}
141
+ }
142
+
143
+ def default_region_vars(plain_region)
144
+ variables.has_key?("region") and raise "replace region with plain_region"
145
+ variables["plain_region"] = plain_region
146
+ cloud_datacenter_and_provider = CLOUD_REGION_TO_PROVIDER_AND_DATACENTER[plain_region] or raise "no CLOUD_REGION_TO_PROVIDER_AND_DATACENTER entry found for #{plain_region.inspect}"
147
+ variables["cloud_provider"], variables["cloud_datacenter"] = cloud_datacenter_and_provider
148
+ variables["cloud_region"] = variables["cloud_datacenter"] # for compatibility with old resource files; cloud_datacenter is preferred now
149
+ if plain_region == "local"
150
+ variables["data_silo"] ||= "local"
151
+ elsif plain_region.start_with?("us-")
152
+ variables["data_silo"] ||= "us"
153
+ end
154
+
155
+ # cannot do ||= because we want to reset when plain_region changes
156
+ variables["availability_zone_for_deploy_group"] = AVAILABILITY_ZONE_FOR_DEPLOY_GROUP[plain_region] or raise "no AVAILABILITY_ZONE_FOR_DEPLOY_GROUP mapping found for #{plain_region.inspect}"
157
+ end
158
+
159
+ def create_directory(directory)
160
+ unless File.exist?(directory)
161
+ puts <<~MESSAGE
162
+
163
+ Directory #{Color.magenta(directory)} doesn't exist, #{Color.green('creating it')}
164
+
165
+ MESSAGE
166
+ FileUtils.mkdir_p(directory)
167
+ end
168
+ end
169
+
170
+ def resources(output_directory)
171
+ @resources[output_directory] ||= standard_resources(output_directory) + grouped_resources(output_directory)
172
+ end
173
+
174
+ def standard_resources(output_directory)
175
+ standard_template_paths.map do |path|
176
+ Resource.new(template_path: path, definitions_path: @definitions_path, variables: variables, output_directory: output_directory)
177
+ end
178
+ end
179
+
180
+ def grouped_resources(output_directory)
181
+ deploy_grouped_template_paths.map do |path|
182
+ DeployGroupedResource.new(
183
+ template_path: path,
184
+ definitions_path: @definitions_path,
185
+ variables: variables,
186
+ output_directory: output_directory,
187
+ groups_to_render: deploy_groups_to_render,
188
+ template_path_exclusions: deploy_group_config["exclude_files"],
189
+ group_variable_name: deploy_group_config["variable_name"]
190
+ )
191
+ end
192
+ end
193
+
194
+ def standard_template_paths
195
+ @standard_template_paths ||=
196
+ (Dir[File.join(template_directory, "*.yaml.erb")] +
197
+ Dir[File.join(template_directory, '*.jsonnet')]) -
198
+ deploy_grouped_template_paths - omitted_resource_paths
199
+ end
200
+
201
+ def deploy_grouped_template_paths
202
+ @deploy_grouped_template_paths ||=
203
+ if deploy_group_config
204
+ deploy_group_config["files"]&.map { |file| File.join(template_directory, file) } ||
205
+ (Dir[File.join(template_directory, "*-deploy.yaml.erb")] + Dir[File.join(template_directory, "*-deploy.jsonnet")]) - omitted_resource_paths
206
+ else
207
+ []
208
+ end
209
+ end
210
+
211
+ def omitted_resource_paths
212
+ @omitted_resource_paths ||= omitted_resources&.map { |file| File.join(template_directory, file) } || []
213
+ end
214
+
215
+ def deploy_groups_to_render
216
+ group_names = deploy_group_config["group_names"]
217
+ if array_of_arrays?(group_names)
218
+ first, *rest = group_names
219
+ first.product(*rest).map { |group| group.join("-") }
220
+ else
221
+ group_names
222
+ end
223
+ end
224
+
225
+ def array_of_arrays?(array)
226
+ array.all? { |item| item.is_a?(Array) }
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require "open3"
5
+ require "shellwords"
6
+ require "yaml"
7
+
8
+ # This is a base class for all Templates. Derived classes must implement the render method.
9
+ module KubernetesTemplateRendering
10
+ class Template < OpenStruct
11
+ attr_reader :template_path, :variables
12
+ def initialize(template_path, variables)
13
+ @template_path = template_path
14
+ @variables = variables
15
+ end
16
+
17
+ def render(args)
18
+ raise "must be defined by subclass"
19
+ end
20
+
21
+ private
22
+
23
+ def with_auto_generated_yaml_comment(yaml_string)
24
+ comment = <<~EOS
25
+ # WARNING: DO NO EDIT THIS FILE!
26
+ # Any changes made here will be lost.
27
+ # This file is autogenerated from #{template_path}
28
+ EOS
29
+ comment + yaml_string
30
+ end
31
+
32
+ class << self
33
+ # @param [String] template_path: file path to template file that needs to be rendered.
34
+ # @param [Hash]: variables that will be used in the template file to generate distict files.
35
+ #
36
+ # @return [String] generated YAML file
37
+ # @return [Hash] Hash of file names as keys with the values being corresponding generated YAML.
38
+ #
39
+ # @raise [UnexpectedFileTypeError] if file type doesn't match [yaml.erb, erb, or jsonnet]
40
+ # @raise [MultiFileJsonnetRenderError] if Jsonnet template files don't follow proper MultiFileJsonnet templating.
41
+
42
+ # TODO
43
+ # The ErbTemplate and JsonnetTemplate classes both inherit from the Template class and implement a render method.
44
+ # However, the erb_binding parameter is used just in ErbTemplate, while the jsonnet_library_path parameter is used just in JsonnetTemplate.
45
+ # This is a little awkward. Potentially this could be refactored.
46
+ def render(template_path, variables, erb_binding: nil, jsonnet_library_path: nil)
47
+ new(template_path, variables).render(erb_binding: erb_binding, jsonnet_library_path: jsonnet_library_path)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext' # for deep_merge
5
+ require 'invoca/utils'
6
+ require 'yaml'
7
+ require_relative 'resource_set'
8
+ require 'set'
9
+
10
+ # This class points to a collection of template directories to render, and a rendered_directory to render into.
11
+ # Optionally, some of the template directories may be omitted by including them in `omitted_names`.
12
+ module KubernetesTemplateRendering
13
+ class TemplateDirectoryRenderer
14
+ DEFINITIONS_FILENAME = "definitions.yaml"
15
+
16
+ attr_reader :directories, :omitted_names, :rendered_directory, :cluster_type, :region, :color, :variable_overrides
17
+
18
+ def initialize(directories:, rendered_directory:, omitted_names: [], cluster_type: nil, region: nil, color: nil, variable_overrides: nil)
19
+ @directories = directories_with_definitions(Array(directories))
20
+ @omitted_names = Array(omitted_names)
21
+ @rendered_directory = rendered_directory
22
+ @cluster_type = cluster_type
23
+ @region = region
24
+ @color = color
25
+ @variable_overrides = variable_overrides || {}
26
+ end
27
+
28
+ def render(args)
29
+ child_pids = []
30
+
31
+ resource_sets.each do |name, resource_sets|
32
+ puts "Rendering templates for definition #{Color.red(name)}..."
33
+ resource_sets.each do |resource_set|
34
+ if args.fork?
35
+ if (pid = Process.fork)
36
+ # this is the parent
37
+ child_pids << pid
38
+ wait_if_max_forked(child_pids)
39
+ else
40
+ # this is the child
41
+ render_set(args, resource_set)
42
+ Kernel.exit! # skip at_exit handlers since parent will run those
43
+ end
44
+ else
45
+ render_set(args, resource_set)
46
+ end
47
+ end
48
+ end
49
+
50
+ if args.fork?
51
+ Process.waitall
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def read_definitions(path)
58
+ File.read(path)
59
+ end
60
+
61
+ MAX_FORKED_PROCESSES = 9
62
+
63
+ def wait_if_max_forked(child_pids)
64
+ while child_pids.size >= MAX_FORKED_PROCESSES
65
+ begin
66
+ Process.waitpid # this is a race condition because 1 or more processes could exit before we get here
67
+ rescue SystemCallError # this will happen if they all exited before we called waitpid
68
+ end
69
+ child_pids.delete_if do |pid|
70
+ Process.waitpid(pid, Process::WNOHANG)
71
+ rescue Errno::ECHILD # No child processes
72
+ true
73
+ end
74
+ end
75
+ end
76
+
77
+ def render_set(args, resource_set)
78
+ resource_set.render(args)
79
+ rescue => ex
80
+ raise "error rendering ResourceSet from #{resource_set.definitions_path}\n#{ex.class}: #{ex.message}"
81
+ end
82
+
83
+ def directories_with_definitions(directories)
84
+ directories.select do |dir|
85
+ definitions_path = definitions_path_for_dir(dir)
86
+ File.exist?(definitions_path)
87
+ end
88
+ end
89
+
90
+ def definitions_path_for_dir(dir)
91
+ File.join(dir, DEFINITIONS_FILENAME)
92
+ end
93
+
94
+ def resource_sets
95
+ @resource_sets ||= @directories.each_with_object({}) do |dir, hash|
96
+ definitions_path = definitions_path_for_dir(dir)
97
+ config = load_config(definitions_path)
98
+
99
+ config.map do |name, config|
100
+ next if omitted_names.include?(name)
101
+
102
+ kubernetes_cluster_type = name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
103
+
104
+ hash[name] ||= []
105
+ hash[name] << ResourceSet.new(config: config, template_directory: dir, rendered_directory: @rendered_directory, kubernetes_cluster_type: kubernetes_cluster_type, definitions_path: definitions_path)
106
+ end
107
+ end
108
+ end
109
+
110
+ def build_libsonnet(dir, config)
111
+ fname = File.join(dir, 'definitions.libsonnet')
112
+ existing = File.exists?(fname) ? File.read(fname) : ""
113
+ proposed = build_json(config)
114
+
115
+ if existing != proposed
116
+ puts("Generating updated #{Color.magenta(File.basename(fname))}")
117
+ File.write(fname, proposed)
118
+ end
119
+ end
120
+
121
+ def build_json(config)
122
+ hash = transform_for_jsonnet(config)
123
+ JSON.pretty_generate(hash)
124
+ end
125
+
126
+ # This method ensures that OpenStructs are
127
+ # converted to hashes to support to_json operation
128
+ # It also converts any embedded variable place holders
129
+ # into a Jsonnet friendly format:
130
+ #
131
+ # %{variable} is converted to %(variable)s which can
132
+ # then be used with the Jsonnet function std.format
133
+ def transform_for_jsonnet(hash)
134
+ hash.transform_values do |value|
135
+ case value
136
+ when OpenStruct
137
+ transform_for_jsonnet(value.to_h)
138
+ when Hash
139
+ transform_for_jsonnet(value)
140
+ else
141
+ value
142
+ end
143
+ end
144
+ end
145
+
146
+ def load_config(definitions_path)
147
+ begin
148
+ config = YAML.safe_load(read_definitions(definitions_path), aliases: true)
149
+ rescue => ex
150
+ raise "error loading YAML from #{definitions_path}:\n#{ex.class}: #{ex.message}"
151
+ end
152
+
153
+ expand_config(config).each_with_object({}) do |(name, data), hash|
154
+ if !cluster_type || cluster_type == name.sub('SPP-PLACEHOLDER', 'staging').sub(/\..*/, '') # prod.gcp => prod
155
+ cluster_type_config = OpenStruct.new(data)
156
+
157
+ cluster_type_config.regions = cluster_type_config.regions & [region] if region
158
+ cluster_type_config.colors = cluster_type_config.colors & [color] if color
159
+ cluster_type_config.variables = (cluster_type_config.variables || {}).merge(variable_overrides)
160
+
161
+ hash[name] = cluster_type_config if (region.nil? && color.nil?) || (cluster_type_config.regions.any? && cluster_type_config.colors.any?)
162
+ end
163
+ end
164
+ end
165
+
166
+ # returns a copy of the given config hash with the COMMON: k-v removed and deep merged into the other config values
167
+ # (explicit config values take precedence over COMMON: ones)
168
+ def expand_config(config)
169
+ common = config.delete('COMMON') || {}
170
+
171
+ Hash[config.map { |k, v| [k, common.deep_merge(v)] }]
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesTemplateRendering
4
+ VERSION = "0.1.0.pre.1"
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kubernetes_template_rendering/version"
4
+
5
+ module KubernetesTemplateRendering
6
+ class Error < StandardError; end
7
+ # Your code goes here...
8
+ end
@@ -0,0 +1,6 @@
1
+ module Invoca
2
+ module KubernetesTemplateRendering
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kubernetes_template_rendering
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre.1
5
+ platform: ruby
6
+ authors:
7
+ - Octothorpe
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-04-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: jsonnet
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: invoca-utils
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Tool for rendering ERB and Jsonnet templates
56
+ email:
57
+ - octothorpe@invoca.com
58
+ executables:
59
+ - render_templates
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".buildkite/pipeline.yml"
64
+ - ".rspec"
65
+ - ".rubocop.yml"
66
+ - ".ruby-version"
67
+ - CHANGELOG.md
68
+ - README.md
69
+ - Rakefile
70
+ - exe/render_templates
71
+ - lib/kubernetes_template_rendering.rb
72
+ - lib/kubernetes_template_rendering/cli.rb
73
+ - lib/kubernetes_template_rendering/cli_arguments.rb
74
+ - lib/kubernetes_template_rendering/color.rb
75
+ - lib/kubernetes_template_rendering/deploy_grouped_resource.rb
76
+ - lib/kubernetes_template_rendering/erb_template.rb
77
+ - lib/kubernetes_template_rendering/jsonnet_template.rb
78
+ - lib/kubernetes_template_rendering/resource.rb
79
+ - lib/kubernetes_template_rendering/resource_set.rb
80
+ - lib/kubernetes_template_rendering/template.rb
81
+ - lib/kubernetes_template_rendering/template_directory_renderer.rb
82
+ - lib/kubernetes_template_rendering/version.rb
83
+ - sig/invoca/kubernetes_templates.rbs
84
+ homepage: https://github.com/Invoca/kubernetes_template_rendering
85
+ licenses: []
86
+ metadata:
87
+ allowed_push_host: https://rubygems.org
88
+ homepage_uri: https://github.com/Invoca/kubernetes_template_rendering
89
+ source_code_uri: https://github.com/Invoca/kubernetes_template_rendering
90
+ changelog_uri: https://github.com/Invoca/kubernetes_template_rendering/blob/main/CHANGELOG.md
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 3.1.0
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">"
103
+ - !ruby/object:Gem::Version
104
+ version: 1.3.1
105
+ requirements: []
106
+ rubygems_version: 3.4.21
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: Tool for rendering ERB and Jsonnet templates
110
+ test_files: []