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.
@@ -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
@@ -0,0 +1,6 @@
1
+ apiVersion: v1
2
+ kind: Namespace
3
+ metadata:
4
+ name: __NAMESPACE__
5
+ labels:
6
+ app.kubernetes.io/part-of: metatron-integration
@@ -0,0 +1,9 @@
1
+ apiVersion: metatron.therubyist.org/v1
2
+ kind: Widget
3
+ metadata:
4
+ name: sample
5
+ namespace: __NAMESPACE__
6
+ spec:
7
+ replicas: 2
8
+ image: registry.k8s.io/pause:3.9
9
+ configValue: sample
@@ -32,7 +32,10 @@ module Metatron
32
32
 
33
33
  headers = {}
34
34
 
35
- return Rack::Response[412, headers, []].to_a if etag_matches?(headers)
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
@@ -20,21 +20,63 @@ module Metatron
20
20
 
21
21
  def call
22
22
  begin
23
- if request&.content_type&.include?("json")
24
- body = request.body.read
25
- request.body.rewind if request.body.respond_to?(:rewind)
26
-
27
- self.params = JSON.parse(body) unless body.empty?
28
- end
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
- _call
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
@@ -38,6 +38,10 @@ module Metatron
38
38
  end
39
39
 
40
40
  def initialize(name)
41
+ unless name.is_a?(String) && !name.strip.empty?
42
+ raise ArgumentError, "name must be a non-empty string"
43
+ end
44
+
41
45
  @name = name
42
46
  @api_version = "v1"
43
47
  @kind = find_kind
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Metatron
4
- VERSION = "0.11.4"
4
+ VERSION = "0.11.5"
5
5
  end
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
- spec.add_dependency "json", "~> 2.6"
32
- spec.add_dependency "rack", ">= 2.2.8", "< 4"
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"
@@ -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