Commit a210c43e authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent c9687bdf
......@@ -492,6 +492,41 @@ const Api = {
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
/**
* Returns pods logs for an environment with an optional pod and container
*
* @param {Object} params
* @param {Object} param.environment - Environment object
* @param {string=} params.podName - Pod name, if not set the backend assumes a default one
* @param {string=} params.containerName - Container name, if not set the backend assumes a default one
* @param {string=} params.start - Starting date to query the logs in ISO format
* @param {string=} params.end - Ending date to query the logs in ISO format
* @returns {Promise} Axios promise for the result of a GET request of logs
*/
getPodLogs({ environment, podName, containerName, search, start, end }) {
const url = this.buildUrl(environment.logs_api_path);
const params = {};
if (podName) {
params.pod_name = podName;
}
if (containerName) {
params.container_name = containerName;
}
if (search) {
params.search = search;
}
if (start) {
params.start = start;
}
if (end) {
params.end = end;
}
return axios.get(url, { params });
},
};
export default Api;
......@@ -27,6 +27,10 @@ export default {
key: 'size',
label: __('Size'),
},
{
key: 'cpu',
label: __('Total cores (vCPUs)'),
},
{
key: 'memory',
label: __('Total memory (GB)'),
......
<script>
import { isNil } from 'lodash';
import $ from 'jquery';
import { GlIcon } from '@gitlab/ui';
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
const toArray = value => [].concat(value);
const toArray = value => (isNil(value) ? [] : [].concat(value));
const itemsProp = (items, prop) => items.map(item => item[prop]);
const defaultSearchFn = (searchQuery, labelProp) => item =>
item[labelProp].toLowerCase().indexOf(searchQuery) > -1;
......
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { scrollDown } from '~/lib/utils/scroll_utils';
import LogControlButtons from './log_control_buttons.vue';
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
import { timeRangeFromUrl } from '~/monitoring/utils';
export default {
components: {
GlAlert,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlSearchBoxByClick,
DateTimePicker,
LogControlButtons,
},
props: {
environmentName: {
type: String,
required: false,
default: '',
},
currentPodName: {
type: [String, null],
required: false,
default: null,
},
environmentsPath: {
type: String,
required: false,
default: '',
},
clusterApplicationsDocumentationPath: {
type: String,
required: true,
},
},
data() {
return {
searchQuery: '',
timeRanges,
isElasticStackCalloutDismissed: false,
};
},
computed: {
...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
...mapGetters('environmentLogs', ['trace']),
timeRangeModel: {
get() {
return this.timeRange.current;
},
set(val) {
this.setTimeRange(val);
},
},
showLoader() {
return this.logs.isLoading || !this.logs.isComplete;
},
advancedFeaturesEnabled() {
const environment = this.environments.options.find(
({ name }) => name === this.environments.current,
);
return environment && environment.enable_advanced_logs_querying;
},
disableAdvancedControls() {
return this.environments.isLoading || !this.advancedFeaturesEnabled;
},
shouldShowElasticStackCallout() {
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
},
},
watch: {
trace(val) {
this.$nextTick(() => {
if (val) {
scrollDown();
}
this.$refs.scrollButtons.update();
});
},
},
mounted() {
this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange,
environmentName: this.environmentName,
podName: this.currentPodName,
});
this.fetchEnvironments(this.environmentsPath);
},
methods: {
...mapActions('environmentLogs', [
'setInitData',
'setSearch',
'setTimeRange',
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
]),
},
};
</script>
<template>
<div class="build-page-pod-logs mt-3">
<gl-alert
v-if="shouldShowElasticStackCallout"
class="mb-3 js-elasticsearch-alert"
@dismiss="isElasticStackCalloutDismissed = true"
>
{{
s__(
'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
)
}}
<a :href="clusterApplicationsDocumentationPath">
<strong>
{{ s__('View Documentation') }}
</strong>
</a>
</gl-alert>
<div class="top-bar js-top-bar d-flex">
<div class="row mx-n1">
<gl-form-group
id="environments-dropdown-fg"
:label="s__('Environments|Environment')"
label-size="sm"
label-for="environments-dropdown"
class="col-3 px-1"
>
<gl-dropdown
id="environments-dropdown"
:text="environments.current"
:disabled="environments.isLoading"
class="d-flex gl-h-32 js-environments-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="env in environments.options"
:key="env.id"
@click="showEnvironment(env.name)"
>
{{ env.name }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="pods-dropdown-fg"
:label="s__('Environments|Logs from')"
label-size="sm"
label-for="pods-dropdown"
class="col-3 px-1"
>
<gl-dropdown
id="pods-dropdown"
:text="pods.current || s__('Environments|No pods to display')"
:disabled="environments.isLoading"
class="d-flex gl-h-32 js-pods-dropdown"
toggle-class="dropdown-menu-toggle"
>
<gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
@click="showPodLogs(podName)"
>
{{ podName }}
</gl-dropdown-item>
</gl-dropdown>
</gl-form-group>
<gl-form-group
id="dates-fg"
:label="s__('Environments|Show last')"
label-size="sm"
label-for="time-window-dropdown"
class="col-3 px-1"
>
<date-time-picker
ref="dateTimePicker"
v-model="timeRangeModel"
class="w-100 gl-h-32"
:disabled="disableAdvancedControls"
:options="timeRanges"
/>
</gl-form-group>
<gl-form-group
id="search-fg"
:label="s__('Environments|Search')"
label-size="sm"
label-for="search"
class="col-3 px-1"
>
<gl-search-box-by-click
v-model.trim="searchQuery"
:disabled="disableAdvancedControls"
:placeholder="s__('Environments|Search')"
class="js-logs-search"
type="search"
autofocus
@submit="setSearch(searchQuery)"
/>
</gl-form-group>
</div>
<log-control-buttons
ref="scrollButtons"
class="controllers align-self-end mb-1"
@refresh="showPodLogs(pods.current)"
/>
</div>
<pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
<div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div></code></pre>
</div>
</template>
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
data() {
return {
scrollToTopEnabled: false,
scrollToBottomEnabled: false,
};
},
created() {
window.addEventListener('scroll', this.update);
},
destroyed() {
window.removeEventListener('scroll', this.update);
},
methods: {
/**
* Checks if page can be scrolled and updates
* enabled/disabled state of buttons accordingly
*/
update() {
this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
},
handleRefreshClick() {
this.$emit('refresh');
},
scrollUp,
scrollDown,
},
};
</script>
<template>
<div>
<div
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to top')"
aria-labelledby="scroll-to-top"
>
<gl-button
id="scroll-to-top"
class="btn-blank js-scroll-to-top"
:aria-label="__('Scroll to top')"
:disabled="!scrollToTopEnabled"
@click="scrollUp()"
><icon name="scroll_up"
/></gl-button>
</div>
<div
v-gl-tooltip
class="controllers-buttons"
:title="__('Scroll to bottom')"
aria-labelledby="scroll-to-bottom"
>
<gl-button
id="scroll-to-bottom"
class="btn-blank js-scroll-to-bottom"
:aria-label="__('Scroll to bottom')"
:disabled="!scrollToBottomEnabled"
@click="scrollDown()"
><icon name="scroll_down"
/></gl-button>
</div>
<gl-button
id="refresh-log"
v-gl-tooltip
class="ml-1 px-2 js-refresh-log"
:title="__('Refresh')"
:aria-label="__('Refresh')"
@click="handleRefreshClick"
>
<icon name="retry" />
</gl-button>
</div>
</template>
import Vue from 'vue';
import { getParameterValues } from '~/lib/utils/url_utility';
import LogViewer from './components/environment_logs.vue';
import store from './stores';
export default (props = {}) => {
const el = document.getElementById('environment-logs');
const [currentPodName] = getParameterValues('pod_name');
// eslint-disable-next-line no-new
new Vue({
el,
store,
render(createElement) {
return createElement(LogViewer, {
props: {
...el.dataset,
currentPodName,
...props,
},
});
},
});
};
import Api from '~/api';
import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { s__ } from '~/locale';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import * as types from './mutation_types';
const flashTimeRangeWarning = () => {
flash(s__('Metrics|Invalid time range, please verify.'), 'warning');
};
const flashLogsError = () => {
flash(s__('Metrics|There was an error fetching the logs, please try again'));
};
const requestLogsUntilData = params =>
backOff((next, stop) => {
Api.getPodLogs(params)
.then(res => {
if (res.status === httpStatusCodes.ACCEPTED) {
next();
return;
}
stop(res);
})
.catch(err => {
stop(err);
});
});
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
if (timeRange) {
commit(types.SET_TIME_RANGE, timeRange);
}
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
dispatch('fetchLogs');
};
export const setSearch = ({ dispatch, commit }, searchQuery) => {
commit(types.SET_SEARCH, searchQuery);
dispatch('fetchLogs');
};
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
dispatch('fetchLogs');
};
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, null);
dispatch('fetchLogs');
};
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA);
axios
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
dispatch('fetchLogs');
})
.catch(() => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
flash(s__('Metrics|There was an error fetching the environments data, please try again'));
});
};
export const fetchLogs = ({ commit, state }) => {
const params = {
environment: state.environments.options.find(({ name }) => name === state.environments.current),
podName: state.pods.current,
search: state.search,
};
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start = start;
params.end = end;
} catch {
flashTimeRangeWarning();
}
}
commit(types.REQUEST_PODS_DATA);
commit(types.REQUEST_LOGS_DATA);
return requestLogsUntilData(params)
.then(({ data }) => {
const { pod_name, pods, logs } = data;
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs);
})
.catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR);
commit(types.RECEIVE_LOGS_DATA_ERROR);
flashLogsError();
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import dateFormat from 'dateformat';
export const trace = state =>
state.logs.lines
.map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
.join('\n');
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
environmentLogs: {
namespaced: true,
actions,
mutations,
state: state(),
getters,
},
},
});
export default createStore;
export const SET_PROJECT_ENVIRONMENT = 'SET_PROJECT_ENVIRONMENT';
export const SET_SEARCH = 'SET_SEARCH';
export const SET_TIME_RANGE = 'SET_TIME_RANGE';
export const SET_CURRENT_POD_NAME = 'SET_CURRENT_POD_NAME';
export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR';
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
export const RECEIVE_PODS_DATA_ERROR = 'RECEIVE_PODS_DATA_ERROR';
import * as types from './mutation_types';
export default {
/** Search data */
[types.SET_SEARCH](state, searchQuery) {
state.search = searchQuery;
},
/** Time Range data */
[types.SET_TIME_RANGE](state, timeRange) {
state.timeRange.current = timeRange;
},
/** Environments data */
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
state.environments.current = environmentName;
},
[types.REQUEST_ENVIRONMENTS_DATA](state) {
state.environments.options = [];
state.environments.isLoading = true;
},
[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environmentOptions) {
state.environments.options = environmentOptions;
state.environments.isLoading = false;
},
[types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state) {
state.environments.options = [];
state.environments.isLoading = false;
},
/** Logs data */
[types.REQUEST_LOGS_DATA](state) {
state.logs.lines = [];
state.logs.isLoading = true;
state.logs.isComplete = false;
},
[types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) {
state.logs.lines = lines;
state.logs.isLoading = false;
state.logs.isComplete = true;
},
[types.RECEIVE_LOGS_DATA_ERROR](state) {
state.logs.lines = [];
state.logs.isLoading = false;
state.logs.isComplete = true;
},
/** Pods data */
[types.SET_CURRENT_POD_NAME](state, podName) {
state.pods.current = podName;
},
[types.REQUEST_PODS_DATA](state) {
state.pods.options = [];
},
[types.RECEIVE_PODS_DATA_SUCCESS](state, podOptions) {
state.pods.options = podOptions;
},
[types.RECEIVE_PODS_DATA_ERROR](state) {
state.pods.options = [];
},
};
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
export default () => ({
/**
* Full text search
*/
search: '',
/**
* Time range (Show last)
*/
timeRange: {
options: timeRanges,
current: defaultTimeRange,
},
/**
* Environments list information
*/
environments: {
options: [],
isLoading: false,
current: null,
},
/**
* Logs including trace
*/
logs: {
lines: [],
isLoading: false,
isComplete: true,
},
/**
* Pods list information
*/
pods: {
options: [],
current: null,
},
});
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
/**
* Returns a time range (`start`, `end`) where `start` is the
* current time minus a given number of seconds and `end`
* is the current time (`now()`).
*
* @param {Number} seconds Seconds duration, defaults to 0.
* @returns {Object} range Time range
* @returns {String} range.start ISO String of current time minus given seconds
* @returns {String} range.end ISO String of current time
*/
export const getTimeRange = (seconds = 0) => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
const start = end - seconds;
return {
start: new Date(secondsToMilliseconds(start)).toISOString(),
end: new Date(secondsToMilliseconds(end)).toISOString(),
};
};
export default {};
import logsBundle from '~/logs';
document.addEventListener('DOMContentLoaded', logsBundle);
......@@ -524,6 +524,8 @@ img.emoji {
cursor: pointer;
}
.cursor-not-allowed { cursor: not-allowed; }
// this needs to use "!important" due to some very specific styles
// around buttons
.cursor-default {
......
......@@ -357,3 +357,42 @@
}
}
}
.build-page-pod-logs {
.build-trace-container {
position: relative;
}
.build-trace {
@include build-trace();
}
.top-bar {
@include build-trace-top-bar($gl-line-height * 5);
.dropdown-menu-toggle {
width: 200px;
@include media-breakpoint-up(sm) {
width: 300px;
}
}
.controllers {
@include build-controllers(16px, flex-end, true, 2);
}
.refresh-control {
@include build-controllers(16px, flex-end, true, 0);
margin-left: 2px;
}
}
.btn-refresh svg {
top: 0;
}
.build-loader-animation {
@include build-loader-animation;
}
}
# frozen_string_literal: true
module Projects
class LogsController < Projects::ApplicationController
before_action :authorize_read_pod_logs!
before_action :environment
before_action :ensure_deployments, only: %i(k8s elasticsearch)
def index
if environment.nil?
render :empty_logs
else
render :index
end
end
def k8s
render_logs(::PodLogs::KubernetesService, k8s_params)
end
def elasticsearch
render_logs(::PodLogs::ElasticsearchService, elasticsearch_params)
end
private
def render_logs(service, permitted_params)
::Gitlab::UsageCounters::PodLogs.increment(project.id)
::Gitlab::PollingInterval.set_header(response, interval: 3_000)
result = service.new(cluster, namespace, params: permitted_params).execute
if result.nil?
head :accepted
elsif result[:status] == :success
render json: result
else
render status: :bad_request, json: result
end
end
def index_params
params.permit(:environment_name)
end
def k8s_params
params.permit(:container_name, :pod_name)
end
def elasticsearch_params
params.permit(:container_name, :pod_name, :search, :start, :end)
end
def environment
@environment ||= if index_params.key?(:environment_name)
EnvironmentsFinder.new(project, current_user, name: index_params[:environment_name]).find.first
else
project.default_environment
end
end
def cluster
environment.deployment_platform&.cluster
end
def namespace
environment.deployment_namespace
end
def ensure_deployments
return if cluster && namespace.present?
render status: :bad_request, json: {
status: :error,
message: _('Environment does not have deployments')
}
end
end
end
......@@ -41,4 +41,13 @@ module EnvironmentsHelper
"external-dashboard-url" => project.metrics_setting_external_dashboard_url
}
end
def environment_logs_data(project, environment)
{
"environment-name": environment.name,
"environments-path": project_environments_path(project, format: :json),
"environment-id": environment.id,
"cluster-applications-documentation-path" => help_page_path('user/clusters/applications.md', anchor: 'elastic-stack')
}
end
end
......@@ -330,6 +330,10 @@ class Environment < ApplicationRecord
self.auto_stop_at = parsed_result.seconds.from_now
end
def elastic_stack_available?
!!deployment_platform&.cluster&.application_elastic_stack&.available?
end
private
def has_metrics_and_can_query?
......
......@@ -18,12 +18,6 @@ class SnippetRepository < ApplicationRecord
end
end
def create_file(user, path, content, **options)
options[:actions] = transform_file_entries([{ file_path: path, content: content }])
capture_git_error { repository.multi_action(user, **options) }
end
def multi_files_action(user, files = [], **options)
return if files.nil? || files.empty?
......
......@@ -314,6 +314,7 @@ class ProjectPolicy < BasePolicy
enable :admin_operations
enable :read_deploy_token
enable :create_deploy_token
enable :read_pod_logs
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
......
......@@ -47,6 +47,22 @@ class EnvironmentEntity < Grape::Entity
environment.available? && can?(current_user, :stop_environment, environment)
end
expose :logs_path, if: -> (*) { can_read_pod_logs? } do |environment|
project_logs_path(environment.project, environment_name: environment.name)
end
expose :logs_api_path, if: -> (*) { can_read_pod_logs? } do |environment|
if environment.elastic_stack_available?
elasticsearch_project_logs_path(environment.project, environment_name: environment.name, format: :json)
else
k8s_project_logs_path(environment.project, environment_name: environment.name, format: :json)
end
end
expose :enable_advanced_logs_querying, if: -> (*) { can_read_pod_logs? } do |environment|
environment.elastic_stack_available?
end
private
alias_method :environment, :object
......@@ -63,6 +79,10 @@ class EnvironmentEntity < Grape::Entity
can?(current_user, :update_environment, environment)
end
def can_read_pod_logs?
can?(current_user, :read_pod_logs, environment.project)
end
def cluster_platform_kubernetes?
deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes)
end
......
# frozen_string_literal: true
module PodLogs
class BaseService < ::BaseService
include ReactiveCaching
include Stepable
attr_reader :cluster, :namespace, :params
CACHE_KEY_GET_POD_LOG = 'get_pod_log'
K8S_NAME_MAX_LENGTH = 253
SUCCESS_RETURN_KEYS = %i(status logs pod_name container_name pods).freeze
def id
cluster.id
end
def initialize(cluster, namespace, params: {})
@cluster = cluster
@namespace = namespace
@params = filter_params(params.dup.stringify_keys).to_hash
end
def execute
with_reactive_cache(
CACHE_KEY_GET_POD_LOG,
namespace,
params
) do |result|
result
end
end
def calculate_reactive_cache(request, _namespace, _params)
case request
when CACHE_KEY_GET_POD_LOG
execute_steps
else
exception = StandardError.new('Unknown reactive cache request')
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception, request: request)
error(_('Unknown cache key'))
end
end
private
def valid_params
%w(pod_name container_name)
end
def check_arguments(result)
return error(_('Cluster does not exist')) if cluster.nil?
return error(_('Namespace is empty')) if namespace.blank?
success(result)
end
def check_param_lengths(_result)
pod_name = params['pod_name'].presence
container_name = params['container_name'].presence
if 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 }))
elsif 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
success(pod_name: pod_name, container_name: container_name)
end
def get_raw_pods(result)
result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace)
success(result)
end
def get_pod_names(result)
result[:pods] = result[:raw_pods].map(&:metadata).map(&:name)
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[: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].first { |p| p.metadata.name == result[:pod_name] }
containers = pod_details.spec.containers.map(&:name)
# select first container if not specified
result[:container_name] ||= containers.first
unless result[:container_name]
return error(_('No containers available'))
end
unless containers.include?(result[:container_name])
return error(_('Container does not exist'))
end
success(result)
end
def pod_logs(result)
raise NotImplementedError
end
def filter_return_keys(result)
result.slice(*SUCCESS_RETURN_KEYS)
end
def filter_params(params)
params.slice(*valid_params)
end
end
end
# frozen_string_literal: true
module PodLogs
class ElasticsearchService < BaseService
steps :check_arguments,
:check_param_lengths,
:get_raw_pods,
:get_pod_names,
:check_pod_name,
:check_container_name,
:check_times,
:check_search,
:pod_logs,
:filter_return_keys
self.reactive_cache_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
def valid_params
%w(pod_name container_name search start end)
end
def check_times(result)
result[:start] = params['start'] if params.key?('start') && Time.iso8601(params['start'])
result[:end] = params['end'] if params.key?('end') && Time.iso8601(params['end'])
success(result)
rescue ArgumentError
error(_('Invalid start or end time format'))
end
def check_search(result)
result[:search] = params['search'] if params.key?('search')
success(result)
end
def pod_logs(result)
client = cluster&.application_elastic_stack&.elasticsearch_client
return error(_('Unable to connect to Elasticsearch')) unless client
result[:logs] = ::Gitlab::Elasticsearch::Logs.new(client).pod_logs(
namespace,
result[:pod_name],
result[:container_name],
result[:search],
result[:start],
result[:end]
)
success(result)
rescue Elasticsearch::Transport::Transport::ServerError => e
::Gitlab::ErrorTracking.track_exception(e)
error(_('Elasticsearch returned status code: %{status_code}') % {
# ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound"
# there is no method on the exception other than the class name to determine the type of error encountered.
status_code: e.class.name.split('::').last
})
end
end
end
# frozen_string_literal: true
module PodLogs
class KubernetesService < BaseService
LOGS_LIMIT = 500.freeze
REPLACEMENT_CHAR = "\u{FFFD}"
EncodingHelperError = Class.new(StandardError)
steps :check_arguments,
:check_param_lengths,
: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_worker_finder = ->(id, _cache_key, namespace, params) { new(::Clusters::Cluster.find(id), namespace, params: params) }
private
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]
}
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
......@@ -263,7 +263,11 @@
%span
= _('Serverless')
= render_if_exists 'layouts/nav/sidebar/pod_logs_link' # EE-specific
- if project_nav_tab?(:environments) && can?(current_user, :read_pod_logs, @project)
= nav_link(controller: :logs, action: [:index]) do
= link_to project_logs_path(@project), title: _('Logs') do
%span
= _('Logs')
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
......
- page_title _('Logs')
.row.empty-state
.col-sm-12
.svg-content
= image_tag 'illustrations/operations_log_pods_empty.svg'
.col-12
.text-content
%h4.text-center
= s_('Environments|No deployed environments')
%p.state-description.text-center
= s_('Logs|To see the logs, deploy your code to an environment.')
.text-center
= link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success'
#environment-logs{ data: environment_logs_data(@project, @environment) }
---
title: Move pod logs to core
merge_request: 25455
author:
type: changed
---
title: Fix capybara screenshots path name for rails configuration
merge_request: 27002
author:
type: fixed
......@@ -1227,7 +1227,7 @@ test:
client_path: tmp/tests/gitaly
token: secret
workhorse:
secret_file: tmp/tests/gitlab_workhorse_secret
secret_file: tmp/gitlab_workhorse_test_secret
backup:
path: tmp/tests/backups
pseudonymizer:
......
......@@ -175,6 +175,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resources :logs, only: [:index] do
collection do
get :k8s
get :elasticsearch
end
end
resources :starrers, only: [:index]
resources :forks, only: [:index, :new, :create]
resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ }
......
......@@ -56,6 +56,7 @@ are very appreciative of the work done by translators and proofreaders!
- Adi Ferdian - [GitLab](https://gitlab.com/adiferd), [Crowdin](https://crowdin.com/profile/adiferd)
- Ahmad Naufal Mukhtar - [GitLab](https://gitlab.com/anaufalm), [Crowdin](https://crowdin.com/profile/anaufalm)
- Italian
- Massimiliano Cuttini - [GitLab](https://gitlab.com/maxcuttins), [Crowdin](https://crowdin.com/profile/maxcuttins)
- Paolo Falomo - [GitLab](https://gitlab.com/paolofalomo), [Crowdin](https://crowdin.com/profile/paolo.falomo)
- Japanese
- Hiroyuki Sato - [GitLab](https://gitlab.com/hiroponz), [Crowdin](https://crowdin.com/profile/hiroponz)
......
......@@ -191,3 +191,16 @@ To set required pipeline configuration:
1. Click **Save changes**.
![Required pipeline](img/admin_required_pipeline.png)
## Package Registry configuration **(PREMIUM ONLY)**
GitLab administrators can disable the forwarding of NPM requests to [npmjs.com](https://www.npmjs.com/).
To disable it:
1. Go to **Admin Area > Settings > CI/CD**.
1. Expand the **Package Registry** section.
1. Uncheck **Enable forwarding of NPM package requests to npmjs.org**.
1. Click **Save changes**.
![NPM package requests forwarding](img/admin_package_registry_npm_package_requests_forward.png)
......@@ -88,12 +88,13 @@ dropdown box above the upper right corner of the panel:
The options are:
- [View logs](#view-logs-ultimate) **(ULTIMATE)**
- [View logs](#view-logs)
- [Download CSV](#download-csv)
##### View logs **(ULTIMATE)**
##### View logs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/201846) in GitLab Ultimate 12.8.
> [Moved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25455) to [GitLab Core](https://about.gitlab.com/pricing/) 12.9.
This can be useful if you are triaging an application incident and need to
[explore logs](../project/integrations/prometheus.md#view-logs-ultimate)
......
......@@ -269,6 +269,14 @@ Or if you're using Yarn:
yarn add @my-project-scope/my-package
```
### Forwarding requests to npmjs.org
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/55344) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9.
By default, when an NPM package is not found in the GitLab NPM Registry, the request will be forwarded to [npmjs.com](https://www.npmjs.com/).
Administrators can disable this behavior in the [Continuous Integration settings](../../admin_area/settings/continuous_integration.md).
## Removing a package
In the packages view of your project page, you can delete packages by clicking
......
......@@ -27,7 +27,7 @@ Using the GitLab project Kubernetes integration, you can:
- Use [Web terminals](#web-terminals).
- Use [Deploy Boards](#deploy-boards-premium). **(PREMIUM)**
- Use [Canary Deployments](#canary-deployments-premium). **(PREMIUM)**
- View [Logs](#logs-ultimate). **(ULTIMATE)**
- View [Logs](#logs).
- Run serverless workloads on [Kubernetes with Knative](serverless/index.md).
### Deploy Boards **(PREMIUM)**
......@@ -48,7 +48,7 @@ the need to leave GitLab.
[Read more about Canary Deployments](../canary_deployments.md)
### Logs **(ULTIMATE)**
### Logs
GitLab makes it easy to view the logs of running pods in connected Kubernetes clusters. By displaying the logs directly in GitLab, developers can avoid having to manage console tools or jump to a different interface.
......
# Kubernetes Logs **(ULTIMATE)**
# Kubernetes Logs
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4752) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.0.
> [Moved](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/25455) to [GitLab Core](https://about.gitlab.com/pricing/) 12.9.
GitLab makes it easy to view the logs of running pods in [connected Kubernetes clusters](index.md).
By displaying the logs directly in GitLab, developers can avoid having to manage console tools or jump to a different interface.
......
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# Class that will fill the project_repositories table for projects that
# are on hashed storage and an entry is missing in this table.
class BackfillSnippetRepositories
MAX_RETRIES = 2
def perform(start_id, stop_id)
Snippet.includes(:author, snippet_repository: :shard).where(id: start_id..stop_id).find_each do |snippet|
# We need to expire the exists? value for the cached method in case it was cached
snippet.repository.expire_exists_cache
next if repository_present?(snippet)
retry_index = 0
begin
create_repository_and_files(snippet)
logger.info(message: 'Snippet Migration: repository created and migrated', snippet: snippet.id)
rescue => e
retry_index += 1
retry if retry_index < MAX_RETRIES
logger.error(message: "Snippet Migration: error migrating snippet. Reason: #{e.message}", snippet: snippet.id)
destroy_snippet_repository(snippet)
delete_repository(snippet)
end
end
end
private
def repository_present?(snippet)
snippet.snippet_repository && !snippet.empty_repo?
end
def create_repository_and_files(snippet)
snippet.create_repository
create_commit(snippet)
end
def destroy_snippet_repository(snippet)
# Removing the db record
snippet.snippet_repository&.destroy
rescue => e
logger.error(message: "Snippet Migration: error destroying snippet repository. Reason: #{e.message}", snippet: snippet.id)
end
def delete_repository(snippet)
# Removing the repository in disk
snippet.repository.remove if snippet.repository_exists?
rescue => e
logger.error(message: "Snippet Migration: error deleting repository. Reason: #{e.message}", snippet: snippet.id)
end
def logger
@logger ||= Gitlab::BackgroundMigration::Logger.build
end
def snippet_action(snippet)
# We don't need the previous_path param
# Because we're not updating any existing file
[{ file_path: filename(snippet),
content: snippet.content }]
end
def filename(snippet)
snippet.file_name.presence || empty_file_name
end
def empty_file_name
@empty_file_name ||= "#{SnippetRepository::DEFAULT_EMPTY_FILE_NAME}1.txt"
end
def commit_attrs
@commit_attrs ||= { branch_name: 'master', message: 'Initial commit' }
end
def create_commit(snippet)
snippet.snippet_repository.multi_files_action(snippet.author, snippet_action(snippet), commit_attrs)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Elasticsearch
class Logs
# How many log lines to fetch in a query
LOGS_LIMIT = 500
def initialize(client)
@client = client
end
def pod_logs(namespace, pod_name, container_name = nil, search = nil, start_time = nil, end_time = nil)
query = { bool: { must: [] } }.tap do |q|
filter_pod_name(q, pod_name)
filter_namespace(q, namespace)
filter_container_name(q, container_name)
filter_search(q, search)
filter_times(q, start_time, end_time)
end
body = build_body(query)
response = @client.search body: body
format_response(response)
end
private
def build_body(query)
{
query: query,
# reverse order so we can query N-most recent records
sort: [
{ "@timestamp": { order: :desc } },
{ "offset": { order: :desc } }
],
# only return these fields in the response
_source: ["@timestamp", "message"],
# fixed limit for now, we should support paginated queries
size: ::Gitlab::Elasticsearch::Logs::LOGS_LIMIT
}
end
def filter_pod_name(query, pod_name)
query[:bool][:must] << {
match_phrase: {
"kubernetes.pod.name" => {
query: pod_name
}
}
}
end
def filter_namespace(query, namespace)
query[:bool][:must] << {
match_phrase: {
"kubernetes.namespace" => {
query: namespace
}
}
}
end
def filter_container_name(query, container_name)
# A pod can contain multiple containers.
# By default we return logs from every container
return if container_name.nil?
query[:bool][:must] << {
match_phrase: {
"kubernetes.container.name" => {
query: container_name
}
}
}
end
def filter_search(query, search)
return if search.nil?
query[:bool][:must] << {
simple_query_string: {
query: search,
fields: [:message],
default_operator: :and
}
}
end
def filter_times(query, start_time, end_time)
return unless start_time || end_time
time_range = { range: { :@timestamp => {} } }.tap do |tr|
tr[:range][:@timestamp][:gte] = start_time if start_time
tr[:range][:@timestamp][:lt] = end_time if end_time
end
query[:bool][:filter] = [time_range]
end
def format_response(response)
result = response.fetch("hits", {}).fetch("hits", []).map do |hit|
{
timestamp: hit["_source"]["@timestamp"],
message: hit["_source"]["message"]
}
end
# we queried for the N-most recent records but we want them ordered oldest to newest
result.reverse
end
end
end
end
# frozen_string_literal: true
module Gitlab
module UsageCounters
class Common
class << self
def increment(project_id)
Gitlab::Redis::SharedState.with { |redis| redis.hincrby(base_key, project_id, 1) }
end
def usage_totals
Gitlab::Redis::SharedState.with do |redis|
total_sum = 0
totals = redis.hgetall(base_key).each_with_object({}) do |(project_id, count), result|
total_sum += result[project_id.to_i] = count.to_i
end
totals[:total] = total_sum
totals
end
end
def base_key
raise NotImplementedError
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module UsageCounters
class PodLogs < Common
def self.base_key
'POD_LOGS_USAGE_COUNTS'
end
end
end
end
......@@ -6110,6 +6110,9 @@ msgstr ""
msgid "CycleAnalyticsStage|should be under a group"
msgstr ""
msgid "CycleAnalytics|%{selectedLabelsCount} selected (%{maxLabels} max)"
msgstr ""
msgid "CycleAnalytics|%{stageCount} stages selected"
msgstr ""
......@@ -6131,6 +6134,9 @@ msgstr ""
msgid "CycleAnalytics|Number of tasks"
msgstr ""
msgid "CycleAnalytics|Only %{maxLabels} labels can be selected at this time"
msgstr ""
msgid "CycleAnalytics|Project selected"
msgid_plural "CycleAnalytics|%d projects selected"
msgstr[0] ""
......@@ -13170,6 +13176,9 @@ msgstr ""
msgid "No licenses found."
msgstr ""
msgid "No matching labels"
msgstr ""
msgid "No matching results"
msgstr ""
......@@ -19906,6 +19915,9 @@ msgstr ""
msgid "There was an error fetching the environments information."
msgstr ""
msgid "There was an error fetching the top labels for the selected group"
msgstr ""
msgid "There was an error fetching the variables."
msgstr ""
......@@ -20864,6 +20876,9 @@ msgstr ""
msgid "Total artifacts size: %{total_size}"
msgstr ""
msgid "Total cores (vCPUs)"
msgstr ""
msgid "Total issues"
msgstr ""
......
......@@ -40,7 +40,7 @@
"@babel/preset-env": "^7.8.4",
"@gitlab/at.js": "^1.5.5",
"@gitlab/svgs": "^1.110.0",
"@gitlab/ui": "^9.23.0",
"@gitlab/ui": "^9.23.1",
"@gitlab/visual-review-tools": "1.5.1",
"@sentry/browser": "^5.10.2",
"@sourcegraph/code-host-integration": "0.0.31",
......@@ -215,4 +215,4 @@
"node": ">=10.13.0",
"yarn": "^1.10.0"
}
}
}
\ No newline at end of file
......@@ -7,6 +7,8 @@ module QA
attr_accessor :title
attribute :id
def_delegators :key, :private_key, :public_key, :md5_fingerprint
def key
......@@ -21,6 +23,35 @@ module QA
profile_page.add_key(public_key, title)
end
end
def fabricate_via_api!
api_post
end
def api_delete
QA::Runtime::Logger.debug("Deleting SSH key with title '#{title}' and fingerprint '#{md5_fingerprint}'")
super
end
def api_get_path
"/user/keys/#{id}"
end
def api_post_path
'/user/keys'
end
def api_post_body
{
title: title,
key: public_key
}
end
def api_delete_path
"/user/keys/#{id}"
end
end
end
end
......@@ -8,7 +8,7 @@ module QA
it 'user adds and then removes an SSH key', :smoke do
Flow::Login.sign_in
key = Resource::SSHKey.fabricate! do |resource|
key = Resource::SSHKey.fabricate_via_browser_ui! do |resource|
resource.title = key_title
end
......
......@@ -11,29 +11,24 @@ module QA
let(:key_title) { "key for ssh tests #{Time.now.to_f}" }
let(:ssh_key) do
Resource::SSHKey.fabricate! do |resource|
Resource::SSHKey.fabricate_via_api! do |resource|
resource.title = key_title
end
end
around do |example|
# Create an SSH key to be used with Git
# Create an SSH key to be used with Git, then remove it after the test
Flow::Login.sign_in
ssh_key
example.run
# Remove the SSH key
Flow::Login.sign_in
Page::Main::Menu.perform(&:click_settings_link)
Page::Profile::Menu.perform(&:click_ssh_keys)
Page::Profile::SSHKeys.perform do |ssh_keys|
ssh_keys.remove_key(key_title)
end
ssh_key.remove_via_api!
Page::Main::Menu.perform(&:sign_out_if_signed_in)
end
it 'user pushes to the repository' do
# Create a project to push to
project = Resource::Project.fabricate_via_api! do |project|
project.name = 'git-protocol-project'
end
......@@ -68,11 +63,8 @@ module QA
project.visit!
project.wait_for_push_new_branch
# Check that the push worked
expect(page).to have_content(file_name)
expect(page).to have_content(file_content)
# And check that the correct Git protocol was used
expect(git_protocol_reported).to eq(git_protocol)
end
end
......
......@@ -11,7 +11,7 @@ module QA
it 'user adds an ssh key and pushes code to the repository' do
Flow::Login.sign_in
key = Resource::SSHKey.fabricate! do |resource|
key = Resource::SSHKey.fabricate_via_api! do |resource|
resource.title = key_title
end
......
# frozen_string_literal: true
require 'spec_helper'
describe Projects::LogsController do
include KubernetesHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:environment) do
create(:environment, name: 'production', project: project)
end
let(:pod_name) { "foo" }
let(:container) { 'container-1' }
before do
project.add_maintainer(user)
sign_in(user)
end
describe 'GET #index' do
let(:empty_project) { create(:project) }
it 'renders empty logs page if no environment exists' do
empty_project.add_maintainer(user)
get :index, params: { namespace_id: empty_project.namespace, project_id: empty_project }
expect(response).to be_ok
expect(response).to render_template 'empty_logs'
end
it 'renders index template' do
get :index, params: environment_params
expect(response).to be_ok
expect(response).to render_template 'index'
end
end
shared_examples 'pod logs service' do |endpoint, service|
let(:service_result) do
{
status: :success,
logs: ['Log 1', 'Log 2', 'Log 3'],
pods: [pod_name],
pod_name: pod_name,
container_name: container
}
end
let(:service_result_json) { JSON.parse(service_result.to_json) }
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project]) }
before do
allow_next_instance_of(service) do |instance|
allow(instance).to receive(:execute).and_return(service_result)
end
end
it 'returns the service result' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
expect(response).to have_gitlab_http_status(:success)
expect(json_response).to eq(service_result_json)
end
it 'registers a usage of the endpoint' do
expect(::Gitlab::UsageCounters::PodLogs).to receive(:increment).with(project.id)
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
expect(response).to have_gitlab_http_status(:success)
end
it 'sets the polling header' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
expect(response).to have_gitlab_http_status(:success)
expect(response.headers['Poll-Interval']).to eq('3000')
end
context 'when service is processing' do
let(:service_result) { nil }
it 'returns a 202' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
expect(response).to have_gitlab_http_status(:accepted)
end
end
shared_examples 'unsuccessful execution response' do |message|
let(:service_result) do
{
status: :error,
message: message
}
end
it 'returns the error' do
get endpoint, params: environment_params(pod_name: pod_name, format: :json)
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response).to eq(service_result_json)
end
end
context 'when service is failing' do
it_behaves_like 'unsuccessful execution response', 'some error'
end
context 'when cluster is nil' do
let!(:cluster) { nil }
it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
end
context 'when namespace is empty' do
before do
allow(environment).to receive(:deployment_namespace).and_return('')
end
it_behaves_like 'unsuccessful execution response', 'Environment does not have deployments'
end
end
describe 'GET #k8s' do
it_behaves_like 'pod logs service', :k8s, PodLogs::KubernetesService
end
describe 'GET #elasticsearch' do
it_behaves_like 'pod logs service', :elasticsearch, PodLogs::ElasticsearchService
end
def environment_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace,
project_id: project,
environment_name: environment.name)
end
end
......@@ -70,6 +70,7 @@ describe 'Project navbar' do
_('Environments'),
_('Error Tracking'),
_('Serverless'),
_('Logs'),
_('Kubernetes')
]
},
......
......@@ -26,6 +26,9 @@
"stop_path": { "type": "string" },
"cancel_auto_stop_path": { "type": "string" },
"folder_path": { "type": "string" },
"logs_path": { "type": "string" },
"logs_api_path": { "type": "string" },
"enable_advanced_logs_querying": { "type": "boolean" },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" },
"auto_stop_at": { "type": "string", "format": "date-time" },
......
{
"took": 7087,
"timed_out": false,
"_shards": {
"total": 151,
"successful": 151,
"skipped": 0,
"failed": 0,
"failures": []
},
"hits": {
"total": 486924,
"max_score": null,
"hits": [
{
"_index": "filebeat-6.7.0-2019.10.25",
"_type": "doc",
"_id": "SkbxAW4BWzhswgK-C5-R",
"_score": null,
"_source": {
"message": "10.8.2.1 - - [25/Oct/2019:08:03:22 UTC] \"GET / HTTP/1.1\" 200 13",
"@timestamp": "2019-12-13T14:35:34.034Z"
},
"sort": [
9999998,
1571990602947
]
},
{
"_index": "filebeat-6.7.0-2019.10.27",
"_type": "doc",
"_id": "wEigD24BWzhswgK-WUU2",
"_score": null,
"_source": {
"message": "10.8.2.1 - - [27/Oct/2019:23:49:54 UTC] \"GET / HTTP/1.1\" 200 13",
"@timestamp": "2019-12-13T14:35:35.034Z"
},
"sort": [
9999949,
1572220194500
]
},
{
"_index": "filebeat-6.7.0-2019.11.04",
"_type": "doc",
"_id": "gE6uOG4BWzhswgK-M0x2",
"_score": null,
"_source": {
"message": "10.8.2.1 - - [04/Nov/2019:23:09:24 UTC] \"GET / HTTP/1.1\" 200 13",
"@timestamp": "2019-12-13T14:35:36.034Z"
},
"sort": [
9999944,
1572908964497
]
},
{
"_index": "filebeat-6.7.0-2019.10.30",
"_type": "doc",
"_id": "0klPHW4BWzhswgK-nfCF",
"_score": null,
"_source": {
"message": "- -\u003e /",
"@timestamp": "2019-12-13T14:35:37.034Z"
},
"sort": [
9999934,
1572449784442
]
}
]
}
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
},
{
"match_phrase": {
"kubernetes.container.name": {
"query": "auto-deploy-app"
}
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
}
],
"filter": [
{
"range": {
"@timestamp": {
"lt": "2019-12-13T14:35:34.034Z"
}
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
},
{
"simple_query_string": {
"query": "foo +bar ",
"fields": [
"message"
],
"default_operator": "and"
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
}
],
"filter": [
{
"range": {
"@timestamp": {
"gte": "2019-12-13T14:35:34.034Z"
}
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"kubernetes.pod.name": {
"query": "production-6866bc8974-m4sk4"
}
}
},
{
"match_phrase": {
"kubernetes.namespace": {
"query": "autodevops-deploy-9-production"
}
}
}
],
"filter": [
{
"range": {
"@timestamp": {
"gte": "2019-12-13T14:35:34.034Z",
"lt": "2019-12-13T14:35:34.034Z"
}
}
}
]
}
},
"sort": [
{
"@timestamp": {
"order": "desc"
}
},
{
"offset": {
"order": "desc"
}
}
],
"_source": [
"@timestamp",
"message"
],
"size": 500
}
/* eslint-disable no-new, promise/catch-or-return */
import JSZip from 'jszip';
import SketchLoader from '~/blob/sketch';
describe('Sketch viewer', () => {
const generateZipFileArrayBuffer = (zipFile, resolve, done) => {
zipFile.generateAsync({ type: 'arrayBuffer' }).then(content => {
resolve(content);
setTimeout(() => {
done();
}, 100);
});
};
jest.mock('jszip');
describe('Sketch viewer', () => {
preloadFixtures('static/sketch_viewer.html');
beforeEach(() => {
loadFixtures('static/sketch_viewer.html');
window.URL = {
createObjectURL: jest.fn(() => 'http://foo/bar'),
};
});
afterEach(() => {
window.URL = {};
});
describe('with error message', () => {
beforeEach(done => {
spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(
jest.spyOn(SketchLoader.prototype, 'getZipFile').mockImplementation(
() =>
new Promise((resolve, reject) => {
reject();
setTimeout(() => {
done();
});
done();
}),
);
new SketchLoader(document.getElementById('js-sketch-viewer'));
return new SketchLoader(document.getElementById('js-sketch-viewer'));
});
it('renders error message', () => {
......@@ -43,39 +38,39 @@ describe('Sketch viewer', () => {
);
});
it('removes render the loading icon', () => {
it('removes the loading icon', () => {
expect(document.querySelector('.js-loading-icon')).toBeNull();
});
});
describe('success', () => {
beforeEach(done => {
spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(
const loadAsyncMock = {
files: {
'previews/preview.png': {
async: jest.fn(),
},
},
};
loadAsyncMock.files['previews/preview.png'].async.mockImplementation(
() =>
new Promise(resolve => {
const zipFile = new JSZip();
zipFile
.folder('previews')
.file(
'preview.png',
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAMAAAAoyzS7AAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAA1JREFUeNoBAgD9/wAAAAIAAVMrnDAAAAAASUVORK5CYII=',
{
base64: true,
},
);
generateZipFileArrayBuffer(zipFile, resolve, done);
resolve('foo');
done();
}),
);
new SketchLoader(document.getElementById('js-sketch-viewer'));
jest.spyOn(SketchLoader.prototype, 'getZipFile').mockResolvedValue();
jest.spyOn(JSZip, 'loadAsync').mockResolvedValue(loadAsyncMock);
return new SketchLoader(document.getElementById('js-sketch-viewer'));
});
it('does not render error message', () => {
expect(document.querySelector('#js-sketch-viewer p')).toBeNull();
});
it('removes render the loading icon', () => {
it('removes the loading icon', () => {
expect(document.querySelector('.js-loading-icon')).toBeNull();
});
......@@ -94,27 +89,4 @@ describe('Sketch viewer', () => {
expect(link.target).toBe('_blank');
});
});
describe('incorrect file', () => {
beforeEach(done => {
spyOn(SketchLoader.prototype, 'getZipFile').and.callFake(
() =>
new Promise(resolve => {
const zipFile = new JSZip();
generateZipFileArrayBuffer(zipFile, resolve, done);
}),
);
new SketchLoader(document.getElementById('js-sketch-viewer'));
});
it('renders error message', () => {
expect(document.querySelector('#js-sketch-viewer p')).not.toBeNull();
expect(document.querySelector('#js-sketch-viewer p').textContent.trim()).toContain(
'Cannot show preview.',
);
});
});
});
......@@ -5,6 +5,7 @@ export default [
size: '3',
clusterType: 'group_type',
status: 'disabled',
cpu: '6 (100% free)',
memory: '22.50 (30% free)',
},
{
......@@ -13,6 +14,7 @@ export default [
size: '12',
clusterType: 'project_type',
status: 'unreachable',
cpu: '3 (50% free)',
memory: '11 (60% free)',
},
{
......@@ -21,6 +23,7 @@ export default [
size: '12',
clusterType: 'project_type',
status: 'authentication_failure',
cpu: '1 (0% free)',
memory: '22 (33% free)',
},
{
......@@ -29,6 +32,7 @@ export default [
size: '12',
clusterType: 'project_type',
status: 'deleting',
cpu: '6 (100% free)',
memory: '45 (15% free)',
},
{
......@@ -37,6 +41,7 @@ export default [
size: '12',
clusterType: 'project_type',
status: 'connected',
cpu: '6 (100% free)',
memory: '20.12 (35% free)',
},
];
import Vue from 'vue';
import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import EnvironmentLogs from '~/logs/components/environment_logs.vue';
import { createStore } from '~/logs/stores';
import { scrollDown } from '~/lib/utils/scroll_utils';
import {
mockEnvName,
mockEnvironments,
mockPods,
mockLogsResult,
mockTrace,
mockPodName,
mockSearch,
mockEnvironmentsEndpoint,
mockDocumentationPath,
} from '../mock_data';
jest.mock('~/lib/utils/scroll_utils');
describe('EnvironmentLogs', () => {
let EnvironmentLogsComponent;
let store;
let wrapper;
let state;
const propsData = {
environmentName: mockEnvName,
environmentsPath: mockEnvironmentsEndpoint,
clusterApplicationsDocumentationPath: mockDocumentationPath,
};
const actionMocks = {
setInitData: jest.fn(),
setSearch: jest.fn(),
showPodLogs: jest.fn(),
showEnvironment: jest.fn(),
fetchEnvironments: jest.fn(),
};
const updateControlBtnsMock = jest.fn();
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
const findPodsDropdown = () => wrapper.find('.js-pods-dropdown');
const findSearchBar = () => wrapper.find('.js-logs-search');
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const findInfoAlert = () => wrapper.find('.js-elasticsearch-alert');
const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
const findLogTrace = () => wrapper.find('.js-log-trace');
const mockSetInitData = () => {
state.pods.options = mockPods;
state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
};
const mockShowPodLogs = podName => {
state.pods.options = mockPods;
[state.pods.current] = podName;
state.logs.isComplete = false;
state.logs.lines = mockLogsResult;
};
const mockFetchEnvs = () => {
state.environments.options = mockEnvironments;
};
const initWrapper = () => {
wrapper = shallowMount(EnvironmentLogsComponent, {
propsData,
store,
stubs: {
LogControlButtons: {
name: 'log-control-buttons-stub',
template: '<div/>',
methods: {
update: updateControlBtnsMock,
},
},
},
methods: {
...actionMocks,
},
});
};
beforeEach(() => {
store = createStore();
state = store.state.environmentLogs;
EnvironmentLogsComponent = Vue.extend(EnvironmentLogs);
});
afterEach(() => {
actionMocks.setInitData.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
if (wrapper) {
wrapper.destroy();
}
});
it('displays UI elements', () => {
initWrapper();
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
// top bar
expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
expect(findPodsDropdown().is(GlDropdown)).toBe(true);
expect(findLogControlButtons().exists()).toBe(true);
expect(findSearchBar().exists()).toBe(true);
expect(findSearchBar().is(GlSearchBoxByClick)).toBe(true);
expect(findTimeRangePicker().exists()).toBe(true);
expect(findTimeRangePicker().is(DateTimePicker)).toBe(true);
// log trace
expect(findLogTrace().isEmpty()).toBe(false);
});
it('mounted inits data', () => {
initWrapper();
expect(actionMocks.setInitData).toHaveBeenCalledTimes(1);
expect(actionMocks.setInitData).toHaveBeenLastCalledWith({
timeRange: expect.objectContaining({
default: true,
}),
environmentName: mockEnvName,
podName: null,
});
expect(actionMocks.fetchEnvironments).toHaveBeenCalledTimes(1);
expect(actionMocks.fetchEnvironments).toHaveBeenLastCalledWith(mockEnvironmentsEndpoint);
});
describe('loading state', () => {
beforeEach(() => {
state.pods.options = [];
state.logs = {
lines: [],
isLoading: true,
};
state.environments = {
options: [],
isLoading: true,
};
initWrapper();
});
it('displays a disabled environments dropdown', () => {
expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true');
expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('displays a disabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toBe('true');
expect(findPodsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('displays a disabled search bar', () => {
expect(findSearchBar().exists()).toBe(true);
expect(findSearchBar().attributes('disabled')).toBe('true');
});
it('displays a disabled time window dropdown', () => {
expect(findTimeRangePicker().attributes('disabled')).toBe('true');
});
it('does not update buttons state', () => {
expect(updateControlBtnsMock).not.toHaveBeenCalled();
});
it('shows a logs trace', () => {
expect(findLogTrace().text()).toBe('');
expect(
findLogTrace()
.find('.js-build-loader-animation')
.isVisible(),
).toBe(true);
});
});
describe('legacy environment', () => {
beforeEach(() => {
state.pods.options = [];
state.logs = {
lines: [],
isLoading: false,
};
state.environments = {
options: mockEnvironments,
current: 'staging',
isLoading: false,
};
initWrapper();
});
it('displays a disabled time window dropdown', () => {
expect(findTimeRangePicker().attributes('disabled')).toBe('true');
});
it('displays a disabled search bar', () => {
expect(findSearchBar().attributes('disabled')).toBe('true');
});
it('displays an alert to upgrade to ES', () => {
expect(findInfoAlert().exists()).toBe(true);
});
});
describe('state with data', () => {
beforeEach(() => {
actionMocks.setInitData.mockImplementation(mockSetInitData);
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs);
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs);
initWrapper();
});
afterEach(() => {
scrollDown.mockReset();
updateControlBtnsMock.mockReset();
actionMocks.setInitData.mockReset();
actionMocks.showPodLogs.mockReset();
actionMocks.fetchEnvironments.mockReset();
});
it('displays an enabled search bar', () => {
expect(findSearchBar().attributes('disabled')).toBeFalsy();
// input a query and click `search`
findSearchBar().vm.$emit('input', mockSearch);
findSearchBar().vm.$emit('submit');
expect(actionMocks.setSearch).toHaveBeenCalledTimes(1);
expect(actionMocks.setSearch).toHaveBeenCalledWith(mockSearch);
});
it('displays an enabled time window dropdown', () => {
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('does not display an alert to upgrade to ES', () => {
expect(findInfoAlert().exists()).toBe(false);
});
it('populates environments dropdown', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName);
expect(items.length).toBe(mockEnvironments.length);
mockEnvironments.forEach((env, i) => {
const item = items.at(i);
expect(item.text()).toBe(env.name);
});
});
it('populates pods dropdown', () => {
const items = findPodsDropdown().findAll(GlDropdownItem);
expect(findPodsDropdown().props('text')).toBe(mockPodName);
expect(items.length).toBe(mockPods.length);
mockPods.forEach((pod, i) => {
const item = items.at(i);
expect(item.text()).toBe(pod);
});
});
it('populates logs trace', () => {
const trace = findLogTrace();
expect(trace.text().split('\n').length).toBe(mockTrace.length);
expect(trace.text().split('\n')).toEqual(mockTrace);
});
it('update control buttons state', () => {
expect(updateControlBtnsMock).toHaveBeenCalledTimes(1);
});
it('scrolls to bottom when loaded', () => {
expect(scrollDown).toHaveBeenCalledTimes(1);
});
describe('when user clicks', () => {
it('environment name, trace is refreshed', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
const index = 1; // any env
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(0);
items.at(index).vm.$emit('click');
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(1);
expect(actionMocks.showEnvironment).toHaveBeenLastCalledWith(mockEnvironments[index].name);
});
it('pod name, trace is refreshed', () => {
const items = findPodsDropdown().findAll(GlDropdownItem);
const index = 2; // any pod
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
items.at(index).vm.$emit('click');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPods[index]);
});
it('refresh button, trace is refreshed', () => {
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
findLogControlButtons().vm.$emit('refresh');
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPodName);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import LogControlButtons from '~/logs/components/log_control_buttons.vue';
import {
canScroll,
isScrolledToTop,
isScrolledToBottom,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
jest.mock('~/lib/utils/scroll_utils');
describe('LogControlButtons', () => {
let wrapper;
const findScrollToTop = () => wrapper.find('.js-scroll-to-top');
const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
const findRefreshBtn = () => wrapper.find('.js-refresh-log');
const initWrapper = () => {
wrapper = shallowMount(LogControlButtons);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
it('displays UI elements', () => {
initWrapper();
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findScrollToTop().is(GlButton)).toBe(true);
expect(findScrollToBottom().is(GlButton)).toBe(true);
expect(findRefreshBtn().is(GlButton)).toBe(true);
});
it('emits a `refresh` event on click on `refresh` button', () => {
initWrapper();
// An `undefined` value means no event was emitted
expect(wrapper.emitted('refresh')).toBe(undefined);
findRefreshBtn().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('refresh')).toHaveLength(1);
});
});
describe('when scrolling actions are enabled', () => {
beforeEach(() => {
// mock scrolled to the middle of a long page
canScroll.mockReturnValue(true);
isScrolledToBottom.mockReturnValue(false);
isScrolledToTop.mockReturnValue(false);
initWrapper();
wrapper.vm.update();
return wrapper.vm.$nextTick();
});
afterEach(() => {
canScroll.mockReset();
isScrolledToTop.mockReset();
isScrolledToBottom.mockReset();
});
it('click on "scroll to top" scrolls up', () => {
expect(findScrollToTop().is('[disabled]')).toBe(false);
findScrollToTop().vm.$emit('click');
expect(scrollUp).toHaveBeenCalledTimes(1);
});
it('click on "scroll to bottom" scrolls down', () => {
expect(findScrollToBottom().is('[disabled]')).toBe(false);
findScrollToBottom().vm.$emit('click');
expect(scrollDown).toHaveBeenCalledTimes(1); // plus one time when trace was loaded
});
});
describe('when scrolling actions are disabled', () => {
beforeEach(() => {
// mock a short page without a scrollbar
canScroll.mockReturnValue(false);
isScrolledToBottom.mockReturnValue(true);
isScrolledToTop.mockReturnValue(true);
initWrapper();
});
it('buttons are disabled', () => {
wrapper.vm.update();
return wrapper.vm.$nextTick(() => {
expect(findScrollToTop().is('[disabled]')).toBe(true);
expect(findScrollToBottom().is('[disabled]')).toBe(true);
});
});
});
});
export const mockProjectPath = 'root/autodevops-deploy';
export const mockEnvName = 'production';
export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`;
export const mockEnvId = '99';
export const mockDocumentationPath = '/documentation.md';
const makeMockEnvironment = (id, name, advancedQuerying) => ({
id,
project_path: mockProjectPath,
name,
logs_api_path: '/dummy_logs_path.json',
enable_advanced_logs_querying: advancedQuerying,
});
export const mockEnvironment = makeMockEnvironment(mockEnvId, mockEnvName, true);
export const mockEnvironments = [
mockEnvironment,
makeMockEnvironment(101, 'staging', false),
makeMockEnvironment(102, 'review/a-feature', false),
];
export const mockPodName = 'production-764c58d697-aaaaa';
export const mockPods = [
mockPodName,
'production-764c58d697-bbbbb',
'production-764c58d697-ccccc',
'production-764c58d697-ddddd',
];
export const mockLogsResult = [
{
timestamp: '2019-12-13T13:43:18.2760123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:26.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:28.3710123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:28.3710123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:36.8860123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:36.8860123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:38.4000123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:38.4000123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:46.8420123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:46.8430123Z', message: '- -> /' },
{
timestamp: '2019-12-13T13:43:48.3240123Z',
message: '10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
},
{ timestamp: '2019-12-13T13:43:48.3250123Z', message: '- -> /' },
];
export const mockTrace = [
'Dec 13 13:43:18.276Z | 10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:18.276Z | - -> /',
'Dec 13 13:43:26.842Z | 10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:26.842Z | - -> /',
'Dec 13 13:43:28.371Z | 10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:28.371Z | - -> /',
'Dec 13 13:43:36.886Z | 10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:36.886Z | - -> /',
'Dec 13 13:43:38.400Z | 10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:38.400Z | - -> /',
'Dec 13 13:43:46.842Z | 10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:46.843Z | - -> /',
'Dec 13 13:43:48.324Z | 10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
'Dec 13 13:43:48.325Z | - -> /',
];
export const mockSearch = 'foo +bar';
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as types from '~/logs/stores/mutation_types';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import logsPageState from '~/logs/stores/state';
import {
setInitData,
setSearch,
showPodLogs,
fetchEnvironments,
fetchLogs,
} from '~/logs/stores/actions';
import { defaultTimeRange } from '~/monitoring/constants';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import {
mockProjectPath,
mockPodName,
mockEnvironmentsEndpoint,
mockEnvironments,
mockPods,
mockLogsResult,
mockEnvName,
mockSearch,
} from '../mock_data';
jest.mock('~/flash');
jest.mock('~/lib/utils/datetime_range');
jest.mock('~/logs/utils');
const mockDefaultRange = {
start: '2020-01-10T18:00:00.000Z',
end: '2020-01-10T10:00:00.000Z',
};
const mockFixedRange = {
start: '2020-01-09T18:06:20.000Z',
end: '2020-01-09T18:36:20.000Z',
};
const mockRollingRange = {
duration: 120,
};
const mockRollingRangeAsFixed = {
start: '2020-01-10T18:00:00.000Z',
end: '2020-01-10T17:58:00.000Z',
};
describe('Logs Store actions', () => {
let state;
let mock;
convertToFixedRange.mockImplementation(range => {
if (range === defaultTimeRange) {
return { ...mockDefaultRange };
}
if (range === mockFixedRange) {
return { ...mockFixedRange };
}
if (range === mockRollingRange) {
return { ...mockRollingRangeAsFixed };
}
throw new Error('Invalid time range');
});
beforeEach(() => {
state = logsPageState();
});
afterEach(() => {
flash.mockClear();
});
describe('setInitData', () => {
it('should commit environment and pod name mutation', () =>
testAction(setInitData, { environmentName: mockEnvName, podName: mockPodName }, state, [
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
]));
});
describe('setSearch', () => {
it('should commit search mutation', () =>
testAction(
setSearch,
mockSearch,
state,
[{ type: types.SET_SEARCH, payload: mockSearch }],
[{ type: 'fetchLogs' }],
));
});
describe('showPodLogs', () => {
it('should commit pod name', () =>
testAction(
showPodLogs,
mockPodName,
state,
[{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
[{ type: 'fetchLogs' }],
));
});
describe('fetchEnvironments', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});
it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => {
mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, { environments: mockEnvironments });
return testAction(
fetchEnvironments,
mockEnvironmentsEndpoint,
state,
[
{ type: types.REQUEST_ENVIRONMENTS_DATA },
{ type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
],
[{ type: 'fetchLogs' }],
);
});
it('should commit RECEIVE_ENVIRONMENTS_DATA_ERROR on wrong data', () => {
mock.onGet(mockEnvironmentsEndpoint).replyOnce(500);
return testAction(
fetchEnvironments,
mockEnvironmentsEndpoint,
state,
[
{ type: types.REQUEST_ENVIRONMENTS_DATA },
{ type: types.RECEIVE_ENVIRONMENTS_DATA_ERROR },
],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
},
);
});
});
describe('fetchLogs', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.reset();
});
it('should commit logs and pod data when there is pod name defined', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
pod_name: mockPodName,
...mockDefaultRange,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
);
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.projectPath = mockProjectPath;
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
pod_name: mockPodName,
start: mockFixedRange.start,
end: mockFixedRange.end,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
});
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
);
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
const endpoint = '/dummy_logs_path.json';
mock
.onGet(endpoint, {
params: {
pod_name: mockPodName,
search: mockSearch,
},
})
.reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
() => {
// Warning about time ranges was issued
expect(flash).toHaveBeenCalledTimes(1);
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
},
);
});
it('should commit logs and pod data when no pod name defined', done => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
const endpoint = '/dummy_logs_path.json';
mock.onGet(endpoint, { params: { ...mockDefaultRange } }).reply(200, {
pod_name: mockPodName,
pods: mockPods,
logs: mockLogsResult,
});
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
],
[],
done,
);
});
it('should commit logs and pod errors when backend fails', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
const endpoint = `/${mockProjectPath}/-/logs/elasticsearch.json?environment_name=${mockEnvName}`;
mock.onGet(endpoint).replyOnce(500);
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_PODS_DATA },
{ type: types.REQUEST_LOGS_DATA },
{ type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_ERROR },
],
[],
() => {
expect(flash).toHaveBeenCalledTimes(1);
},
);
});
});
});
import * as getters from '~/logs/stores/getters';
import logsPageState from '~/logs/stores/state';
import { mockLogsResult, mockTrace } from '../mock_data';
describe('Logs Store getters', () => {
let state;
beforeEach(() => {
state = logsPageState();
});
describe('trace', () => {
describe('when state is initialized', () => {
it('returns an empty string', () => {
expect(getters.trace(state)).toEqual('');
});
});
describe('when state logs are empty', () => {
beforeEach(() => {
state.logs.lines = [];
});
it('returns an empty string', () => {
expect(getters.trace(state)).toEqual('');
});
});
describe('when state logs are set', () => {
beforeEach(() => {
state.logs.lines = mockLogsResult;
});
it('returns an empty string', () => {
expect(getters.trace(state)).toEqual(mockTrace.join('\n'));
});
});
});
});
import mutations from '~/logs/stores/mutations';
import * as types from '~/logs/stores/mutation_types';
import logsPageState from '~/logs/stores/state';
import {
mockEnvName,
mockEnvironments,
mockPods,
mockPodName,
mockLogsResult,
mockSearch,
} from '../mock_data';
describe('Logs Store Mutations', () => {
let state;
beforeEach(() => {
state = logsPageState();
});
it('ensures mutation types are correctly named', () => {
Object.keys(types).forEach(k => {
expect(k).toEqual(types[k]);
});
});
describe('SET_PROJECT_ENVIRONMENT', () => {
it('sets the environment', () => {
mutations[types.SET_PROJECT_ENVIRONMENT](state, mockEnvName);
expect(state.environments.current).toEqual(mockEnvName);
});
});
describe('SET_SEARCH', () => {
it('sets the search', () => {
mutations[types.SET_SEARCH](state, mockSearch);
expect(state.search).toEqual(mockSearch);
});
});
describe('REQUEST_ENVIRONMENTS_DATA', () => {
it('inits data', () => {
mutations[types.REQUEST_ENVIRONMENTS_DATA](state);
expect(state.environments.options).toEqual([]);
expect(state.environments.isLoading).toEqual(true);
});
});
describe('RECEIVE_ENVIRONMENTS_DATA_SUCCESS', () => {
it('receives environments data and stores it as options', () => {
expect(state.environments.options).toEqual([]);
mutations[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, mockEnvironments);
expect(state.environments.options).toEqual(mockEnvironments);
expect(state.environments.isLoading).toEqual(false);
});
});
describe('RECEIVE_ENVIRONMENTS_DATA_ERROR', () => {
it('captures an error loading environments', () => {
mutations[types.RECEIVE_ENVIRONMENTS_DATA_ERROR](state);
expect(state.environments).toEqual({
options: [],
isLoading: false,
current: null,
});
});
});
describe('REQUEST_LOGS_DATA', () => {
it('starts loading for logs', () => {
mutations[types.REQUEST_LOGS_DATA](state);
expect(state.logs).toEqual(
expect.objectContaining({
lines: [],
isLoading: true,
isComplete: false,
}),
);
});
});
describe('RECEIVE_LOGS_DATA_SUCCESS', () => {
it('receives logs lines', () => {
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, mockLogsResult);
expect(state.logs).toEqual(
expect.objectContaining({
lines: mockLogsResult,
isLoading: false,
isComplete: true,
}),
);
});
});
describe('RECEIVE_LOGS_DATA_ERROR', () => {
it('receives log data error and stops loading', () => {
mutations[types.RECEIVE_LOGS_DATA_ERROR](state);
expect(state.logs).toEqual(
expect.objectContaining({
lines: [],
isLoading: false,
isComplete: true,
}),
);
});
});
describe('SET_CURRENT_POD_NAME', () => {
it('set current pod name', () => {
mutations[types.SET_CURRENT_POD_NAME](state, mockPodName);
expect(state.pods.current).toEqual(mockPodName);
});
});
describe('SET_TIME_RANGE', () => {
it('sets a default range', () => {
expect(state.timeRange.current).toEqual(expect.any(Object));
});
it('sets a time range', () => {
const mockRange = {
start: '2020-01-10T18:00:00.000Z',
end: '2020-01-10T10:00:00.000Z',
};
mutations[types.SET_TIME_RANGE](state, mockRange);
expect(state.timeRange.current).toEqual(mockRange);
});
});
describe('REQUEST_PODS_DATA', () => {
it('receives log data error and stops loading', () => {
mutations[types.REQUEST_PODS_DATA](state);
expect(state.pods).toEqual(
expect.objectContaining({
options: [],
}),
);
});
});
describe('RECEIVE_PODS_DATA_SUCCESS', () => {
it('receives pods data success', () => {
mutations[types.RECEIVE_PODS_DATA_SUCCESS](state, mockPods);
expect(state.pods).toEqual(
expect.objectContaining({
options: mockPods,
}),
);
});
});
describe('RECEIVE_PODS_DATA_ERROR', () => {
it('receives pods data error', () => {
mutations[types.RECEIVE_PODS_DATA_ERROR](state);
expect(state.pods).toEqual(
expect.objectContaining({
options: [],
}),
);
});
});
});
import { getTimeRange } from '~/logs/utils';
describe('logs/utils', () => {
describe('getTimeRange', () => {
const nowTimestamp = 1577836800000;
const nowString = '2020-01-01T00:00:00.000Z';
beforeEach(() => {
jest.spyOn(Date, 'now').mockImplementation(() => nowTimestamp);
});
afterEach(() => {
Date.now.mockRestore();
});
it('returns the right values', () => {
expect(getTimeRange(0)).toEqual({
start: '2020-01-01T00:00:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 30)).toEqual({
start: '2019-12-31T23:30:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 60 * 24 * 7 * 1)).toEqual({
start: '2019-12-25T00:00:00.000Z',
end: nowString,
});
expect(getTimeRange(60 * 60 * 24 * 7 * 4)).toEqual({
start: '2019-12-04T00:00:00.000Z',
end: nowString,
});
});
});
});
// this file can't be migrated to jest because it relies on the browser to perform integration tests:
// see: https://gitlab.com/gitlab-org/gitlab/-/issues/194207#note_301878738
import { FIXTURES_PATH } from 'spec/test_constants';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::BackgroundMigration::BackfillSnippetRepositories, :migration, schema: 2020_02_26_162723 do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:users) { table(:users) }
let(:snippets) { table(:snippets) }
let(:snippet_repositories) { table(:snippet_repositories) }
let(:user) { users.create(id: 1, email: 'user@example.com', projects_limit: 10, username: 'test', name: 'Test') }
let!(:snippet_with_repo) { snippets.create(id: 1, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let!(:snippet_with_empty_repo) { snippets.create(id: 2, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let!(:snippet_without_repo) { snippets.create(id: 3, type: 'PersonalSnippet', author_id: user.id, file_name: file_name, content: content) }
let(:file_name) { 'file_name.rb' }
let(:content) { 'content' }
let(:ids) { snippets.pluck('MIN(id)', 'MAX(id)').first }
let(:service) { described_class.new }
subject { service.perform(*ids) }
before do
allow(snippet_with_repo).to receive(:disk_path).and_return(disk_path(snippet_with_repo))
TestEnv.copy_repo(snippet_with_repo,
bare_repo: TestEnv.factory_repo_path_bare,
refs: TestEnv::BRANCH_SHA)
raw_repository(snippet_with_empty_repo).create_repository
end
after do
raw_repository(snippet_with_repo).remove
raw_repository(snippet_without_repo).remove
raw_repository(snippet_with_empty_repo).remove
end
describe '#perform' do
it 'logs successful migrated snippets' do
expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
expect(instance).to receive(:info).exactly(3).times
end
subject
end
context 'when snippet has a non empty repository' do
it 'does not perform any action' do
expect(service).not_to receive(:create_repository_and_files).with(snippet_with_repo)
subject
end
end
shared_examples 'commits the file to the repository' do
it do
subject
blob = blob_at(snippet, file_name)
aggregate_failures do
expect(blob).to be
expect(blob.data).to eq content
end
end
end
context 'when snippet has an empty repo' do
before do
expect(repository_exists?(snippet_with_empty_repo)).to be_truthy
end
it_behaves_like 'commits the file to the repository' do
let(:snippet) { snippet_with_empty_repo }
end
end
context 'when snippet does not have a repository' do
it 'creates the repository' do
expect { subject }.to change { repository_exists?(snippet_without_repo) }.from(false).to(true)
end
it_behaves_like 'commits the file to the repository' do
let(:snippet) { snippet_without_repo }
end
end
context 'when an error is raised' do
before do
allow(service).to receive(:create_commit).and_raise(StandardError)
end
it 'logs errors' do
expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
expect(instance).to receive(:error).exactly(3).times
end
subject
end
it "retries #{described_class::MAX_RETRIES} times the operation if it fails" do
expect(service).to receive(:create_commit).exactly(snippets.count * described_class::MAX_RETRIES).times
subject
end
it 'destroys the snippet repository' do
expect(service).to receive(:destroy_snippet_repository).exactly(3).times.and_call_original
subject
expect(snippet_repositories.count).to eq 0
end
it 'deletes the repository on disk' do
subject
aggregate_failures do
expect(repository_exists?(snippet_with_repo)).to be_falsey
expect(repository_exists?(snippet_without_repo)).to be_falsey
expect(repository_exists?(snippet_with_empty_repo)).to be_falsey
end
end
end
end
def blob_at(snippet, path)
raw_repository(snippet).blob_at('master', path)
end
def repository_exists?(snippet)
gitlab_shell.repository_exists?('default', "#{disk_path(snippet)}.git")
end
def raw_repository(snippet)
Gitlab::Git::Repository.new('default',
"#{disk_path(snippet)}.git",
Gitlab::GlRepository::SNIPPET.identifier_for_container(snippet),
"@snippets/#{snippet.id}")
end
def hashed_repository(snippet)
Storage::Hashed.new(snippet, prefix: '@snippets')
end
def disk_path(snippet)
hashed_repository(snippet).disk_path
end
def ls_files(snippet)
raw_repository(snippet).ls_files(nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Elasticsearch::Logs do
let(:client) { Elasticsearch::Transport::Client }
let(:es_message_1) { { timestamp: "2019-12-13T14:35:34.034Z", message: "10.8.2.1 - - [25/Oct/2019:08:03:22 UTC] \"GET / HTTP/1.1\" 200 13" } }
let(:es_message_2) { { timestamp: "2019-12-13T14:35:35.034Z", message: "10.8.2.1 - - [27/Oct/2019:23:49:54 UTC] \"GET / HTTP/1.1\" 200 13" } }
let(:es_message_3) { { timestamp: "2019-12-13T14:35:36.034Z", message: "10.8.2.1 - - [04/Nov/2019:23:09:24 UTC] \"GET / HTTP/1.1\" 200 13" } }
let(:es_message_4) { { timestamp: "2019-12-13T14:35:37.034Z", message: "- -\u003e /" } }
let(:es_response) { JSON.parse(fixture_file('lib/elasticsearch/logs_response.json')) }
subject { described_class.new(client) }
let(:namespace) { "autodevops-deploy-9-production" }
let(:pod_name) { "production-6866bc8974-m4sk4" }
let(:container_name) { "auto-deploy-app" }
let(:search) { "foo +bar "}
let(:start_time) { "2019-12-13T14:35:34.034Z" }
let(:end_time) { "2019-12-13T14:35:34.034Z" }
let(:body) { JSON.parse(fixture_file('lib/elasticsearch/query.json')) }
let(:body_with_container) { JSON.parse(fixture_file('lib/elasticsearch/query_with_container.json')) }
let(:body_with_search) { JSON.parse(fixture_file('lib/elasticsearch/query_with_search.json')) }
let(:body_with_times) { JSON.parse(fixture_file('lib/elasticsearch/query_with_times.json')) }
let(:body_with_start_time) { JSON.parse(fixture_file('lib/elasticsearch/query_with_start_time.json')) }
let(:body_with_end_time) { JSON.parse(fixture_file('lib/elasticsearch/query_with_end_time.json')) }
RSpec::Matchers.define :a_hash_equal_to_json do |expected|
match do |actual|
actual.as_json == expected
end
end
describe '#pod_logs' do
it 'returns the logs as an array' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
it 'can further filter the logs by container name' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_container)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name, container_name)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
it 'can further filter the logs by search' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_search)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name, nil, search)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
it 'can further filter the logs by start_time and end_time' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_times)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name, nil, nil, start_time, end_time)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
it 'can further filter the logs by only start_time' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_start_time)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name, nil, nil, start_time)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
it 'can further filter the logs by only end_time' do
expect(client).to receive(:search).with(body: a_hash_equal_to_json(body_with_end_time)).and_return(es_response)
result = subject.pod_logs(namespace, pod_name, nil, nil, nil, end_time)
expect(result).to eq([es_message_4, es_message_3, es_message_2, es_message_1])
end
end
end
......@@ -1266,4 +1266,39 @@ describe Environment, :use_clean_rails_memory_store_caching do
expect(env).to be_persisted
end
end
describe '#elastic_stack_available?' do
let!(:cluster) { create(:cluster, :project, :provided_by_user, projects: [project]) }
let!(:deployment) { create(:deployment, :success, environment: environment, project: project, cluster: cluster) }
context 'when app does not exist' do
it 'returns false' do
expect(environment.elastic_stack_available?).to be(false)
end
end
context 'when app exists' do
let!(:application) { create(:clusters_applications_elastic_stack, cluster: cluster) }
it 'returns false' do
expect(environment.elastic_stack_available?).to be(false)
end
end
context 'when app is installed' do
let!(:application) { create(:clusters_applications_elastic_stack, :installed, cluster: cluster) }
it 'returns true' do
expect(environment.elastic_stack_available?).to be(true)
end
end
context 'when app is updated' do
let!(:application) { create(:clusters_applications_elastic_stack, :updated, cluster: cluster) }
it 'returns true' do
expect(environment.elastic_stack_available?).to be(true)
end
end
end
end
......@@ -26,44 +26,6 @@ describe SnippetRepository do
end
end
describe '#create_file' do
let(:snippet) { create(:personal_snippet, :empty_repo, author: user) }
it 'creates the file' do
snippet_repository.create_file(user, 'foo', 'bar', commit_opts)
blob = first_blob(snippet)
aggregate_failures do
expect(blob).not_to be_nil
expect(blob.path).to eq 'foo'
expect(blob.data).to eq 'bar'
end
end
it 'fills the file path if empty' do
snippet_repository.create_file(user, nil, 'bar', commit_opts)
blob = first_blob(snippet)
aggregate_failures do
expect(blob).not_to be_nil
expect(blob.path).to eq 'snippetfile1.txt'
expect(blob.data).to eq 'bar'
end
end
context 'when the file exists' do
let(:snippet) { create(:personal_snippet, :repository, author: user) }
it 'captures the git exception and raises a SnippetRepository::CommitError' do
existing_blob = first_blob(snippet)
expect do
snippet_repository.create_file(user, existing_blob.path, existing_blob.data, commit_opts)
end.to raise_error described_class::CommitError
end
end
end
describe '#multi_files_action' do
let(:new_file) { { file_path: 'new_file_test', content: 'bar' } }
let(:move_file) { { previous_path: 'CHANGELOG', file_path: 'CHANGELOG_new', content: 'bar' } }
......
......@@ -3,6 +3,8 @@
require 'spec_helper'
describe EnvironmentEntity do
include Gitlab::Routing.url_helpers
let(:request) { double('request') }
let(:entity) do
described_class.new(environment, request: spy('request'))
......@@ -71,4 +73,22 @@ describe EnvironmentEntity do
expect(subject).to include(:cancel_auto_stop_path, :auto_stop_at)
end
end
context 'pod_logs' do
it 'exposes logs keys' do
expect(subject).to include(:logs_path)
expect(subject).to include(:logs_api_path)
expect(subject).to include(:enable_advanced_logs_querying)
end
it 'uses k8s api when ES is not available' do
expect(subject[:logs_api_path]).to eq(k8s_project_logs_path(environment.project, environment_name: environment.name, format: :json))
end
it 'uses ES api when ES is available' do
allow(environment).to receive(:elastic_stack_available?).and_return(true)
expect(subject[:logs_api_path]).to eq(elasticsearch_project_logs_path(environment.project, environment_name: environment.name, format: :json))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::PodLogs::BaseService do
include KubernetesHelpers
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:container_name) { 'container-0' }
let(:params) { {} }
let(:raw_pods) do
JSON.parse([
kube_pod(name: pod_name)
].to_json, object_class: OpenStruct)
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#initialize' do
let(:params) do
{
'container_name' => container_name,
'another_param' => 'foo'
}
end
it 'filters the parameters' do
expect(subject.cluster).to eq(cluster)
expect(subject.namespace).to eq(namespace)
expect(subject.params).to eq({
'container_name' => container_name
})
expect(subject.params.equal?(params)).to be(false)
end
end
describe '#check_arguments' do
context 'when cluster and namespace are provided' do
it 'returns success' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:success)
end
end
context 'when cluster is nil' do
let(:cluster) { nil }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Cluster does not exist')
end
end
context 'when namespace is nil' do
let(:namespace) { nil }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Namespace is empty')
end
end
context 'when namespace is empty' do
let(:namespace) { '' }
it 'returns an error' do
result = subject.send(:check_arguments, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Namespace is empty')
end
end
end
describe '#check_param_lengths' do
context 'when pod_name and container_name are provided' do
let(:params) do
{
'pod_name' => pod_name,
'container_name' => container_name
}
end
it 'returns success' do
result = subject.send(:check_param_lengths, {})
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
expect(result[:container_name]).to eq(container_name)
end
end
context 'when pod_name is too long' do
let(:params) do
{
'pod_name' => "a very long string." * 15
}
end
it 'returns an error' do
result = subject.send(:check_param_lengths, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('pod_name cannot be larger than 253 chars')
end
end
context 'when container_name is too long' do
let(:params) do
{
'container_name' => "a very long string." * 15
}
end
it 'returns an error' do
result = subject.send(:check_param_lengths, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('container_name cannot be larger than 253 chars')
end
end
end
describe '#get_raw_pods' do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
it 'returns success with passthrough k8s response' do
stub_kubeclient_pods(namespace)
result = subject.send(:get_raw_pods, {})
expect(result[:status]).to eq(:success)
expect(result[:raw_pods].first).to be_a(Kubeclient::Resource)
end
end
describe '#get_pod_names' do
it 'returns success with a list of pods' do
result = subject.send(:get_pod_names, raw_pods: raw_pods)
expect(result[:status]).to eq(:success)
expect(result[:pods]).to eq([pod_name])
end
end
describe '#check_pod_name' do
it 'returns success if pod_name was specified' do
result = subject.send(:check_pod_name, pod_name: pod_name, pods: [pod_name])
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
end
it 'returns success if pod_name was not specified but there are pods' do
result = subject.send(:check_pod_name, pod_name: nil, pods: [pod_name])
expect(result[:status]).to eq(:success)
expect(result[:pod_name]).to eq(pod_name)
end
it 'returns error if pod_name was not specified and there are no pods' do
result = subject.send(:check_pod_name, pod_name: nil, pods: [])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('No pods available')
end
it 'returns error if pod_name was specified but does not exist' do
result = subject.send(:check_pod_name, pod_name: 'another_pod', pods: [pod_name])
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Pod does not exist')
end
end
describe '#check_container_name' do
it 'returns success if container_name was specified' do
result = subject.send(:check_container_name,
container_name: container_name,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:success)
expect(result[:container_name]).to eq(container_name)
end
it 'returns success if container_name was not specified and there are containers' do
result = subject.send(:check_container_name,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:success)
expect(result[:container_name]).to eq(container_name)
end
it 'returns error if container_name was not specified and there are no containers on the pod' do
raw_pods.first.spec.containers = []
result = subject.send(:check_container_name,
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('No containers available')
end
it 'returns error if container_name was specified but does not exist' do
result = subject.send(:check_container_name,
container_name: 'foo',
pod_name: pod_name,
raw_pods: raw_pods
)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Container does not exist')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::PodLogs::ElasticsearchService do
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:container_name) { 'container-1' }
let(:search) { 'foo -bar' }
let(:start_time) { '2019-01-02T12:13:14+02:00' }
let(:end_time) { '2019-01-03T12:13:14+02:00' }
let(:params) { {} }
let(:expected_logs) do
[
{ message: "Log 1", timestamp: "2019-12-13T14:04:22.123456Z" },
{ message: "Log 2", timestamp: "2019-12-13T14:04:23.123456Z" },
{ message: "Log 3", timestamp: "2019-12-13T14:04:24.123456Z" }
]
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#check_times' do
context 'with start and end provided and valid' do
let(:params) do
{
'start' => start_time,
'end' => end_time
}
end
it 'returns success with times' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:success)
expect(result[:start]).to eq(start_time)
expect(result[:end]).to eq(end_time)
end
end
context 'with start and end not provided' do
let(:params) do
{}
end
it 'returns success with nothing else' do
result = subject.send(:check_times, {})
expect(result.keys.length).to eq(1)
expect(result[:status]).to eq(:success)
end
end
context 'with start valid and end invalid' do
let(:params) do
{
'start' => start_time,
'end' => 'invalid date'
}
end
it 'returns error' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid start or end time format')
end
end
context 'with start invalid and end valid' do
let(:params) do
{
'start' => 'invalid date',
'end' => end_time
}
end
it 'returns error' do
result = subject.send(:check_times, {})
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Invalid start or end time format')
end
end
end
describe '#check_search' do
context 'with search provided and valid' do
let(:params) do
{
'search' => search
}
end
it 'returns success with search' do
result = subject.send(:check_search, {})
expect(result[:status]).to eq(:success)
expect(result[:search]).to eq(search)
end
end
context 'with search not provided' do
let(:params) do
{}
end
it 'returns success with nothing else' do
result = subject.send(:check_search, {})
expect(result.keys.length).to eq(1)
expect(result[:status]).to eq(:success)
end
end
end
describe '#pod_logs' do
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
search: search,
start: start_time,
end: end_time
}
end
before do
create(:clusters_applications_elastic_stack, :installed, cluster: cluster)
end
it 'returns the logs' do
allow_any_instance_of(::Clusters::Applications::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs)
.to receive(:pod_logs)
.with(namespace, pod_name, container_name, search, start_time, end_time)
.and_return(expected_logs)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
it 'returns an error when ES is unreachable' do
allow_any_instance_of(::Clusters::Applications::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(nil)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to connect to Elasticsearch')
end
it 'handles server errors from elasticsearch' do
allow_any_instance_of(::Clusters::Applications::ElasticStack)
.to receive(:elasticsearch_client)
.and_return(Elasticsearch::Transport::Client.new)
allow_any_instance_of(::Gitlab::Elasticsearch::Logs)
.to receive(:pod_logs)
.and_raise(Elasticsearch::Transport::Transport::Errors::ServiceUnavailable.new)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Elasticsearch returned status code: ServiceUnavailable')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ::PodLogs::KubernetesService do
include KubernetesHelpers
let_it_be(:cluster) { create(:cluster, :provided_by_gcp, environment_scope: '*') }
let(:namespace) { 'autodevops-deploy-9-production' }
let(:pod_name) { 'pod-1' }
let(:container_name) { 'container-1' }
let(:params) { {} }
let(:raw_logs) do
"2019-12-13T14:04:22.123456Z Log 1\n2019-12-13T14:04:23.123456Z Log 2\n" \
"2019-12-13T14:04:24.123456Z Log 3"
end
subject { described_class.new(cluster, namespace, params: params) }
describe '#pod_logs' do
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name
}
end
let(:expected_logs) { raw_logs }
let(:service) { create(:cluster_platform_kubernetes, :configured) }
it 'returns the logs' do
stub_kubeclient_logs(pod_name, namespace, container: container_name)
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
it 'handles Not Found errors from k8s' do
allow_any_instance_of(Gitlab::Kubernetes::KubeClient)
.to receive(:get_pod_log)
.with(any_args)
.and_raise(Kubeclient::ResourceNotFoundError.new(404, 'Not Found', {}))
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Pod not found')
end
it 'handles HTTP errors from k8s' do
allow_any_instance_of(Gitlab::Kubernetes::KubeClient)
.to receive(:get_pod_log)
.with(any_args)
.and_raise(Kubeclient::HttpError.new(500, 'Error', {}))
result = subject.send(:pod_logs, result_arg)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Kubernetes API returned status code: 500')
end
end
describe '#encode_logs_to_utf8', :aggregate_failures do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:expected_logs) { '2019-12-13T14:04:22.123456Z ✔ Started logging errors to Sentry' }
let(:raw_logs) { expected_logs.dup.force_encoding(Encoding::ASCII_8BIT) }
let(:result) { subject.send(:encode_logs_to_utf8, result_arg) }
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
logs: raw_logs
}
end
it 'converts logs to utf-8' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
it 'returns error if output of encoding helper is blank' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8).and_return('')
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
it 'returns error if output of encoding helper is nil' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8).and_return(nil)
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
it 'returns error if output of encoding helper is not UTF-8' do
allow(Gitlab::EncodingHelper).to receive(:encode_utf8)
.and_return(expected_logs.encode(Encoding::UTF_16BE))
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Unable to convert Kubernetes logs encoding to UTF-8')
end
context 'when logs are nil' do
let(:raw_logs) { nil }
let(:expected_logs) { nil }
it 'returns nil' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
context 'when logs are blank' do
let(:raw_logs) { (+'').force_encoding(Encoding::ASCII_8BIT) }
let(:expected_logs) { '' }
it 'returns blank string' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
context 'when logs are already in utf-8' do
let(:raw_logs) { expected_logs }
it 'does not fail' do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
end
describe '#split_logs' do
let(:service) { create(:cluster_platform_kubernetes, :configured) }
let(:expected_logs) do
[
{ message: "Log 1", timestamp: "2019-12-13T14:04:22.123456Z" },
{ message: "Log 2", timestamp: "2019-12-13T14:04:23.123456Z" },
{ message: "Log 3", timestamp: "2019-12-13T14:04:24.123456Z" }
]
end
let(:result_arg) do
{
pod_name: pod_name,
container_name: container_name,
logs: raw_logs
}
end
it 'returns the logs' do
result = subject.send(:split_logs, result_arg)
aggregate_failures do
expect(result[:status]).to eq(:success)
expect(result[:logs]).to eq(expected_logs)
end
end
end
end
......@@ -82,7 +82,7 @@ Capybara.enable_aria_label = true
Capybara::Screenshot.append_timestamp = false
Capybara::Screenshot.register_filename_prefix_formatter(:rspec) do |example|
::File.join(QA::Runtime::Namespace.name, example.full_description.downcase.parameterize(separator: "_")[0..99])
example.full_description.downcase.parameterize(separator: "_")[0..99]
end
# Keep only the screenshots generated from the last failing test suite
Capybara::Screenshot.prune_strategy = :keep_last_run
......
......@@ -801,10 +801,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.110.0.tgz#3c4f5f0e78fcf616ec63a265754158b84ed80af8"
integrity sha512-bLVUW9Hj6j7zTdeoQELO3Bls5xDKr6AoSEU8gZbEZKLK9PV81hxRl/lJPJUo1qt4E7eJGapCTlH73tTIL4OZ3A==
"@gitlab/ui@^9.23.0":
version "9.23.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-9.23.0.tgz#0ad0232c529d1f8a386c8e86159e273111a55686"
integrity sha512-1VOob5tNPB3zjLHeTuMbQBMG3q6LF36iCq6XqH5eeYzpAI42zj/WhY5T47RKrfvlkflWRSUPTarGo97pQqIKzg==
"@gitlab/ui@^9.23.1":
version "9.23.1"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-9.23.1.tgz#791d0c8a6762b1dd73ed686326c1dfb3f0c7b987"
integrity sha512-7bGcV2W6qh/KK423W/vasv+S6myWJMD1tyMr5MBz1WQRg/B3eUlpr4HbjQXmtALRWiWkag8GMI/HSy0rby4WrA==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment