metatron 0.11.4 → 0.11.5
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 +4 -4
- data/.dockerignore +9 -0
- data/.github/workflows/ci.yml +65 -0
- data/.release-please-manifest.json +1 -1
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +19 -0
- data/CONTRIBUTING.md +32 -1
- data/Gemfile +5 -0
- data/Gemfile.lock +12 -7
- data/README.md +52 -0
- data/Rakefile +5 -0
- data/integration/Dockerfile +29 -0
- data/integration/controller/Gemfile +8 -0
- data/integration/controller/Gemfile.lock +30 -0
- data/integration/controller/config.ru +21 -0
- data/integration/controller/lib/request_logger.rb +18 -0
- data/integration/controller/lib/widget_controller.rb +84 -0
- data/integration/manifests/composite-controller.yaml +62 -0
- data/integration/manifests/controller.yaml +70 -0
- data/integration/manifests/crd.yaml +46 -0
- data/integration/manifests/namespace.yaml +6 -0
- data/integration/manifests/sample-widget.yaml +9 -0
- data/lib/metatron/composite_controller.rb +4 -1
- data/lib/metatron/controller.rb +49 -7
- data/lib/metatron/middleware/auth.rb +103 -0
- data/lib/metatron/middleware/body_limit.rb +85 -0
- data/lib/metatron/problem.rb +47 -0
- data/lib/metatron/template.rb +4 -0
- data/lib/metatron/version.rb +1 -1
- data/lib/metatron.rb +10 -1
- data/metatron.gemspec +3 -2
- data/script/integration +182 -0
- metadata +30 -8
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
apiVersion: apiextensions.k8s.io/v1
|
|
2
|
+
kind: CustomResourceDefinition
|
|
3
|
+
metadata:
|
|
4
|
+
name: widgets.metatron.therubyist.org
|
|
5
|
+
spec:
|
|
6
|
+
group: metatron.therubyist.org
|
|
7
|
+
scope: Namespaced
|
|
8
|
+
names:
|
|
9
|
+
plural: widgets
|
|
10
|
+
singular: widget
|
|
11
|
+
kind: Widget
|
|
12
|
+
shortNames:
|
|
13
|
+
- wdg
|
|
14
|
+
versions:
|
|
15
|
+
- name: v1
|
|
16
|
+
served: true
|
|
17
|
+
storage: true
|
|
18
|
+
subresources:
|
|
19
|
+
status: {}
|
|
20
|
+
additionalPrinterColumns:
|
|
21
|
+
- name: Replicas
|
|
22
|
+
type: integer
|
|
23
|
+
jsonPath: .spec.replicas
|
|
24
|
+
- name: Config
|
|
25
|
+
type: string
|
|
26
|
+
jsonPath: .spec.configValue
|
|
27
|
+
- name: Age
|
|
28
|
+
type: date
|
|
29
|
+
jsonPath: .metadata.creationTimestamp
|
|
30
|
+
schema:
|
|
31
|
+
openAPIV3Schema:
|
|
32
|
+
type: object
|
|
33
|
+
properties:
|
|
34
|
+
spec:
|
|
35
|
+
type: object
|
|
36
|
+
properties:
|
|
37
|
+
replicas:
|
|
38
|
+
type: integer
|
|
39
|
+
minimum: 1
|
|
40
|
+
image:
|
|
41
|
+
type: string
|
|
42
|
+
configValue:
|
|
43
|
+
type: string
|
|
44
|
+
status:
|
|
45
|
+
type: object
|
|
46
|
+
x-kubernetes-preserve-unknown-fields: true
|
|
@@ -32,7 +32,10 @@ module Metatron
|
|
|
32
32
|
|
|
33
33
|
headers = {}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# Metacontroller only accepts 304 Not Modified when it sends
|
|
36
|
+
# If-None-Match; a 412 is treated as a hook failure. See
|
|
37
|
+
# https://github.com/metacontroller/metacontroller/blob/master/pkg/hooks/webhook_etag.go
|
|
38
|
+
return Rack::Response[304, headers, []].to_a if etag_matches?(headers)
|
|
36
39
|
|
|
37
40
|
Rack::Response[200, headers, processed_data].to_a
|
|
38
41
|
end
|
data/lib/metatron/controller.rb
CHANGED
|
@@ -20,21 +20,63 @@ module Metatron
|
|
|
20
20
|
|
|
21
21
|
def call
|
|
22
22
|
begin
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
parse_params
|
|
24
|
+
rescue Metatron::PayloadTooLarge
|
|
25
|
+
return Metatron::Problem.response(
|
|
26
|
+
status: 413,
|
|
27
|
+
title: "Payload Too Large",
|
|
28
|
+
detail: "Request body exceeds the maximum allowed size"
|
|
29
|
+
)
|
|
29
30
|
rescue JSON::ParserError => e
|
|
30
31
|
return [400, {}, [{ error: "Request must be JSON: #{e.message}" }.to_json]]
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
begin
|
|
35
|
+
_call
|
|
36
|
+
rescue StandardError, NotImplementedError => e
|
|
37
|
+
log_exception(e)
|
|
38
|
+
Problem.response(
|
|
39
|
+
status: 500,
|
|
40
|
+
title: "Internal Server Error",
|
|
41
|
+
detail: "The controller failed to handle the request."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
34
44
|
end
|
|
35
45
|
|
|
36
46
|
private
|
|
37
47
|
|
|
38
48
|
attr_reader :request
|
|
49
|
+
|
|
50
|
+
def parse_params
|
|
51
|
+
return unless request&.content_type&.include?("json")
|
|
52
|
+
|
|
53
|
+
body = read_bounded_body
|
|
54
|
+
|
|
55
|
+
self.params = JSON.parse(body) unless body.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Read the request body, enforcing the configured size cap.
|
|
59
|
+
# Raises PayloadTooLarge when the body exceeds the limit; the
|
|
60
|
+
# controller's rescue converts it to a 413 problem document.
|
|
61
|
+
def read_bounded_body
|
|
62
|
+
limit = Metatron.max_body_bytes
|
|
63
|
+
raise Metatron::PayloadTooLarge if request.content_length.to_i > limit
|
|
64
|
+
|
|
65
|
+
# Read at most one byte more than the limit so an oversized
|
|
66
|
+
# chunked body (no Content-Length) is still caught.
|
|
67
|
+
body = request.body.read(limit + 1) || ""
|
|
68
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
69
|
+
raise Metatron::PayloadTooLarge if body.bytesize > limit
|
|
70
|
+
|
|
71
|
+
body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Record the failure for the operator without leaking it to the caller.
|
|
75
|
+
def log_exception(error)
|
|
76
|
+
Metatron.logger&.error(
|
|
77
|
+
"Metatron controller raised #{error.class}: #{error.message}\n" \
|
|
78
|
+
"#{Array(error.backtrace).join("\n")}"
|
|
79
|
+
)
|
|
80
|
+
end
|
|
39
81
|
end
|
|
40
82
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/utils"
|
|
4
|
+
|
|
5
|
+
module Metatron
|
|
6
|
+
# Rack middleware shipped with Metatron.
|
|
7
|
+
module Middleware
|
|
8
|
+
# Requires a shared secret to be presented as a Bearer token before the
|
|
9
|
+
# request reaches the controller.
|
|
10
|
+
#
|
|
11
|
+
# The middleware is opt-in: it enforces nothing until a secret is
|
|
12
|
+
# configured, either by passing `secret:` to the constructor or by setting
|
|
13
|
+
# the `METATRON_AUTH_SECRET` environment variable. With no secret
|
|
14
|
+
# configured, requests are passed straight through, so existing
|
|
15
|
+
# applications behave exactly as they did before.
|
|
16
|
+
#
|
|
17
|
+
# Metacontroller can send the matching credential via the hook spec's
|
|
18
|
+
# `webhook.authorization.secretRef`, which points at a Kubernetes Secret
|
|
19
|
+
# whose value is shared with this middleware.
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# use Metatron::Middleware::Auth, secret: ENV["METATRON_AUTH_SECRET"]
|
|
23
|
+
#
|
|
24
|
+
# @see https://metacontroller.github.io/metacontroller/api/compositecontroller.html
|
|
25
|
+
class Auth
|
|
26
|
+
# Environment variable consulted when no secret is passed explicitly.
|
|
27
|
+
SECRET_ENV_VAR = "METATRON_AUTH_SECRET"
|
|
28
|
+
|
|
29
|
+
# The only authentication scheme understood by this middleware.
|
|
30
|
+
SCHEME = "Bearer"
|
|
31
|
+
|
|
32
|
+
# Rack environment key holding the `Authorization` header.
|
|
33
|
+
AUTHORIZATION_HEADER = "HTTP_AUTHORIZATION"
|
|
34
|
+
|
|
35
|
+
# Challenge sent with unauthorized responses.
|
|
36
|
+
CHALLENGE = "Bearer realm=\"metatron\""
|
|
37
|
+
|
|
38
|
+
attr_reader :app
|
|
39
|
+
|
|
40
|
+
# @param app [#call] the Rack application to protect
|
|
41
|
+
# @param secret [String, nil] the shared secret; falls back to the environment
|
|
42
|
+
# @param env_var [String] the environment variable to read the secret from
|
|
43
|
+
def initialize(app, secret: nil, env_var: SECRET_ENV_VAR)
|
|
44
|
+
@app = app
|
|
45
|
+
@secret = secret
|
|
46
|
+
@env_var = env_var
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(env)
|
|
50
|
+
expected = configured_secret
|
|
51
|
+
return app.call(env) if expected.nil?
|
|
52
|
+
|
|
53
|
+
presented = bearer_token(env)
|
|
54
|
+
return unauthorized if presented.nil?
|
|
55
|
+
return unauthorized unless secure_compare?(presented, expected)
|
|
56
|
+
|
|
57
|
+
app.call(env)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# The secret to check against, or nil when authentication is disabled.
|
|
63
|
+
def configured_secret
|
|
64
|
+
secret = @secret || ENV.fetch(@env_var, nil)
|
|
65
|
+
return nil if secret.nil?
|
|
66
|
+
|
|
67
|
+
secret = secret.to_s
|
|
68
|
+
secret.empty? ? nil : secret
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Extract the token from an `Authorization: Bearer <token>` header.
|
|
72
|
+
#
|
|
73
|
+
# @return [String, nil] the token, or nil when no usable token is present
|
|
74
|
+
def bearer_token(env)
|
|
75
|
+
header = env[AUTHORIZATION_HEADER]
|
|
76
|
+
return nil if header.nil?
|
|
77
|
+
|
|
78
|
+
scheme, token = header.to_s.split(" ", 2)
|
|
79
|
+
return nil if scheme.nil? || token.nil?
|
|
80
|
+
return nil unless scheme.casecmp?(SCHEME)
|
|
81
|
+
|
|
82
|
+
token = token.strip
|
|
83
|
+
token.empty? ? nil : token
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Compare the presented token with the secret in constant time.
|
|
87
|
+
def secure_compare?(presented, expected)
|
|
88
|
+
return false unless presented.bytesize == expected.bytesize
|
|
89
|
+
|
|
90
|
+
Rack::Utils.secure_compare(presented, expected)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def unauthorized
|
|
94
|
+
Problem.response(
|
|
95
|
+
status: 401,
|
|
96
|
+
title: "Unauthorized",
|
|
97
|
+
detail: "A valid Bearer token is required to access this endpoint.",
|
|
98
|
+
headers: { "www-authenticate" => CHALLENGE }
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Metatron
|
|
6
|
+
# Rack middleware shipped with Metatron.
|
|
7
|
+
module Middleware
|
|
8
|
+
# Rejects request bodies larger than a configurable cap.
|
|
9
|
+
#
|
|
10
|
+
# The cap is enforced twice: up front from the `Content-Length` header
|
|
11
|
+
# (cheap, and catches the common case without reading anything), and again
|
|
12
|
+
# while reading the body, so that a missing or understated
|
|
13
|
+
# `Content-Length` cannot be used to exhaust memory. The body is buffered
|
|
14
|
+
# up to the cap and handed to the application as a rewound stream.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# use Metatron::Middleware::BodyLimit, max_bytes: 1_048_576
|
|
18
|
+
class BodyLimit
|
|
19
|
+
# Default cap on request body size: 1 MiB, which is generous for
|
|
20
|
+
# Metacontroller sync payloads.
|
|
21
|
+
DEFAULT_MAX_BYTES = 1024 * 1024
|
|
22
|
+
|
|
23
|
+
# Environment variable consulted when no limit is passed explicitly.
|
|
24
|
+
MAX_BYTES_ENV_VAR = "METATRON_MAX_REQUEST_BODY_BYTES"
|
|
25
|
+
|
|
26
|
+
attr_reader :app
|
|
27
|
+
|
|
28
|
+
# @param app [#call] the Rack application to protect
|
|
29
|
+
# @param max_bytes [Integer, nil] the cap; falls back to the environment
|
|
30
|
+
# @param env_var [String] the environment variable to read the cap from
|
|
31
|
+
def initialize(app, max_bytes: nil, env_var: MAX_BYTES_ENV_VAR)
|
|
32
|
+
@app = app
|
|
33
|
+
@max_bytes = max_bytes
|
|
34
|
+
@env_var = env_var
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(env)
|
|
38
|
+
limit = max_bytes
|
|
39
|
+
|
|
40
|
+
length = env["CONTENT_LENGTH"]
|
|
41
|
+
return payload_too_large(limit) if length && length.to_i > limit
|
|
42
|
+
|
|
43
|
+
input = env["rack.input"]
|
|
44
|
+
return app.call(env) if input.nil?
|
|
45
|
+
|
|
46
|
+
body = read_capped(input, limit)
|
|
47
|
+
return payload_too_large(limit) if body.nil?
|
|
48
|
+
|
|
49
|
+
env["rack.input"] = StringIO.new(body).tap(&:binmode)
|
|
50
|
+
app.call(env)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The maximum number of bytes accepted in a request body.
|
|
56
|
+
def max_bytes
|
|
57
|
+
return @max_bytes unless @max_bytes.nil?
|
|
58
|
+
|
|
59
|
+
configured = ENV.fetch(@env_var, nil)
|
|
60
|
+
return DEFAULT_MAX_BYTES if configured.nil? || configured.empty?
|
|
61
|
+
|
|
62
|
+
configured.to_i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Read at most `limit` bytes from the input stream.
|
|
66
|
+
#
|
|
67
|
+
# @return [String, nil] the body, or nil when it is larger than the limit
|
|
68
|
+
def read_capped(input, limit)
|
|
69
|
+
chunk = input.read(limit + 1)
|
|
70
|
+
chunk = "" if chunk.nil?
|
|
71
|
+
return nil if chunk.bytesize > limit
|
|
72
|
+
|
|
73
|
+
chunk
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def payload_too_large(limit)
|
|
77
|
+
Problem.response(
|
|
78
|
+
status: 413,
|
|
79
|
+
title: "Payload Too Large",
|
|
80
|
+
detail: "Request bodies may not exceed #{limit} bytes."
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json" unless defined? JSON # allow for JSON to be loaded elsewhere
|
|
4
|
+
|
|
5
|
+
module Metatron
|
|
6
|
+
# Helpers for RFC 9457 "Problem Details for HTTP APIs" responses.
|
|
7
|
+
#
|
|
8
|
+
# Problem documents give clients a machine-readable error body without
|
|
9
|
+
# leaking internals (backtraces, environment, gem versions). They are used
|
|
10
|
+
# by the shipped middleware and by the controller's exception boundary.
|
|
11
|
+
module Problem
|
|
12
|
+
# Media type used for problem documents.
|
|
13
|
+
CONTENT_TYPE = "application/problem+json"
|
|
14
|
+
|
|
15
|
+
# Stable problem type identifiers. These are identifiers only; they are
|
|
16
|
+
# not required to resolve to a human-readable page.
|
|
17
|
+
TYPES = {
|
|
18
|
+
401 => "urn:metatron:problem:unauthorized",
|
|
19
|
+
413 => "urn:metatron:problem:payload-too-large",
|
|
20
|
+
500 => "urn:metatron:problem:internal-server-error"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Build a Rack response triple carrying a problem document.
|
|
25
|
+
#
|
|
26
|
+
# @param status [Integer] the HTTP status code
|
|
27
|
+
# @param title [String] a short, human-readable summary of the problem
|
|
28
|
+
# @param detail [String, nil] a human-readable explanation of this occurrence
|
|
29
|
+
# @param type [String, nil] overrides the default problem type for the status
|
|
30
|
+
# @param headers [Hash] additional response headers
|
|
31
|
+
# @return [Array] a Rack response
|
|
32
|
+
def response(status:, title:, detail: nil, type: nil, headers: {})
|
|
33
|
+
body = document(status: status, title: title, detail: detail, type: type)
|
|
34
|
+
[status, { "content-type" => CONTENT_TYPE }.merge(headers), [body]]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Build a problem document.
|
|
38
|
+
#
|
|
39
|
+
# @return [String] the serialized problem document
|
|
40
|
+
def document(status:, title:, detail: nil, type: nil)
|
|
41
|
+
problem = { type: type || TYPES.fetch(status, "about:blank"), title: title, status: status }
|
|
42
|
+
problem[:detail] = detail unless detail.nil?
|
|
43
|
+
problem.to_json
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/metatron/template.rb
CHANGED
data/lib/metatron/version.rb
CHANGED
data/lib/metatron.rb
CHANGED
|
@@ -11,15 +11,24 @@ require "base64"
|
|
|
11
11
|
|
|
12
12
|
# The top-level module for Metatron
|
|
13
13
|
module Metatron
|
|
14
|
+
# Default request body limit (1 MiB). Override via
|
|
15
|
+
# METATRON_MAX_REQUEST_BODY_BYTES or Metatron.max_body_bytes=.
|
|
16
|
+
DEFAULT_MAX_BYTES = 1_048_576
|
|
17
|
+
|
|
14
18
|
class Error < StandardError; end
|
|
19
|
+
class PayloadTooLarge < Error; end
|
|
15
20
|
class ConfigError < Error; end
|
|
16
21
|
|
|
17
|
-
singleton_class.attr_accessor :logger
|
|
22
|
+
singleton_class.attr_accessor :max_body_bytes, :logger
|
|
23
|
+
self.max_body_bytes = ENV.fetch("METATRON_MAX_REQUEST_BODY_BYTES", DEFAULT_MAX_BYTES).to_i
|
|
18
24
|
self.logger = Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", :warn))
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
# Internal requirements
|
|
22
28
|
require "metatron/version"
|
|
29
|
+
require "metatron/problem"
|
|
30
|
+
require "metatron/middleware/auth"
|
|
31
|
+
require "metatron/middleware/body_limit"
|
|
23
32
|
require "metatron/template"
|
|
24
33
|
require "metatron/templates/concerns/annotated"
|
|
25
34
|
require "metatron/templates/concerns/namespaced"
|
data/metatron.gemspec
CHANGED
|
@@ -28,8 +28,9 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.required_ruby_version = ">= 3.3"
|
|
29
29
|
|
|
30
30
|
spec.add_dependency "base64"
|
|
31
|
-
|
|
32
|
-
spec.add_dependency "
|
|
31
|
+
# Floors past the last rubysec/ruby-advisory-db patched releases.
|
|
32
|
+
spec.add_dependency "json", ">= 2.21.2", "< 3"
|
|
33
|
+
spec.add_dependency "rack", ">= 2.2.25", "< 4"
|
|
33
34
|
|
|
34
35
|
spec.add_development_dependency "bundler", "~> 2.3"
|
|
35
36
|
spec.add_development_dependency "byebug", "~> 11"
|
data/script/integration
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Run Metatron's integration suite against a real Metacontroller installation
|
|
4
|
+
# in a throwaway k3d cluster.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# script/integration # create cluster, run specs, delete cluster
|
|
8
|
+
# script/integration --keep # leave the cluster running for debugging
|
|
9
|
+
# script/integration --metacontroller-version v4.17.6
|
|
10
|
+
# script/integration --k3d-version v5.9.0
|
|
11
|
+
#
|
|
12
|
+
# Environment:
|
|
13
|
+
# K3D_CLUSTER_NAME cluster name (default: metatron-integration)
|
|
14
|
+
# K3D_VERSION expected k3d version, verified when set
|
|
15
|
+
# METATRON_IT_NAMESPACE namespace for the fixture controller
|
|
16
|
+
# METATRON_IT_IMAGE fixture image tag (default: metatron-integration:test)
|
|
17
|
+
# METATRON_IT_AUTH_TOKEN bearer token shared with Metacontroller
|
|
18
|
+
# METACONTROLLER_VERSION release tag to install (default: v4.17.6)
|
|
19
|
+
# METACONTROLLER_REPO local Metacontroller clone; used when the tag exists
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
|
|
22
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
23
|
+
|
|
24
|
+
CLUSTER="${K3D_CLUSTER_NAME:-metatron-integration}"
|
|
25
|
+
K3D_VERSION="${K3D_VERSION:-}"
|
|
26
|
+
NAMESPACE="${METATRON_IT_NAMESPACE:-metatron-integration}"
|
|
27
|
+
IMAGE="${METATRON_IT_IMAGE:-metatron-integration:test}"
|
|
28
|
+
AUTH_TOKEN="${METATRON_IT_AUTH_TOKEN:-integration-secret-token}"
|
|
29
|
+
METACONTROLLER_VERSION="${METACONTROLLER_VERSION:-v4.17.6}"
|
|
30
|
+
METACONTROLLER_REPO="${METACONTROLLER_REPO:-$HOME/Development/golang/metacontroller}"
|
|
31
|
+
KEEP=0
|
|
32
|
+
|
|
33
|
+
usage() {
|
|
34
|
+
sed -n '2,/^set -euo pipefail$/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
while [[ $# -gt 0 ]]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--keep)
|
|
40
|
+
KEEP=1
|
|
41
|
+
shift
|
|
42
|
+
;;
|
|
43
|
+
--metacontroller-version)
|
|
44
|
+
METACONTROLLER_VERSION="${2:?missing version}"
|
|
45
|
+
shift 2
|
|
46
|
+
;;
|
|
47
|
+
--k3d-version)
|
|
48
|
+
K3D_VERSION="${2:?missing version}"
|
|
49
|
+
shift 2
|
|
50
|
+
;;
|
|
51
|
+
-h|--help)
|
|
52
|
+
usage
|
|
53
|
+
exit 0
|
|
54
|
+
;;
|
|
55
|
+
*)
|
|
56
|
+
echo "unknown argument: $1" >&2
|
|
57
|
+
usage >&2
|
|
58
|
+
exit 1
|
|
59
|
+
;;
|
|
60
|
+
esac
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
for command in docker k3d kubectl; do
|
|
64
|
+
if ! command -v "$command" >/dev/null 2>&1; then
|
|
65
|
+
echo "error: $command is required but not installed" >&2
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
done
|
|
69
|
+
|
|
70
|
+
if ! docker info >/dev/null 2>&1; then
|
|
71
|
+
echo "error: the Docker daemon is not reachable" >&2
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
if [[ -n "$K3D_VERSION" ]]; then
|
|
76
|
+
installed_k3d="$(k3d version | awk '/^k3d version/ {print $3; exit}')"
|
|
77
|
+
if [[ "$installed_k3d" != "$K3D_VERSION" ]]; then
|
|
78
|
+
echo "error: requested k3d $K3D_VERSION but $installed_k3d is installed" >&2
|
|
79
|
+
exit 1
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
KUBECONFIG_FILE=""
|
|
84
|
+
cleanup() {
|
|
85
|
+
status=$?
|
|
86
|
+
if [[ "$KEEP" -eq 0 ]]; then
|
|
87
|
+
k3d cluster delete "$CLUSTER" >/dev/null 2>&1 || true
|
|
88
|
+
else
|
|
89
|
+
echo
|
|
90
|
+
echo "Cluster '$CLUSTER' left running."
|
|
91
|
+
echo " export KUBECONFIG=$KUBECONFIG_FILE"
|
|
92
|
+
echo " kubectl -n $NAMESPACE get widgets,deployments,services,configmaps,secrets"
|
|
93
|
+
fi
|
|
94
|
+
if [[ "$KEEP" -eq 0 && -n "$KUBECONFIG_FILE" ]]; then
|
|
95
|
+
rm -f "$KUBECONFIG_FILE"
|
|
96
|
+
fi
|
|
97
|
+
exit "$status"
|
|
98
|
+
}
|
|
99
|
+
trap cleanup EXIT INT TERM
|
|
100
|
+
|
|
101
|
+
dump_debug() {
|
|
102
|
+
echo "=== cluster state ===" >&2
|
|
103
|
+
kubectl get pods -A >&2 || true
|
|
104
|
+
echo "=== fixture controller logs ===" >&2
|
|
105
|
+
kubectl -n "$NAMESPACE" logs -l app=widget-controller --tail=200 >&2 || true
|
|
106
|
+
}
|
|
107
|
+
trap dump_debug ERR
|
|
108
|
+
|
|
109
|
+
render_manifest() {
|
|
110
|
+
sed -e "s|__NAMESPACE__|$NAMESPACE|g" \
|
|
111
|
+
-e "s|__IMAGE__|$IMAGE|g" \
|
|
112
|
+
-e "s|__AUTH_TOKEN__|$AUTH_TOKEN|g" \
|
|
113
|
+
"$1"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
apply_metacontroller_manifest() {
|
|
117
|
+
local file="$1"
|
|
118
|
+
if [[ -n "$LOCAL_MANIFESTS" ]]; then
|
|
119
|
+
git -C "$METACONTROLLER_REPO" show "$METACONTROLLER_VERSION:manifests/production/$file" | kubectl apply -f -
|
|
120
|
+
else
|
|
121
|
+
kubectl apply -f "$REMOTE_MANIFESTS/$file"
|
|
122
|
+
fi
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Prefer the local Metacontroller clone when it has the requested tag, so
|
|
126
|
+
# local runs do not depend on GitHub. CI falls back to the pinned release URL.
|
|
127
|
+
LOCAL_MANIFESTS=""
|
|
128
|
+
REMOTE_MANIFESTS=""
|
|
129
|
+
if [[ -d "$METACONTROLLER_REPO/.git" ]] && \
|
|
130
|
+
git -C "$METACONTROLLER_REPO" rev-parse -q --verify "refs/tags/$METACONTROLLER_VERSION" >/dev/null; then
|
|
131
|
+
LOCAL_MANIFESTS=1
|
|
132
|
+
echo "Using Metacontroller $METACONTROLLER_VERSION manifests from $METACONTROLLER_REPO"
|
|
133
|
+
else
|
|
134
|
+
REMOTE_MANIFESTS="https://raw.githubusercontent.com/metacontroller/metacontroller/${METACONTROLLER_VERSION}/manifests/production"
|
|
135
|
+
echo "Using Metacontroller $METACONTROLLER_VERSION manifests from $REMOTE_MANIFESTS"
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
echo "Creating k3d cluster '$CLUSTER'"
|
|
139
|
+
k3d cluster delete "$CLUSTER" >/dev/null 2>&1 || true
|
|
140
|
+
k3d cluster create "$CLUSTER" \
|
|
141
|
+
--agents 1 \
|
|
142
|
+
--wait \
|
|
143
|
+
--timeout 180s \
|
|
144
|
+
--kubeconfig-update-default=false \
|
|
145
|
+
--kubeconfig-switch-context=false
|
|
146
|
+
|
|
147
|
+
KUBECONFIG_FILE="$(mktemp)"
|
|
148
|
+
k3d kubeconfig write "$CLUSTER" --output "$KUBECONFIG_FILE" >/dev/null
|
|
149
|
+
export KUBECONFIG="$KUBECONFIG_FILE"
|
|
150
|
+
|
|
151
|
+
echo "Building fixture controller image '$IMAGE'"
|
|
152
|
+
docker build --file "$ROOT/integration/Dockerfile" --tag "$IMAGE" "$ROOT"
|
|
153
|
+
|
|
154
|
+
echo "Importing '$IMAGE' into '$CLUSTER'"
|
|
155
|
+
k3d image import "$IMAGE" --cluster "$CLUSTER"
|
|
156
|
+
|
|
157
|
+
echo "Installing Metacontroller $METACONTROLLER_VERSION"
|
|
158
|
+
apply_metacontroller_manifest metacontroller-namespace.yaml
|
|
159
|
+
apply_metacontroller_manifest metacontroller-crds-v1.yaml
|
|
160
|
+
apply_metacontroller_manifest metacontroller-rbac.yaml
|
|
161
|
+
apply_metacontroller_manifest metacontroller.yaml
|
|
162
|
+
|
|
163
|
+
kubectl wait --for=condition=Established --timeout=120s \
|
|
164
|
+
crd/compositecontrollers.metacontroller.k8s.io
|
|
165
|
+
kubectl -n metacontroller rollout status statefulset/metacontroller --timeout=180s
|
|
166
|
+
|
|
167
|
+
echo "Deploying fixture controller in namespace '$NAMESPACE'"
|
|
168
|
+
render_manifest "$ROOT/integration/manifests/namespace.yaml" | kubectl apply -f -
|
|
169
|
+
render_manifest "$ROOT/integration/manifests/crd.yaml" | kubectl apply -f -
|
|
170
|
+
kubectl wait --for=condition=Established --timeout=120s \
|
|
171
|
+
crd/widgets.metatron.therubyist.org
|
|
172
|
+
render_manifest "$ROOT/integration/manifests/controller.yaml" | kubectl apply -f -
|
|
173
|
+
kubectl -n "$NAMESPACE" rollout status deployment/widget-controller --timeout=180s
|
|
174
|
+
render_manifest "$ROOT/integration/manifests/composite-controller.yaml" | kubectl apply -f -
|
|
175
|
+
|
|
176
|
+
export RUN_INTEGRATION=1
|
|
177
|
+
export METATRON_IT_NAMESPACE="$NAMESPACE"
|
|
178
|
+
export METATRON_IT_RUN_ID="${METATRON_IT_RUN_ID:-$(date +%s)-$$}"
|
|
179
|
+
export METATRON_IT_AUTH_TOKEN="$AUTH_TOKEN"
|
|
180
|
+
|
|
181
|
+
cd "$ROOT"
|
|
182
|
+
bundle exec rspec spec/integration --format documentation
|