summaryrefslogtreecommitdiff
path: root/app/services/pod_logs/kubernetes_service.rb
blob: b573ceae1aaf8cabc804fe6e71918da88c5944fe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# frozen_string_literal: true

module PodLogs
  class KubernetesService < PodLogs::BaseService
    LOGS_LIMIT = 500.freeze
    REPLACEMENT_CHAR = "\u{FFFD}"

    EncodingHelperError = Class.new(StandardError)

    steps :check_arguments,
          :get_raw_pods,
          :get_pod_names,
          :check_pod_name,
          :check_container_name,
          :pod_logs,
          :encode_logs_to_utf8,
          :split_logs,
          :filter_return_keys

    self.reactive_cache_work_type = :external_dependency
    self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }

    private

    def get_raw_pods(result)
      result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace).map do |pod|
        {
          name: pod.metadata.name,
          container_names: pod.spec.containers.map(&:name)
        }
      end

      success(result)
    end

    def check_pod_name(result)
      # If pod_name is not received as parameter, get the pod logs of the first
      # pod of this namespace.
      result[:pod_name] ||= result[:pods].first

      unless result[:pod_name]
        return error(_('No pods available'))
      end

      unless result[:pod_name].length.to_i <= K8S_NAME_MAX_LENGTH
        return error(_('pod_name cannot be larger than %{max_length}'\
          ' chars' % { max_length: K8S_NAME_MAX_LENGTH }))
      end

      unless result[:pod_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
        return error(_('pod_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
      end

      unless result[:pods].include?(result[:pod_name])
        return error(_('Pod does not exist'))
      end

      success(result)
    end

    def check_container_name(result)
      pod_details = result[:raw_pods].find { |p| p[:name] == result[:pod_name] }
      container_names = pod_details[:container_names]

      # select first container if not specified
      result[:container_name] ||= container_names.first

      unless result[:container_name]
        return error(_('No containers available'))
      end

      unless result[:container_name].length.to_i <= K8S_NAME_MAX_LENGTH
        return error(_('container_name cannot be larger than'\
          ' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH }))
      end

      unless result[:container_name] =~ Gitlab::Regex.kubernetes_dns_subdomain_regex
        return error(_('container_name can contain only lowercase letters, digits, \'-\', and \'.\' and must start and end with an alphanumeric character'))
      end

      unless container_names.include?(result[:container_name])
        return error(_('Container does not exist'))
      end

      success(result)
    end

    def pod_logs(result)
      result[:logs] = cluster.kubeclient.get_pod_log(
        result[:pod_name],
        namespace,
        container: result[:container_name],
        tail_lines: LOGS_LIMIT,
        timestamps: true
      ).body

      success(result)
    rescue Kubeclient::ResourceNotFoundError
      error(_('Pod not found'))
    rescue Kubeclient::HttpError => e
      ::Gitlab::ErrorTracking.track_exception(e)

      error(_('Kubernetes API returned status code: %{error_code}') % {
        error_code: e.error_code
      })
    end

    # Check https://gitlab.com/gitlab-org/gitlab/issues/34965#note_292261879
    # for more details on why this is necessary.
    def encode_logs_to_utf8(result)
      return success(result) if result[:logs].nil?
      return success(result) if result[:logs].encoding == Encoding::UTF_8

      result[:logs] = encode_utf8(result[:logs])

      success(result)
    rescue EncodingHelperError
      error(_('Unable to convert Kubernetes logs encoding to UTF-8'))
    end

    def split_logs(result)
      result[:logs] = result[:logs].strip.lines(chomp: true).map do |line|
        # message contains a RFC3339Nano timestamp, then a space, then the log line.
        # resolution of the nanoseconds can vary, so we split on the first space
        values = line.split(' ', 2)
        {
          timestamp: values[0],
          message: values[1],
          pod: result[:pod_name]
        }
      end

      success(result)
    end

    def encode_utf8(logs)
      utf8_logs = Gitlab::EncodingHelper.encode_utf8(logs.dup, replace: REPLACEMENT_CHAR)

      # Gitlab::EncodingHelper.encode_utf8 can return '' or nil if an exception
      # is raised while encoding. We prefer to return an error rather than wrongly
      # display blank logs.
      no_utf8_logs = logs.present? && utf8_logs.blank?
      unexpected_encoding = utf8_logs&.encoding != Encoding::UTF_8

      if no_utf8_logs || unexpected_encoding
        raise EncodingHelperError, 'Could not convert Kubernetes logs to UTF-8'
      end

      utf8_logs
    end
  end
end