diff options
author | bikebilly <fabio@gitlab.com> | 2017-11-07 09:44:13 +0100 |
---|---|---|
committer | bikebilly <fabio@gitlab.com> | 2017-11-07 09:44:13 +0100 |
commit | a40bb17688f4d2983c22929cfbb1d888c2a6e68c (patch) | |
tree | 90d8c6d008affa2b774b6f2a958355a419283285 | |
parent | ad9c0bae5f573fb14410d421247814673ea9e690 (diff) | |
parent | e99ddb6f374c9f79c1c78e808c5e9bd983bed227 (diff) | |
download | gitlab-ce-a40bb17688f4d2983c22929cfbb1d888c2a6e68c.tar.gz |
Resolve conflicts
280 files changed, 5137 insertions, 2807 deletions
diff --git a/.scss-lint.yml b/.scss-lint.yml index 16a168b7c60..a855ef3c6e9 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -14,7 +14,7 @@ linters: # Whether or not to prefer `border: 0` over `border: none`. BorderZero: - enabled: false + enabled: true # Reports when you define a rule set using a selector with chained classes # (a.k.a. adjoining classes). diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/behaviors/copy_as_gfm.js index 93b0cbf4209..e7dc4ef8304 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/copy_as_gfm.js @@ -1,7 +1,8 @@ /* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ + import _ from 'underscore'; -import { insertText, getSelectedFragment, nodeMatchesSelector } from './lib/utils/common_utils'; -import { placeholderImage } from './lazy_loader'; +import { insertText, getSelectedFragment, nodeMatchesSelector } from '../lib/utils/common_utils'; +import { placeholderImage } from '../lazy_loader'; const gfmRules = { // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert @@ -284,7 +285,7 @@ const gfmRules = { }, }; -class CopyAsGFM { +export class CopyAsGFM { constructor() { $(document).on('copy', '.md, .wiki', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); @@ -469,7 +470,12 @@ class CopyAsGFM { } } -window.gl = window.gl || {}; -window.gl.CopyAsGFM = CopyAsGFM; +// Export CopyAsGFM as a global for rspec to access +// see /spec/features/copy_as_gfm_spec.rb +if (process.env.NODE_ENV !== 'production') { + window.CopyAsGFM = CopyAsGFM; +} -new CopyAsGFM(); +export default function initCopyAsGFM() { + return new CopyAsGFM(); +} diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 44b2c974b9e..671532394a9 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,5 +1,6 @@ import './autosize'; import './bind_in_out'; +import initCopyAsGFM from './copy_as_gfm'; import './details_behavior'; import installGlEmojiElement from './gl_emoji'; import './quick_submit'; @@ -7,3 +8,4 @@ import './requires_input'; import './toggler_behavior'; installGlEmojiElement(); +initCopyAsGFM(); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9117f033c9f..31c5cfc5e55 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -46,7 +46,6 @@ import './commits'; import './compare'; import './compare_autocomplete'; import './confirm_danger_modal'; -import './copy_as_gfm'; import './copy_to_clipboard'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 5aa3865f96a..f8782fde927 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -138,7 +138,7 @@ renderAxesPaths() { this.timeSeries = createTimeSeries( - this.graphData.queries[0], + this.graphData.queries, this.graphWidth, this.graphHeight, this.graphHeightOffset, @@ -153,8 +153,9 @@ const axisYScale = d3.scale.linear() .range([this.graphHeight - this.graphHeightOffset, 0]); - axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time)); - axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]); + const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []); + axisXScale.domain(d3.extent(allValues, d => d.time)); + axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); const xAxis = d3.svg.axis() .scale(axisXScale) @@ -246,6 +247,7 @@ :key="index" :generated-line-path="path.linePath" :generated-area-path="path.areaPath" + :line-style="path.lineStyle" :line-color="path.lineColor" :area-color="path.areaColor" /> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index 85b6d7f4cbe..440b1b12631 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -79,7 +79,8 @@ }, formatMetricUsage(series) { - const value = series.values[this.currentDataIndex].value; + const value = series.values[this.currentDataIndex] && + series.values[this.currentDataIndex].value; if (isNaN(value)) { return '-'; } @@ -92,6 +93,12 @@ } return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`; }, + + strokeDashArray(type) { + if (type === 'dashed') return '6, 3'; + if (type === 'dotted') return '3, 3'; + return null; + }, }, mounted() { this.$nextTick(() => { @@ -162,13 +169,15 @@ v-for="(series, index) in timeSeries" :key="index" :transform="translateLegendGroup(index)"> - <rect - :fill="series.areaColor" - :width="measurements.legends.width" - :height="measurements.legends.height" - x="20" - :y="graphHeight - measurements.legendOffset"> - </rect> + <line + :stroke="series.lineColor" + :stroke-width="measurements.legends.height" + :stroke-dasharray="strokeDashArray(series.lineStyle)" + :x1="measurements.legends.offsetX" + :x2="measurements.legends.offsetX + measurements.legends.width" + :y1="graphHeight - measurements.legends.offsetY" + :y2="graphHeight - measurements.legends.offsetY"> + </line> <text v-if="timeSeries.length > 1" class="legend-metric-title" diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue index 043f1bf66bb..5e6d409033a 100644 --- a/app/assets/javascripts/monitoring/components/graph/path.vue +++ b/app/assets/javascripts/monitoring/components/graph/path.vue @@ -9,6 +9,10 @@ type: String, required: true, }, + lineStyle: { + type: String, + required: false, + }, lineColor: { type: String, required: true, @@ -18,6 +22,13 @@ required: true, }, }, + computed: { + strokeDashArray() { + if (this.lineStyle === 'dashed') return '3, 1'; + if (this.lineStyle === 'dotted') return '1, 1'; + return null; + }, + }, }; </script> <template> @@ -34,6 +45,7 @@ :stroke="lineColor" fill="none" stroke-width="1" + :stroke-dasharray="strokeDashArray" transform="translate(-5, 20)"> </path> </g> diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js index ee3c45efacc..ee866850e13 100644 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ b/app/assets/javascripts/monitoring/utils/measurements.js @@ -7,15 +7,16 @@ export default { left: 40, }, legends: { - width: 10, + width: 15, height: 3, + offsetX: 20, + offsetY: 32, }, backgroundLegend: { width: 30, height: 50, }, axisLabelLineOffset: -20, - legendOffset: 33, }, large: { // This covers both md and lg screen sizes margin: { @@ -27,13 +28,14 @@ export default { legends: { width: 15, height: 3, + offsetX: 20, + offsetY: 34, }, backgroundLegend: { width: 30, height: 150, }, axisLabelLineOffset: 20, - legendOffset: 36, }, xTicks: 8, yTicks: 3, diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index 65eec0d8d02..d21a265bd43 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -11,7 +11,9 @@ const defaultColorPalette = { const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple']; -export default function createTimeSeries(queryData, graphWidth, graphHeight, graphHeightOffset) { +const defaultStyleOrder = ['solid', 'dashed', 'dotted']; + +function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) { let usedColors = []; function pickColor(name) { @@ -31,17 +33,7 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra return defaultColorPalette[pick]; } - const maxValues = queryData.result.map((timeSeries, index) => { - const maxValue = d3.max(timeSeries.values.map(d => d.value)); - return { - maxValue, - index, - }; - }); - - const maxValueFromSeries = _.max(maxValues, val => val.maxValue); - - return queryData.result.map((timeSeries, timeSeriesNumber) => { + return query.result.map((timeSeries, timeSeriesNumber) => { let metricTag = ''; let lineColor = ''; let areaColor = ''; @@ -52,9 +44,9 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra const timeSeriesScaleY = d3.scale.linear() .range([graphHeight - graphHeightOffset, 0]); - timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time)); + timeSeriesScaleX.domain(xDom); timeSeriesScaleX.ticks(d3.time.minute, 60); - timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]); + timeSeriesScaleY.domain(yDom); const defined = d => !isNaN(d.value) && d.value != null; @@ -72,10 +64,10 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra .y1(d => timeSeriesScaleY(d.value)); const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; - const seriesCustomizationData = queryData.series != null && - _.findWhere(queryData.series[0].when, - { value: timeSeriesMetricLabel }); - if (seriesCustomizationData != null) { + const seriesCustomizationData = query.series != null && + _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); + + if (seriesCustomizationData) { metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; [lineColor, areaColor] = pickColor(seriesCustomizationData.color); } else { @@ -83,14 +75,35 @@ export default function createTimeSeries(queryData, graphWidth, graphHeight, gra [lineColor, areaColor] = pickColor(); } + if (query.track) { + metricTag += ` - ${query.track}`; + } + return { linePath: lineFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values), timeSeriesScaleX, values: timeSeries.values, + lineStyle, lineColor, areaColor, metricTag, }; }); } + +export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { + const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat( + query.result.reduce((allResults, result) => allResults.concat(result.values), []), + ), []); + + const xDom = d3.extent(allValues, d => d.time); + const yDom = [0, d3.max(allValues.map(d => d.value))]; + + return queries.reduce((series, query, index) => { + const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length]; + return series.concat( + queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle), + ); + }, []); +} diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index f15452ec683..9dec5d7645a 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -162,13 +162,19 @@ import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from '. items = [ { header: "" + name - }, { + } + ]; + const issueItems = [ + { text: 'Issues assigned to me', url: issuesPath + "/?assignee_username=" + userName }, { text: "Issues I've created", url: issuesPath + "/?author_username=" + userName - }, 'separator', { + } + ]; + const mergeRequestItems = [ + { text: 'Merge requests assigned to me', url: mrPath + "/?assignee_username=" + userName }, { @@ -176,6 +182,11 @@ import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from '. url: mrPath + "/?author_username=" + userName } ]; + if (options.issuesDisabled) { + items = items.concat(mergeRequestItems); + } else { + items = items.concat(...issueItems, 'separator', ...mergeRequestItems); + } if (!name) { items.splice(0, 1); } @@ -408,6 +419,7 @@ import { isInGroupsPage, isInProjectPage, getGroupSlug, getProjectSlug } from '. gl.projectOptions[projectPath] = { name: $projectOptionsDataEl.data('name'), issuesPath: $projectOptionsDataEl.data('issues-path'), + issuesDisabled: $projectOptionsDataEl.data('issues-disabled'), mrPath: $projectOptionsDataEl.data('mr-path') }; } diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index fc97938e3d1..4f4f606d293 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import 'mousetrap'; import ShortcutsNavigation from './shortcuts_navigation'; +import { CopyAsGFM } from './behaviors/copy_as_gfm'; export default class ShortcutsIssuable extends ShortcutsNavigation { constructor(isMergeRequest) { @@ -33,8 +34,8 @@ export default class ShortcutsIssuable extends ShortcutsNavigation { return false; } - const el = window.gl.CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); - const selected = window.gl.CopyAsGFM.nodeToGFM(el); + const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true)); + const selected = CopyAsGFM.nodeToGFM(el); if (selected.trim() === '') { return false; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 6511828e982..a873e00d0f3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -47,8 +47,10 @@ }, }, methods: { - toggleMarkdownPreview() { - this.previewMarkdown = !this.previewMarkdown; + showPreviewTab() { + if (this.previewMarkdown) return; + + this.previewMarkdown = true; /* Can't use `$refs` as the component is technically in the parent component @@ -56,20 +58,22 @@ */ const text = this.$slots.textarea[0].elm.value; - if (!this.previewMarkdown) { - this.markdownPreview = ''; - } else if (text) { + if (text) { this.markdownPreviewLoading = true; this.$http.post(this.markdownPreviewPath, { text }) .then(resp => resp.json()) - .then((data) => { - this.renderMarkdown(data); - }) + .then(data => this.renderMarkdown(data)) .catch(() => new Flash('Error loading markdown preview')); } else { this.renderMarkdown(); } }, + + showWriteTab() { + this.markdownPreview = ''; + this.previewMarkdown = false; + }, + renderMarkdown(data = {}) { this.markdownPreviewLoading = false; this.markdownPreview = data.body || 'Nothing to preview.'; @@ -106,7 +110,8 @@ ref="gl-form"> <markdown-header :preview-markdown="previewMarkdown" - @toggle-markdown="toggleMarkdownPreview" /> + @preview-markdown="showPreviewTab" + @write-markdown="showWriteTab" /> <div class="md-write-holder" v-show="!previewMarkdown"> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 7541731083b..70f5fc1d664 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -18,23 +18,31 @@ icon, }, methods: { - toggleMarkdownPreview(e, form) { - if (form && !form.find('.js-vue-markdown-field').length) { - return; - } else if (e.target.blur) { - e.target.blur(); - } + isMarkdownForm(form) { + return form && !form.find('.js-vue-markdown-field').length; + }, + + previewMarkdownTab(event, form) { + if (event.target.blur) event.target.blur(); + if (this.isMarkdownForm(form)) return; + + this.$emit('preview-markdown'); + }, + + writeMarkdownTab(event, form) { + if (event.target.blur) event.target.blur(); + if (this.isMarkdownForm(form)) return; - this.$emit('toggle-markdown'); + this.$emit('write-markdown'); }, }, mounted() { - $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); - $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview); + $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); + $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab); }, beforeDestroy() { - $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); - $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview); + $(document).off('markdown-preview:show.vue', this.previewMarkdownTab); + $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab); }, }; </script> @@ -44,17 +52,19 @@ <ul class="nav-links clearfix"> <li :class="{ active: !previewMarkdown }"> <a + class="js-write-link" href="#md-write-holder" tabindex="-1" - @click.prevent="toggleMarkdownPreview($event)"> + @click.prevent="writeMarkdownTab($event)"> Write </a> </li> <li :class="{ active: previewMarkdown }"> <a + class="js-preview-link" href="#md-preview-holder" tabindex="-1" - @click.prevent="toggleMarkdownPreview($event)"> + @click.prevent="previewMarkdownTab($event)"> Preview </a> </li> diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss index f1aedc227f3..26db2386879 100644 --- a/app/assets/stylesheets/framework/avatar.scss +++ b/app/assets/stylesheets/framework/avatar.scss @@ -42,8 +42,7 @@ &.avatar-inline { float: none; display: inline-block; - margin-left: 4px; - margin-bottom: 2px; + margin-left: 2px; flex-shrink: 0; -webkit-flex-shrink: 0; @@ -59,7 +58,7 @@ &.avatar-tile { border-radius: 0; - border: none; + border: 0; } &:not([href]):hover { @@ -96,7 +95,7 @@ .avatar { border-radius: 0; - border: none; + border: 0; height: auto; width: 100%; margin: 0; diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index def986180fc..9c1439dfad5 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -39,7 +39,7 @@ } &.top-block { - border-top: none; + border-top: 0; .container-fluid { background-color: inherit; @@ -63,7 +63,7 @@ &.footer-block { margin-top: 0; - border-bottom: none; + border-bottom: 0; margin-bottom: -$gl-padding; } @@ -100,7 +100,7 @@ &.build-content { background-color: $white-light; - border-top: none; + border-top: 0; } } @@ -287,12 +287,12 @@ cursor: pointer; color: $blue-300; z-index: 1; - border: none; + border: 0; background-color: transparent; &:hover, &:focus { - border: none; + border: 0; color: $blue-400; } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 00a0e9cef67..c4a95afc4d2 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -304,7 +304,7 @@ } .btn-clipboard { - border: none; + border: 0; padding: 0 5px; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ea3007f5e08..393a0052114 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -28,7 +28,7 @@ pre { &.clean { background: none; - border: none; + border: 0; margin: 0; padding: 0; } @@ -142,7 +142,7 @@ li.note { img { max-width: 100%; } .note-title { li { - border-bottom: none !important; + border-bottom: 0 !important; } } } @@ -187,7 +187,7 @@ li.note { pre { background: $white-light; - border: none; + border: 0; font-size: 12px; } } @@ -386,7 +386,7 @@ img.emoji { } .hide-bottom-border { - border-bottom: none !important; + border-bottom: 0 !important; } .gl-accessibility { diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 6382551fcc9..1247e5e4876 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -142,7 +142,7 @@ */ &.blame { table { - border: none; + border: 0; margin: 0; } @@ -150,20 +150,20 @@ border-bottom: 1px solid $blame-border; &:last-child { - border-bottom: none; + border-bottom: 0; } } td { - border-top: none; - border-bottom: none; + border-top: 0; + border-bottom: 0; &:first-child { - border-left: none; + border-left: 0; } &:last-child { - border-right: none; + border-right: 0; } &.blame-commit { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index a7333925f80..74b6b31b07e 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -255,7 +255,7 @@ .clear-search { width: 35px; background-color: $white-light; - border: none; + border: 0; outline: none; z-index: 1; @@ -418,7 +418,7 @@ .droplab-dropdown .dropdown-menu .filter-dropdown-item { .btn { - border: none; + border: 0; width: 100%; text-align: left; padding: 8px 16px; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 5d777f0d468..1cdfa904374 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -10,7 +10,7 @@ z-index: 1000; margin-bottom: 0; min-height: $header-height; - border: none; + border: 0; border-bottom: 1px solid $border-color; position: fixed; top: 0; @@ -169,7 +169,7 @@ .navbar-collapse { flex: 0 0 auto; - border-top: none; + border-top: 0; padding: 0; @media (max-width: $screen-xs-max) { @@ -352,77 +352,7 @@ .header-user .dropdown-menu-nav, .header-new .dropdown-menu-nav { - margin-top: 4px; -} - -.search { - margin: 4px 8px 0; - - form { - height: 32px; - border: 0; - border-radius: $border-radius-default; - transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s; - - &:hover { - box-shadow: none; - } - } - - .search-input { - color: $white-light; - background: none; - transition: color ease-in-out 0.15s; - } - - .search-input::placeholder { - transition: color ease-in-out 0.15s; - } - - .location-badge { - font-size: 12px; - margin: -4px 4px -4px -4px; - line-height: 25px; - padding: 4px 8px; - border-radius: 2px 0 0 2px; - height: 32px; - transition: border-color ease-in-out 0.15s; - } - - &.search-active { - form { - background-color: rgba($indigo-200, .3); - box-shadow: none; - - .search-input { - color: $gl-text-color; - transition: color ease-in-out 0.15s; - } - - .search-input::placeholder { - color: $gl-text-color-tertiary; - } - - .search-input-wrap { - .search-icon, - .clear-icon { - color: $gl-text-color-tertiary; - transition: color ease-in-out 0.15s; - } - } - } - - .location-badge { - background-color: $nav-badge-bg; - border-color: $border-color; - } - - .search-input-wrap { - .clear-icon { - color: $white-light; - } - } - } + margin-top: $dropdown-vertical-offset; } .breadcrumbs { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index c63114f85b4..813a1711ea2 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -1,5 +1,5 @@ .file-content.code { - border: none; + border: 0; box-shadow: none; margin: 0; padding: 0; @@ -7,7 +7,7 @@ pre { padding: 10px 0; - border: none; + border: 0; border-radius: 0; font-family: $monospace_font; font-size: $code_font_size; diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 511608c618c..ad3bb0e35d1 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -42,7 +42,7 @@ } &:last-child { - border-bottom: none; + border-bottom: 0; &.bottom { background: $gray-light; @@ -92,7 +92,7 @@ ul.unstyled-list { } ul.unstyled-list > li { - border-bottom: none; + border-bottom: 0; } // Generic content list @@ -178,7 +178,7 @@ ul.content-list { // When dragging a list item &.ui-sortable-helper { - border-bottom: none; + border-bottom: 0; } &.list-placeholder { @@ -295,7 +295,7 @@ ul.indent-list { } > .group-list-tree > .group-row.has-children:first-child { - border-top: none; + border-top: 0; } } @@ -413,7 +413,7 @@ ul.indent-list { padding: 0; &.has-children { - border-top: none; + border-top: 0; } &:first-child { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 2fee2164190..16d5edde61e 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -36,7 +36,7 @@ margin: 0; &:last-child { - border-bottom: none; + border-bottom: 0; } &.active { diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 8b7afdbe1a5..7829d722560 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -24,7 +24,7 @@ @media (min-width: $screen-md-min) { margin: 0; padding: $gl-padding 0; - border: none; + border: 0; &:not(:last-child) { border-bottom: 1px solid $white-normal; diff --git a/app/assets/stylesheets/framework/secondary-navigation-elements.scss b/app/assets/stylesheets/framework/secondary-navigation-elements.scss index 9e1f77e5726..8498b37abe4 100644 --- a/app/assets/stylesheets/framework/secondary-navigation-elements.scss +++ b/app/assets/stylesheets/framework/secondary-navigation-elements.scss @@ -63,7 +63,7 @@ .nav-links { margin-bottom: 0; - border-bottom: none; + border-bottom: 0; float: left; &.wide { @@ -335,69 +335,16 @@ border-bottom: 1px solid $border-color; .nav-links { - border-bottom: none; + border-bottom: 0; } } } -.page-with-layout-nav { - .right-sidebar { - top: ($header-height + 1) * 2; - } - - &.page-with-sub-nav { - .right-sidebar { - top: ($header-height + 1) * 3; - - &.affix { - top: $header-height; - } - } - } -} - -.with-performance-bar .page-with-layout-nav { - .right-sidebar { - top: ($header-height + 1) * 2 + $performance-bar-height; - } - - &.page-with-sub-nav { - .right-sidebar { - top: ($header-height + 1) * 3 + $performance-bar-height; - - &.affix { - top: $header-height + $performance-bar-height; - } - } - } -} - -@media (max-width: $screen-xs-max) { - .top-area { - flex-flow: row wrap; - - .nav-controls { - $controls-margin: $btn-xs-side-margin - 2px; - flex: 0 0 100%; - - &.controls-flex { - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: center; - padding: 0 0 $gl-padding-top; - } - - .controls-item, - .controls-item-full, - .controls-item:last-child { - flex: 1 1 35%; - display: block; - width: 100%; - margin: $controls-margin; - } - } - } +.project-item-select-holder.btn-group { + display: flex; + max-width: 350px; + overflow: hidden; + float: right; .new-project-item-link { white-space: nowrap; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index aa35cd9bea4..bb70b270299 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -17,7 +17,7 @@ .select2-arrow { background-image: none; background-color: transparent; - border: none; + border: 0; padding-top: 12px; padding-right: 20px; font-size: 10px; @@ -60,12 +60,17 @@ border-radius: $border-radius-base; border: 1px solid $dropdown-border-color; min-width: 175px; - color: $gl-grayish-blue; + color: $gl-text-color; + z-index: 999; } -.select2-results .select2-result-label, -.select2-more-results { - padding: 10px 15px; +.select2-drop-mask { + z-index: 998; +} + +.select2-drop.select2-drop-above.select2-drop-active { + border-top: 1px solid $dropdown-border-color; + margin-top: -6px; } .select2-container-active { @@ -158,18 +163,35 @@ } } -.select2-results .select2-no-results, -.select2-results .select2-searching, -.select2-results .select2-ajax-error, -.select2-results .select2-selection-limit { - background: $gray-light; - display: list-item; - padding: 10px 15px; -} - .select2-results { margin: 0; - padding: 10px 0; + padding: #{$gl-padding / 2} 0; + + .select2-no-results, + .select2-searching, + .select2-ajax-error, + .select2-selection-limit { + background: transparent; + padding: #{$gl-padding / 2} $gl-padding; + } + + .select2-result-label, + .select2-more-results { + padding: #{$gl-padding / 2} $gl-padding; + } + + .select2-highlighted { + background: transparent; + color: $gl-text-color; + + .select2-result-label { + background: $dropdown-item-hover-bg; + } + } + + .select2-result { + padding: 0 1px; + } li.select2-result-with-children > .select2-result-label { font-weight: $gl-font-weight-bold; @@ -190,8 +212,6 @@ } .select2-highlighted { - background: $gl-link-color !important; - .group-result { .group-path { color: $white-light; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index ef58382ba41..1a19b7320a0 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -9,7 +9,7 @@ &.container-blank { background: none; padding: 0; - border: none; + border: 0; } } } @@ -111,7 +111,7 @@ } .block:last-of-type { - border: none; + border: 0; } } diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 4dd31bf28cd..5bde96caf42 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -33,7 +33,7 @@ table { th { background-color: $gray-light; font-weight: $gl-font-weight-normal; - border-bottom: none; + border-bottom: 0; &.wide { width: 55%; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index f718ec4bcad..373f35e71d8 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -21,7 +21,7 @@ } &.text-file .diff-file { - border-bottom: none; + border-bottom: 0; } } @@ -66,5 +66,5 @@ .discussion .timeline-entry { margin: 0; - border-right: none; + border-right: 0; } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 3c0b4c82d19..0817cce114c 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -167,7 +167,7 @@ &.plain-readme { background: none; - border: none; + border: 0; padding: 0; margin: 0; font-size: 14px; diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss index 32a0feb1c4b..5a4d3ba0ee9 100644 --- a/app/assets/stylesheets/framework/zen.scss +++ b/app/assets/stylesheets/framework/zen.scss @@ -9,7 +9,7 @@ z-index: 1031; textarea { - border: none; + border: 0; box-shadow: none; border-radius: 0; color: $black; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 46978be8ba0..27b10b536a2 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -48,7 +48,7 @@ overflow-x: auto; font-size: 12px; border-radius: 0; - border: none; + border: 0; .bash { display: block; diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss index bf6a48889bf..fbe1f3081a0 100644 --- a/app/assets/stylesheets/pages/ci_projects.scss +++ b/app/assets/stylesheets/pages/ci_projects.scss @@ -36,7 +36,7 @@ pre.commit-message { background: none; padding: 0; - border: none; + border: 0; margin: 20px 0; border-radius: 0; } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index ee3ca246374..b1850be8a5f 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -1,6 +1,6 @@ .commit-description { background: none; - border: none; + border: 0; padding: 0; margin-top: 10px; word-break: normal; @@ -247,7 +247,7 @@ word-break: normal; pre { - border: none; + border: 0; background: inherit; padding: 0; margin: 0; diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 82d9be29201..292e0ad394b 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -80,7 +80,7 @@ .panel { .content-block { padding: 24px 0; - border-bottom: none; + border-bottom: 0; position: relative; @media (max-width: $screen-xs-max) { @@ -222,11 +222,11 @@ } &:first-child { - border-top: none; + border-top: 0; } &:last-child { - border-bottom: none; + border-bottom: 0; } .stage-nav-item-cell { @@ -290,7 +290,7 @@ border-bottom: 1px solid $gray-darker; &:last-child { - border-bottom: none; + border-bottom: 0; margin-bottom: 0; } diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss index 3d9eff35583..538e50ee306 100644 --- a/app/assets/stylesheets/pages/detail_page.scss +++ b/app/assets/stylesheets/pages/detail_page.scss @@ -3,6 +3,7 @@ border-bottom: 1px solid $border-color; color: $gl-text-color; line-height: 34px; + display: flex; a { color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index faa3d1fb4d5..bce94e09367 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -47,7 +47,7 @@ table { width: 100%; font-family: $monospace_font; - border: none; + border: 0; border-collapse: separate; margin: 0; padding: 0; @@ -105,7 +105,7 @@ .new_line { @include user-select(none); margin: 0; - border: none; + border: 0; padding: 0 5px; border-right: 1px solid; text-align: right; @@ -133,7 +133,7 @@ display: block; margin: 0; padding: 0 1.5em; - border: none; + border: 0; position: relative; &.parallel { @@ -359,7 +359,7 @@ cursor: pointer; &:first-child { - border-left: none; + border-left: 0; } &:hover { @@ -388,7 +388,7 @@ .file-content .diff-file { margin: 0; - border: none; + border: 0; } .diff-wrap-lines .line_content { @@ -400,7 +400,7 @@ } .files-changed { - border-bottom: none; + border-bottom: 0; } .diff-stats-summary-toggler { diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index edfafa79c44..c586dab4cf2 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -3,13 +3,13 @@ border-top: 1px solid $border-color; border-right: 1px solid $border-color; border-left: 1px solid $border-color; - border-bottom: none; + border-bottom: 0; border-radius: $border-radius-small $border-radius-small 0 0; background: $gray-normal; } #editor { - border: none; + border: 0; border-radius: 0; height: 500px; margin: 0; @@ -171,7 +171,7 @@ width: 100%; margin: 5px 0; padding: 0; - border-left: none; + border-left: 0; } } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index b5b0f3d9dfa..26c5f093c6b 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -117,7 +117,7 @@ } .no-btn { - border: none; + border: 0; background: none; outline: none; width: 100%; @@ -133,11 +133,11 @@ } .folder-row { - border-left: none; - border-right: none; + border-left: 0; + border-right: 0; @media (min-width: $screen-sm-max) { - border-top: none; + border-top: 0; } } @@ -256,12 +256,6 @@ padding: 0; padding-bottom: 100%; - .label-axis-text { - fill: $black; - font-weight: $gl-font-weight-normal; - font-size: 10px; - } - .text-metric-usage, .legend-metric-title { fill: $black; @@ -276,19 +270,33 @@ left: 0; top: 0; - .label-axis-text, - .text-metric-usage { + text { + fill: $gl-text-color; + stroke-width: 0; + } + + .text-metric-bold { + font-weight: $gl-font-weight-bold; + } + + .label-axis-text { fill: $black; font-weight: $gl-font-weight-normal; - font-size: 12px; + font-size: 10px; } .legend-axis-text { fill: $black; } - .tick > text { - font-size: 12px; + .tick { + > line { + stroke: $gray-darker; + } + + > text { + font-size: 12px; + } } .text-metric-title { diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 1723d716805..eea8b7dd193 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -85,7 +85,7 @@ } pre { - border: none; + border: 0; background: $gray-light; border-radius: 0; color: $events-pre-color; @@ -128,14 +128,14 @@ } } - &:last-child { border: none; } + &:last-child { border: 0; } .event_commits { li { &.commit { background: transparent; padding: 0; - border: none; + border: 0; .commit-row-title { font-size: $gl-font-size; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7059a4cfe85..760c7c80aff 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -79,7 +79,7 @@ .title { padding: 0; margin-bottom: 16px; - border-bottom: none; + border-bottom: 0; } .btn-edit { @@ -131,12 +131,12 @@ top: $header-height; bottom: 0; right: 0; - transition: width .3s; + transition: width $right-sidebar-transition-duration; background: $gray-light; z-index: 200; overflow: hidden; - a, + a:not(.btn-retry), .btn-link { color: inherit; } @@ -164,7 +164,7 @@ } &:last-child { - border: none; + border: 0; } span { @@ -338,7 +338,7 @@ .block { width: $gutter_collapsed_width - 2px; padding: 15px 0 0; - border-bottom: none; + border-bottom: 0; overflow: hidden; } @@ -399,7 +399,7 @@ } .btn-clipboard { - border: none; + border: 0; color: $issuable-sidebar-color; &:hover { @@ -613,6 +613,8 @@ float: none; display: inline-block; margin-top: 0; + height: auto; + align-self: center; @media (max-width: $screen-xs-max) { position: absolute; @@ -626,6 +628,8 @@ padding-left: 45px; padding-right: 45px; line-height: 35px; + display: flex; + flex-grow: 1; @media (min-width: $screen-sm-min) { float: left; @@ -637,11 +641,12 @@ .issuable-actions { @include new-style-dropdown; - padding-top: 10px; + align-self: center; + flex-shrink: 0; + flex: 0 0 auto; @media (min-width: $screen-sm-min) { float: right; - padding-top: 0; } } @@ -655,8 +660,9 @@ .issuable-meta { display: inline-block; - line-height: 18px; font-size: 14px; + line-height: 24px; + align-self: center; } .js-issuable-selector-wrap { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index e8ca5cedaee..8bb68ad2425 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -134,11 +134,24 @@ ul.related-merge-requests > li { } @media (max-width: $screen-xs-max) { - .issue-btn-group { - width: 100%; + .detail-page-header, + .issuable-header { + display: block; + + .issuable-meta { + line-height: 18px; + } + } - .btn { + .issuable-actions { + margin-top: 10px; + + .issue-btn-group { width: 100%; + + .btn { + width: 100%; + } } } } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 92d49bd864a..b7985c4dea5 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -139,7 +139,7 @@ border-left: 1px solid $border-color; &:first-of-type { - border-left: none; + border-left: 0; border-top-left-radius: $border-radius-default; } @@ -165,7 +165,7 @@ border-bottom: 1px solid $border-color; a { - border: none; + border: 0; border-bottom: 2px solid $link-underline-blue; margin-right: 0; color: $black; diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss index dbf3e2b763c..04bde64c752 100644 --- a/app/assets/stylesheets/pages/merge_conflicts.scss +++ b/app/assets/stylesheets/pages/merge_conflicts.scss @@ -262,7 +262,7 @@ $colors: ( .editor { pre { height: 350px; - border: none; + border: 0; border-radius: 0; margin-bottom: 0; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6e485ebad1b..5832cf4637f 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -150,18 +150,6 @@ display: block; } - .mr-widget-body { - @include clearfix; - - &.media > *:first-child { - margin-right: 10px; - } - - .approve-btn { - margin-right: 5px; - } - } - .mr-widget-pipeline-graph { padding: 0 4px; @@ -169,9 +157,8 @@ z-index: 300; } - .ci-action-icon-wrapper svg { - width: 16px; - height: 16px; + .ci-action-icon-wrapper { + line-height: 16px; } } @@ -195,10 +182,6 @@ overflow: hidden; word-break: break-all; - &.media > *:first-child { - margin-right: 10px; - } - &.label-truncated { position: relative; display: inline-block; @@ -216,6 +199,18 @@ background-color: $gray-light; } } + } + + .mr-widget-body { + @include clearfix; + + &.media > *:first-child { + margin-right: 10px; + } + + .approve-btn { + margin-right: 5px; + } h4 { float: left; @@ -239,10 +234,6 @@ margin-right: 7px; } - .approve-btn { - margin-right: 5px; - } - label { font-weight: $gl-font-weight-normal; } @@ -342,17 +333,6 @@ } } - .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item { - display: flex; - align-items: center; - - .ci-status-text, - .ci-status-icon { - top: 0; - margin-right: 10px; - } - } - .mr-widget-help { padding: 10px 16px 10px 48px; font-style: italic; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 5127307c5e7..14514b2f193 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -16,7 +16,7 @@ .discussion { .new-note { margin: 0; - border: none; + border: 0; } } @@ -106,15 +106,35 @@ background-color: $orange-100; border-radius: $border-radius-default $border-radius-default 0 0; border: 1px solid $border-gray-normal; - border-bottom: none; + border-bottom: 0; padding: 3px 12px; margin: auto; align-items: center; + .icon { + margin-right: $issuable-warning-icon-margin; + } + + .md-area { border-top-left-radius: 0; border-top-right-radius: 0; } + + .disabled-comment { + border: 0; + border-radius: $label-border-radius; + padding-top: $gl-vert-padding; + padding-bottom: $gl-vert-padding; + + .icon svg { + position: relative; + top: 2px; + margin-right: $btn-xs-side-margin; + width: $gl-font-size; + height: $gl-font-size; + fill: $orange-600; + } + } } .sidebar-item-value { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index ca363c6eac4..9537eeeee97 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -331,7 +331,7 @@ ul.notes { td { border: 1px solid $white-normal; - border-left: none; + border-left: 0; &.notes_line { vertical-align: middle; @@ -476,6 +476,10 @@ ul.notes { float: none; margin-left: 0; } + + .btn-group > .discussion-next-btn { + margin-left: -1px; + } } .note-actions { @@ -666,7 +670,7 @@ ul.notes { .timeline-entry-inner { padding-left: $gl-padding; padding-right: $gl-padding; - border-bottom: none; + border-bottom: 0; } } } @@ -679,7 +683,7 @@ ul.notes { padding: 90px 0; &.discussion-locked { - border: none; + border: 0; background-color: $white-light; } @@ -759,7 +763,7 @@ ul.notes { top: 0; padding: 0; background-color: transparent; - border: none; + border: 0; outline: 0; color: $gray-darkest; transition: color $general-hover-transition-duration $general-hover-transition-curve; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 2a8cbc61af7..cb24274c612 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -179,7 +179,7 @@ * Play button with icon in dropdowns */ .no-btn { - border: none; + border: 0; background: none; outline: none; width: 100%; @@ -288,7 +288,7 @@ .pipeline-actions { @include new-style-dropdown; - border-bottom: none; + border-bottom: 0; } .tab-pane { @@ -318,7 +318,7 @@ } .build-log { - border: none; + border: 0; line-height: initial; } } @@ -386,13 +386,13 @@ // Remove right connecting horizontal line from first build in last stage &:first-child { &::after { - border: none; + border: 0; } } // Remove right curved connectors from all builds in last stage &:not(:first-child) { &::after { - border: none; + border: 0; } } // Remove opposite curve @@ -409,7 +409,7 @@ // Remove left curved connectors from all builds in first stage &:not(:first-child) { &::before { - border: none; + border: 0; } } // Remove opposite curve @@ -518,7 +518,7 @@ .dropdown-menu-toggle { background-color: transparent; - border: none; + border: 0; padding: 0; &:focus { @@ -823,6 +823,11 @@ button.mini-pipeline-graph-dropdown-toggle { margin-left: 2px; display: inline-block; + &::after { + content: ''; + display: block; + } + @media (max-width: $screen-xs-max) { max-width: 60%; } @@ -951,7 +956,7 @@ button.mini-pipeline-graph-dropdown-toggle { .terminal-container { .content-block { - border-bottom: none; + border-bottom: 0; } #terminal { diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index eab39f698c3..28dc71dc641 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -113,7 +113,7 @@ li { padding: 3px 0; - border: none; + border: 0; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b0c3474e3d5..aaad6dbba8e 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -80,7 +80,7 @@ .project-feature-settings { background: $gray-lighter; - border-top: none; + border-top: 0; margin-bottom: 16px; } @@ -128,7 +128,7 @@ .project-feature-toggle { position: relative; - border: none; + border: 0; outline: 0; display: block; width: 100px; @@ -483,7 +483,7 @@ a.deploy-project-label { flex: 1; padding: 0; background: transparent; - border: none; + border: 0; line-height: 34px; margin: 0; @@ -1012,7 +1012,7 @@ pre.light-well { margin: 0; border-radius: 0 0 1px 1px; padding: 20px 0; - border: none; + border: 0; } .table-bordered { @@ -1165,7 +1165,7 @@ pre.light-well { table-layout: fixed; &.table-responsive { - border: none; + border: 0; } .variable-key { diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 1bb4e3cc345..fee4638e20f 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -64,7 +64,7 @@ .monaco-editor.vs { .current-line { - border: none; + border: 0; background: $well-light-border; } @@ -139,7 +139,7 @@ &.active { background: $white-light; - border-bottom: none; + border-bottom: 0; } a { @@ -181,7 +181,7 @@ &.tabs-divider { width: 100%; background-color: $white-light; - border-right: none; + border-right: 0; border-top-right-radius: 2px; } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index eed711b1b66..fe455a04960 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -5,7 +5,7 @@ margin-bottom: $gl-padding; &:last-child { - border-bottom: none; + border-bottom: 0; } } @@ -57,7 +57,7 @@ input[type="checkbox"]:hover { } .search-input { - border: none; + border: 0; font-size: 14px; padding: 0 20px 0 0; margin-left: 5px; @@ -78,10 +78,6 @@ input[type="checkbox"]:hover { } .search-input-wrap { - // Fallback if flexbox is not supported - display: inline-block; - width: 100%; - .search-icon, .clear-icon { position: absolute; diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 6c8d87185e9..2139a029fc7 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -141,7 +141,7 @@ } pre { - border: none; + border: 0; background: $gray-light; border-radius: 0; color: $todo-body-pre-color; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index e2f6e511c86..50f0ef4414a 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -252,7 +252,7 @@ margin-top: 20px; padding: 0; border-top: 1px solid $white-dark; - border-bottom: none; + border-bottom: 0; } .commit-stats li { diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index 57b45f335fa..3c64fd964ff 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -39,7 +39,7 @@ module NotesActions @note = Notes::CreateService.new(note_project, current_user, create_params).execute if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) + Notes::RenderService.new(current_user).execute([@note], @project) end respond_to do |format| @@ -52,7 +52,7 @@ module NotesActions @note = Notes::UpdateService.new(project, current_user, note_params).execute(note) if @note.is_a?(Note) - Banzai::NoteRenderer.render([@note], @project, current_user) + Notes::RenderService.new(current_user).execute([@note], @project) end respond_to do |format| diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index 4791bc561a4..824ad06465c 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -3,7 +3,7 @@ module RendersNotes preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) preload_first_time_contribution_for_authors(noteable, notes) - Banzai::NoteRenderer.render(notes, @project, current_user) + Notes::RenderService.new(current_user).execute(notes, @project) notes end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index cd94a36a6e7..d9884a47ec4 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -57,5 +57,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @events = EventCollection .new(projects, offset: params[:offset].to_i, filter: event_filter) .to_a + + Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 19a5db6fd17..280ed93faf8 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -32,6 +32,8 @@ class DashboardController < Dashboard::ApplicationController @events = EventCollection .new(projects, offset: params[:offset].to_i, filter: @event_filter) .to_a + + Events::RenderService.new(current_user).execute(@events) end def set_show_full_reference diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index bc3e95f1aed..eb53a522f90 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -155,6 +155,8 @@ class GroupsController < Groups::ApplicationController @events = EventCollection .new(@projects, offset: params[:offset].to_i, filter: event_filter) .to_a + + Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) end def user_actions diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 37587a52eaf..d81ad135198 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -3,10 +3,16 @@ class MetricsController < ActionController::Base protect_from_forgery with: :exception - before_action :validate_prometheus_metrics - def index - render text: metrics_service.metrics_text, content_type: 'text/plain; version=0.0.4' + response = if Gitlab::Metrics.prometheus_metrics_enabled? + metrics_service.metrics_text + else + help_page = help_page_url('administration/monitoring/prometheus/gitlab_metrics', + anchor: 'gitlab-prometheus-metrics' + ) + "# Metrics are disabled, see: #{help_page}\n" + end + render text: response, content_type: 'text/plain; version=0.0.4' end private @@ -14,8 +20,4 @@ class MetricsController < ActionController::Base def metrics_service @metrics_service ||= MetricsService.new end - - def validate_prometheus_metrics - render_404 unless Gitlab::Metrics.prometheus_metrics_enabled? - end end diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 03019b0becc..c1692ea2569 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -27,11 +27,13 @@ class Projects::ClustersController < Projects::ApplicationController end def new - @cluster = project.build_cluster + @cluster = Clusters::Cluster.new.tap do |cluster| + cluster.build_provider_gcp + end end def create - @cluster = Ci::CreateClusterService + @cluster = Clusters::CreateService .new(project, current_user, create_params) .execute(token_in_session) @@ -58,7 +60,7 @@ class Projects::ClustersController < Projects::ApplicationController end def update - Ci::UpdateClusterService + Clusters::UpdateService .new(project, current_user, update_params) .execute(cluster) @@ -88,19 +90,19 @@ class Projects::ClustersController < Projects::ApplicationController def create_params params.require(:cluster).permit( - :gcp_project_id, - :gcp_cluster_zone, - :gcp_cluster_name, - :gcp_cluster_size, - :gcp_machine_type, - :project_namespace, - :enabled) + :enabled, + :name, + :provider_type, + provider_gcp_attributes: [ + :gcp_project_id, + :zone, + :num_nodes, + :machine_type + ]) end def update_params - params.require(:cluster).permit( - :project_namespace, - :enabled) + params.require(:cluster).permit(:enabled) end def authorize_google_api diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 0e71977a58a..1269759fc2b 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -2,7 +2,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont before_action :check_merge_requests_available! before_action :merge_request before_action :authorize_read_merge_request! - before_action :ensure_ref_fetched private @@ -10,12 +9,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end - # Make sure merge requests created before 8.0 - # have head file in refs/merge-requests/ - def ensure_ref_fetched - @merge_request.ensure_ref_fetched if Gitlab::Database.read_write? - end - def merge_request_params params.require(:merge_request).permit(merge_request_params_attributes) end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 99dc3dda9e7..129682f64aa 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -4,7 +4,6 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap include RendersCommits skip_before_action :merge_request - skip_before_action :ensure_ref_fetched before_action :authorize_create_merge_request! before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] before_action :build_merge_request, except: [:create] diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 17cac69e588..c86acae8fe4 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -7,7 +7,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include IssuableCollections skip_before_action :merge_request, only: [:index, :bulk_update] - skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update] before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] @@ -52,7 +51,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def show validates_merge_request - ensure_ref_fetched close_merge_request_without_source_project check_if_can_be_merged diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index db543d688a0..1688121e27e 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -300,6 +300,8 @@ class ProjectsController < Projects::ApplicationController @events = EventCollection .new(projects, offset: params[:offset].to_i, filter: event_filter) .to_a + + Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) end def project_params diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4ee855806ab..5fca31b4956 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -108,6 +108,8 @@ class UsersController < ApplicationController .references(:project) .with_associations .limit_recent(20, params[:offset]) + + Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) end def load_projects diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index fd88e0d794a..079b3cd3aa0 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -172,16 +172,6 @@ module EventsHelper end end - def event_note(text, options = {}) - text = first_line_in_markdown(text, 150, options) - - sanitize( - text, - tags: %w(a img gl-emoji b pre code p span), - attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version'] - ) - end - def event_commit_title(message) message ||= '' (message.split("\n").first || "").truncate(70) diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index 420622399f3..2c85d7d7720 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -69,10 +69,16 @@ module MarkupHelper # as Markdown. HTML tags in the parsed output are not counted toward the # +max_chars+ limit. If the length limit falls within a tag's contents, then # the tag contents are truncated without removing the closing tag. - def first_line_in_markdown(text, max_chars = nil, options = {}) - md = markdown(text, options).strip + def first_line_in_markdown(object, attribute, max_chars = nil, options = {}) + md = markdown_field(object, attribute, options) - truncate_visible(md, max_chars || md.length) if md.present? + text = truncate_visible(md, max_chars || md.length) if md.present? + + sanitize( + text, + tags: %w(a img gl-emoji b pre code p span), + attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version'] + ) end def markdown(text, context = {}) @@ -83,15 +89,17 @@ module MarkupHelper prepare_for_rendering(html, context) end - def markdown_field(object, field) + def markdown_field(object, field, context = {}) object = object.for_display if object.respond_to?(:for_display) redacted_field_html = object.try(:"redacted_#{field}_html") return '' unless object.present? return redacted_field_html if redacted_field_html - html = Banzai.render_field(object, field) - prepare_for_rendering(html, object.banzai_render_context(field)) + html = Banzai.render_field(object, field, context) + context.reverse_merge!(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context) + + prepare_for_rendering(html, context) end def markup(file_name, text, context = {}) diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb new file mode 100644 index 00000000000..955dba51745 --- /dev/null +++ b/app/models/clusters/cluster.rb @@ -0,0 +1,73 @@ +module Clusters + class Cluster < ActiveRecord::Base + include Presentable + + self.table_name = 'clusters' + + belongs_to :user + + has_many :cluster_projects, class_name: 'Clusters::Project' + has_many :projects, through: :cluster_projects, class_name: '::Project' + + # we force autosave to happen when we save `Cluster` model + has_one :provider_gcp, class_name: 'Clusters::Providers::Gcp', autosave: true + + # We have to ":destroy" it today to ensure that we clean also the Kubernetes Integration + has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + + accepts_nested_attributes_for :provider_gcp, update_only: true + accepts_nested_attributes_for :platform_kubernetes, update_only: true + + validates :name, cluster_name: true + validate :restrict_modification, on: :update + + # TODO: Move back this into Clusters::Platforms::Kubernetes in 10.3 + # We need callback here because `enabled` belongs to Clusters::Cluster + # Callbacks in Clusters::Platforms::Kubernetes will not be called after update + after_save :update_kubernetes_integration! + + delegate :status, to: :provider, allow_nil: true + delegate :status_reason, to: :provider, allow_nil: true + delegate :status_name, to: :provider, allow_nil: true + delegate :on_creation?, to: :provider, allow_nil: true + delegate :update_kubernetes_integration!, to: :platform, allow_nil: true + + enum platform_type: { + kubernetes: 1 + } + + enum provider_type: { + user: 0, + gcp: 1 + } + + scope :enabled, -> { where(enabled: true) } + scope :disabled, -> { where(enabled: false) } + + def provider + return provider_gcp if gcp? + end + + def platform + return platform_kubernetes if kubernetes? + end + + def first_project + return @first_project if defined?(@first_project) + + @first_project = projects.first + end + alias_method :project, :first_project + + private + + def restrict_modification + if provider&.on_creation? + errors.add(:base, "cannot modify during creation") + return false + end + + true + end + end +end diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb new file mode 100644 index 00000000000..b11701797c2 --- /dev/null +++ b/app/models/clusters/platforms/kubernetes.rb @@ -0,0 +1,101 @@ +module Clusters + module Platforms + class Kubernetes < ActiveRecord::Base + self.table_name = 'cluster_platforms_kubernetes' + + belongs_to :cluster, inverse_of: :platform_kubernetes, class_name: 'Clusters::Cluster' + + attr_encrypted :password, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + before_validation :enforce_namespace_to_lower_case + + validates :namespace, + allow_blank: true, + length: 1..63, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } + + # We expect to be `active?` only when enabled and cluster is created (the api_url is assigned) + validates :api_url, url: true, presence: true + validates :token, presence: true + + # TODO: Glue code till we migrate Kubernetes Integration into Platforms::Kubernetes + after_destroy :destroy_kubernetes_integration! + + alias_attribute :ca_pem, :ca_cert + + delegate :project, to: :cluster, allow_nil: true + delegate :enabled?, to: :cluster, allow_nil: true + + class << self + def namespace_for_project(project) + "#{project.path}-#{project.id}" + end + end + + def actual_namespace + if namespace.present? + namespace + else + default_namespace + end + end + + def default_namespace + self.class.namespace_for_project(project) if project + end + + def update_kubernetes_integration! + raise 'Kubernetes service already configured' unless manages_kubernetes_service? + + # This is neccesary, otheriwse enabled? returns true even though cluster updated with enabled: false + cluster.reload + + ensure_kubernetes_service&.update!( + active: enabled?, + api_url: api_url, + namespace: namespace, + token: token, + ca_pem: ca_cert + ) + end + + private + + def enforce_namespace_to_lower_case + self.namespace = self.namespace&.downcase + end + + # TODO: glue code till we migrate Kubernetes Service into Platforms::Kubernetes class + def manages_kubernetes_service? + return true unless kubernetes_service&.active? + + kubernetes_service.api_url == api_url + end + + def destroy_kubernetes_integration! + return unless manages_kubernetes_service? + + kubernetes_service&.destroy! + end + + def kubernetes_service + @kubernetes_service ||= project&.kubernetes_service + end + + def ensure_kubernetes_service + @kubernetes_service ||= kubernetes_service || project&.build_kubernetes_service + end + end + end +end diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb new file mode 100644 index 00000000000..eeb734b20b8 --- /dev/null +++ b/app/models/clusters/project.rb @@ -0,0 +1,8 @@ +module Clusters + class Project < ActiveRecord::Base + self.table_name = 'cluster_projects' + + belongs_to :cluster, class_name: 'Clusters::Cluster' + belongs_to :project, class_name: '::Project' + end +end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb new file mode 100644 index 00000000000..c4391729dd7 --- /dev/null +++ b/app/models/clusters/providers/gcp.rb @@ -0,0 +1,79 @@ +module Clusters + module Providers + class Gcp < ActiveRecord::Base + self.table_name = 'cluster_providers_gcp' + + belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster' + + default_value_for :zone, 'us-central1-a' + default_value_for :num_nodes, 3 + default_value_for :machine_type, 'n1-standard-4' + + attr_encrypted :access_token, + mode: :per_attribute_iv, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + validates :gcp_project_id, + length: 1..63, + format: { + with: Gitlab::Regex.kubernetes_namespace_regex, + message: Gitlab::Regex.kubernetes_namespace_regex_message + } + + validates :zone, presence: true + + validates :num_nodes, + presence: true, + numericality: { + only_integer: true, + greater_than: 0 + } + + state_machine :status, initial: :scheduled do + state :scheduled, value: 1 + state :creating, value: 2 + state :created, value: 3 + state :errored, value: 4 + + event :make_creating do + transition any - [:creating] => :creating + end + + event :make_created do + transition any - [:created] => :created + end + + event :make_errored do + transition any - [:errored] => :errored + end + + before_transition any => [:errored, :created] do |provider| + provider.access_token = nil + provider.operation_id = nil + end + + before_transition any => [:creating] do |provider, transition| + operation_id = transition.args.first + raise ArgumentError.new('operation_id is required') unless operation_id.present? + provider.operation_id = operation_id + end + + before_transition any => [:errored] do |provider, transition| + status_reason = transition.args.first + provider.status_reason = status_reason if status_reason + end + end + + def on_creation? + scheduled? || creating? + end + + def api_client + return unless access_token + + @api_client ||= GoogleApi::CloudPlatform::Client.new(access_token, nil) + end + end + end +end diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb index eb9f3423e48..03793e8bcbb 100644 --- a/app/models/concerns/ignorable_column.rb +++ b/app/models/concerns/ignorable_column.rb @@ -21,8 +21,8 @@ module IgnorableColumn @ignored_columns ||= Set.new end - def ignore_column(name) - ignored_columns << name.to_s + def ignore_column(*names) + ignored_columns.merge(names.map(&:to_s)) end end end diff --git a/app/models/gcp/cluster.rb b/app/models/gcp/cluster.rb deleted file mode 100644 index f7842376e2e..00000000000 --- a/app/models/gcp/cluster.rb +++ /dev/null @@ -1,116 +0,0 @@ -module Gcp - class Cluster < ActiveRecord::Base - extend Gitlab::Gcp::Model - include Presentable - - belongs_to :project, inverse_of: :cluster - belongs_to :user - belongs_to :service - - scope :enabled, -> { where(enabled: true) } - scope :disabled, -> { where(enabled: false) } - - default_value_for :gcp_cluster_zone, 'us-central1-a' - default_value_for :gcp_cluster_size, 3 - default_value_for :gcp_machine_type, 'n1-standard-2' - - attr_encrypted :password, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - attr_encrypted :kubernetes_token, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - attr_encrypted :gcp_token, - mode: :per_attribute_iv, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - validates :gcp_project_id, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - validates :gcp_cluster_name, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - validates :gcp_cluster_zone, presence: true - - validates :gcp_cluster_size, - presence: true, - numericality: { - only_integer: true, - greater_than: 0 - } - - validates :project_namespace, - allow_blank: true, - length: 1..63, - format: { - with: Gitlab::Regex.kubernetes_namespace_regex, - message: Gitlab::Regex.kubernetes_namespace_regex_message - } - - # if we do not do status transition we prevent change - validate :restrict_modification, on: :update, unless: :status_changed? - - state_machine :status, initial: :scheduled do - state :scheduled, value: 1 - state :creating, value: 2 - state :created, value: 3 - state :errored, value: 4 - - event :make_creating do - transition any - [:creating] => :creating - end - - event :make_created do - transition any - [:created] => :created - end - - event :make_errored do - transition any - [:errored] => :errored - end - - before_transition any => [:errored, :created] do |cluster| - cluster.gcp_token = nil - cluster.gcp_operation_id = nil - end - - before_transition any => [:errored] do |cluster, transition| - status_reason = transition.args.first - cluster.status_reason = status_reason if status_reason - end - end - - def project_namespace_placeholder - "#{project.path}-#{project.id}" - end - - def on_creation? - scheduled? || creating? - end - - def api_url - 'https://' + endpoint if endpoint - end - - def restrict_modification - if on_creation? - errors.add(:base, "cannot modify during creation") - return false - end - - true - end - end -end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 3133dc9e7eb..f80601f3484 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -8,7 +8,8 @@ class MergeRequest < ActiveRecord::Base include CreatedAtFilterable include TimeTrackable - ignore_column :locked_at + ignore_column :locked_at, + :ref_fetched belongs_to :target_project, class_name: "Project" belongs_to :source_project, class_name: "Project" @@ -426,7 +427,7 @@ class MergeRequest < ActiveRecord::Base end def create_merge_request_diff - fetch_ref + fetch_ref! # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37435 Gitlab::GitalyClient.allow_n_plus_1_calls do @@ -811,29 +812,14 @@ class MergeRequest < ActiveRecord::Base end end - def fetch_ref - write_ref - update_column(:ref_fetched, true) + def fetch_ref! + target_project.repository.fetch_source_branch!(source_project.repository, source_branch, ref_path) end def ref_path "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end - def ref_fetched? - super || - begin - computed_value = project.repository.ref_exists?(ref_path) - update_column(:ref_fetched, true) if computed_value - - computed_value - end - end - - def ensure_ref_fetched - fetch_ref unless ref_fetched? - end - def in_locked_state begin lock_mr @@ -975,10 +961,4 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end - - private - - def write_ref - target_project.repository.fetch_source_branch(source_project.repository, source_branch, ref_path) - end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 0601a61a926..4d401e7ba18 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -36,7 +36,7 @@ class Namespace < ActiveRecord::Base validates :path, presence: true, length: { maximum: 255 }, - dynamic_path: true + namespace_path: true validate :nesting_level_allowed diff --git a/app/models/project.rb b/app/models/project.rb index 3f810ee977b..d8607d04a98 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -186,7 +186,9 @@ class Project < ActiveRecord::Base has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true has_one :project_feature, inverse_of: :project has_one :statistics, class_name: 'ProjectStatistics' - has_one :cluster, class_name: 'Gcp::Cluster', inverse_of: :project + + has_one :cluster_project, class_name: 'Clusters::Project' + has_one :cluster, through: :cluster_project, class_name: 'Clusters::Cluster' # Container repositories need to remove data from the container registry, # which is not managed by the DB. Hence we're still using dependent: :destroy @@ -240,10 +242,8 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_name_regex_message } validates :path, presence: true, - dynamic_path: true, + project_path: true, length: { maximum: 255 }, - format: { with: Gitlab::PathRegex.project_path_format_regex, - message: Gitlab::PathRegex.project_path_format_message }, uniqueness: { scope: :namespace_id } validates :namespace, presence: true diff --git a/app/models/repository.rb b/app/models/repository.rb index 69cddb36b2e..ef715d982ae 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -969,8 +969,8 @@ class Repository gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags) end - def fetch_source_branch(source_repository, source_branch, local_ref) - raw_repository.fetch_source_branch(source_repository.raw_repository, source_branch, local_ref) + def fetch_source_branch!(source_repository, source_branch, local_ref) + raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref) end def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:) diff --git a/app/models/user.rb b/app/models/user.rb index bcda4564595..aa88cda4dc0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -146,7 +146,7 @@ class User < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } validates :username, - dynamic_path: true, + user_path: true, presence: true, uniqueness: { case_sensitive: false } @@ -164,7 +164,7 @@ class User < ActiveRecord::Base before_validation :set_notification_email, if: :email_changed? before_validation :set_public_email, if: :public_email_changed? before_save :ensure_incoming_email_token - before_save :ensure_user_rights_and_limits, if: :external_changed? + before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? } before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) } before_save :check_for_verified_email, if: ->(user) { user.email_changed? && !user.new_record? } after_save :ensure_namespace_correct @@ -1139,8 +1139,9 @@ class User < ActiveRecord::Base self.can_create_group = false self.projects_limit = 0 else - self.can_create_group = gitlab_config.default_can_create_group - self.projects_limit = current_application_settings.default_projects_limit + # Only revert these back to the default if they weren't specifically changed in this update. + self.can_create_group = gitlab_config.default_can_create_group unless can_create_group_changed? + self.projects_limit = current_application_settings.default_projects_limit unless projects_limit_changed? end end diff --git a/app/policies/gcp/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb index e77173ea6e1..1f7c13072b9 100644 --- a/app/policies/gcp/cluster_policy.rb +++ b/app/policies/clusters/cluster_policy.rb @@ -1,8 +1,8 @@ -module Gcp +module Clusters class ClusterPolicy < BasePolicy alias_method :cluster, :subject - delegate { @subject.project } + delegate { cluster.project } rule { can?(:master_access) }.policy do enable :update_cluster diff --git a/app/presenters/gcp/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index f7908f92a37..01cb59d0d44 100644 --- a/app/presenters/gcp/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -1,9 +1,9 @@ -module Gcp +module Clusters class ClusterPresenter < Gitlab::View::Presenter::Delegated presents :cluster def gke_cluster_url - "https://console.cloud.google.com/kubernetes/clusters/details/#{gcp_cluster_zone}/#{gcp_cluster_name}" + "https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp? end end end diff --git a/app/services/base_renderer.rb b/app/services/base_renderer.rb new file mode 100644 index 00000000000..d6e30bd7008 --- /dev/null +++ b/app/services/base_renderer.rb @@ -0,0 +1,7 @@ +class BaseRenderer + attr_reader :current_user + + def initialize(current_user = nil) + @current_user = current_user + end +end diff --git a/app/services/ci/create_cluster_service.rb b/app/services/ci/create_cluster_service.rb deleted file mode 100644 index f7ee0e468e2..00000000000 --- a/app/services/ci/create_cluster_service.rb +++ /dev/null @@ -1,15 +0,0 @@ -module Ci - class CreateClusterService < BaseService - def execute(access_token) - params['gcp_machine_type'] ||= GoogleApi::CloudPlatform::Client::DEFAULT_MACHINE_TYPE - - cluster_params = - params.merge(user: current_user, - gcp_token: access_token) - - project.create_cluster(cluster_params).tap do |cluster| - ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? - end - end - end -end diff --git a/app/services/ci/fetch_gcp_operation_service.rb b/app/services/ci/fetch_gcp_operation_service.rb deleted file mode 100644 index 0b68e4d6ea9..00000000000 --- a/app/services/ci/fetch_gcp_operation_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -module Ci - class FetchGcpOperationService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - operation = api_client.projects_zones_operations( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_operation_id) - - yield(operation) if block_given? - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - end -end diff --git a/app/services/ci/finalize_cluster_creation_service.rb b/app/services/ci/finalize_cluster_creation_service.rb deleted file mode 100644 index 347875c5697..00000000000 --- a/app/services/ci/finalize_cluster_creation_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -module Ci - class FinalizeClusterCreationService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - begin - gke_cluster = api_client.projects_zones_clusters_get( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_cluster_name) - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - - endpoint = gke_cluster.endpoint - api_url = 'https://' + endpoint - ca_cert = Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate) - username = gke_cluster.master_auth.username - password = gke_cluster.master_auth.password - - kubernetes_token = Ci::FetchKubernetesTokenService.new( - api_url, ca_cert, username, password).execute - - unless kubernetes_token - return cluster.make_errored!('Failed to get a default token of kubernetes') - end - - Ci::IntegrateClusterService.new.execute( - cluster, endpoint, ca_cert, kubernetes_token, username, password) - end - end -end diff --git a/app/services/ci/integrate_cluster_service.rb b/app/services/ci/integrate_cluster_service.rb deleted file mode 100644 index d123ce8d26b..00000000000 --- a/app/services/ci/integrate_cluster_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Ci - class IntegrateClusterService - def execute(cluster, endpoint, ca_cert, token, username, password) - Gcp::Cluster.transaction do - cluster.update!( - enabled: true, - endpoint: endpoint, - ca_cert: ca_cert, - kubernetes_token: token, - username: username, - password: password, - service: cluster.project.find_or_initialize_service('kubernetes'), - status_event: :make_created) - - cluster.service.update!( - active: true, - api_url: cluster.api_url, - ca_pem: ca_cert, - namespace: cluster.project_namespace, - token: token) - end - rescue ActiveRecord::RecordInvalid => e - cluster.make_errored!("Failed to integrate cluster into kubernetes_service: #{e.message}") - end - end -end diff --git a/app/services/ci/provision_cluster_service.rb b/app/services/ci/provision_cluster_service.rb deleted file mode 100644 index 52d80b01813..00000000000 --- a/app/services/ci/provision_cluster_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Ci - class ProvisionClusterService - def execute(cluster) - api_client = - GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil) - - begin - operation = api_client.projects_zones_clusters_create( - cluster.gcp_project_id, - cluster.gcp_cluster_zone, - cluster.gcp_cluster_name, - cluster.gcp_cluster_size, - machine_type: cluster.gcp_machine_type) - rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e - return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}") - end - - unless operation.status == 'RUNNING' || operation.status == 'PENDING' - return cluster.make_errored!("Operation status is unexpected; #{operation.status_message}") - end - - cluster.gcp_operation_id = api_client.parse_operation_id(operation.self_link) - - unless cluster.gcp_operation_id - return cluster.make_errored!('Can not find operation_id from self_link') - end - - if cluster.make_creating - WaitForClusterCreationWorker.perform_in( - WaitForClusterCreationWorker::INITIAL_INTERVAL, cluster.id) - else - return cluster.make_errored!("Failed to update cluster record; #{cluster.errors}") - end - end - end -end diff --git a/app/services/ci/update_cluster_service.rb b/app/services/ci/update_cluster_service.rb deleted file mode 100644 index 70d88fca660..00000000000 --- a/app/services/ci/update_cluster_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -module Ci - class UpdateClusterService < BaseService - def execute(cluster) - Gcp::Cluster.transaction do - cluster.update!(params) - - if params['enabled'] == 'true' - cluster.service.update!( - active: true, - api_url: cluster.api_url, - ca_pem: cluster.ca_cert, - namespace: cluster.project_namespace, - token: cluster.kubernetes_token) - else - cluster.service.update!(active: false) - end - end - rescue ActiveRecord::RecordInvalid => e - cluster.errors.add(:base, e.message) - end - end -end diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb new file mode 100644 index 00000000000..1d407739b21 --- /dev/null +++ b/app/services/clusters/create_service.rb @@ -0,0 +1,29 @@ +module Clusters + class CreateService < BaseService + attr_reader :access_token + + def execute(access_token) + @access_token = access_token + + create_cluster.tap do |cluster| + ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted? + end + end + + private + + def create_cluster + Clusters::Cluster.create(cluster_params) + end + + def cluster_params + return @cluster_params if defined?(@cluster_params) + + params[:provider_gcp_attributes].try do |provider| + provider[:access_token] = access_token + end + + @cluster_params = params.merge(user: current_user, projects: [project]) + end + end +end diff --git a/app/services/clusters/gcp/fetch_operation_service.rb b/app/services/clusters/gcp/fetch_operation_service.rb new file mode 100644 index 00000000000..a4cd3ca5c11 --- /dev/null +++ b/app/services/clusters/gcp/fetch_operation_service.rb @@ -0,0 +1,16 @@ +module Clusters + module Gcp + class FetchOperationService + def execute(provider) + operation = provider.api_client.projects_zones_operations( + provider.gcp_project_id, + provider.zone, + provider.operation_id) + + yield(operation) if block_given? + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + end + end + end +end diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb new file mode 100644 index 00000000000..cea56f4e849 --- /dev/null +++ b/app/services/clusters/gcp/finalize_creation_service.rb @@ -0,0 +1,56 @@ +module Clusters + module Gcp + class FinalizeCreationService + attr_reader :provider + + def execute(provider) + @provider = provider + + configure_provider + configure_kubernetes + + cluster.save! + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + rescue ActiveRecord::RecordInvalid => e + provider.make_errored!("Failed to configure GKE Cluster: #{e.message}") + end + + private + + def configure_provider + provider.endpoint = gke_cluster.endpoint + provider.status_event = :make_created + end + + def configure_kubernetes + cluster.platform_type = :kubernetes + cluster.build_platform_kubernetes( + api_url: 'https://' + gke_cluster.endpoint, + ca_cert: Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), + username: gke_cluster.master_auth.username, + password: gke_cluster.master_auth.password, + token: request_kuberenetes_token) + end + + def request_kuberenetes_token + Ci::FetchKubernetesTokenService.new( + 'https://' + gke_cluster.endpoint, + Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate), + gke_cluster.master_auth.username, + gke_cluster.master_auth.password).execute + end + + def gke_cluster + @gke_cluster ||= provider.api_client.projects_zones_clusters_get( + provider.gcp_project_id, + provider.zone, + cluster.name) + end + + def cluster + @cluster ||= provider.cluster + end + end + end +end diff --git a/app/services/clusters/gcp/provision_service.rb b/app/services/clusters/gcp/provision_service.rb new file mode 100644 index 00000000000..8beea5a8cfb --- /dev/null +++ b/app/services/clusters/gcp/provision_service.rb @@ -0,0 +1,47 @@ +module Clusters + module Gcp + class ProvisionService + attr_reader :provider + + def execute(provider) + @provider = provider + + get_operation_id do |operation_id| + if provider.make_creating(operation_id) + WaitForClusterCreationWorker.perform_in( + Clusters::Gcp::VerifyProvisionStatusService::INITIAL_INTERVAL, + provider.cluster_id) + else + provider.make_errored!("Failed to update provider record; #{provider.errors}") + end + end + end + + private + + def get_operation_id + operation = provider.api_client.projects_zones_clusters_create( + provider.gcp_project_id, + provider.zone, + provider.cluster.name, + provider.num_nodes, + machine_type: provider.machine_type) + + unless operation.status == 'PENDING' || operation.status == 'RUNNING' + return provider.make_errored!("Operation status is unexpected; #{operation.status_message}") + end + + operation_id = provider.api_client.parse_operation_id(operation.self_link) + + unless operation_id + return provider.make_errored!('Can not find operation_id from self_link') + end + + yield(operation_id) + + rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e + provider.make_errored!("Failed to request to CloudPlatform; #{e.message}") + end + end + end +end diff --git a/app/services/clusters/gcp/verify_provision_status_service.rb b/app/services/clusters/gcp/verify_provision_status_service.rb new file mode 100644 index 00000000000..bc33756f27c --- /dev/null +++ b/app/services/clusters/gcp/verify_provision_status_service.rb @@ -0,0 +1,48 @@ +module Clusters + module Gcp + class VerifyProvisionStatusService + attr_reader :provider + + INITIAL_INTERVAL = 2.minutes + EAGER_INTERVAL = 10.seconds + TIMEOUT = 20.minutes + + def execute(provider) + @provider = provider + + request_operation do |operation| + case operation.status + when 'PENDING', 'RUNNING' + continue_creation(operation) + when 'DONE' + finalize_creation + else + return provider.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") + end + end + end + + private + + def continue_creation(operation) + if elapsed_time_from_creation(operation) < TIMEOUT + WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, provider.cluster_id) + else + provider.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}") + end + end + + def elapsed_time_from_creation(operation) + Time.now.utc - operation.start_time.to_time.utc + end + + def finalize_creation + Clusters::Gcp::FinalizeCreationService.new.execute(provider) + end + + def request_operation(&blk) + Clusters::Gcp::FetchOperationService.new.execute(provider, &blk) + end + end + end +end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb new file mode 100644 index 00000000000..989218e32a2 --- /dev/null +++ b/app/services/clusters/update_service.rb @@ -0,0 +1,7 @@ +module Clusters + class UpdateService < BaseService + def execute(cluster) + cluster.update(params) + end + end +end diff --git a/app/services/events/render_service.rb b/app/services/events/render_service.rb new file mode 100644 index 00000000000..0b62d8aedf1 --- /dev/null +++ b/app/services/events/render_service.rb @@ -0,0 +1,21 @@ +module Events + class RenderService < BaseRenderer + def execute(events, atom_request: false) + events.map(&:note).compact.group_by(&:project).each do |project, notes| + render_notes(notes, project, atom_request) + end + end + + private + + def render_notes(notes, project, atom_request) + Notes::RenderService.new(current_user).execute(notes, project, render_options(atom_request)) + end + + def render_options(atom_request) + return {} unless atom_request + + { only_path: false, xhtml: true } + end + end +end diff --git a/app/services/notes/render_service.rb b/app/services/notes/render_service.rb new file mode 100644 index 00000000000..a77e98c2b07 --- /dev/null +++ b/app/services/notes/render_service.rb @@ -0,0 +1,21 @@ +module Notes + class RenderService < BaseRenderer + # Renders a collection of Note instances. + # + # notes - The notes to render. + # project - The project to use for redacting. + # user - The user viewing the notes. + + # Possible options: + # requested_path - The request path. + # project_wiki - The project's wiki. + # ref - The current Git reference. + # only_path - flag to turn relative paths into absolute ones. + # xhtml - flag to save the html in XHTML + def execute(notes, project, **opts) + renderer = Banzai::ObjectRenderer.new(project, current_user, **opts) + + renderer.render(notes, :note) + end + end +end diff --git a/app/validators/abstract_path_validator.rb b/app/validators/abstract_path_validator.rb new file mode 100644 index 00000000000..adbccb65a84 --- /dev/null +++ b/app/validators/abstract_path_validator.rb @@ -0,0 +1,38 @@ +class AbstractPathValidator < ActiveModel::EachValidator + extend Gitlab::EncodingHelper + + def self.path_regex + raise NotImplementedError + end + + def self.format_regex + raise NotImplementedError + end + + def self.format_error_message + raise NotImplementedError + end + + def self.full_path(record, value) + value + end + + def self.valid_path?(path) + encode!(path) + "#{path}/" =~ path_regex + end + + def validate_each(record, attribute, value) + unless value =~ self.class.format_regex + record.errors.add(attribute, self.class.format_error_message) + return + end + + full_path = self.class.full_path(record, value) + return unless full_path + + unless self.class.valid_path?(full_path) + record.errors.add(attribute, "#{value} is a reserved name") + end + end +end diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb new file mode 100644 index 00000000000..13ec342f399 --- /dev/null +++ b/app/validators/cluster_name_validator.rb @@ -0,0 +1,24 @@ +# ClusterNameValidator +# +# Custom validator for ClusterName. +class ClusterNameValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if record.user? + unless value.present? + record.errors.add(attribute, " has to be present") + end + elsif record.gcp? + if record.persisted? && record.name_changed? + record.errors.add(attribute, " can not be changed because it's synchronized with provider") + end + + unless value.length >= 1 && value.length <= 63 + record.errors.add(attribute, " is invalid syntax") + end + + unless value =~ Gitlab::Regex.kubernetes_namespace_regex + record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message) + end + end + end +end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb deleted file mode 100644 index 4688aabc2a8..00000000000 --- a/app/validators/dynamic_path_validator.rb +++ /dev/null @@ -1,53 +0,0 @@ -# DynamicPathValidator -# -# Custom validator for GitLab path values. -# These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` -# -# Values are checked for formatting and exclusion from a list of illegal path -# names. -class DynamicPathValidator < ActiveModel::EachValidator - extend Gitlab::EncodingHelper - - class << self - def valid_user_path?(path) - encode!(path) - "#{path}/" =~ Gitlab::PathRegex.root_namespace_path_regex - end - - def valid_group_path?(path) - encode!(path) - "#{path}/" =~ Gitlab::PathRegex.full_namespace_path_regex - end - - def valid_project_path?(path) - encode!(path) - "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex - end - end - - def path_valid_for_record?(record, value) - full_path = record.respond_to?(:build_full_path) ? record.build_full_path : value - - return true unless full_path - - case record - when Project - self.class.valid_project_path?(full_path) - when Group - self.class.valid_group_path?(full_path) - else # User or non-Group Namespace - self.class.valid_user_path?(full_path) - end - end - - def validate_each(record, attribute, value) - unless value =~ Gitlab::PathRegex.namespace_format_regex - record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message) - return - end - - unless path_valid_for_record?(record, value) - record.errors.add(attribute, "#{value} is a reserved name") - end - end -end diff --git a/app/validators/namespace_path_validator.rb b/app/validators/namespace_path_validator.rb new file mode 100644 index 00000000000..4a0aa64ae0c --- /dev/null +++ b/app/validators/namespace_path_validator.rb @@ -0,0 +1,19 @@ +class NamespacePathValidator < AbstractPathValidator + extend Gitlab::EncodingHelper + + def self.path_regex + Gitlab::PathRegex.full_namespace_path_regex + end + + def self.format_regex + Gitlab::PathRegex.namespace_format_regex + end + + def self.format_error_message + Gitlab::PathRegex.namespace_format_message + end + + def self.full_path(record, value) + record.build_full_path + end +end diff --git a/app/validators/project_path_validator.rb b/app/validators/project_path_validator.rb new file mode 100644 index 00000000000..829b596ad3c --- /dev/null +++ b/app/validators/project_path_validator.rb @@ -0,0 +1,19 @@ +class ProjectPathValidator < AbstractPathValidator + extend Gitlab::EncodingHelper + + def self.path_regex + Gitlab::PathRegex.full_project_path_regex + end + + def self.format_regex + Gitlab::PathRegex.project_path_format_regex + end + + def self.format_error_message + Gitlab::PathRegex.project_path_format_message + end + + def self.full_path(record, value) + record.build_full_path + end +end diff --git a/app/validators/user_path_validator.rb b/app/validators/user_path_validator.rb new file mode 100644 index 00000000000..adf02901802 --- /dev/null +++ b/app/validators/user_path_validator.rb @@ -0,0 +1,15 @@ +class UserPathValidator < AbstractPathValidator + extend Gitlab::EncodingHelper + + def self.path_regex + Gitlab::PathRegex.root_namespace_path_regex + end + + def self.format_regex + Gitlab::PathRegex.namespace_format_regex + end + + def self.format_error_message + Gitlab::PathRegex.namespace_format_message + end +end diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index 3ef8f2a3acb..f0cc4d7ee62 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -42,4 +42,4 @@ .panel.panel-default - %iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: none" } + %iframe{ src: sidekiq_path, width: '100%', height: 970, style: "border: 0" } diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 38fd053ae65..efe1fb99efc 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -36,7 +36,7 @@ .todo-body .todo-note .md - = event_note(todo.body, project: todo.project) + = first_line_in_markdown(todo, :body, 150, project: todo.project) - if todo.pending? .todo-actions diff --git a/app/views/events/_event_note.atom.haml b/app/views/events/_event_note.atom.haml index 6fa2f9bd4db..7e264eb5575 100644 --- a/app/views/events/_event_note.atom.haml +++ b/app/views/events/_event_note.atom.haml @@ -1,2 +1,2 @@ %div{ xmlns: "http://www.w3.org/1999/xhtml" } - = markdown(note.note, pipeline: :atom, project: note.project, author: note.author) + = markdown_field(note, :note) diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index df4b9562215..de6383e4097 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -10,7 +10,7 @@ .event-body .event-note .md - = event_note(event.target.note, project: event.project) + = first_line_in_markdown(event.target, :note, 150, project: event.project) - note = event.target - if note.attachment.url - if note.attachment.image? diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 29387d6627e..4c5cc249159 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -5,7 +5,7 @@ - if @group && @group.persisted? && @group.path - group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) } - if @project && @project.persisted? - - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project) } + - project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? } .search.search-form{ class: "#{'has-location-badge' if label.present?}" } = form_tag search_path, method: :get, class: 'navbar-form' do |f| .search-input-container diff --git a/app/views/projects/clusters/_form.html.haml b/app/views/projects/clusters/_form.html.haml index dc878fa188d..1f8ae463d0f 100644 --- a/app/views/projects/clusters/_form.html.haml +++ b/app/views/projects/clusters/_form.html.haml @@ -4,34 +4,32 @@ - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Read our %{link_to_help_page} on cluster integration.').html_safe % { link_to_help_page: link_to_help_page} - = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field| + = form_for @cluster, url: namespace_project_clusters_path(@project.namespace, @project, @cluster), as: :cluster do |field| + = field.hidden_field :provider_type, value: :gcp = form_errors(@cluster) .form-group - = field.label :gcp_cluster_name, s_('ClusterIntegration|Cluster name') - = field.text_field :gcp_cluster_name, class: 'form-control' + = field.label :name, s_('ClusterIntegration|Cluster name') + = field.text_field :name, class: 'form-control' - .form-group - = field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') - = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_project_id, class: 'form-control' - - .form-group - = field.label :gcp_cluster_zone, s_('ClusterIntegration|Zone') - = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_cluster_zone, class: 'form-control', placeholder: 'us-central1-a' + = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| + .form-group + = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') + = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :gcp_project_id, class: 'form-control' - .form-group - = field.label :gcp_cluster_size, s_('ClusterIntegration|Number of nodes') - = field.text_field :gcp_cluster_size, class: 'form-control', placeholder: '3' + .form-group + = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') + = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a' - .form-group - = field.label :gcp_machine_type, s_('ClusterIntegration|Machine type') - = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') - = field.text_field :gcp_machine_type, class: 'form-control', placeholder: 'n1-standard-2' + .form-group + = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') + = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3' .form-group - = field.label :project_namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = field.text_field :project_namespace, class: 'form-control', placeholder: @cluster.project_namespace_placeholder + = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') + = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') + = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-2' .form-group = field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save' diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index dbe6f8beb95..ebb9383ca12 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -33,7 +33,7 @@ - else = s_('ClusterIntegration|Cluster integration is disabled for this project.') - = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field| + = form_for @cluster, url: namespace_project_cluster_path(@project.namespace, @project, @cluster), as: :cluster do |field| = form_errors(@cluster) .form-group.append-bottom-20 %label.append-bottom-10 @@ -62,9 +62,9 @@ %label.append-bottom-10{ for: 'cluter-name' } = s_('ClusterIntegration|Cluster name') .input-group - %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true } + %input.form-control.cluster-name{ value: @cluster.name, disabled: true } %span.input-group-addon.clipboard-addon - = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name')) + = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy cluster name')) %section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) } .settings-header diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml index 83821326aec..1d6a0fa38ca 100644 --- a/app/views/projects/commit/_ajax_signature.html.haml +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -1,2 +1,2 @@ - if commit.has_signature? - %button{ class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } } + %a{ href: '#', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'auto top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index edff018ba6d..b6b7aae6f9a 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -24,5 +24,5 @@ = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') -%button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } +%a{ href: '#', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } = label diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index e1b4a49850a..4f78102be0c 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,3 +1,7 @@ +- can_create_merge_request = can?(current_user, :create_merge_request, @project) +- data_action = can_create_merge_request ? 'create-mr' : 'create-branch' +- value = can_create_merge_request ? 'Create a merge request' : 'Create a branch' + - if can?(current_user, :push_code, @project) .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } .btn-group.unavailable @@ -6,20 +10,21 @@ %span.text Checking branch availability… .btn-group.available.hide - %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: 'Create a merge request', data: { action: 'create-mr' } } + %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } } %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } = icon('caret-down') %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } - %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } - .menu-item - .icon-container - = icon('check') - .description - %strong Create a merge request - %span - Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. - %li.divider.droplab-item-ignore - %li{ role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } + - if can_create_merge_request + %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } + .menu-item + .icon-container + = icon('check') + .description + %strong Create a merge request + %span + Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. + %li.divider.droplab-item-ignore + %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } .menu-item .icon-container = icon('check') diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 1927216e191..467f19b4c56 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -7,7 +7,7 @@ - if protected_tag?(@project, tag) %span.label.label-success.prepend-left-4 - protected + = s_('TagsPage|protected') - if tag.message.present? @@ -18,7 +18,7 @@ = render 'projects/branches/commit', commit: commit, project: @project - else %p - Cant find HEAD commit for this tag + = s_("TagsPage|Can't find HEAD commit for this tag") - if release && release.description.present? .description.prepend-top-default .wiki @@ -28,9 +28,9 @@ = render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name] - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: "Edit release notes", data: { container: "body" } do + = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do = icon("pencil") - if can?(current_user, :admin_project, @project) - = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do + = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do = icon("trash-o") diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index 27d58d4c0e8..fd3b8c01b83 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,16 +1,16 @@ - @no_container = true - @sort ||= sort_value_recently_updated -- page_title "Tags" +- page_title _('TagsPage|Tags') - add_to_breadcrumbs("Repository", project_tree_path(@project)) .flex-list{ class: container_class } .top-area.adjust .nav-text.row-main-content - Tags give the ability to mark specific points in history as being important + = s_('TagsPage|Tags give the ability to mark specific points in history as being important') .nav-controls.row-fixed-content = form_tag(filter_tags_path, method: :get) do - = search_field_tag :search, params[:search], { placeholder: 'Filter by tag name', id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } + = search_field_tag :search, params[:search], { placeholder: s_('TagsPage|Filter by tag name'), id: 'tag-search', class: 'form-control search-text-input input-short', spellcheck: false } .dropdown %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown'} } @@ -19,13 +19,13 @@ = icon('chevron-down') %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header - Sort by + = s_('TagsPage|Sort by') - tags_sort_options_hash.each do |value, title| %li = link_to title, filter_tags_path(sort: value), class: ("is-active" if @sort == value) - if can?(current_user, :push_code, @project) = link_to new_project_tag_path(@project), class: 'btn btn-create new-tag-btn' do - New tag + = s_('TagsPage|New tag') .tags - if @tags.any? @@ -36,9 +36,9 @@ - else .nothing-here-block - Repository has no tags yet. + = s_('TagsPage|Repository has no tags yet.') %br %small - Use git tag command to add a new one: + = s_('TagsPage|Use git tag command to add a new one:') %br %span.monospace git tag -a v1.4 -m 'version 1.4' diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index 521b4d927bc..3e99e0e8234 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -1,4 +1,4 @@ -- page_title "New Tag" +- page_title s_('TagsPage|New Tag') - default_ref = params[:ref] || @project.default_branch - if @error @@ -7,7 +7,7 @@ = @error %h3.page-title - New Tag + = s_('TagsPage|New Tag') %hr = form_tag namespace_project_tags_path, method: :post, id: "new-tag-form", class: "form-horizontal common-note-form tag-form js-quick-submit js-requires-input" do @@ -23,20 +23,23 @@ = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide form-control js-branch-select', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do .text-left.dropdown-toggle-text= default_ref = render 'shared/ref_dropdown', dropdown_class: 'wide' - .help-block Existing branch name, tag, or commit SHA + .help-block + = s_('TagsPage|Existing branch name, tag, or commit SHA') .form-group = label_tag :message, nil, class: 'control-label' .col-sm-10 = text_area_tag :message, @message, required: false, tabindex: 3, class: 'form-control', rows: 5 - .help-block Optionally, add a message to the tag. + .help-block + = s_('TagsPage|Optionally, add a message to the tag.') %hr .form-group = label_tag :release_description, 'Release notes', class: 'control-label' .col-sm-10 = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here...", current_text: @release_description + = render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here...'), current_text: @release_description = render 'shared/notes/hints' - .help-block Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page. + .help-block + = s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.') .form-actions = button_tag 'Create tag', class: 'btn btn-create', tabindex: 3 = link_to 'Cancel', project_tags_path(@project), class: 'btn btn-cancel' diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index 43aa2b27af6..dfe2c37ed8e 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -1,7 +1,7 @@ - @no_container = true -- add_to_breadcrumbs "Tags", project_tags_path(@project) +- add_to_breadcrumbs s_('TagsPage|Tags'), project_tags_path(@project) - breadcrumb_title @tag.name -- page_title @tag.name, "Tags" +- page_title @tag.name, s_('TagsPage|Tags') %div{ class: container_class } .top-area.multi-line @@ -12,25 +12,25 @@ = @tag.name - if protected_tag?(@project, @tag) %span.label.label-success - protected + = s_('TagsPage|protected') - if @commit = render 'projects/branches/commit', commit: @commit, project: @project - else - Cant find HEAD commit for this tag + = s_("TagsPage|Can't find HEAD commit for this tag") .nav-controls.controls-flex - if can?(current_user, :push_code, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Edit release notes' do + = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do = icon("pencil") - = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse files' do + = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do = icon('files-o') - = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: 'Browse commits' do + = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do = icon('history') .btn-container.controls-item = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_project, @project) .btn-container.controls-item-full - = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do + = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do %i.fa.fa-trash-o - if @tag.message.present? @@ -43,4 +43,4 @@ .wiki = markdown_field(@release, :description) - else - This tag has no release notes. + = s_('TagsPage|This tag has no release notes.') diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 63300b58a25..b01f9708424 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -3,8 +3,10 @@ class ClusterProvisionWorker include ClusterQueue def perform(cluster_id) - Gcp::Cluster.find_by_id(cluster_id).try do |cluster| - Ci::ProvisionClusterService.new.execute(cluster) + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + cluster.provider.try do |provider| + Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? + end end end end diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 5aa3bbdaa9d..241ed3901dc 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -2,25 +2,10 @@ class WaitForClusterCreationWorker include Sidekiq::Worker include ClusterQueue - INITIAL_INTERVAL = 2.minutes - EAGER_INTERVAL = 10.seconds - TIMEOUT = 20.minutes - def perform(cluster_id) - Gcp::Cluster.find_by_id(cluster_id).try do |cluster| - Ci::FetchGcpOperationService.new.execute(cluster) do |operation| - case operation.status - when 'RUNNING' - if TIMEOUT < Time.now.utc - operation.start_time.to_time.utc - return cluster.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}") - end - - WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, cluster.id) - when 'DONE' - Ci::FinalizeClusterCreationService.new.execute(cluster) - else - return cluster.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}") - end + Clusters::Cluster.find_by_id(cluster_id).try do |cluster| + cluster.provider.try do |provider| + Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) if cluster.gcp? end end end diff --git a/changelogs/unreleased/20666-404-error-issue-assigned-with-issues-disabled.yml b/changelogs/unreleased/20666-404-error-issue-assigned-with-issues-disabled.yml new file mode 100644 index 00000000000..830a275bfd5 --- /dev/null +++ b/changelogs/unreleased/20666-404-error-issue-assigned-with-issues-disabled.yml @@ -0,0 +1,6 @@ +--- +title: Fixes 404 error to 'Issues assigned to me' and 'Issues I've created' when issues + are disabled +merge_request: 15021 +author: Jacopo Beschi @jacopo-beschi +type: fixed diff --git a/changelogs/unreleased/27375-dashboard-activity-performance.yml b/changelogs/unreleased/27375-dashboard-activity-performance.yml new file mode 100644 index 00000000000..87c6197a24d --- /dev/null +++ b/changelogs/unreleased/27375-dashboard-activity-performance.yml @@ -0,0 +1,5 @@ +--- +title: Improve DashboardController#activity.json performance +merge_request: 14985 +author: +type: performance diff --git a/changelogs/unreleased/34768-fix-issuable-header-wrapping.yml b/changelogs/unreleased/34768-fix-issuable-header-wrapping.yml new file mode 100644 index 00000000000..49195bd4168 --- /dev/null +++ b/changelogs/unreleased/34768-fix-issuable-header-wrapping.yml @@ -0,0 +1,5 @@ +--- +title: Fix problem with issuable header wrapping when content is too long +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/38247-hide-create-mr-button-in-issue-show.yml b/changelogs/unreleased/38247-hide-create-mr-button-in-issue-show.yml new file mode 100644 index 00000000000..57ddd8f8388 --- /dev/null +++ b/changelogs/unreleased/38247-hide-create-mr-button-in-issue-show.yml @@ -0,0 +1,5 @@ +--- +title: Remove create MR button from issues when MRs are disabled +merge_request: 15071 +author: George Andrinopoulos +type: fixed diff --git a/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml b/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml new file mode 100644 index 00000000000..c7e840f0723 --- /dev/null +++ b/changelogs/unreleased/38385-gpg-tooltips-not-working-in-safari.yml @@ -0,0 +1,5 @@ +--- +title: Fix GPG signature popup info in Safari and Firefox +merge_request: 15228 +author: +type: fixed diff --git a/changelogs/unreleased/38589-internationalize-tags-page.yml b/changelogs/unreleased/38589-internationalize-tags-page.yml new file mode 100644 index 00000000000..4af3da8c23c --- /dev/null +++ b/changelogs/unreleased/38589-internationalize-tags-page.yml @@ -0,0 +1,5 @@ +--- +title: Internationalized tags page +merge_request: 38589 +author: +type: other diff --git a/changelogs/unreleased/39668-tooltip-safari.yml b/changelogs/unreleased/39668-tooltip-safari.yml new file mode 100644 index 00000000000..5a0f677cf10 --- /dev/null +++ b/changelogs/unreleased/39668-tooltip-safari.yml @@ -0,0 +1,5 @@ +--- +title: Remove native title tooltip in pipeline jobs dropdown in Safari +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/39757-border-zero-of-scss-lint.yml b/changelogs/unreleased/39757-border-zero-of-scss-lint.yml new file mode 100644 index 00000000000..ef0ac6c7df9 --- /dev/null +++ b/changelogs/unreleased/39757-border-zero-of-scss-lint.yml @@ -0,0 +1,5 @@ +--- +title: Enable BorderZero rule in scss-lint +merge_request: 15168 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml b/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml new file mode 100644 index 00000000000..42bcf9b1edd --- /dev/null +++ b/changelogs/unreleased/dm-block-group-and-project-creation-when-external-by-default.yml @@ -0,0 +1,6 @@ +--- +title: Make sure group and project creation is blocked for new users that are external + by default +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dm-reallow-project-path-ending-in-period.yml b/changelogs/unreleased/dm-reallow-project-path-ending-in-period.yml new file mode 100644 index 00000000000..ad41d9b84c3 --- /dev/null +++ b/changelogs/unreleased/dm-reallow-project-path-ending-in-period.yml @@ -0,0 +1,5 @@ +--- +title: Reallow project paths ending in periods +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fix-md-form-tabs-double-click-toggle.yml b/changelogs/unreleased/fix-md-form-tabs-double-click-toggle.yml new file mode 100644 index 00000000000..0ec9bcbcde2 --- /dev/null +++ b/changelogs/unreleased/fix-md-form-tabs-double-click-toggle.yml @@ -0,0 +1,6 @@ +--- +title: Fix markdown form tabs toggling preview mode from double clicking write mode + button +merge_request: 15119 +author: +type: fixed diff --git a/changelogs/unreleased/jej-fs-prevent-push-when-missing-objects.yml b/changelogs/unreleased/jej-fs-prevent-push-when-missing-objects.yml new file mode 100644 index 00000000000..4eeedec2c99 --- /dev/null +++ b/changelogs/unreleased/jej-fs-prevent-push-when-missing-objects.yml @@ -0,0 +1,5 @@ +--- +title: Prevent git push when LFS objects are missing +merge_request: 13837 +author: +type: added diff --git a/changelogs/unreleased/multiple-query-prometheus-graphs.yml b/changelogs/unreleased/multiple-query-prometheus-graphs.yml new file mode 100644 index 00000000000..9d09166845e --- /dev/null +++ b/changelogs/unreleased/multiple-query-prometheus-graphs.yml @@ -0,0 +1,6 @@ +--- +title: Allow multiple queries in a single Prometheus graph to support additional environments + (Canary, Staging, et al.) +merge_request: 15201 +author: +type: added diff --git a/changelogs/unreleased/pawel-metrics-to-prometheus-33643.yml b/changelogs/unreleased/pawel-metrics-to-prometheus-33643.yml new file mode 100644 index 00000000000..abab2e55f90 --- /dev/null +++ b/changelogs/unreleased/pawel-metrics-to-prometheus-33643.yml @@ -0,0 +1,5 @@ +--- +title: Add Prometheus equivalent of all InfluxDB metrics +merge_request: 13891 +author: +type: changed diff --git a/changelogs/unreleased/pawel-show_empty_page_when_prometheus_metrics_are_disabled-35639.yml b/changelogs/unreleased/pawel-show_empty_page_when_prometheus_metrics_are_disabled-35639.yml new file mode 100644 index 00000000000..987f7286244 --- /dev/null +++ b/changelogs/unreleased/pawel-show_empty_page_when_prometheus_metrics_are_disabled-35639.yml @@ -0,0 +1,5 @@ +--- +title: Make Prometheus metrics endpoint return empty response when metrics are disabled +merge_request: 14490 +author: +type: changed diff --git a/changelogs/unreleased/remove-ensure-ref-fetched-from-controllers.yml b/changelogs/unreleased/remove-ensure-ref-fetched-from-controllers.yml new file mode 100644 index 00000000000..57f54bec1e6 --- /dev/null +++ b/changelogs/unreleased/remove-ensure-ref-fetched-from-controllers.yml @@ -0,0 +1,5 @@ +--- +title: Stop merge requests from fetching their refs when the data is already available. +merge_request: 15129 +author: +type: removed diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 12694f8016f..d1156b0c8a8 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -535,6 +535,7 @@ Settings.webpack.dev_server['port'] ||= 3808 Settings['monitoring'] ||= Settingslogic.new({}) Settings.monitoring['ip_whitelist'] ||= ['127.0.0.1/8'] Settings.monitoring['unicorn_sampler_interval'] ||= 10 +Settings.monitoring['ruby_sampler_interval'] ||= 60 Settings.monitoring['sidekiq_exporter'] ||= Settingslogic.new({}) Settings.monitoring.sidekiq_exporter['enabled'] ||= false Settings.monitoring.sidekiq_exporter['address'] ||= 'localhost' diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb index 31839297523..e8f33593fe0 100644 --- a/config/initializers/7_prometheus_metrics.rb +++ b/config/initializers/7_prometheus_metrics.rb @@ -11,7 +11,15 @@ Prometheus::Client.configure do |config| config.multiprocess_files_dir ||= Rails.root.join('tmp/prometheus_multiproc_dir') end - config.pid_provider = Prometheus::Client::Support::Unicorn.method(:worker_pid_provider) + config.pid_provider = -> do + wid = Prometheus::Client::Support::Unicorn.worker_id + wid = Process.pid if wid.nil? + if wid.nil? + "process_pid_#{Process.pid}" + else + "worker_id_#{wid}" + end + end end Sidekiq.configure_server do |config| @@ -19,3 +27,11 @@ Sidekiq.configure_server do |config| Gitlab::Metrics::SidekiqMetricsExporter.instance.start end end + +if Gitlab::Metrics.prometheus_metrics_enabled? + unless Sidekiq.server? + Gitlab::Metrics::Samplers::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start + end + + Gitlab::Metrics::Samplers::RubySampler.initialize_instance(Settings.monitoring.ruby_sampler_interval).start +end diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index 2d8704622b6..7ef594836d6 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -77,7 +77,6 @@ def instrument_classes(instrumentation) instrumentation.instrument_instance_methods(Banzai::ObjectRenderer) instrumentation.instrument_instance_methods(Banzai::Redactor) - instrumentation.instrument_methods(Banzai::NoteRenderer) [Issuable, Mentionable, Participable].each do |klass| instrumentation.instrument_instance_methods(klass) @@ -116,17 +115,9 @@ def instrument_classes(instrumentation) # Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/30224#note_32306159 instrumentation.instrument_instance_method(MergeRequestDiff, :load_commits) - - # Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/36061 - instrumentation.instrument_instance_method(MergeRequest, :ensure_ref_fetched) - instrumentation.instrument_instance_method(MergeRequest, :fetch_ref) end # rubocop:enable Metrics/AbcSize -unless Sidekiq.server? - Gitlab::Metrics::UnicornSampler.initialize_instance(Settings.monitoring.unicorn_sampler_interval).start -end - Gitlab::Application.configure do |config| # 0 should be Sentry to catch errors in this middleware config.middleware.insert(1, Gitlab::Metrics::RequestsRackMiddleware) @@ -192,7 +183,7 @@ if Gitlab::Metrics.enabled? GC::Profiler.enable - Gitlab::Metrics::InfluxSampler.initialize_instance.start + Gitlab::Metrics::Samplers::InfluxSampler.initialize_instance.start module TrackNewRedisConnections def connect(*args) diff --git a/config/prometheus/additional_metrics.yml b/config/prometheus/additional_metrics.yml index 33b897f46e2..190eeb59a2c 100644 --- a/config/prometheus/additional_metrics.yml +++ b/config/prometheus/additional_metrics.yml @@ -145,7 +145,7 @@ - container_memory_usage_bytes weight: 1 queries: - - query_range: '(sum(container_memory_usage_bytes{container_name!="POD",%{environment_filter}}) / count(container_memory_usage_bytes{container_name!="POD",%{environment_filter}})) /1024/1024' + - query_range: '(sum(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"}) / count(container_memory_usage_bytes{container_name!="POD",environment="%{ci_environment_slug}"})) /1024/1024' label: Average unit: MB - title: "CPU Utilization" @@ -154,7 +154,7 @@ - container_cpu_usage_seconds_total weight: 1 queries: - - query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",%{environment_filter}}[2m])) by (cpu) * 100' + - query_range: 'sum(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="%{ci_environment_slug}"}[2m])) * 100' label: CPU unit: "%" series: diff --git a/db/migrate/20171013094327_create_new_clusters_architectures.rb b/db/migrate/20171013094327_create_new_clusters_architectures.rb new file mode 100644 index 00000000000..dabb3e25e48 --- /dev/null +++ b/db/migrate/20171013094327_create_new_clusters_architectures.rb @@ -0,0 +1,68 @@ +class CreateNewClustersArchitectures < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :clusters do |t| + t.references :user, index: true, foreign_key: { on_delete: :nullify } + + t.integer :provider_type + t.integer :platform_type + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.boolean :enabled, index: true, default: true + + t.string :name, null: false # If manual, read-write. If gcp, read-only. + end + + create_table :cluster_projects do |t| + t.references :project, null: false, index: true, foreign_key: { on_delete: :cascade } + t.references :cluster, null: false, index: true, foreign_key: { on_delete: :cascade } + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + end + + create_table :cluster_platforms_kubernetes do |t| + t.references :cluster, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.text :api_url + t.text :ca_cert + + t.string :namespace + + t.string :username + t.text :encrypted_password + t.string :encrypted_password_iv + + t.text :encrypted_token + t.string :encrypted_token_iv + end + + create_table :cluster_providers_gcp do |t| + t.references :cluster, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + + t.integer :status + t.integer :num_nodes, null: false + + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + t.text :status_reason + + t.string :gcp_project_id, null: false + t.string :zone, null: false + t.string :machine_type + t.string :operation_id + + t.string :endpoint + + t.text :encrypted_access_token + t.string :encrypted_access_token_iv + end + end +end diff --git a/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb b/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb new file mode 100644 index 00000000000..4758c694563 --- /dev/null +++ b/db/post_migrate/20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb @@ -0,0 +1,99 @@ +class MigrateGcpClustersToNewClustersArchitectures < ActiveRecord::Migration + DOWNTIME = false + + class GcpCluster < ActiveRecord::Base + self.table_name = 'gcp_clusters' + + belongs_to :project, class_name: 'Project' + + include EachBatch + end + + class Cluster < ActiveRecord::Base + self.table_name = 'clusters' + + has_many :cluster_projects, class_name: 'ClustersProject' + has_many :projects, through: :cluster_projects, class_name: 'Project' + has_one :provider_gcp, class_name: 'ProvidersGcp' + has_one :platform_kubernetes, class_name: 'PlatformsKubernetes' + + accepts_nested_attributes_for :provider_gcp + accepts_nested_attributes_for :platform_kubernetes + + enum platform_type: { + kubernetes: 1 + } + + enum provider_type: { + user: 0, + gcp: 1 + } + end + + class Project < ActiveRecord::Base + self.table_name = 'projects' + + has_one :cluster_project, class_name: 'ClustersProject' + has_one :cluster, through: :cluster_project, class_name: 'Cluster' + end + + class ClustersProject < ActiveRecord::Base + self.table_name = 'cluster_projects' + + belongs_to :cluster, class_name: 'Cluster' + belongs_to :project, class_name: 'Project' + end + + class ProvidersGcp < ActiveRecord::Base + self.table_name = 'cluster_providers_gcp' + end + + class PlatformsKubernetes < ActiveRecord::Base + self.table_name = 'cluster_platforms_kubernetes' + end + + def up + GcpCluster.all.find_each(batch_size: 1) do |gcp_cluster| + Cluster.create( + enabled: gcp_cluster.enabled, + user_id: gcp_cluster.user_id, + name: gcp_cluster.gcp_cluster_name, + provider_type: Cluster.provider_types[:gcp], + platform_type: Cluster.platform_types[:kubernetes], + projects: [gcp_cluster.project], + provider_gcp_attributes: { + status: gcp_cluster.status, + status_reason: gcp_cluster.status_reason, + gcp_project_id: gcp_cluster.gcp_project_id, + zone: gcp_cluster.gcp_cluster_zone, + num_nodes: gcp_cluster.gcp_cluster_size, + machine_type: gcp_cluster.gcp_machine_type, + operation_id: gcp_cluster.gcp_operation_id, + endpoint: gcp_cluster.endpoint, + encrypted_access_token: gcp_cluster.encrypted_gcp_token, + encrypted_access_token_iv: gcp_cluster.encrypted_gcp_token_iv + }, + platform_kubernetes_attributes: { + cluster_id: gcp_cluster.id, + api_url: api_url(gcp_cluster.endpoint), + ca_cert: gcp_cluster.ca_cert, + namespace: gcp_cluster.project_namespace, + username: gcp_cluster.username, + encrypted_password: gcp_cluster.encrypted_password, + encrypted_password_iv: gcp_cluster.encrypted_password_iv, + encrypted_token: gcp_cluster.encrypted_kubernetes_token, + encrypted_token_iv: gcp_cluster.encrypted_kubernetes_token_iv + } ) + end + end + + def down + execute('DELETE FROM clusters') + end + + private + + def api_url(endpoint) + endpoint ? 'https://' + endpoint : nil + end +end diff --git a/db/post_migrate/20171101134435_remove_ref_fetched_from_merge_requests.rb b/db/post_migrate/20171101134435_remove_ref_fetched_from_merge_requests.rb new file mode 100644 index 00000000000..4e8f495d65d --- /dev/null +++ b/db/post_migrate/20171101134435_remove_ref_fetched_from_merge_requests.rb @@ -0,0 +1,14 @@ +class RemoveRefFetchedFromMergeRequests < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # We don't need to cache this anymore: the refs are now created + # upon save/update and there is no more use for this flag + # + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/36061 + def change + remove_column :merge_requests, :ref_fetched, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 80d8ff92d6e..c58555f664f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171026082505) do +ActiveRecord::Schema.define(version: 20171101134435) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -462,6 +462,63 @@ ActiveRecord::Schema.define(version: 20171026082505) do add_index "ci_variables", ["project_id", "key", "environment_scope"], name: "index_ci_variables_on_project_id_and_key_and_environment_scope", unique: true, using: :btree + create_table "cluster_platforms_kubernetes", force: :cascade do |t| + t.integer "cluster_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.text "api_url" + t.text "ca_cert" + t.string "namespace" + t.string "username" + t.text "encrypted_password" + t.string "encrypted_password_iv" + t.text "encrypted_token" + t.string "encrypted_token_iv" + end + + add_index "cluster_platforms_kubernetes", ["cluster_id"], name: "index_cluster_platforms_kubernetes_on_cluster_id", unique: true, using: :btree + + create_table "cluster_projects", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "cluster_id", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + end + + add_index "cluster_projects", ["cluster_id"], name: "index_cluster_projects_on_cluster_id", using: :btree + add_index "cluster_projects", ["project_id"], name: "index_cluster_projects_on_project_id", using: :btree + + create_table "cluster_providers_gcp", force: :cascade do |t| + t.integer "cluster_id", null: false + t.integer "status" + t.integer "num_nodes", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.text "status_reason" + t.string "gcp_project_id", null: false + t.string "zone", null: false + t.string "machine_type" + t.string "operation_id" + t.string "endpoint" + t.text "encrypted_access_token" + t.string "encrypted_access_token_iv" + end + + add_index "cluster_providers_gcp", ["cluster_id"], name: "index_cluster_providers_gcp_on_cluster_id", unique: true, using: :btree + + create_table "clusters", force: :cascade do |t| + t.integer "user_id" + t.integer "provider_type" + t.integer "platform_type" + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.boolean "enabled", default: true + t.string "name", null: false + end + + add_index "clusters", ["enabled"], name: "index_clusters_on_enabled", using: :btree + add_index "clusters", ["user_id"], name: "index_clusters_on_user_id", using: :btree + create_table "container_repositories", force: :cascade do |t| t.integer "project_id", null: false t.string "name", null: false @@ -970,7 +1027,6 @@ ActiveRecord::Schema.define(version: 20171026082505) do t.datetime "last_edited_at" t.integer "last_edited_by_id" t.integer "head_pipeline_id" - t.boolean "ref_fetched" t.string "merge_jid" t.boolean "discussion_locked" t.integer "latest_merge_request_diff_id" @@ -1810,6 +1866,11 @@ ActiveRecord::Schema.define(version: 20171026082505) do add_foreign_key "ci_triggers", "projects", name: "fk_e3e63f966e", on_delete: :cascade add_foreign_key "ci_triggers", "users", column: "owner_id", name: "fk_e8e10d1964", on_delete: :cascade add_foreign_key "ci_variables", "projects", name: "fk_ada5eb64b3", on_delete: :cascade + add_foreign_key "cluster_platforms_kubernetes", "clusters", on_delete: :cascade + add_foreign_key "cluster_projects", "clusters", on_delete: :cascade + add_foreign_key "cluster_projects", "projects", on_delete: :cascade + add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade + add_foreign_key "clusters", "users", on_delete: :nullify add_foreign_key "container_repositories", "projects" add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade diff --git a/doc/administration/logs.md b/doc/administration/logs.md index c9ed2d84ccb..debaa2330d0 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -192,4 +192,13 @@ installations from source. It logs information whenever a [repository check is run][repocheck] on a project. +## Reconfigure Logs + +Reconfigure log files live in `/var/log/gitlab/reconfigure` for Omnibus GitLab +packages. Installations from source don't have reconfigure logs. A reconfigure log +is populated whenever `gitlab-ctl reconfigure` is run manually or as part of an upgrade. + +Reconfigure logs files are named according to the UNIX timestamp of when the reconfigure +was initiated, such as `1509705644.log` + [repocheck]: repository_checks.md diff --git a/lib/banzai.rb b/lib/banzai.rb index 35ca234c1ba..5df98f66f3b 100644 --- a/lib/banzai.rb +++ b/lib/banzai.rb @@ -3,8 +3,8 @@ module Banzai Renderer.render(text, context) end - def self.render_field(object, field) - Renderer.render_field(object, field) + def self.render_field(object, field, context = {}) + Renderer.render_field(object, field, context) end def self.cache_collection_render(texts_and_contexts) diff --git a/lib/banzai/filter/absolute_link_filter.rb b/lib/banzai/filter/absolute_link_filter.rb new file mode 100644 index 00000000000..1ec6201523f --- /dev/null +++ b/lib/banzai/filter/absolute_link_filter.rb @@ -0,0 +1,34 @@ +require 'uri' + +module Banzai + module Filter + # HTML filter that converts relative urls into absolute ones. + class AbsoluteLinkFilter < HTML::Pipeline::Filter + def call + return doc unless context[:only_path] == false + + doc.search('a.gfm').each do |el| + process_link_attr el.attribute('href') + end + + doc + end + + protected + + def process_link_attr(html_attr) + return if html_attr.blank? + return if html_attr.value.start_with?('//') + + uri = URI(html_attr.value) + html_attr.value = absolute_link_attr(uri) if uri.relative? + rescue URI::Error + # noop + end + + def absolute_link_attr(uri) + URI.join(Gitlab.config.gitlab.url, uri).to_s + end + end + end +end diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index a0f7e4e5ad5..9fef386de16 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -311,30 +311,6 @@ module Banzai def project_refs_cache RequestStore[:banzai_project_refs] ||= {} end - - def cached_call(request_store_key, cache_key, path: []) - if RequestStore.active? - cache = RequestStore[request_store_key] ||= Hash.new do |hash, key| - hash[key] = Hash.new { |h, k| h[k] = {} } - end - - cache = cache.dig(*path) if path.any? - - get_or_set_cache(cache, cache_key) { yield } - else - yield - end - end - - def get_or_set_cache(cache, key) - if cache.key?(key) - cache[key] - else - value = yield - cache[key] = value if key.present? - value - end - end end end end diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index c6ae28adf87..b9d5ecf70ec 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -8,6 +8,8 @@ module Banzai # :project (required) - Current project, ignored if reference is cross-project. # :only_path - Generate path-only links. class ReferenceFilter < HTML::Pipeline::Filter + include RequestStoreReferenceCache + class << self attr_accessor :reference_type end diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb index afb6e25963c..c7fa8a8119f 100644 --- a/lib/banzai/filter/user_reference_filter.rb +++ b/lib/banzai/filter/user_reference_filter.rb @@ -60,10 +60,14 @@ module Banzai self.class.references_in(text) do |match, username| if username == 'all' && !skip_project_check? link_to_all(link_content: link_content) - elsif namespace = namespaces[username.downcase] - link_to_namespace(namespace, link_content: link_content) || match else - match + cached_call(:banzai_url_for_object, match, path: [User, username.downcase]) do + if namespace = namespaces[username.downcase] + link_to_namespace(namespace, link_content: link_content) || match + else + match + end + end end end end @@ -74,7 +78,10 @@ module Banzai # The keys of this Hash are the namespace paths, the values the # corresponding Namespace objects. def namespaces - @namespaces ||= Namespace.where_full_path_in(usernames).index_by(&:full_path).transform_keys(&:downcase) + @namespaces ||= Namespace.eager_load(:owner, :route) + .where_full_path_in(usernames) + .index_by(&:full_path) + .transform_keys(&:downcase) end # Returns all usernames referenced in the current document. diff --git a/lib/banzai/note_renderer.rb b/lib/banzai/note_renderer.rb deleted file mode 100644 index 2b7c10f1a0e..00000000000 --- a/lib/banzai/note_renderer.rb +++ /dev/null @@ -1,21 +0,0 @@ -module Banzai - module NoteRenderer - # Renders a collection of Note instances. - # - # notes - The notes to render. - # project - The project to use for redacting. - # user - The user viewing the notes. - # path - The request path. - # wiki - The project's wiki. - # git_ref - The current Git reference. - def self.render(notes, project, user = nil, path = nil, wiki = nil, git_ref = nil) - renderer = ObjectRenderer.new(project, - user, - requested_path: path, - project_wiki: wiki, - ref: git_ref) - - renderer.render(notes, :note) - end - end -end diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index e40556e869c..9bb8ed913d8 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -37,7 +37,7 @@ module Banzai objects.each_with_index do |object, index| redacted_data = redacted[index] - object.__send__("redacted_#{attribute}_html=", redacted_data[:document].to_html.html_safe) # rubocop:disable GitlabSecurity/PublicSend + object.__send__("redacted_#{attribute}_html=", redacted_data[:document].to_html(save_options).html_safe) # rubocop:disable GitlabSecurity/PublicSend object.user_visible_reference_count = redacted_data[:visible_reference_count] if object.respond_to?(:user_visible_reference_count) end end @@ -83,5 +83,10 @@ module Banzai skip_redaction: true ) end + + def save_options + return {} unless base_context[:xhtml] + { save_with: Nokogiri::XML::Node::SaveOptions::AS_XHTML } + end end end diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb index 131ac3b0eec..dcd52bc03c7 100644 --- a/lib/banzai/pipeline/post_process_pipeline.rb +++ b/lib/banzai/pipeline/post_process_pipeline.rb @@ -3,9 +3,10 @@ module Banzai class PostProcessPipeline < BasePipeline def self.filters FilterArray[ + Filter::RedactorFilter, Filter::RelativeLinkFilter, Filter::IssuableStateFilter, - Filter::RedactorFilter + Filter::AbsoluteLinkFilter ] end diff --git a/lib/banzai/renderer.rb b/lib/banzai/renderer.rb index 5f91884a878..5cb9adf52b0 100644 --- a/lib/banzai/renderer.rb +++ b/lib/banzai/renderer.rb @@ -32,12 +32,9 @@ module Banzai # Convert a Markdown-containing field on an object into an HTML-safe String # of HTML. This method is analogous to calling render(object.field), but it # can cache the rendered HTML in the object, rather than Redis. - # - # The context to use is managed by the object and cannot be changed. - # Use #render, passing it the field text, if a custom rendering is needed. - def self.render_field(object, field) + def self.render_field(object, field, context = {}) unless object.respond_to?(:cached_markdown_fields) - return cacheless_render_field(object, field) + return cacheless_render_field(object, field, context) end object.refresh_markdown_cache! unless object.cached_html_up_to_date?(field) @@ -46,9 +43,9 @@ module Banzai end # Same as +render_field+, but without consulting or updating the cache field - def self.cacheless_render_field(object, field, options = {}) + def self.cacheless_render_field(object, field, context = {}) text = object.__send__(field) # rubocop:disable GitlabSecurity/PublicSend - context = object.banzai_render_context(field).merge(options) + context = context.reverse_merge(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context) cacheless_render(text, context) end diff --git a/lib/banzai/request_store_reference_cache.rb b/lib/banzai/request_store_reference_cache.rb new file mode 100644 index 00000000000..426131442a2 --- /dev/null +++ b/lib/banzai/request_store_reference_cache.rb @@ -0,0 +1,27 @@ +module Banzai + module RequestStoreReferenceCache + def cached_call(request_store_key, cache_key, path: []) + if RequestStore.active? + cache = RequestStore[request_store_key] ||= Hash.new do |hash, key| + hash[key] = Hash.new { |h, k| h[k] = {} } + end + + cache = cache.dig(*path) if path.any? + + get_or_set_cache(cache, cache_key) { yield } + else + yield + end + end + + def get_or_set_cache(cache, key) + if cache.key?(key) + cache[key] + else + value = yield + cache[key] = value if key.present? + value + end + end + end +end diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index 6fc1d56d7a0..fd2ac2db0a9 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -2,7 +2,7 @@ class GroupUrlConstrainer def matches?(request) full_path = request.params[:group_id] || request.params[:id] - return false unless DynamicPathValidator.valid_group_path?(full_path) + return false unless NamespacePathValidator.valid_path?(full_path) Group.find_by_full_path(full_path, follow_redirects: request.get?).present? end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index 5bef29eb1da..e90ecb5ec69 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -4,7 +4,7 @@ class ProjectUrlConstrainer project_path = request.params[:project_id] || request.params[:id] full_path = [namespace_path, project_path].join('/') - return false unless DynamicPathValidator.valid_project_path?(full_path) + return false unless ProjectPathValidator.valid_path?(full_path) # We intentionally allow SELECT(*) here so result of this query can be used # as cache for further Project.find_by_full_path calls within request diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index d16ae7f3f40..b7633aa7cbb 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -2,7 +2,7 @@ class UserUrlConstrainer def matches?(request) full_path = request.params[:username] - return false unless DynamicPathValidator.valid_user_path?(full_path) + return false unless UserPathValidator.valid_path?(full_path) User.find_by_full_path(full_path, follow_redirects: request.get?).present? end diff --git a/lib/github/import.rb b/lib/github/import.rb index 8cabbdec940..fef63dd7168 100644 --- a/lib/github/import.rb +++ b/lib/github/import.rb @@ -163,7 +163,6 @@ module Github iid: pull_request.iid, title: pull_request.title, description: description, - ref_fetched: true, source_project: pull_request.source_project, source_branch: pull_request.source_branch_name, source_branch_sha: pull_request.source_branch_sha, diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index b6805230348..ef92fc5a0a0 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -12,7 +12,8 @@ module Gitlab change_existing_tags: 'You are not allowed to change existing tags on this project.', update_protected_tag: 'Protected tags cannot be updated.', delete_protected_tag: 'Protected tags cannot be deleted.', - create_protected_tag: 'You are not allowed to create this tag as it is protected.' + create_protected_tag: 'You are not allowed to create this tag as it is protected.', + lfs_objects_missing: 'LFS objects are missing. Ensure LFS is properly set up or try a manual "git lfs push --all".' }.freeze attr_reader :user_access, :project, :skip_authorization, :protocol @@ -36,6 +37,7 @@ module Gitlab push_checks branch_checks tag_checks + lfs_objects_exist_check true end @@ -136,6 +138,14 @@ module Gitlab def matching_merge_request? Checks::MatchingMergeRequest.new(@newrev, @branch_name, @project).match? end + + def lfs_objects_exist_check + lfs_check = Checks::LfsIntegrity.new(project, @newrev) + + if lfs_check.objects_missing? + raise GitAccess::UnauthorizedError, ERROR_MESSAGES[:lfs_objects_missing] + end + end end end end diff --git a/lib/gitlab/checks/lfs_integrity.rb b/lib/gitlab/checks/lfs_integrity.rb new file mode 100644 index 00000000000..27a95764dc1 --- /dev/null +++ b/lib/gitlab/checks/lfs_integrity.rb @@ -0,0 +1,24 @@ +module Gitlab + module Checks + class LfsIntegrity + REV_LIST_OBJECT_LIMIT = 2_000 + + def initialize(project, newrev) + @project = project + @newrev = newrev + end + + def objects_missing? + return false unless @newrev && @project.lfs_enabled? + + new_lfs_pointers = Gitlab::Git::LfsChanges.new(@project.repository, @newrev).new_pointers(object_limit: REV_LIST_OBJECT_LIMIT) + + return false unless new_lfs_pointers.present? + + existing_count = @project.lfs_objects.where(oid: new_lfs_pointers.map(&:lfs_oid)).count + + existing_count != new_lfs_pointers.count + end + end + end +end diff --git a/lib/gitlab/daemon.rb b/lib/gitlab/daemon.rb index dfd17e35707..f07fd1dfdda 100644 --- a/lib/gitlab/daemon.rb +++ b/lib/gitlab/daemon.rb @@ -43,7 +43,7 @@ module Gitlab if thread thread.wakeup if thread.alive? - thread.join + thread.join unless Thread.current == thread @thread = nil end end diff --git a/lib/gitlab/gcp/model.rb b/lib/gitlab/gcp/model.rb deleted file mode 100644 index 195391f0e3c..00000000000 --- a/lib/gitlab/gcp/model.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Gitlab - module Gcp - module Model - def table_name_prefix - "gcp_" - end - - def model_name - @model_name ||= ActiveModel::Name.new(self, nil, self.name.split("::").last) - end - end - end -end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 182ffc96ef9..df4ad586e12 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1044,7 +1044,7 @@ module Gitlab delete_refs(tmp_ref) if tmp_ref end - def fetch_source_branch(source_repository, source_branch, local_ref) + def fetch_source_branch!(source_repository, source_branch, local_ref) with_repo_branch_commit(source_repository, source_branch) do |commit| if commit write_ref(local_ref, commit.sha) diff --git a/lib/gitlab/hook_data/merge_request_builder.rb b/lib/gitlab/hook_data/merge_request_builder.rb index eaef19c9d04..503452c8ff3 100644 --- a/lib/gitlab/hook_data/merge_request_builder.rb +++ b/lib/gitlab/hook_data/merge_request_builder.rb @@ -19,7 +19,6 @@ module Gitlab merge_user_id merge_when_pipeline_succeeds milestone_id - ref_fetched source_branch source_project_id state diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 469b230377d..a790dcfe8a6 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -8,8 +8,8 @@ module Gitlab triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', - cluster: 'Gcp::Cluster', - clusters: 'Gcp::Cluster', + cluster: 'Clusters::Cluster', + clusters: 'Clusters::Cluster', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', diff --git a/lib/gitlab/metrics/background_transaction.rb b/lib/gitlab/metrics/background_transaction.rb new file mode 100644 index 00000000000..d01de5eef0a --- /dev/null +++ b/lib/gitlab/metrics/background_transaction.rb @@ -0,0 +1,16 @@ +module Gitlab + module Metrics + class BackgroundTransaction < Transaction + def initialize(worker_class) + super() + @worker_class = worker_class + end + + protected + + def labels + { controller: @worker_class.name, action: 'perform' } + end + end + end +end diff --git a/lib/gitlab/metrics/base_sampler.rb b/lib/gitlab/metrics/base_sampler.rb deleted file mode 100644 index 716d20bb91a..00000000000 --- a/lib/gitlab/metrics/base_sampler.rb +++ /dev/null @@ -1,63 +0,0 @@ -require 'logger' -module Gitlab - module Metrics - class BaseSampler < Daemon - # interval - The sampling interval in seconds. - def initialize(interval) - interval_half = interval.to_f / 2 - - @interval = interval - @interval_steps = (-interval_half..interval_half).step(0.1).to_a - - super() - end - - def safe_sample - sample - rescue => e - Rails.logger.warn("#{self.class}: #{e}, stopping") - stop - end - - def sample - raise NotImplementedError - end - - # Returns the sleep interval with a random adjustment. - # - # The random adjustment is put in place to ensure we: - # - # 1. Don't generate samples at the exact same interval every time (thus - # potentially missing anything that happens in between samples). - # 2. Don't sample data at the same interval two times in a row. - def sleep_interval - while (step = @interval_steps.sample) - if step != @last_step - @last_step = step - - return @interval + @last_step - end - end - end - - private - - attr_reader :running - - def start_working - @running = true - sleep(sleep_interval) - - while running - safe_sample - - sleep(sleep_interval) - end - end - - def stop_working - @running = false - end - end - end -end diff --git a/lib/gitlab/metrics/influx_db.rb b/lib/gitlab/metrics/influx_db.rb index 7b06bb953aa..bdf7910b7c7 100644 --- a/lib/gitlab/metrics/influx_db.rb +++ b/lib/gitlab/metrics/influx_db.rb @@ -11,6 +11,8 @@ module Gitlab settings[:enabled] || false end + # Prometheus histogram buckets used for arbitrary code measurements + EXECUTION_MEASUREMENT_BUCKETS = [0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1].freeze RAILS_ROOT = Rails.root.to_s METRICS_ROOT = Rails.root.join('lib', 'gitlab', 'metrics').to_s PATH_REGEX = /^#{RAILS_ROOT}\/?/ @@ -99,24 +101,27 @@ module Gitlab cpu_stop = System.cpu_time real_stop = Time.now.to_f - real_time = (real_stop - real_start) * 1000.0 + real_time = (real_stop - real_start) cpu_time = cpu_stop - cpu_start - trans.increment("#{name}_real_time", real_time) - trans.increment("#{name}_cpu_time", cpu_time) - trans.increment("#{name}_call_count", 1) + Gitlab::Metrics.histogram("gitlab_#{name}_real_duration_seconds".to_sym, + "Measure #{name}", + Transaction::BASE_LABELS, + EXECUTION_MEASUREMENT_BUCKETS) + .observe(trans.labels, real_time) - retval - end + Gitlab::Metrics.histogram("gitlab_#{name}_cpu_duration_seconds".to_sym, + "Measure #{name}", + Transaction::BASE_LABELS, + EXECUTION_MEASUREMENT_BUCKETS) + .observe(trans.labels, cpu_time / 1000.0) - # Adds a tag to the current transaction (if any) - # - # name - The name of the tag to add. - # value - The value of the tag. - def tag_transaction(name, value) - trans = current_transaction + # InfluxDB stores the _real_time time values as milliseconds + trans.increment("#{name}_real_time", real_time * 1000, false) + trans.increment("#{name}_cpu_time", cpu_time, false) + trans.increment("#{name}_call_count", 1, false) - trans&.add_tag(name, value) + retval end # Sets the action of the current transaction (if any) diff --git a/lib/gitlab/metrics/influx_sampler.rb b/lib/gitlab/metrics/influx_sampler.rb deleted file mode 100644 index 6db1dd755b7..00000000000 --- a/lib/gitlab/metrics/influx_sampler.rb +++ /dev/null @@ -1,101 +0,0 @@ -module Gitlab - module Metrics - # Class that sends certain metrics to InfluxDB at a specific interval. - # - # This class is used to gather statistics that can't be directly associated - # with a transaction such as system memory usage, garbage collection - # statistics, etc. - class InfluxSampler < BaseSampler - # interval - The sampling interval in seconds. - def initialize(interval = Metrics.settings[:sample_interval]) - super(interval) - @last_step = nil - - @metrics = [] - - @last_minor_gc = Delta.new(GC.stat[:minor_gc_count]) - @last_major_gc = Delta.new(GC.stat[:major_gc_count]) - - if Gitlab::Metrics.mri? - require 'allocations' - - Allocations.start - end - end - - def sample - sample_memory_usage - sample_file_descriptors - sample_objects - sample_gc - - flush - ensure - GC::Profiler.clear - @metrics.clear - end - - def flush - Metrics.submit_metrics(@metrics.map(&:to_hash)) - end - - def sample_memory_usage - add_metric('memory_usage', value: System.memory_usage) - end - - def sample_file_descriptors - add_metric('file_descriptors', value: System.file_descriptor_count) - end - - if Metrics.mri? - def sample_objects - sample = Allocations.to_hash - counts = sample.each_with_object({}) do |(klass, count), hash| - name = klass.name - - next unless name - - hash[name] = count - end - - # Symbols aren't allocated so we'll need to add those manually. - counts['Symbol'] = Symbol.all_symbols.length - - counts.each do |name, count| - add_metric('object_counts', { count: count }, type: name) - end - end - else - def sample_objects - end - end - - def sample_gc - time = GC::Profiler.total_time * 1000.0 - stats = GC.stat.merge(total_time: time) - - # We want the difference of GC runs compared to the last sample, not the - # total amount since the process started. - stats[:minor_gc_count] = - @last_minor_gc.compared_with(stats[:minor_gc_count]) - - stats[:major_gc_count] = - @last_major_gc.compared_with(stats[:major_gc_count]) - - stats[:count] = stats[:minor_gc_count] + stats[:major_gc_count] - - add_metric('gc_statistics', stats) - end - - def add_metric(series, values, tags = {}) - prefix = sidekiq? ? 'sidekiq_' : 'rails_' - - @metrics << Metric.new("#{prefix}#{series}", values, tags) - end - - def sidekiq? - Sidekiq.server? - end - end - end -end diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb index 6aa38542cb4..023e9963493 100644 --- a/lib/gitlab/metrics/instrumentation.rb +++ b/lib/gitlab/metrics/instrumentation.rb @@ -118,19 +118,21 @@ module Gitlab def self.instrument(type, mod, name) return unless Metrics.enabled? - name = name.to_sym + name = name.to_sym target = type == :instance ? mod : mod.singleton_class if type == :instance target = mod - label = "#{mod.name}##{name}" + method_name = "##{name}" method = mod.instance_method(name) else target = mod.singleton_class - label = "#{mod.name}.#{name}" + method_name = ".#{name}" method = mod.method(name) end + label = "#{mod.name}#{method_name}" + unless instrumented?(target) target.instance_variable_set(PROXY_IVAR, Module.new) end @@ -153,7 +155,8 @@ module Gitlab proxy_module.class_eval <<-EOF, __FILE__, __LINE__ + 1 def #{name}(#{args_signature}) if trans = Gitlab::Metrics::Instrumentation.transaction - trans.method_call_for(#{label.to_sym.inspect}).measure { super } + trans.method_call_for(#{label.to_sym.inspect}, #{mod.name.inspect}, "#{method_name}") + .measure { super } else super end diff --git a/lib/gitlab/metrics/method_call.rb b/lib/gitlab/metrics/method_call.rb index d3465e5ec19..90235095306 100644 --- a/lib/gitlab/metrics/method_call.rb +++ b/lib/gitlab/metrics/method_call.rb @@ -2,15 +2,45 @@ module Gitlab module Metrics # Class for tracking timing information about method calls class MethodCall - attr_reader :real_time, :cpu_time, :call_count + MUTEX = Mutex.new + BASE_LABELS = { module: nil, method: nil }.freeze + attr_reader :real_time, :cpu_time, :call_count, :labels + + def self.call_real_duration_histogram + return @call_real_duration_histogram if @call_real_duration_histogram + + MUTEX.synchronize do + @call_real_duration_histogram ||= Gitlab::Metrics.histogram( + :gitlab_method_call_real_duration_seconds, + 'Method calls real duration', + Transaction::BASE_LABELS.merge(BASE_LABELS), + [0.1, 0.2, 0.5, 1, 2, 5, 10] + ) + end + end + + def self.call_cpu_duration_histogram + return @call_cpu_duration_histogram if @call_cpu_duration_histogram + + MUTEX.synchronize do + @call_duration_histogram ||= Gitlab::Metrics.histogram( + :gitlab_method_call_cpu_duration_seconds, + 'Method calls cpu duration', + Transaction::BASE_LABELS.merge(BASE_LABELS), + [0.1, 0.2, 0.5, 1, 2, 5, 10] + ) + end + end # name - The full name of the method (including namespace) such as # `User#sign_in`. # - # series - The series to use for storing the data. - def initialize(name, series) + def initialize(name, module_name, method_name, transaction) + @module_name = module_name + @method_name = method_name + @transaction = transaction @name = name - @series = series + @labels = { module: @module_name, method: @method_name } @real_time = 0 @cpu_time = 0 @call_count = 0 @@ -22,21 +52,27 @@ module Gitlab start_cpu = System.cpu_time retval = yield - @real_time += System.monotonic_time - start_real - @cpu_time += System.cpu_time - start_cpu + real_time = System.monotonic_time - start_real + cpu_time = System.cpu_time - start_cpu + + @real_time += real_time + @cpu_time += cpu_time @call_count += 1 + self.class.call_real_duration_histogram.observe(@transaction.labels.merge(labels), real_time / 1000.0) + self.class.call_cpu_duration_histogram.observe(@transaction.labels.merge(labels), cpu_time / 1000.0) + retval end # Returns a Metric instance of the current method call. def to_metric Metric.new( - @series, + Instrumentation.series, { - duration: real_time, + duration: real_time, cpu_duration: cpu_time, - call_count: call_count + call_count: call_count }, method: @name ) diff --git a/lib/gitlab/metrics/prometheus.rb b/lib/gitlab/metrics/prometheus.rb index 460dab47276..09103b4ca2d 100644 --- a/lib/gitlab/metrics/prometheus.rb +++ b/lib/gitlab/metrics/prometheus.rb @@ -5,6 +5,9 @@ module Gitlab module Prometheus include Gitlab::CurrentSettings + REGISTRY_MUTEX = Mutex.new + PROVIDER_MUTEX = Mutex.new + def metrics_folder_present? multiprocess_files_dir = ::Prometheus::Client.configuration.multiprocess_files_dir @@ -20,23 +23,38 @@ module Gitlab end def registry - @registry ||= ::Prometheus::Client.registry + return @registry if @registry + + REGISTRY_MUTEX.synchronize do + @registry ||= ::Prometheus::Client.registry + end end def counter(name, docstring, base_labels = {}) - provide_metric(name) || registry.counter(name, docstring, base_labels) + safe_provide_metric(:counter, name, docstring, base_labels) end def summary(name, docstring, base_labels = {}) - provide_metric(name) || registry.summary(name, docstring, base_labels) + safe_provide_metric(:summary, name, docstring, base_labels) end def gauge(name, docstring, base_labels = {}, multiprocess_mode = :all) - provide_metric(name) || registry.gauge(name, docstring, base_labels, multiprocess_mode) + safe_provide_metric(:gauge, name, docstring, base_labels, multiprocess_mode) end def histogram(name, docstring, base_labels = {}, buckets = ::Prometheus::Client::Histogram::DEFAULT_BUCKETS) - provide_metric(name) || registry.histogram(name, docstring, base_labels, buckets) + safe_provide_metric(:histogram, name, docstring, base_labels, buckets) + end + + private + + def safe_provide_metric(method, name, *args) + metric = provide_metric(name) + return metric if metric + + PROVIDER_MUTEX.synchronize do + provide_metric(name) || registry.method(method).call(name, *args) + end end def provide_metric(name) @@ -47,8 +65,6 @@ module Gitlab end end - private - def prometheus_metrics_enabled_unmemoized metrics_folder_present? && current_application_settings[:prometheus_metrics_enabled] || false end diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index adc0db1a874..2d45765df3f 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -2,20 +2,6 @@ module Gitlab module Metrics # Rack middleware for tracking Rails and Grape requests. class RackMiddleware - CONTROLLER_KEY = 'action_controller.instance'.freeze - ENDPOINT_KEY = 'api.endpoint'.freeze - CONTENT_TYPES = { - 'text/html' => :html, - 'text/plain' => :txt, - 'application/json' => :json, - 'text/js' => :js, - 'application/atom+xml' => :atom, - 'image/png' => :png, - 'image/jpeg' => :jpeg, - 'image/gif' => :gif, - 'image/svg+xml' => :svg - }.freeze - def initialize(app) @app = app end @@ -35,12 +21,6 @@ module Gitlab # Even in the event of an error we want to submit any metrics we # might've gathered up to this point. ensure - if env[CONTROLLER_KEY] - tag_controller(trans, env) - elsif env[ENDPOINT_KEY] - tag_endpoint(trans, env) - end - trans.finish end @@ -48,60 +28,19 @@ module Gitlab end def transaction_from_env(env) - trans = Transaction.new + trans = WebTransaction.new(env) - trans.set(:request_uri, filtered_path(env)) - trans.set(:request_method, env['REQUEST_METHOD']) + trans.set(:request_uri, filtered_path(env), false) + trans.set(:request_method, env['REQUEST_METHOD'], false) trans end - def tag_controller(trans, env) - controller = env[CONTROLLER_KEY] - action = "#{controller.class.name}##{controller.action_name}" - suffix = CONTENT_TYPES[controller.content_type] - - if suffix && suffix != :html - action += ".#{suffix}" - end - - trans.action = action - end - - def tag_endpoint(trans, env) - endpoint = env[ENDPOINT_KEY] - - begin - route = endpoint.route - rescue - # endpoint.route is calling env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info] - # but env[Grape::Env::GRAPE_ROUTING_ARGS] is nil in the case of a 405 response - # so we're rescuing exceptions and bailing out - end - - if route - path = endpoint_paths_cache[route.request_method][route.path] - trans.action = "Grape##{route.request_method} #{path}" - end - end - private def filtered_path(env) ActionDispatch::Request.new(env).filtered_path.presence || env['REQUEST_URI'] end - - def endpoint_paths_cache - @endpoint_paths_cache ||= Hash.new do |hash, http_method| - hash[http_method] = Hash.new do |inner_hash, raw_path| - inner_hash[raw_path] = endpoint_instrumentable_path(raw_path) - end - end - end - - def endpoint_instrumentable_path(raw_path) - raw_path.sub('(.:format)', '').sub('/:version', '') - end end end end diff --git a/lib/gitlab/metrics/samplers/base_sampler.rb b/lib/gitlab/metrics/samplers/base_sampler.rb new file mode 100644 index 00000000000..37f90c4673d --- /dev/null +++ b/lib/gitlab/metrics/samplers/base_sampler.rb @@ -0,0 +1,64 @@ +require 'logger' + +module Gitlab + module Metrics + module Samplers + class BaseSampler < Daemon + # interval - The sampling interval in seconds. + def initialize(interval) + interval_half = interval.to_f / 2 + + @interval = interval + @interval_steps = (-interval_half..interval_half).step(0.1).to_a + + super() + end + + def safe_sample + sample + rescue => e + Rails.logger.warn("#{self.class}: #{e}, stopping") + stop + end + + def sample + raise NotImplementedError + end + + # Returns the sleep interval with a random adjustment. + # + # The random adjustment is put in place to ensure we: + # + # 1. Don't generate samples at the exact same interval every time (thus + # potentially missing anything that happens in between samples). + # 2. Don't sample data at the same interval two times in a row. + def sleep_interval + while step = @interval_steps.sample + if step != @last_step + @last_step = step + + return @interval + @last_step + end + end + end + + private + + attr_reader :running + + def start_working + @running = true + sleep(sleep_interval) + while running + safe_sample + sleep(sleep_interval) + end + end + + def stop_working + @running = false + end + end + end + end +end diff --git a/lib/gitlab/metrics/samplers/influx_sampler.rb b/lib/gitlab/metrics/samplers/influx_sampler.rb new file mode 100644 index 00000000000..f4f9b5ca792 --- /dev/null +++ b/lib/gitlab/metrics/samplers/influx_sampler.rb @@ -0,0 +1,103 @@ +module Gitlab + module Metrics + module Samplers + # Class that sends certain metrics to InfluxDB at a specific interval. + # + # This class is used to gather statistics that can't be directly associated + # with a transaction such as system memory usage, garbage collection + # statistics, etc. + class InfluxSampler < BaseSampler + # interval - The sampling interval in seconds. + def initialize(interval = Metrics.settings[:sample_interval]) + super(interval) + @last_step = nil + + @metrics = [] + + @last_minor_gc = Delta.new(GC.stat[:minor_gc_count]) + @last_major_gc = Delta.new(GC.stat[:major_gc_count]) + + if Gitlab::Metrics.mri? + require 'allocations' + + Allocations.start + end + end + + def sample + sample_memory_usage + sample_file_descriptors + sample_objects + sample_gc + + flush + ensure + GC::Profiler.clear + @metrics.clear + end + + def flush + Metrics.submit_metrics(@metrics.map(&:to_hash)) + end + + def sample_memory_usage + add_metric('memory_usage', value: System.memory_usage) + end + + def sample_file_descriptors + add_metric('file_descriptors', value: System.file_descriptor_count) + end + + if Metrics.mri? + def sample_objects + sample = Allocations.to_hash + counts = sample.each_with_object({}) do |(klass, count), hash| + name = klass.name + + next unless name + + hash[name] = count + end + + # Symbols aren't allocated so we'll need to add those manually. + counts['Symbol'] = Symbol.all_symbols.length + + counts.each do |name, count| + add_metric('object_counts', { count: count }, type: name) + end + end + else + def sample_objects + end + end + + def sample_gc + time = GC::Profiler.total_time * 1000.0 + stats = GC.stat.merge(total_time: time) + + # We want the difference of GC runs compared to the last sample, not the + # total amount since the process started. + stats[:minor_gc_count] = + @last_minor_gc.compared_with(stats[:minor_gc_count]) + + stats[:major_gc_count] = + @last_major_gc.compared_with(stats[:major_gc_count]) + + stats[:count] = stats[:minor_gc_count] + stats[:major_gc_count] + + add_metric('gc_statistics', stats) + end + + def add_metric(series, values, tags = {}) + prefix = sidekiq? ? 'sidekiq_' : 'rails_' + + @metrics << Metric.new("#{prefix}#{series}", values, tags) + end + + def sidekiq? + Sidekiq.server? + end + end + end + end +end diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb new file mode 100644 index 00000000000..8b5a60e6b8b --- /dev/null +++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb @@ -0,0 +1,110 @@ +require 'prometheus/client/support/unicorn' + +module Gitlab + module Metrics + module Samplers + class RubySampler < BaseSampler + def metrics + @metrics ||= init_metrics + end + + def with_prefix(prefix, name) + "ruby_#{prefix}_#{name}".to_sym + end + + def to_doc_string(name) + name.to_s.humanize + end + + def labels + {} + end + + def initialize(interval) + super(interval) + + if Metrics.mri? + require 'allocations' + + Allocations.start + end + end + + def init_metrics + metrics = {} + metrics[:sampler_duration] = Metrics.histogram(with_prefix(:sampler_duration, :seconds), 'Sampler time', {}) + metrics[:total_time] = Metrics.gauge(with_prefix(:gc, :time_total), 'Total GC time', labels, :livesum) + GC.stat.keys.each do |key| + metrics[key] = Metrics.gauge(with_prefix(:gc, key), to_doc_string(key), labels, :livesum) + end + + metrics[:objects_total] = Metrics.gauge(with_prefix(:objects, :total), 'Objects total', labels.merge(class: nil), :livesum) + metrics[:memory_usage] = Metrics.gauge(with_prefix(:memory, :usage_total), 'Memory used total', labels, :livesum) + metrics[:file_descriptors] = Metrics.gauge(with_prefix(:file, :descriptors_total), 'File descriptors total', labels, :livesum) + + metrics + end + + def sample + start_time = System.monotonic_time + sample_gc + sample_objects + + metrics[:memory_usage].set(labels, System.memory_usage) + metrics[:file_descriptors].set(labels, System.file_descriptor_count) + + metrics[:sampler_duration].observe(labels.merge(worker_label), (System.monotonic_time - start_time) / 1000.0) + ensure + GC::Profiler.clear + end + + private + + def sample_gc + metrics[:total_time].set(labels, GC::Profiler.total_time * 1000) + + GC.stat.each do |key, value| + metrics[key].set(labels, value) + end + end + + def sample_objects + list_objects.each do |name, count| + metrics[:objects_total].set(labels.merge(class: name), count) + end + end + + if Metrics.mri? + def list_objects + sample = Allocations.to_hash + counts = sample.each_with_object({}) do |(klass, count), hash| + name = klass.name + + next unless name + + hash[name] = count + end + + # Symbols aren't allocated so we'll need to add those manually. + counts['Symbol'] = Symbol.all_symbols.length + counts + end + else + def list_objects + end + end + + def worker_label + return {} unless defined?(Unicorn::Worker) + worker_no = ::Prometheus::Client::Support::Unicorn.worker_id + + if worker_no + { unicorn: worker_no } + else + { unicorn: 'master' } + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/samplers/unicorn_sampler.rb b/lib/gitlab/metrics/samplers/unicorn_sampler.rb new file mode 100644 index 00000000000..ea325651fbb --- /dev/null +++ b/lib/gitlab/metrics/samplers/unicorn_sampler.rb @@ -0,0 +1,50 @@ +module Gitlab + module Metrics + module Samplers + class UnicornSampler < BaseSampler + def initialize(interval) + super(interval) + end + + def unicorn_active_connections + @unicorn_active_connections ||= Gitlab::Metrics.gauge(:unicorn_active_connections, 'Unicorn active connections', {}, :max) + end + + def unicorn_queued_connections + @unicorn_queued_connections ||= Gitlab::Metrics.gauge(:unicorn_queued_connections, 'Unicorn queued connections', {}, :max) + end + + def enabled? + # Raindrops::Linux.tcp_listener_stats is only present on Linux + unicorn_with_listeners? && Raindrops::Linux.respond_to?(:tcp_listener_stats) + end + + def sample + Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats| + unicorn_active_connections.set({ type: 'tcp', address: addr }, stats.active) + unicorn_queued_connections.set({ type: 'tcp', address: addr }, stats.queued) + end + + Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats| + unicorn_active_connections.set({ type: 'unix', address: addr }, stats.active) + unicorn_queued_connections.set({ type: 'unix', address: addr }, stats.queued) + end + end + + private + + def tcp_listeners + @tcp_listeners ||= Unicorn.listener_names.grep(%r{\A[^/]+:\d+\z}) + end + + def unix_listeners + @unix_listeners ||= Unicorn.listener_names - tcp_listeners + end + + def unicorn_with_listeners? + defined?(Unicorn) && Unicorn.listener_names.any? + end + end + end + end +end diff --git a/lib/gitlab/metrics/sidekiq_middleware.rb b/lib/gitlab/metrics/sidekiq_middleware.rb index b983a40611f..55c707d5386 100644 --- a/lib/gitlab/metrics/sidekiq_middleware.rb +++ b/lib/gitlab/metrics/sidekiq_middleware.rb @@ -5,7 +5,7 @@ module Gitlab # This middleware is intended to be used as a server-side middleware. class SidekiqMiddleware def call(worker, message, queue) - trans = Transaction.new("#{worker.class.name}#perform") + trans = BackgroundTransaction.new(worker.class) begin # Old gitlad-shell messages don't provide enqueued_at/created_at attributes diff --git a/lib/gitlab/metrics/subscribers/action_view.rb b/lib/gitlab/metrics/subscribers/action_view.rb index d435a33e9c7..3da474fc1ec 100644 --- a/lib/gitlab/metrics/subscribers/action_view.rb +++ b/lib/gitlab/metrics/subscribers/action_view.rb @@ -15,10 +15,24 @@ module Gitlab private + def metric_view_rendering_duration_seconds + @metric_view_rendering_duration_seconds ||= Gitlab::Metrics.histogram( + :gitlab_view_rendering_duration_seconds, + 'View rendering time', + Transaction::BASE_LABELS.merge({ path: nil }), + [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0] + ) + end + def track(event) values = values_for(event) tags = tags_for(event) + metric_view_rendering_duration_seconds.observe( + current_transaction.labels.merge(tags), + event.duration + ) + current_transaction.increment(:view_duration, event.duration) current_transaction.add_metric(SERIES, values, tags) end diff --git a/lib/gitlab/metrics/subscribers/active_record.rb b/lib/gitlab/metrics/subscribers/active_record.rb index 96cad941d5c..064299f40c8 100644 --- a/lib/gitlab/metrics/subscribers/active_record.rb +++ b/lib/gitlab/metrics/subscribers/active_record.rb @@ -7,9 +7,10 @@ module Gitlab def sql(event) return unless current_transaction + metric_sql_duration_seconds.observe(current_transaction.labels, event.duration / 1000.0) - current_transaction.increment(:sql_duration, event.duration) - current_transaction.increment(:sql_count, 1) + current_transaction.increment(:sql_duration, event.duration, false) + current_transaction.increment(:sql_count, 1, false) end private @@ -17,6 +18,15 @@ module Gitlab def current_transaction Transaction.current end + + def metric_sql_duration_seconds + @metric_sql_duration_seconds ||= Gitlab::Metrics.histogram( + :gitlab_sql_duration_seconds, + 'SQL time', + Transaction::BASE_LABELS, + [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0] + ) + end end end end diff --git a/lib/gitlab/metrics/subscribers/rails_cache.rb b/lib/gitlab/metrics/subscribers/rails_cache.rb index aaed2184f44..efd3c9daf79 100644 --- a/lib/gitlab/metrics/subscribers/rails_cache.rb +++ b/lib/gitlab/metrics/subscribers/rails_cache.rb @@ -7,28 +7,29 @@ module Gitlab attach_to :active_support def cache_read(event) - increment(:cache_read, event.duration) + observe(:read, event.duration) return unless current_transaction return if event.payload[:super_operation] == :fetch if event.payload[:hit] - current_transaction.increment(:cache_read_hit_count, 1) + current_transaction.increment(:cache_read_hit_count, 1, false) else - current_transaction.increment(:cache_read_miss_count, 1) + metric_cache_misses_total.increment(current_transaction.labels) + current_transaction.increment(:cache_read_miss_count, 1, false) end end def cache_write(event) - increment(:cache_write, event.duration) + observe(:write, event.duration) end def cache_delete(event) - increment(:cache_delete, event.duration) + observe(:delete, event.duration) end def cache_exist?(event) - increment(:cache_exists, event.duration) + observe(:exists, event.duration) end def cache_fetch_hit(event) @@ -40,16 +41,18 @@ module Gitlab def cache_generate(event) return unless current_transaction + metric_cache_misses_total.increment(current_transaction.labels) current_transaction.increment(:cache_read_miss_count, 1) end - def increment(key, duration) + def observe(key, duration) return unless current_transaction - current_transaction.increment(:cache_duration, duration) - current_transaction.increment(:cache_count, 1) - current_transaction.increment("#{key}_duration".to_sym, duration) - current_transaction.increment("#{key}_count".to_sym, 1) + metric_cache_operation_duration_seconds.observe(current_transaction.labels.merge({ operation: key }), duration / 1000.0) + current_transaction.increment(:cache_duration, duration, false) + current_transaction.increment(:cache_count, 1, false) + current_transaction.increment("cache_#{key}_duration".to_sym, duration, false) + current_transaction.increment("cache_#{key}_count".to_sym, 1, false) end private @@ -57,6 +60,23 @@ module Gitlab def current_transaction Transaction.current end + + def metric_cache_operation_duration_seconds + @metric_cache_operation_duration_seconds ||= Gitlab::Metrics.histogram( + :gitlab_cache_operation_duration_seconds, + 'Cache access time', + Transaction::BASE_LABELS.merge({ action: nil }), + [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0] + ) + end + + def metric_cache_misses_total + @metric_cache_misses_total ||= Gitlab::Metrics.counter( + :gitlab_cache_misses_total, + 'Cache read miss', + Transaction::BASE_LABELS + ) + end end end end diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index 4f9fb1c7853..ee3afc5ffdb 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -2,34 +2,33 @@ module Gitlab module Metrics # Class for storing metrics information of a single transaction. class Transaction + # base labels shared among all transactions + BASE_LABELS = { controller: nil, action: nil }.freeze + THREAD_KEY = :_gitlab_metrics_transaction + METRICS_MUTEX = Mutex.new # The series to store events (e.g. Git pushes) in. EVENT_SERIES = 'events'.freeze attr_reader :tags, :values, :method, :metrics - attr_accessor :action - def self.current Thread.current[THREAD_KEY] end - # action - A String describing the action performed, usually the class - # plus method name. - def initialize(action = nil) + def initialize @metrics = [] @methods = {} - @started_at = nil + @started_at = nil @finished_at = nil @values = Hash.new(0) - @tags = {} - @action = action + @tags = {} @memory_before = 0 - @memory_after = 0 + @memory_after = 0 end def duration @@ -44,12 +43,15 @@ module Gitlab Thread.current[THREAD_KEY] = self @memory_before = System.memory_usage - @started_at = System.monotonic_time + @started_at = System.monotonic_time yield ensure @memory_after = System.memory_usage - @finished_at = System.monotonic_time + @finished_at = System.monotonic_time + + self.class.metric_transaction_duration_seconds.observe(labels, duration * 1000) + self.class.metric_transaction_allocated_memory_bytes.observe(labels, allocated_memory * 1024.0) Thread.current[THREAD_KEY] = nil end @@ -66,33 +68,29 @@ module Gitlab # event_name - The name of the event (e.g. "git_push"). # tags - A set of tags to attach to the event. def add_event(event_name, tags = {}) - @metrics << Metric.new(EVENT_SERIES, - { count: 1 }, - { event: event_name }.merge(tags), - :event) + self.class.metric_event_counter(event_name, tags).increment(tags.merge(labels)) + @metrics << Metric.new(EVENT_SERIES, { count: 1 }, tags.merge(event: event_name), :event) end # Returns a MethodCall object for the given name. - def method_call_for(name) + def method_call_for(name, module_name, method_name) unless method = @methods[name] - @methods[name] = method = MethodCall.new(name, Instrumentation.series) + @methods[name] = method = MethodCall.new(name, module_name, method_name, self) end method end - def increment(name, value) + def increment(name, value, use_prometheus = true) + self.class.metric_transaction_counter(name).increment(labels, value) if use_prometheus @values[name] += value end - def set(name, value) + def set(name, value, use_prometheus = true) + self.class.metric_transaction_gauge(name).set(labels, value) if use_prometheus @values[name] = value end - def add_tag(key, value) - @tags[key] = value - end - def finish track_self submit @@ -117,14 +115,83 @@ module Gitlab submit_hashes = submit.map do |metric| hash = metric.to_hash - - hash[:tags][:action] ||= @action if @action && !metric.event? + hash[:tags][:action] ||= action if action && !metric.event? hash end Metrics.submit_metrics(submit_hashes) end + + def labels + BASE_LABELS + end + + # returns string describing the action performed, usually the class plus method name. + def action + "#{labels[:controller]}##{labels[:action]}" if labels && !labels.empty? + end + + def self.metric_transaction_duration_seconds + return @metric_transaction_duration_seconds if @metric_transaction_duration_seconds + + METRICS_MUTEX.synchronize do + @metric_transaction_duration_seconds ||= Gitlab::Metrics.histogram( + :gitlab_transaction_duration_seconds, + 'Transaction duration', + BASE_LABELS, + [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.500, 2.0, 10.0] + ) + end + end + + def self.metric_transaction_allocated_memory_bytes + return @metric_transaction_allocated_memory_bytes if @metric_transaction_allocated_memory_bytes + + METRICS_MUTEX.synchronize do + @metric_transaction_allocated_memory_bytes ||= Gitlab::Metrics.histogram( + :gitlab_transaction_allocated_memory_bytes, + 'Transaction allocated memory bytes', + BASE_LABELS, + [1000, 10000, 20000, 500000, 1000000, 2000000, 5000000, 10000000, 20000000, 100000000] + ) + end + end + + def self.metric_event_counter(event_name, tags) + return @metric_event_counters[event_name] if @metric_event_counters&.has_key?(event_name) + + METRICS_MUTEX.synchronize do + @metric_event_counters ||= {} + @metric_event_counters[event_name] ||= Gitlab::Metrics.counter( + "gitlab_transaction_event_#{event_name}_total".to_sym, + "Transaction event #{event_name} counter", + tags.merge(BASE_LABELS) + ) + end + end + + def self.metric_transaction_counter(name) + return @metric_transaction_counters[name] if @metric_transaction_counters&.has_key?(name) + + METRICS_MUTEX.synchronize do + @metric_transaction_counters ||= {} + @metric_transaction_counters[name] ||= Gitlab::Metrics.counter( + "gitlab_transaction_#{name}_total".to_sym, "Transaction #{name} counter", BASE_LABELS + ) + end + end + + def self.metric_transaction_gauge(name) + return @metric_transaction_gauges[name] if @metric_transaction_gauges&.has_key?(name) + + METRICS_MUTEX.synchronize do + @metric_transaction_gauges ||= {} + @metric_transaction_gauges[name] ||= Gitlab::Metrics.gauge( + "gitlab_transaction_#{name}".to_sym, "Transaction gauge #{name}", BASE_LABELS, :livesum + ) + end + end end end end diff --git a/lib/gitlab/metrics/unicorn_sampler.rb b/lib/gitlab/metrics/unicorn_sampler.rb deleted file mode 100644 index f6987252039..00000000000 --- a/lib/gitlab/metrics/unicorn_sampler.rb +++ /dev/null @@ -1,48 +0,0 @@ -module Gitlab - module Metrics - class UnicornSampler < BaseSampler - def initialize(interval) - super(interval) - end - - def unicorn_active_connections - @unicorn_active_connections ||= Gitlab::Metrics.gauge(:unicorn_active_connections, 'Unicorn active connections', {}, :max) - end - - def unicorn_queued_connections - @unicorn_queued_connections ||= Gitlab::Metrics.gauge(:unicorn_queued_connections, 'Unicorn queued connections', {}, :max) - end - - def enabled? - # Raindrops::Linux.tcp_listener_stats is only present on Linux - unicorn_with_listeners? && Raindrops::Linux.respond_to?(:tcp_listener_stats) - end - - def sample - Raindrops::Linux.tcp_listener_stats(tcp_listeners).each do |addr, stats| - unicorn_active_connections.set({ type: 'tcp', address: addr }, stats.active) - unicorn_queued_connections.set({ type: 'tcp', address: addr }, stats.queued) - end - - Raindrops::Linux.unix_listener_stats(unix_listeners).each do |addr, stats| - unicorn_active_connections.set({ type: 'unix', address: addr }, stats.active) - unicorn_queued_connections.set({ type: 'unix', address: addr }, stats.queued) - end - end - - private - - def tcp_listeners - @tcp_listeners ||= Unicorn.listener_names.grep(%r{\A[^/]+:\d+\z}) - end - - def unix_listeners - @unix_listeners ||= Unicorn.listener_names - tcp_listeners - end - - def unicorn_with_listeners? - defined?(Unicorn) && Unicorn.listener_names.any? - end - end - end -end diff --git a/lib/gitlab/metrics/web_transaction.rb b/lib/gitlab/metrics/web_transaction.rb new file mode 100644 index 00000000000..89ff02a96d6 --- /dev/null +++ b/lib/gitlab/metrics/web_transaction.rb @@ -0,0 +1,82 @@ +module Gitlab + module Metrics + class WebTransaction < Transaction + CONTROLLER_KEY = 'action_controller.instance'.freeze + ENDPOINT_KEY = 'api.endpoint'.freeze + + CONTENT_TYPES = { + 'text/html' => :html, + 'text/plain' => :txt, + 'application/json' => :json, + 'text/js' => :js, + 'application/atom+xml' => :atom, + 'image/png' => :png, + 'image/jpeg' => :jpeg, + 'image/gif' => :gif, + 'image/svg+xml' => :svg + }.freeze + + def initialize(env) + super() + @env = env + end + + def labels + return @labels if @labels + + # memoize transaction labels only source env variables were present + @labels = if @env[CONTROLLER_KEY] + labels_from_controller || {} + elsif @env[ENDPOINT_KEY] + labels_from_endpoint || {} + end + + @labels || {} + end + + private + + def labels_from_controller + controller = @env[CONTROLLER_KEY] + + action = "#{controller.action_name}" + suffix = CONTENT_TYPES[controller.content_type] + + if suffix && suffix != :html + action += ".#{suffix}" + end + + { controller: controller.class.name, action: action } + end + + def labels_from_endpoint + endpoint = @env[ENDPOINT_KEY] + + begin + route = endpoint.route + rescue + # endpoint.route is calling env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info] + # but env[Grape::Env::GRAPE_ROUTING_ARGS] is nil in the case of a 405 response + # so we're rescuing exceptions and bailing out + end + + if route + path = endpoint_paths_cache[route.request_method][route.path] + { controller: 'Grape', action: "#{route.request_method} #{path}" } + end + end + + def endpoint_paths_cache + @endpoint_paths_cache ||= Hash.new do |hash, http_method| + hash[http_method] = Hash.new do |inner_hash, raw_path| + inner_hash[raw_path] = endpoint_instrumentable_path(raw_path) + end + end + end + + def endpoint_instrumentable_path(raw_path) + raw_path.sub('(.:format)', '').sub('/:version', '') + end + end + end +end diff --git a/lib/gitlab/middleware/rails_queue_duration.rb b/lib/gitlab/middleware/rails_queue_duration.rb index 63c3372da51..bc70b2459ef 100644 --- a/lib/gitlab/middleware/rails_queue_duration.rb +++ b/lib/gitlab/middleware/rails_queue_duration.rb @@ -14,11 +14,22 @@ module Gitlab proxy_start = env['HTTP_GITLAB_WORKHORSE_PROXY_START'].presence if trans && proxy_start # Time in milliseconds since gitlab-workhorse started the request - trans.set(:rails_queue_duration, Time.now.to_f * 1_000 - proxy_start.to_f / 1_000_000) + duration = Time.now.to_f * 1_000 - proxy_start.to_f / 1_000_000 + trans.set(:rails_queue_duration, duration) + metric_rails_queue_duration_seconds.observe(trans.labels, duration / 1_000) end @app.call(env) end + + private + + def metric_rails_queue_duration_seconds + @metric_rails_queue_duration_seconds ||= Gitlab::Metrics.histogram( + :gitlab_rails_queue_duration_seconds, + Gitlab::Metrics::Transaction::BASE_LABELS + ) + end end end end diff --git a/lib/gitlab/o_auth/user.rb b/lib/gitlab/o_auth/user.rb index 47c2a422387..b4b3b00c84d 100644 --- a/lib/gitlab/o_auth/user.rb +++ b/lib/gitlab/o_auth/user.rb @@ -179,7 +179,7 @@ module Gitlab valid_username = ::Namespace.clean_path(username) uniquify = Uniquify.new - valid_username = uniquify.string(valid_username) { |s| !DynamicPathValidator.valid_user_path?(s) } + valid_username = uniquify.string(valid_username) { |s| !UserPathValidator.valid_path?(s) } name = auth_hash.name name = valid_username if name.strip.empty? diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 70a403652e7..112d4939582 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -48,9 +48,9 @@ module Gitlab deploy_keys: DeployKey.count, deployments: Deployment.count, environments: ::Environment.count, - gcp_clusters: ::Gcp::Cluster.count, - gcp_clusters_enabled: ::Gcp::Cluster.enabled.count, - gcp_clusters_disabled: ::Gcp::Cluster.disabled.count, + clusters: ::Clusters::Cluster.count, + clusters_enabled: ::Clusters::Cluster.enabled.count, + clusters_disabled: ::Clusters::Cluster.disabled.count, in_review_folder: ::Environment.in_review_folder.count, groups: Group.count, issues: Issue.count, diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb index a440a3e3562..9242cbe840c 100644 --- a/lib/google_api/cloud_platform/client.rb +++ b/lib/google_api/cloud_platform/client.rb @@ -3,7 +3,6 @@ require 'google/apis/container_v1' module GoogleApi module CloudPlatform class Client < GoogleApi::Auth - DEFAULT_MACHINE_TYPE = 'n1-standard-1'.freeze SCOPE = 'https://www.googleapis.com/auth/cloud-platform'.freeze LEAST_TOKEN_LIFE_TIME = 10.minutes diff --git a/qa/qa/scenario/test/integration/mattermost.rb b/qa/qa/scenario/test/integration/mattermost.rb index 9a84e5c8fd8..59d7dcd3d23 100644 --- a/qa/qa/scenario/test/integration/mattermost.rb +++ b/qa/qa/scenario/test/integration/mattermost.rb @@ -7,7 +7,7 @@ module QA # including staging and on-premises installation. # class Mattermost < Scenario::Entrypoint - tags :core, :mattermost + tags :mattermost def perform(address, mattermost, *files) Runtime::Scenario.mattermost = mattermost diff --git a/spec/controllers/metrics_controller_spec.rb b/spec/controllers/metrics_controller_spec.rb index 4aed2a25baa..9e8a37171ec 100644 --- a/spec/controllers/metrics_controller_spec.rb +++ b/spec/controllers/metrics_controller_spec.rb @@ -67,7 +67,8 @@ describe MetricsController do it 'returns proper response' do get :index - expect(response.status).to eq(404) + expect(response.status).to eq(200) + expect(response.body).to eq("# Metrics are disabled, see: http://test.host/help/administration/monitoring/prometheus/gitlab_metrics#gitlab-prometheus-metrics\n") end end end diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index bd924a1c7be..de4cf40b492 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -1,68 +1,108 @@ require 'spec_helper' describe Projects::ClustersController do - set(:user) { create(:user) } - set(:project) { create(:project) } - let(:role) { :master } + include AccessMatchersForController + include GoogleApi::CloudPlatformHelpers - before do - project.team << [user, role] + describe 'GET index' do + describe 'functionality' do + let(:user) { create(:user) } - sign_in(user) - end + before do + project.add_master(user) + sign_in(user) + end - describe 'GET index' do - subject do - get :index, namespace_id: project.namespace, - project_id: project - end + context 'when project has a cluster' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - context 'when cluster is already created' do - let!(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + it { expect(go).to redirect_to(project_cluster_path(project, project.cluster)) } + end - it 'redirects to show a cluster' do - subject + context 'when project does not have a cluster' do + let(:project) { create(:project) } - expect(response).to redirect_to(project_cluster_path(project, cluster)) + it { expect(go).to redirect_to(new_project_cluster_path(project)) } end end - context 'when we do not have cluster' do - it 'redirects to create a cluster' do - subject + describe 'security' do + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to redirect_to(new_project_cluster_path(project)) - end + def go + get :index, namespace_id: project.namespace.to_param, project_id: project end end describe 'GET login' do - render_views + let(:project) { create(:project) } - subject do - get :login, namespace_id: project.namespace, - project_id: project - end - - context 'when we do have omniauth configured' do - it 'shows login button' do - subject + describe 'functionality' do + let(:user) { create(:user) } - expect(response.body).to include('auth_buttons/signin_with_google') + before do + project.add_master(user) + sign_in(user) end - end - context 'when we do not have omniauth configured' do - before do - stub_omniauth_setting(providers: []) + context 'when omniauth has been configured' do + let(:key) { 'secere-key' } + + let(:session_key_for_redirect_uri) do + GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(key) + end + + before do + allow(SecureRandom).to receive(:hex).and_return(key) + end + + it 'has authorize_url' do + go + + expect(assigns(:authorize_url)).to include(key) + expect(session[session_key_for_redirect_uri]).to eq(project_clusters_url(project)) + end end - it 'shows notice message' do - subject + context 'when omniauth has not configured' do + before do + stub_omniauth_setting(providers: []) + end + + it 'does not have authorize_url' do + go - expect(response.body).to include('Ask your GitLab administrator if you want to use this service.') + expect(assigns(:authorize_url)).to be_nil + end end end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + get :login, namespace_id: project.namespace, project_id: project + end end shared_examples 'requires to login' do @@ -74,235 +114,335 @@ describe Projects::ClustersController do end describe 'GET new' do - render_views + let(:project) { create(:project) } - subject do - get :new, namespace_id: project.namespace, - project_id: project - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged' do before do - make_logged_in + project.add_master(user) + sign_in(user) + end + + context 'when access token is valid' do + before do + stub_google_api_validate_token + end + + it 'has new object' do + go + + expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster) + end end - it 'shows a creation form' do - subject + context 'when access token is expired' do + before do + stub_google_api_expired_token + end + + it { expect(go).to redirect_to(login_project_clusters_path(project)) } + end - expect(response.body).to include('Create cluster') + context 'when access token is not stored in session' do + it { expect(go).to redirect_to(login_project_clusters_path(project)) } end end - context 'when not logged' do - it_behaves_like 'requires to login' + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + get :new, namespace_id: project.namespace, project_id: project end end describe 'POST create' do - subject do - post :create, params.merge(namespace_id: project.namespace, - project_id: project) + let(:project) { create(:project) } + + let(:params) do + { + cluster: { + name: 'new-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: '111' + } + } + } end - context 'when not logged' do - let(:params) { {} } - - it_behaves_like 'requires to login' - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged in' do before do - make_logged_in + project.add_master(user) + sign_in(user) end - context 'when all required parameters are set' do - let(:params) do - { - cluster: { - gcp_cluster_name: 'new-cluster', - gcp_project_id: '111' - } - } - end - + context 'when access token is valid' do before do - expect(ClusterProvisionWorker).to receive(:perform_async) { } + stub_google_api_validate_token end - it 'creates a new cluster' do - expect { subject }.to change { Gcp::Cluster.count } - - expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + context 'when creates a cluster on gke' do + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { go }.to change { Clusters::Cluster.count } + expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + end end end - context 'when not all required parameters are set' do - render_views - - let(:params) do - { - cluster: { - project_namespace: 'some namespace' - } - } + context 'when access token is expired' do + before do + stub_google_api_expired_token end - it 'shows an error message' do - expect { subject }.not_to change { Gcp::Cluster.count } + it 'redirects to login page' do + expect(go).to redirect_to(login_project_clusters_path(project)) + end + end - expect(response).to render_template(:new) + context 'when access token is not stored in session' do + it 'redirects to login page' do + expect(go).to redirect_to(login_project_clusters_path(project)) end end end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go + post :create, params.merge(namespace_id: project.namespace, project_id: project) + end end describe 'GET status' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + let(:project) { cluster.project } + + describe 'functionality' do + let(:user) { create(:user) } - subject do + before do + project.add_master(user) + sign_in(user) + end + + it "responds with matching schema" do + go + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('cluster_status') + end + end + + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + + def go get :status, namespace_id: project.namespace, project_id: project, id: cluster, format: :json end - - it "responds with matching schema" do - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('cluster_status') - end end describe 'GET show' do - render_views + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + describe 'functionality' do + let(:user) { create(:user) } - subject do - get :show, namespace_id: project.namespace, - project_id: project, - id: cluster - end - - context 'when logged as master' do - it "allows to update cluster" do - subject - - expect(response).to have_gitlab_http_status(:ok) - expect(response.body).to include("Save") + before do + project.add_master(user) + sign_in(user) end - it "allows remove integration" do - subject + it "renders view" do + go expect(response).to have_gitlab_http_status(:ok) - expect(response.body).to include("Remove integration") + expect(assigns(:cluster)).to eq(cluster) end end - context 'when logged as developer' do - let(:role) { :developer } - - it "does not allow to access page" do - subject + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to have_gitlab_http_status(:not_found) - end + def go + get :show, namespace_id: project.namespace, + project_id: project, + id: cluster end end describe 'PUT update' do - render_views + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - let(:service) { project.build_kubernetes_service } - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project, service: service) } - let(:params) { {} } + describe 'functionality' do + let(:user) { create(:user) } - subject do - put :update, params.merge(namespace_id: project.namespace, - project_id: project, - id: cluster) - end + before do + project.add_master(user) + sign_in(user) + end - context 'when logged as master' do - context 'when valid params are used' do + context 'when update enabled' do let(:params) do { cluster: { enabled: false } } end - it "redirects back to show page" do - subject + it "updates and redirects back to show page" do + go + cluster.reload expect(response).to redirect_to(project_cluster_path(project, project.cluster)) expect(flash[:notice]).to eq('Cluster was successfully updated.') + expect(cluster.enabled).to be_falsey end - end - context 'when invalid params are used' do - let(:params) do - { - cluster: { project_namespace: 'my Namespace 321321321 #' } - } - end + context 'when cluster is being created' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } - it "rejects changes" do - subject + it "rejects changes" do + go - expect(response).to have_gitlab_http_status(:ok) - expect(response).to render_template(:show) + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + expect(cluster.enabled).to be_truthy + end end end end - context 'when logged as developer' do - let(:role) { :developer } + describe 'security' do + let(:params) do + { + cluster: { enabled: false } + } + end - it "does not allow to update cluster" do - subject + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - expect(response).to have_gitlab_http_status(:not_found) - end + def go + put :update, params.merge(namespace_id: project.namespace, + project_id: project, + id: cluster) end end describe 'delete update' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } - subject do - delete :destroy, namespace_id: project.namespace, - project_id: project, - id: cluster - end + describe 'functionality' do + let(:user) { create(:user) } - context 'when logged as master' do - it "redirects back to clusters list" do - subject + before do + project.add_master(user) + sign_in(user) + end + + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Platforms::Kubernetes.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(-1) expect(response).to redirect_to(project_clusters_path(project)) expect(flash[:notice]).to eq('Cluster integration was successfully removed.') end - end - context 'when logged as developer' do - let(:role) { :developer } + context 'when cluster is being created' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(-1) + + expect(response).to redirect_to(project_clusters_path(project)) + expect(flash[:notice]).to eq('Cluster integration was successfully removed.') + end + end + + context 'when provider is user' do + let(:cluster) { create(:cluster, :project, :provided_by_user) } - it "does not allow to destroy cluster" do - subject + it "destroys and redirects back to clusters list" do + expect { go } + .to change { Clusters::Cluster.count }.by(-1) + .and change { Clusters::Platforms::Kubernetes.count }.by(-1) + .and change { Clusters::Providers::Gcp.count }.by(0) - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to redirect_to(project_clusters_path(project)) + expect(flash[:notice]).to eq('Cluster integration was successfully removed.') + end end end - end - def make_logged_in - session[GoogleApi::CloudPlatform::Client.session_key_for_token] = '1234' - session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = in_hour.to_i.to_s - end + describe 'security' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end - def in_hour - Time.now + 1.hour + def go + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: cluster + end end end diff --git a/spec/factories/clusters/cluster.rb b/spec/factories/clusters/cluster.rb new file mode 100644 index 00000000000..c4261178f2d --- /dev/null +++ b/spec/factories/clusters/cluster.rb @@ -0,0 +1,39 @@ +FactoryGirl.define do + factory :cluster, class: Clusters::Cluster do + user + name 'test-cluster' + + trait :project do + after(:create) do |cluster, evaluator| + cluster.projects << create(:project) + end + end + + trait :provided_by_user do + provider_type :user + platform_type :kubernetes + + platform_kubernetes do + create(:cluster_platform_kubernetes, :configured) + end + end + + trait :provided_by_gcp do + provider_type :gcp + platform_type :kubernetes + + before(:create) do |cluster, evaluator| + cluster.platform_kubernetes = build(:cluster_platform_kubernetes, :configured) + cluster.provider_gcp = build(:cluster_provider_gcp, :created) + end + end + + trait :providing_by_gcp do + provider_type :gcp + + provider_gcp do + create(:cluster_provider_gcp, :creating) + end + end + end +end diff --git a/spec/factories/clusters/platforms/kubernetes.rb b/spec/factories/clusters/platforms/kubernetes.rb new file mode 100644 index 00000000000..8b3e6ff35fa --- /dev/null +++ b/spec/factories/clusters/platforms/kubernetes.rb @@ -0,0 +1,20 @@ +FactoryGirl.define do + factory :cluster_platform_kubernetes, class: Clusters::Platforms::Kubernetes do + cluster + namespace nil + api_url 'https://kubernetes.example.com' + token 'a' * 40 + + trait :configured do + api_url 'https://kubernetes.example.com' + token 'a' * 40 + username 'xxxxxx' + password 'xxxxxx' + + after(:create) do |platform_kubernetes, evaluator| + pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) + platform_kubernetes.ca_cert = File.read(pem_file) + end + end + end +end diff --git a/spec/factories/clusters/providers/gcp.rb b/spec/factories/clusters/providers/gcp.rb new file mode 100644 index 00000000000..a815410512a --- /dev/null +++ b/spec/factories/clusters/providers/gcp.rb @@ -0,0 +1,32 @@ +FactoryGirl.define do + factory :cluster_provider_gcp, class: Clusters::Providers::Gcp do + cluster + gcp_project_id 'test-gcp-project' + + trait :scheduled do + access_token 'access_token_123' + end + + trait :creating do + access_token 'access_token_123' + + after(:build) do |gcp, evaluator| + gcp.make_creating('operation-123') + end + end + + trait :created do + endpoint '111.111.111.111' + + after(:build) do |gcp, evaluator| + gcp.make_created + end + end + + trait :errored do + after(:build) do |gcp, evaluator| + gcp.make_errored('Something wrong') + end + end + end +end diff --git a/spec/factories/gcp/cluster.rb b/spec/factories/gcp/cluster.rb deleted file mode 100644 index 61a4b01bb6b..00000000000 --- a/spec/factories/gcp/cluster.rb +++ /dev/null @@ -1,38 +0,0 @@ -FactoryGirl.define do - factory :gcp_cluster, class: Gcp::Cluster do - project - user - enabled true - gcp_project_id 'gcp-project-12345' - gcp_cluster_name 'test-cluster' - gcp_cluster_zone 'us-central1-a' - gcp_cluster_size 1 - gcp_machine_type 'n1-standard-2' - - trait :with_kubernetes_service do - after(:create) do |cluster, evaluator| - create(:kubernetes_service, project: cluster.project).tap do |service| - cluster.update(service: service) - end - end - end - - trait :custom_project_namespace do - project_namespace 'sample-app' - end - - trait :created_on_gke do - status_event :make_created - endpoint '111.111.111.111' - ca_cert 'xxxxxx' - kubernetes_token 'xxxxxx' - username 'xxxxxx' - password 'xxxxxx' - end - - trait :errored do - status_event :make_errored - status_reason 'general error' - end - end -end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 7c4a22c94c2..cc6cef63b47 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -83,10 +83,10 @@ FactoryGirl.define do target_project = merge_request.target_project source_project = merge_request.source_project - # Fake `write_ref` if we don't have repository + # Fake `fetch_ref!` if we don't have repository # We have too many existing tests replying on this behaviour unless [target_project, source_project].all?(&:repository_exists?) - allow(merge_request).to receive(:write_ref) + allow(merge_request).to receive(:fetch_ref!) end end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index c6ba1211b9e..1fcb8d5bc67 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -664,7 +664,7 @@ describe 'Copy as GFM', :js do def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) js = <<-JS.strip_heredoc (function(html) { - var transformer = window.gl.CopyAsGFM[#{transformer.inspect}]; + var transformer = window.CopyAsGFM[#{transformer.inspect}]; var node = document.createElement('div'); $(html).each(function() { node.appendChild(this) }); @@ -678,7 +678,7 @@ describe 'Copy as GFM', :js do node = transformer(node, target); if (!node) return null; - return window.gl.CopyAsGFM.nodeToGFM(node); + return window.CopyAsGFM.nodeToGFM(node); })("#{escape_javascript(html)}") JS page.evaluate_script(js) diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb index 546dc7e8a49..edea95c6699 100644 --- a/spec/features/issues/create_branch_merge_request_spec.rb +++ b/spec/features/issues/create_branch_merge_request_spec.rb @@ -64,6 +64,19 @@ feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do end end + context 'when merge requests are disabled' do + before do + project.project_feature.update(merge_requests_access_level: 0) + + visit project_issue_path(project, issue) + end + + it 'shows only create branch button' do + expect(page).not_to have_button('Create a merge request') + expect(page).to have_button('Create a branch') + end + end + context 'when issue is confidential' do it 'disables the create branch button' do issue = create(:issue, :confidential, project: project) diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index 810f2c39b43..27c1f5062f5 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Clusters', :js do + include GoogleApi::CloudPlatformHelpers + let!(:project) { create(:project, :repository) } let!(:user) { create(:user) } @@ -11,8 +13,10 @@ feature 'Clusters', :js do context 'when user has signed in Google' do before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:validate_token).and_return(true) + allow_any_instance_of(Projects::ClustersController) + .to receive(:token_in_session).and_return('token') + allow_any_instance_of(Projects::ClustersController) + .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s) end context 'when user does not have a cluster and visits cluster index page' do @@ -36,15 +40,15 @@ feature 'Clusters', :js do allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) - fill_in 'cluster_gcp_project_id', with: 'gcp-project-123' - fill_in 'cluster_gcp_cluster_name', with: 'dev-cluster' + fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123' + fill_in 'cluster_name', with: 'dev-cluster' click_button 'Create cluster' end it 'user sees a cluster details page and creation status' do expect(page).to have_content('Cluster is being created on Google Container Engine...') - Gcp::Cluster.last.make_created! + Clusters::Cluster.last.provider.make_created! expect(page).to have_content('Cluster was successfully created on Google Container Engine') end @@ -62,7 +66,8 @@ feature 'Clusters', :js do end context 'when user has a cluster and visits cluster index page' do - let!(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service, project: project) } + let!(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } before do visit project_clusters_path(project) @@ -70,7 +75,7 @@ feature 'Clusters', :js do it 'user sees an cluster details page' do expect(page).to have_button('Save') - expect(page.find(:css, '.cluster-name').value).to eq(cluster.gcp_cluster_name) + expect(page.find(:css, '.cluster-name').value).to eq(cluster.name) end context 'when user disables the cluster' do diff --git a/spec/fixtures/clusters/sample_cert.pem b/spec/fixtures/clusters/sample_cert.pem new file mode 100644 index 00000000000..e39a2b34416 --- /dev/null +++ b/spec/fixtures/clusters/sample_cert.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFtTCCA52gAwIBAgIJAOutg3Kf2y5dMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcxMDI5MTgxOTU3WhcNMTgxMDI5MTgxOTU3WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAvQysroM3TLxaavadSPnFIltrYnxCnU4PvCR8971HMWXsq7Z4ShU4BbbE +8yp7oUFjulSwW6DhdIvnQb8ihLKictLmrA0isQqrD/iNpKZ6/lI4DGWw4QzrvMnW +V4yy2QZNpg9tzQHd4+xkeeIoG23RijDU/sPd5dqxF+rPHBfCVInmYvSzLvMhneNj +Bt6gV02gU9e9hsnMatsDvEbvWKp7wcbPot0nWrfZulx2QAWyXy+zG9mJQUds6yc0 +4agAeT9JEb/xtRgR/kS0aUHSGnfSnhZiEn17s0PhTmbu7qSHgzgB+7oJrC9jPoUh +S2Wo3n0xykAjHrA8wC/Ddw3L38S41VQ58GEfNchistPswyMmXo/Oenv9P3s/kCOI +fndiksFNdqVo51y9Vjngj589hpOseFDyKmWPIEQZ9kxW/crjP6RZWWLHgz26KtxZ +uJaoYL8VBbYfrk/bucw0Ma2GEOp8rTsBE7SvgejXZa78q+381Kzc/utW6VwSXqzY +xeIitft0rXi17SZ+XoiTkIXtHn0ZwMtOXNDBADTpFmKa6wVACQilvcpOYD8gUHyH +pB+EDRdST3M4Fiq1MBAVhk8Lj3tHSJ/1ymeF1PWSu57AnJlzerzq2fcfPotNNd37 +ZPNkPh0kxPLwxbAyrHflzx9qVVdI1irY9055mNSnhzlec4qJ9cECAwEAAaOBpzCB +pDAdBgNVHQ4EFgQUnVa5dYPoIG/3+qXml0bX8+N16GwwdQYDVR0jBG4wbIAUnVa5 +dYPoIG/3+qXml0bX8+N16GyhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT +b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQDr +rYNyn9suXTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQAUg4cyxXi1 +VR8ejTpaAruRyJ1pEG9Kc3kiIRXODy60z3hJXnx9LkScPkWGiuL5XacfZ2rMd4bw +oVXIyi8U1UHWfAH8EZdrFKkU92jCiL5soHUONxLAvQEJ/FTR/qijrpzLCxXBdVQE +xFEDWUu6rxLFyjEwzwnRTLgpjR606fdb7qXHkuAMvZ/ezJj8j97hok3Odpn4lr2H +6hMTpK7HmDBX+kmdJJ+yBrm9hG1Pzpl7QU0dkxZ+qJNFjYMLnziiTwkv0c5ZaA9E +NykZUcOv3Sjb6spu1A/E2BSq4WTjkIjrogFlfimE1vmUmObTRJOqUB0Vky1kHEwN +pg7QqIJQmof1EAIaSM/YpUWXyumBwGLDUEud1JUz05In9Q4IZjEwZSJwbQW4fUia +A93m9rk3Lw3xsFcaUdPMFIXk0rPoF1IgmV/oqb0gK95lOWRLbN+AV8qpKPpcKXOc +TkIdFE47ZisEDhIdF6wC1izEMLeMEsPAO7/Y6MY4nRxsinSe95lRaw+yQpzx+mvJ +Q7n1kiHI9Pd5M3+CiQda0d/GO1o5ORJnUGJRvr9HKuNmE7Lif0As/N0AlywjzE7A +6Z8AEiWyRV1ffshu1k2UKmzvZuZeGGKRtrIjbJIRAtpRVtVZZGzhq5/sojCLoJ+u +texqFBUo/4mFRZa4pDItUdyOlDy2/LO/ag== +-----END CERTIFICATE----- diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index d5536fcb22b..8a80b88da5d 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -1,96 +1,6 @@ require 'spec_helper' describe EventsHelper do - describe '#event_note' do - let(:user) { build(:user) } - - before do - allow(helper).to receive(:current_user).and_return(user) - end - - it 'displays one line of plain text without alteration' do - input = 'A short, plain note' - expect(helper.event_note(input)).to match(input) - expect(helper.event_note(input)).not_to match(/\.\.\.\z/) - end - - it 'displays inline code' do - input = 'A note with `inline code`' - expected = 'A note with <code>inline code</code>' - - expect(helper.event_note(input)).to match(expected) - end - - it 'truncates a note with multiple paragraphs' do - input = "Paragraph 1\n\nParagraph 2" - expected = 'Paragraph 1...' - - expect(helper.event_note(input)).to match(expected) - end - - it 'displays the first line of a code block' do - input = "```\nCode block\nwith two lines\n```" - expected = %r{<pre.+><code><span class="line">Code block\.\.\.</span>\n</code></pre>} - - expect(helper.event_note(input)).to match(expected) - end - - it 'truncates a single long line of text' do - text = 'The quick brown fox jumped over the lazy dog twice' # 50 chars - input = text * 4 - expected = (text * 2).sub(/.{3}/, '...') - - expect(helper.event_note(input)).to match(expected) - end - - it 'preserves a link href when link text is truncated' do - text = 'The quick brown fox jumped over the lazy dog' # 44 chars - input = "#{text}#{text}#{text} " # 133 chars - link_url = 'http://example.com/foo/bar/baz' # 30 chars - input << link_url - expected_link_text = 'http://example...</a>' - - expect(helper.event_note(input)).to match(link_url) - expect(helper.event_note(input)).to match(expected_link_text) - end - - it 'preserves code color scheme' do - input = "```ruby\ndef test\n 'hello world'\nend\n```" - expected = "\n<pre class=\"code highlight js-syntax-highlight ruby\">" \ - "<code><span class=\"line\"><span class=\"k\">def</span> <span class=\"nf\">test</span>...</span>\n" \ - "</code></pre>" - expect(helper.event_note(input)).to eq(expected) - end - - it 'preserves data-src for lazy images' do - input = "![ImageTest](/uploads/test.png)" - image_url = "data-src=\"/uploads/test.png\"" - expect(helper.event_note(input)).to match(image_url) - end - - context 'labels formatting' do - let(:input) { 'this should be ~label_1' } - - def format_event_note(project) - create(:label, title: 'label_1', project: project) - - helper.event_note(input, { project: project }) - end - - it 'preserves style attribute for a label that can be accessed by current_user' do - project = create(:project, :public) - - expect(format_event_note(project)).to match(/span class=.*style=.*/) - end - - it 'does not style a label that can not be accessed by current_user' do - project = create(:project, :private) - - expect(format_event_note(project)).to eq("<p>#{input}</p>") - end - end - end - describe '#event_commit_title' do let(:message) { "foo & bar " + "A" * 70 + "\n" + "B" * 80 } subject { helper.event_commit_title(message) } diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 03d706062b7..62ea6d48542 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -67,7 +67,7 @@ describe MarkupHelper do describe 'without redacted attribute' do it 'renders the markdown value' do - expect(Banzai).to receive(:render_field).with(commit, attribute).and_call_original + expect(Banzai).to receive(:render_field).with(commit, attribute, {}).and_call_original helper.markdown_field(commit, attribute) end @@ -252,38 +252,141 @@ describe MarkupHelper do end describe '#first_line_in_markdown' do - it 'truncates Markdown properly' do - text = "@#{user.username}, can you look at this?\nHello world\n" - actual = first_line_in_markdown(text, 100, project: project) + shared_examples_for 'common markdown examples' do + let(:project_base) { build(:project, :repository) } - doc = Nokogiri::HTML.parse(actual) + it 'displays inline code' do + object = create_object('Text with `inline code`') + expected = 'Text with <code>inline code</code>' - # Make sure we didn't create invalid markup - expect(doc.errors).to be_empty + expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) + end - # Leading user link - expect(doc.css('a').length).to eq(1) - expect(doc.css('a')[0].attr('href')).to eq user_path(user) - expect(doc.css('a')[0].text).to eq "@#{user.username}" + it 'truncates the text with multiple paragraphs' do + object = create_object("Paragraph 1\n\nParagraph 2") + expected = 'Paragraph 1...' - expect(doc.content).to eq "@#{user.username}, can you look at this?..." - end + expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) + end - it 'truncates Markdown with emoji properly' do - text = "foo :wink:\nbar :grinning:" - actual = first_line_in_markdown(text, 100, project: project) + it 'displays the first line of a code block' do + object = create_object("```\nCode block\nwith two lines\n```") + expected = %r{<pre.+><code><span class="line">Code block\.\.\.</span>\n</code></pre>} - doc = Nokogiri::HTML.parse(actual) + expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) + end - # Make sure we didn't create invalid markup - # But also account for the 2 errors caused by the unknown `gl-emoji` elements - expect(doc.errors.length).to eq(2) + it 'truncates a single long line of text' do + text = 'The quick brown fox jumped over the lazy dog twice' # 50 chars + object = create_object(text * 4) + expected = (text * 2).sub(/.{3}/, '...') + + expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected) + end + + it 'preserves a link href when link text is truncated' do + text = 'The quick brown fox jumped over the lazy dog' # 44 chars + input = "#{text}#{text}#{text} " # 133 chars + link_url = 'http://example.com/foo/bar/baz' # 30 chars + input << link_url + object = create_object(input) + expected_link_text = 'http://example...</a>' + + expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(link_url) + expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected_link_text) + end + + it 'preserves code color scheme' do + object = create_object("```ruby\ndef test\n 'hello world'\nend\n```") + expected = "\n<pre class=\"code highlight js-syntax-highlight ruby\">" \ + "<code><span class=\"line\"><span class=\"k\">def</span> <span class=\"nf\">test</span>...</span>\n" \ + "</code></pre>" + + expect(first_line_in_markdown(object, attribute, 150, project: project)).to eq(expected) + end + + it 'preserves data-src for lazy images' do + object = create_object("![ImageTest](/uploads/test.png)") + image_url = "data-src=\".*/uploads/test.png\"" + + expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(image_url) + end + + context 'labels formatting' do + let(:label_title) { 'this should be ~label_1' } + + def create_and_format_label(project) + create(:label, title: 'label_1', project: project) + object = create_object(label_title, project: project) - expect(doc.css('gl-emoji').length).to eq(2) - expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' - expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + first_line_in_markdown(object, attribute, 150, project: project) + end - expect(doc.content).to eq "foo 😉\nbar 😀" + it 'preserves style attribute for a label that can be accessed by current_user' do + project = create(:project, :public) + + expect(create_and_format_label(project)).to match(/span class=.*style=.*/) + end + + it 'does not style a label that can not be accessed by current_user' do + project = create(:project, :private) + + expect(create_and_format_label(project)).to eq("<p>#{label_title}</p>") + end + end + + it 'truncates Markdown properly' do + object = create_object("@#{user.username}, can you look at this?\nHello world\n") + actual = first_line_in_markdown(object, attribute, 100, project: project) + + doc = Nokogiri::HTML.parse(actual) + + # Make sure we didn't create invalid markup + expect(doc.errors).to be_empty + + # Leading user link + expect(doc.css('a').length).to eq(1) + expect(doc.css('a')[0].attr('href')).to eq user_path(user) + expect(doc.css('a')[0].text).to eq "@#{user.username}" + + expect(doc.content).to eq "@#{user.username}, can you look at this?..." + end + + it 'truncates Markdown with emoji properly' do + object = create_object("foo :wink:\nbar :grinning:") + actual = first_line_in_markdown(object, attribute, 100, project: project) + + doc = Nokogiri::HTML.parse(actual) + + # Make sure we didn't create invalid markup + # But also account for the 2 errors caused by the unknown `gl-emoji` elements + expect(doc.errors.length).to eq(2) + + expect(doc.css('gl-emoji').length).to eq(2) + expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' + expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + + expect(doc.content).to eq "foo 😉\nbar 😀" + end + end + + context 'when the asked attribute can be redacted' do + include_examples 'common markdown examples' do + let(:attribute) { :note } + def create_object(title, project: project_base) + build(:note, note: title, project: project) + end + end + end + + context 'when the asked attribute can not be redacted' do + include_examples 'common markdown examples' do + let(:attribute) { :body } + def create_object(title, project: project_base) + issue = build(:issue, title: title) + build(:todo, :done, project: project_base, author: user, target: issue) + end + end end end diff --git a/spec/initializers/8_metrics_spec.rb b/spec/initializers/8_metrics_spec.rb index 4e6052a9f80..80c77057065 100644 --- a/spec/initializers/8_metrics_spec.rb +++ b/spec/initializers/8_metrics_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' describe 'instrument_classes' do let(:config) { double(:config) } - let(:unicorn_sampler) { double(:unicorn_sampler) } let(:influx_sampler) { double(:influx_sampler) } before do @@ -11,9 +10,7 @@ describe 'instrument_classes' do allow(config).to receive(:instrument_methods) allow(config).to receive(:instrument_instance_method) allow(config).to receive(:instrument_instance_methods) - allow(Gitlab::Metrics::UnicornSampler).to receive(:initialize_instance).and_return(unicorn_sampler) - allow(Gitlab::Metrics::InfluxSampler).to receive(:initialize_instance).and_return(influx_sampler) - allow(unicorn_sampler).to receive(:start) + allow(Gitlab::Metrics::Samplers::InfluxSampler).to receive(:initialize_instance).and_return(influx_sampler) allow(influx_sampler).to receive(:start) allow(Gitlab::Application).to receive(:configure) end diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js new file mode 100644 index 00000000000..b8155144e2a --- /dev/null +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -0,0 +1,47 @@ +import { CopyAsGFM } from '~/behaviors/copy_as_gfm'; + +describe('CopyAsGFM', () => { + describe('CopyAsGFM.pasteGFM', () => { + function callPasteGFM() { + const e = { + originalEvent: { + clipboardData: { + getData(mimeType) { + // When GFM code is copied, we put the regular plain text + // on the clipboard as `text/plain`, and the GFM as `text/x-gfm`. + // This emulates the behavior of `getData` with that data. + if (mimeType === 'text/plain') { + return 'code'; + } + if (mimeType === 'text/x-gfm') { + return '`code`'; + } + return null; + }, + }, + }, + preventDefault() {}, + }; + + CopyAsGFM.pasteGFM(e); + } + + it('wraps pasted code when not already in code tags', () => { + spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { + const insertedText = textFunc('This is code: ', ''); + expect(insertedText).toEqual('`code`'); + }); + + callPasteGFM(); + }); + + it('does not wrap pasted code when already in code tags', () => { + spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { + const insertedText = textFunc('This is code: `', '`'); + expect(insertedText).toEqual('code'); + }); + + callPasteGFM(); + }); + }); +}); diff --git a/spec/javascripts/copy_as_gfm_spec.js b/spec/javascripts/copy_as_gfm_spec.js deleted file mode 100644 index ded450749d3..00000000000 --- a/spec/javascripts/copy_as_gfm_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import '~/copy_as_gfm'; - -(() => { - describe('gl.CopyAsGFM', () => { - describe('gl.CopyAsGFM.pasteGFM', () => { - function callPasteGFM() { - const e = { - originalEvent: { - clipboardData: { - getData(mimeType) { - // When GFM code is copied, we put the regular plain text - // on the clipboard as `text/plain`, and the GFM as `text/x-gfm`. - // This emulates the behavior of `getData` with that data. - if (mimeType === 'text/plain') { - return 'code'; - } - if (mimeType === 'text/x-gfm') { - return '`code`'; - } - return null; - }, - }, - }, - preventDefault() {}, - }; - - window.gl.CopyAsGFM.pasteGFM(e); - } - - it('wraps pasted code when not already in code tags', () => { - spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { - const insertedText = textFunc('This is code: ', ''); - expect(insertedText).toEqual('`code`'); - }); - - callPasteGFM(); - }); - - it('does not wrap pasted code when already in code tags', () => { - spyOn(window.gl.utils, 'insertText').and.callFake((el, textFunc) => { - const insertedText = textFunc('This is code: `', '`'); - expect(insertedText).toEqual('code'); - }); - - callPasteGFM(); - }); - }); - }); -})(); diff --git a/spec/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb index 5774f36f026..8e74c4f859c 100644 --- a/spec/javascripts/fixtures/clusters.rb +++ b/spec/javascripts/fixtures/clusters.rb @@ -6,7 +6,7 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace) } - let(:cluster) { project.create_cluster!(gcp_cluster_name: "gke-test-creation-1", gcp_project_id: 'gitlab-internal-153318', gcp_cluster_zone: 'us-central1-a', gcp_cluster_size: '1', project_namespace: 'aaa', gcp_machine_type: 'n1-standard-1')} + let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } render_views diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml index 7785120da5b..0421ed2182f 100644 --- a/spec/javascripts/fixtures/search_autocomplete.html.haml +++ b/spec/javascripts/fixtures/search_autocomplete.html.haml @@ -8,3 +8,4 @@ %input#search.search-input.dropdown-menu-toggle .dropdown-menu.dropdown-select .dropdown-content + %input{ type: "hidden", class: "js-search-project-options" } diff --git a/spec/javascripts/lib/utils/datefix_spec.js b/spec/javascripts/lib/utils/datefix_spec.js index 0b9fde2be67..e58ac4300ba 100644 --- a/spec/javascripts/lib/utils/datefix_spec.js +++ b/spec/javascripts/lib/utils/datefix_spec.js @@ -1,4 +1,4 @@ -import { pad, parsePikadayDate, pikadayToString } from '~/lib/utils/datefix'; +import { pad, pikadayToString } from '~/lib/utils/datefix'; describe('datefix', () => { describe('pad', () => { @@ -16,9 +16,7 @@ describe('datefix', () => { }); describe('parsePikadayDate', () => { - it('should return a UTC date', () => { - expect(parsePikadayDate('2020-01-29')).toEqual(new Date('2020-01-29')); - }); + // removed because of https://gitlab.com/gitlab-org/gitlab-ce/issues/39834 }); describe('pikadayToString', () => { diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js index 2571b7ef869..145c8db28d5 100644 --- a/spec/javascripts/monitoring/graph/legend_spec.js +++ b/spec/javascripts/monitoring/graph/legend_spec.js @@ -28,7 +28,7 @@ const defaultValuesComponent = { currentDataIndex: 0, }; -const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], +const timeSeries = createTimeSeries(convertedMetrics[0].queries, defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight, defaultValuesComponent.graphHeightOffset); diff --git a/spec/javascripts/monitoring/graph_path_spec.js b/spec/javascripts/monitoring/graph_path_spec.js index 81825a3ae87..8ece913ada8 100644 --- a/spec/javascripts/monitoring/graph_path_spec.js +++ b/spec/javascripts/monitoring/graph_path_spec.js @@ -13,7 +13,7 @@ const createComponent = (propsData) => { const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); -const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], 428, 272, 120); +const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120); const firstTimeSeries = timeSeries[0]; describe('Monitoring Paths', () => { diff --git a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js index 7e44a9ade9e..99584c75287 100644 --- a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js +++ b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js @@ -2,7 +2,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series'; import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data'; const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); -const timeSeries = createTimeSeries(convertedMetrics[0].queries[0], 428, 272, 120); +const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120); const firstTimeSeries = timeSeries[0]; describe('Multiple time series', () => { diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 5e55a5d2686..a2394857b82 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -57,6 +57,10 @@ import '~/lib/utils/common_utils'; } }; + const disableProjectIssues = function() { + document.querySelector('.js-search-project-options').setAttribute('data-issues-disabled', true); + }; + // Mock `gl` object in window for dashboard specific page. App code will need it. mockDashboardOptions = function() { window.gl || (window.gl = {}); @@ -91,18 +95,20 @@ import '~/lib/utils/common_utils'; assertLinks = function(list, issuesPath, mrsPath) { var a1, a2, a3, a4, issuesAssignedToMeLink, issuesIHaveCreatedLink, mrsAssignedToMeLink, mrsIHaveCreatedLink; - issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName; - issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName; + if (issuesPath) { + issuesAssignedToMeLink = issuesPath + "/?assignee_username=" + userName; + issuesIHaveCreatedLink = issuesPath + "/?author_username=" + userName; + a1 = "a[href='" + issuesAssignedToMeLink + "']"; + a2 = "a[href='" + issuesIHaveCreatedLink + "']"; + expect(list.find(a1).length).toBe(1); + expect(list.find(a1).text()).toBe('Issues assigned to me'); + expect(list.find(a2).length).toBe(1); + expect(list.find(a2).text()).toBe("Issues I've created"); + } mrsAssignedToMeLink = mrsPath + "/?assignee_username=" + userName; mrsIHaveCreatedLink = mrsPath + "/?author_username=" + userName; - a1 = "a[href='" + issuesAssignedToMeLink + "']"; - a2 = "a[href='" + issuesIHaveCreatedLink + "']"; a3 = "a[href='" + mrsAssignedToMeLink + "']"; a4 = "a[href='" + mrsIHaveCreatedLink + "']"; - expect(list.find(a1).length).toBe(1); - expect(list.find(a1).text()).toBe('Issues assigned to me'); - expect(list.find(a2).length).toBe(1); - expect(list.find(a2).text()).toBe("Issues I've created"); expect(list.find(a3).length).toBe(1); expect(list.find(a3).text()).toBe('Merge requests assigned to me'); expect(list.find(a4).length).toBe(1); @@ -153,6 +159,14 @@ import '~/lib/utils/common_utils'; list = widget.wrap.find('.dropdown-menu').find('ul'); return assertLinks(list, projectIssuesPath, projectMRsPath); }); + it('should show only Project mergeRequest dropdown menu items when project issues are disabled', function() { + addBodyAttributes('project'); + disableProjectIssues(); + mockProjectOptions(); + widget.searchInput.triggerHandler('focus'); + const list = widget.wrap.find('.dropdown-menu').find('ul'); + assertLinks(list, null, projectMRsPath); + }); it('should not show category related menu if there is text in the input', function() { var link, list; addBodyAttributes('project'); diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index f6320db8dc4..5d6a885d4cc 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,6 +1,8 @@ -import '~/copy_as_gfm'; +import initCopyAsGFM from '~/behaviors/copy_as_gfm'; import ShortcutsIssuable from '~/shortcuts_issuable'; +initCopyAsGFM(); + describe('ShortcutsIssuable', () => { const fixtureName = 'merge_requests/diff_comment.html.raw'; preloadFixtures(fixtureName); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index d4e134583c7..fd7aa332d17 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -11,6 +11,12 @@ const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent); Vue.config.devtools = !isHeadlessChrome; Vue.config.productionTip = false; +let hasVueWarnings = false; +Vue.config.warnHandler = (msg, vm, trace) => { + hasVueWarnings = true; + fail(`${msg}${trace}`); +}; + Vue.use(VueResource); // enable test fixtures @@ -34,11 +40,6 @@ window.addEventListener('unhandledrejection', (event) => { console.error(event.reason.stack || event.reason); }); -const checkUnhandledPromiseRejections = (done) => { - expect(hasUnhandledPromiseRejections).toBe(false); - done(); -}; - // HACK: Chrome 59 disconnects if there are too many synchronous tests in a row // because it appears to lock up the thread that communicates to Karma's socket // This async beforeEach gets called on every spec and releases the JS thread long @@ -47,17 +48,6 @@ const checkUnhandledPromiseRejections = (done) => { // to run our unit tests. beforeEach(done => done()); -beforeAll(() => { - const origError = console.error; - spyOn(console, 'error').and.callFake((message) => { - if (/^\[Vue warn\]/.test(message)) { - fail(message); - } else { - origError(message); - } - }); -}); - const builtinVueHttpInterceptors = Vue.http.interceptors.slice(); beforeEach(() => { @@ -80,8 +70,22 @@ testsContext.keys().forEach(function (path) { } }); -it('has no unhandled Promise rejections', (done) => { - setTimeout(checkUnhandledPromiseRejections(done), 1000); +describe('test errors', () => { + beforeAll((done) => { + if (hasUnhandledPromiseRejections || hasVueWarnings) { + setTimeout(done, 1000); + } else { + done(); + } + }); + + it('has no unhandled Promise rejections', () => { + expect(hasUnhandledPromiseRejections).toBe(false); + }); + + it('has no Vue warnings', () => { + expect(hasVueWarnings).toBe(false); + }); }); // if we're generating coverage reports, make sure to include all files so diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js index 65c49b9f30b..24209be83fe 100644 --- a/spec/javascripts/vue_shared/components/markdown/field_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -1,6 +1,12 @@ import Vue from 'vue'; import fieldComponent from '~/vue_shared/components/markdown/field.vue'; +function assertMarkdownTabs(isWrite, writeLink, previewLink, vm) { + expect(writeLink.parentNode.classList.contains('active')).toEqual(isWrite); + expect(previewLink.parentNode.classList.contains('active')).toEqual(!isWrite); + expect(vm.$el.querySelector('.md-preview').style.display).toEqual(isWrite ? 'none' : ''); +} + describe('Markdown field component', () => { let vm; @@ -39,6 +45,7 @@ describe('Markdown field component', () => { describe('markdown preview', () => { let previewLink; + let writeLink; beforeEach(() => { spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => { @@ -53,7 +60,8 @@ describe('Markdown field component', () => { }); })); - previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a'); + previewLink = vm.$el.querySelector('.nav-links .js-preview-link'); + writeLink = vm.$el.querySelector('.nav-links .js-write-link'); }); it('sets preview link as active', (done) => { @@ -105,6 +113,23 @@ describe('Markdown field component', () => { done(); }, 0); }); + + it('clicking already active write or preview link does nothing', (done) => { + writeLink.click(); + Vue.nextTick() + .then(() => assertMarkdownTabs(true, writeLink, previewLink, vm)) + .then(() => writeLink.click()) + .then(() => Vue.nextTick()) + .then(() => assertMarkdownTabs(true, writeLink, previewLink, vm)) + .then(() => previewLink.click()) + .then(() => Vue.nextTick()) + .then(() => assertMarkdownTabs(false, writeLink, previewLink, vm)) + .then(() => previewLink.click()) + .then(() => Vue.nextTick()) + .then(() => assertMarkdownTabs(false, writeLink, previewLink, vm)) + .then(done) + .catch(done.fail); + }); }); describe('markdown buttons', () => { diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js index 7110ff36937..edebd822295 100644 --- a/spec/javascripts/vue_shared/components/markdown/header_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -43,11 +43,13 @@ describe('Markdown field header component', () => { it('emits toggle markdown event when clicking preview', () => { spyOn(vm, '$emit'); - vm.$el.querySelector('li:nth-child(2) a').click(); + vm.$el.querySelector('.js-preview-link').click(); - expect( - vm.$emit, - ).toHaveBeenCalledWith('toggle-markdown'); + expect(vm.$emit).toHaveBeenCalledWith('preview-markdown'); + + vm.$el.querySelector('.js-write-link').click(); + + expect(vm.$emit).toHaveBeenCalledWith('write-markdown'); }); it('blurs preview link after click', (done) => { diff --git a/spec/lib/banzai/commit_renderer_spec.rb b/spec/lib/banzai/commit_renderer_spec.rb index 049d025a5b9..84adaebdcbe 100644 --- a/spec/lib/banzai/commit_renderer_spec.rb +++ b/spec/lib/banzai/commit_renderer_spec.rb @@ -10,7 +10,7 @@ describe Banzai::CommitRenderer do described_class::ATTRIBUTES.each do |attr| expect_any_instance_of(Banzai::ObjectRenderer).to receive(:render).with([project.commit], attr).once.and_call_original - expect(Banzai::Renderer).to receive(:cacheless_render_field).with(project.commit, attr) + expect(Banzai::Renderer).to receive(:cacheless_render_field).with(project.commit, attr, {}) end described_class.render([project.commit], project, user) diff --git a/spec/lib/banzai/filter/absolute_link_filter_spec.rb b/spec/lib/banzai/filter/absolute_link_filter_spec.rb new file mode 100644 index 00000000000..a3ad056efcd --- /dev/null +++ b/spec/lib/banzai/filter/absolute_link_filter_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Banzai::Filter::AbsoluteLinkFilter do + def filter(doc, context = {}) + described_class.call(doc, context) + end + + context 'with html links' do + context 'if only_path is false' do + let(:only_path_context) do + { only_path: false } + end + let(:fake_url) { 'http://www.example.com' } + + before do + allow(Gitlab.config.gitlab).to receive(:url).and_return(fake_url) + end + + context 'has the .gfm class' do + it 'converts a relative url into absolute' do + doc = filter(link('/foo', 'gfm'), only_path_context) + expect(doc.at_css('a')['href']).to eq "#{fake_url}/foo" + end + + it 'does not change the url if it already absolute' do + doc = filter(link("#{fake_url}/foo", 'gfm'), only_path_context) + expect(doc.at_css('a')['href']).to eq "#{fake_url}/foo" + end + + context 'if relative_url_root is set' do + it 'joins the url without without doubling the path' do + allow(Gitlab.config.gitlab).to receive(:url).and_return("#{fake_url}/gitlab/") + doc = filter(link("/gitlab/foo", 'gfm'), only_path_context) + expect(doc.at_css('a')['href']).to eq "#{fake_url}/gitlab/foo" + end + end + end + + context 'has not the .gfm class' do + it 'does not convert a relative url into absolute' do + doc = filter(link('/foo'), only_path_context) + expect(doc.at_css('a')['href']).to eq '/foo' + end + end + end + + context 'if only_path is not false' do + it 'does not convert a relative url into absolute' do + expect(filter(link('/foo', 'gfm')).at_css('a')['href']).to eq '/foo' + expect(filter(link('/foo')).at_css('a')['href']).to eq '/foo' + end + end + end + + def link(path, css_class = '') + %(<a class="#{css_class}" href="#{path}">example</a>) + end +end diff --git a/spec/lib/banzai/note_renderer_spec.rb b/spec/lib/banzai/note_renderer_spec.rb deleted file mode 100644 index 32764bee5eb..00000000000 --- a/spec/lib/banzai/note_renderer_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'spec_helper' - -describe Banzai::NoteRenderer do - describe '.render' do - it 'renders a Note' do - note = double(:note) - project = double(:project) - wiki = double(:wiki) - user = double(:user) - - expect(Banzai::ObjectRenderer).to receive(:new) - .with(project, user, - requested_path: 'foo', - project_wiki: wiki, - ref: 'bar') - .and_call_original - - expect_any_instance_of(Banzai::ObjectRenderer) - .to receive(:render).with([note], :note) - - described_class.render([note], project, user, 'foo', wiki, 'bar') - end - end -end diff --git a/spec/lib/banzai/object_renderer_spec.rb b/spec/lib/banzai/object_renderer_spec.rb index b172a1b718c..074d521a5c6 100644 --- a/spec/lib/banzai/object_renderer_spec.rb +++ b/spec/lib/banzai/object_renderer_spec.rb @@ -22,7 +22,7 @@ describe Banzai::ObjectRenderer do end it 'retrieves field content using Banzai::Renderer.render_field' do - expect(Banzai::Renderer).to receive(:render_field).with(object, :note).and_call_original + expect(Banzai::Renderer).to receive(:render_field).with(object, :note, {}).and_call_original renderer.render([object], :note) end @@ -68,7 +68,7 @@ describe Banzai::ObjectRenderer do end it 'retrieves field content using Banzai::Renderer.cacheless_render_field' do - expect(Banzai::Renderer).to receive(:cacheless_render_field).with(commit, :title).and_call_original + expect(Banzai::Renderer).to receive(:cacheless_render_field).with(commit, :title, {}).and_call_original renderer.render([commit], :title) end diff --git a/spec/lib/banzai/renderer_spec.rb b/spec/lib/banzai/renderer_spec.rb index 81a04a2d46d..650cecfc778 100644 --- a/spec/lib/banzai/renderer_spec.rb +++ b/spec/lib/banzai/renderer_spec.rb @@ -18,7 +18,7 @@ describe Banzai::Renderer do let(:commit) { create(:project, :repository).commit } it 'returns cacheless render field' do - expect(renderer).to receive(:cacheless_render_field).with(commit, :title) + expect(renderer).to receive(:cacheless_render_field).with(commit, :title, {}) renderer.render_field(commit, :title) end diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index 6c25b7349e1..74a24a4424b 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -11,13 +11,13 @@ describe Gitlab::Checks::ChangeAccess do let(:changes) { { oldrev: oldrev, newrev: newrev, ref: ref } } let(:protocol) { 'ssh' } - subject do + subject(:change_access) do described_class.new( changes, project: project, user_access: user_access, protocol: protocol - ).exec + ) end before do @@ -26,7 +26,7 @@ describe Gitlab::Checks::ChangeAccess do context 'without failed checks' do it "doesn't raise an error" do - expect { subject }.not_to raise_error + expect { subject.exec }.not_to raise_error end end @@ -34,7 +34,7 @@ describe Gitlab::Checks::ChangeAccess do it 'raises an error' do expect(user_access).to receive(:can_do_action?).with(:push_code).and_return(false) - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to this project.') end end @@ -45,7 +45,7 @@ describe Gitlab::Checks::ChangeAccess do allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) expect(user_access).to receive(:can_do_action?).with(:admin_project).and_return(false) - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to change existing tags on this project.') end context 'with protected tag' do @@ -61,7 +61,7 @@ describe Gitlab::Checks::ChangeAccess do let(:newrev) { '0000000000000000000000000000000000000000' } it 'is prevented' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be deleted/) end end @@ -70,7 +70,7 @@ describe Gitlab::Checks::ChangeAccess do let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } it 'is prevented' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /cannot be updated/) end end end @@ -81,14 +81,14 @@ describe Gitlab::Checks::ChangeAccess do let(:ref) { 'refs/tags/v9.1.0' } it 'prevents creation below access level' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /allowed to create this tag as it is protected/) end context 'when user has access' do let!(:protected_tag) { create(:protected_tag, :developers_can_create, project: project, name: 'v*') } it 'allows tag creation' do - expect { subject }.not_to raise_error + expect { subject.exec }.not_to raise_error end end end @@ -101,7 +101,7 @@ describe Gitlab::Checks::ChangeAccess do let(:ref) { 'refs/heads/master' } it 'raises an error' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'The default branch of a project cannot be deleted.') end end @@ -114,7 +114,7 @@ describe Gitlab::Checks::ChangeAccess do it 'raises an error if the user is not allowed to do forced pushes to protected branches' do expect(Gitlab::Checks::ForcePush).to receive(:force_push?).and_return(true) - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to force push code to a protected branch on this project.') end it 'raises an error if the user is not allowed to merge to protected branches' do @@ -122,13 +122,13 @@ describe Gitlab::Checks::ChangeAccess do expect(user_access).to receive(:can_merge_to_branch?).and_return(false) expect(user_access).to receive(:can_push_to_branch?).and_return(false) - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to merge code into protected branches on this project.') end it 'raises an error if the user is not allowed to push to protected branches' do expect(user_access).to receive(:can_push_to_branch?).and_return(false) - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to push code to protected branches on this project.') end context 'branch deletion' do @@ -137,7 +137,7 @@ describe Gitlab::Checks::ChangeAccess do context 'if the user is not allowed to delete protected branches' do it 'raises an error' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You are not allowed to delete protected branches from this project. Only a project master or owner can delete a protected branch.') end end @@ -150,18 +150,63 @@ describe Gitlab::Checks::ChangeAccess do let(:protocol) { 'web' } it 'allows branch deletion' do - expect { subject }.not_to raise_error + expect { subject.exec }.not_to raise_error end end context 'over SSH or HTTP' do it 'raises an error' do - expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, 'You can only delete protected branches using the web interface.') end end end end end end + + context 'LFS integrity check' do + let(:blob_object) { project.repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') } + + before do + allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects) do |&lazy_block| + lazy_block.call([blob_object.id]) + end + end + + context 'with LFS not enabled' do + it 'skips integrity check' do + expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects) + + subject.exec + end + end + + context 'with LFS enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + context 'deletion' do + let(:changes) { { oldrev: oldrev, ref: ref } } + + it 'skips integrity check' do + expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects) + + subject.exec + end + end + + it 'fails if any LFS blobs are missing' do + expect { subject.exec }.to raise_error(Gitlab::GitAccess::UnauthorizedError, /LFS objects are missing/) + end + + it 'succeeds if LFS objects have already been uploaded' do + lfs_object = create(:lfs_object, oid: blob_object.lfs_oid) + create(:lfs_objects_project, project: project, lfs_object: lfs_object) + + expect { subject.exec }.not_to raise_error + end + end + end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 1d4d0c300eb..96e162ac087 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1521,7 +1521,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe '#fetch_source_branch' do + describe '#fetch_source_branch!' do let(:local_ref) { 'refs/merge-requests/1/head' } context 'when the branch exists' do @@ -1530,11 +1530,11 @@ describe Gitlab::Git::Repository, seed_helper: true do it 'writes the ref' do expect(repository).to receive(:write_ref).with(local_ref, /\h{40}/) - repository.fetch_source_branch(repository, source_branch, local_ref) + repository.fetch_source_branch!(repository, source_branch, local_ref) end it 'returns true' do - expect(repository.fetch_source_branch(repository, source_branch, local_ref)).to eq(true) + expect(repository.fetch_source_branch!(repository, source_branch, local_ref)).to eq(true) end end @@ -1544,11 +1544,11 @@ describe Gitlab::Git::Repository, seed_helper: true do it 'does not write the ref' do expect(repository).not_to receive(:write_ref) - repository.fetch_source_branch(repository, source_branch, local_ref) + repository.fetch_source_branch!(repository, source_branch, local_ref) end it 'returns false' do - expect(repository.fetch_source_branch(repository, source_branch, local_ref)).to eq(false) + expect(repository.fetch_source_branch!(repository, source_branch, local_ref)).to eq(false) end end end diff --git a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb index 92bf87bbad4..78475403f9e 100644 --- a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb @@ -26,7 +26,6 @@ describe Gitlab::HookData::MergeRequestBuilder do merge_user_id merge_when_pipeline_succeeds milestone_id - ref_fetched source_branch source_project_id state diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 6c6b9154a0a..96efdd0949b 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -148,9 +148,18 @@ deploy_keys: - deploy_keys_projects - projects cluster: -- project +- cluster_projects +- projects - user -- service +- provider_gcp +- platform_kubernetes +cluster_projects: +- projects +- clusters +provider_gcp: +- cluster +platform_kubernetes: +- cluster services: - project - service_hook @@ -182,6 +191,7 @@ project: - tags - chat_services - cluster +- cluster_project - creator - group - namespace diff --git a/spec/lib/gitlab/import_export/fork_spec.rb b/spec/lib/gitlab/import_export/fork_spec.rb index dd0ce0dae41..cfb15ee7e8b 100644 --- a/spec/lib/gitlab/import_export/fork_spec.rb +++ b/spec/lib/gitlab/import_export/fork_spec.rb @@ -46,7 +46,7 @@ describe 'forked project import' do end it 'can access the MR' do - project.merge_requests.first.ensure_ref_fetched + project.merge_requests.first.fetch_ref! expect(project.repository.ref_exists?('refs/merge-requests/1/head')).to be_truthy end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 89d30407077..4b79e9f18c6 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -313,30 +313,47 @@ Ci::PipelineSchedule: - deleted_at - created_at - updated_at -Gcp::Cluster: +Clusters::Cluster: - id -- project_id - user_id -- service_id - enabled +- name +- provider_type +- platform_type +- created_at +- updated_at +Clusters::Project: +- id +- project_id +- cluster_id +- created_at +- updated_at +Clusters::Providers::Gcp: +- id +- cluster_id - status - status_reason -- project_namespace +- gcp_project_id +- zone +- num_nodes +- machine_type +- operation_id - endpoint +- encrypted_access_token +- encrypted_access_token_iv +- created_at +- updated_at +Clusters::Platforms::Kubernetes: +- id +- cluster_id +- api_url - ca_cert -- encrypted_kubernetes_token -- encrypted_kubernetes_token_iv +- namespace - username - encrypted_password - encrypted_password_iv -- gcp_project_id -- gcp_cluster_zone -- gcp_cluster_name -- gcp_cluster_size -- gcp_machine_type -- gcp_operation_id -- encrypted_gcp_token -- encrypted_gcp_token_iv +- encrypted_token +- encrypted_token_iv - created_at - updated_at DeployKey: diff --git a/spec/lib/gitlab/metrics/background_transaction_spec.rb b/spec/lib/gitlab/metrics/background_transaction_spec.rb new file mode 100644 index 00000000000..96052b8dc2f --- /dev/null +++ b/spec/lib/gitlab/metrics/background_transaction_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Metrics::BackgroundTransaction do + let(:test_worker_class) { double(:class, name: 'TestWorker') } + + subject { described_class.new(test_worker_class) } + + describe '#action' do + it 'returns transaction action name' do + expect(subject.action).to eq('TestWorker#perform') + end + end +end diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index 4b19ee19103..977bc250049 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe Gitlab::Metrics::Instrumentation do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:env) { {} } + let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } before do @dummy = Class.new do diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb index a247f03b2da..f1e9e414e0d 100644 --- a/spec/lib/gitlab/metrics/method_call_spec.rb +++ b/spec/lib/gitlab/metrics/method_call_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe Gitlab::Metrics::MethodCall do - let(:method_call) { described_class.new('Foo#bar', 'foo') } + let(:transaction) { double(:transaction, labels: {}) } + let(:method_call) { described_class.new('Foo#bar', :Foo, '#bar', transaction) } describe '#measure' do it 'measures the performance of the supplied block' do @@ -11,6 +12,18 @@ describe Gitlab::Metrics::MethodCall do expect(method_call.cpu_time).to be_a_kind_of(Numeric) expect(method_call.call_count).to eq(1) end + + it 'observes the performance of the supplied block' do + expect(described_class.call_real_duration_histogram) + .to receive(:observe) + .with({ module: :Foo, method: '#bar' }, be_a_kind_of(Numeric)) + + expect(described_class.call_cpu_duration_histogram) + .to receive(:observe) + .with({ module: :Foo, method: '#bar' }, be_a_kind_of(Numeric)) + + method_call.measure { 'foo' } + end end describe '#to_metric' do @@ -19,7 +32,7 @@ describe Gitlab::Metrics::MethodCall do metric = method_call.to_metric expect(metric).to be_an_instance_of(Gitlab::Metrics::Metric) - expect(metric.series).to eq('foo') + expect(metric.series).to eq('rails_method_calls') expect(metric.values[:duration]).to be_a_kind_of(Numeric) expect(metric.values[:cpu_duration]).to be_a_kind_of(Numeric) diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index ec415f2bd85..b84387204ee 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -18,34 +18,6 @@ describe Gitlab::Metrics::RackMiddleware do expect(middleware.call(env)).to eq('yay') end - it 'tags a transaction with the name and action of a controller' do - klass = double(:klass, name: 'TestController', content_type: 'text/html') - controller = double(:controller, class: klass, action_name: 'show') - - env['action_controller.instance'] = controller - - allow(app).to receive(:call).with(env) - - expect(middleware).to receive(:tag_controller) - .with(an_instance_of(Gitlab::Metrics::Transaction), env) - - middleware.call(env) - end - - it 'tags a transaction with the method and path of the route in the grape endpoint' do - route = double(:route, request_method: "GET", path: "/:version/projects/:id/archive(.:format)") - endpoint = double(:endpoint, route: route) - - env['api.endpoint'] = endpoint - - allow(app).to receive(:call).with(env) - - expect(middleware).to receive(:tag_endpoint) - .with(an_instance_of(Gitlab::Metrics::Transaction), env) - - middleware.call(env) - end - it 'tracks any raised exceptions' do expect(app).to receive(:call).with(env).and_raise(RuntimeError) @@ -60,7 +32,7 @@ describe Gitlab::Metrics::RackMiddleware do let(:transaction) { middleware.transaction_from_env(env) } it 'returns a Transaction' do - expect(transaction).to be_an_instance_of(Gitlab::Metrics::Transaction) + expect(transaction).to be_an_instance_of(Gitlab::Metrics::WebTransaction) end it 'stores the request method and URI in the transaction as values' do @@ -84,58 +56,4 @@ describe Gitlab::Metrics::RackMiddleware do end end end - - describe '#tag_controller' do - let(:transaction) { middleware.transaction_from_env(env) } - let(:content_type) { 'text/html' } - - before do - klass = double(:klass, name: 'TestController') - controller = double(:controller, class: klass, action_name: 'show', content_type: content_type) - - env['action_controller.instance'] = controller - end - - it 'tags a transaction with the name and action of a controller' do - middleware.tag_controller(transaction, env) - - expect(transaction.action).to eq('TestController#show') - end - - context 'when the response content type is not :html' do - let(:content_type) { 'application/json' } - - it 'appends the mime type to the transaction action' do - middleware.tag_controller(transaction, env) - - expect(transaction.action).to eq('TestController#show.json') - end - end - end - - describe '#tag_endpoint' do - let(:transaction) { middleware.transaction_from_env(env) } - - it 'tags a transaction with the method and path of the route in the grape endpount' do - route = double(:route, request_method: "GET", path: "/:version/projects/:id/archive(.:format)") - endpoint = double(:endpoint, route: route) - - env['api.endpoint'] = endpoint - - middleware.tag_endpoint(transaction, env) - - expect(transaction.action).to eq('Grape#GET /projects/:id/archive') - end - - it 'does not tag a transaction if route infos are missing' do - endpoint = double(:endpoint) - allow(endpoint).to receive(:route).and_raise - - env['api.endpoint'] = endpoint - - middleware.tag_endpoint(transaction, env) - - expect(transaction.action).to be_nil - end - end end diff --git a/spec/lib/gitlab/metrics/influx_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb index 999a9536d82..667e4747897 100644 --- a/spec/lib/gitlab/metrics/influx_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/influx_sampler_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Metrics::InfluxSampler do +describe Gitlab::Metrics::Samplers::InfluxSampler do let(:sampler) { described_class.new(5) } after do diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb new file mode 100644 index 00000000000..53699327da1 --- /dev/null +++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb @@ -0,0 +1,90 @@ +require 'spec_helper' + +describe Gitlab::Metrics::Samplers::RubySampler do + let(:sampler) { described_class.new(5) } + + after do + Allocations.stop if Gitlab::Metrics.mri? + end + + describe '#sample' do + it 'samples various statistics' do + expect(Gitlab::Metrics::System).to receive(:memory_usage) + expect(Gitlab::Metrics::System).to receive(:file_descriptor_count) + expect(sampler).to receive(:sample_objects) + expect(sampler).to receive(:sample_gc) + + sampler.sample + end + + it 'adds a metric containing the memory usage' do + expect(Gitlab::Metrics::System).to receive(:memory_usage) + .and_return(9000) + + expect(sampler.metrics[:memory_usage]).to receive(:set) + .with({}, 9000) + .and_call_original + + sampler.sample + end + + it 'adds a metric containing the amount of open file descriptors' do + expect(Gitlab::Metrics::System).to receive(:file_descriptor_count) + .and_return(4) + + expect(sampler.metrics[:file_descriptors]).to receive(:set) + .with({}, 4) + .and_call_original + + sampler.sample + end + + it 'clears any GC profiles' do + expect(GC::Profiler).to receive(:clear) + + sampler.sample + end + end + + describe '#sample_gc' do + it 'adds a metric containing garbage collection time statistics' do + expect(GC::Profiler).to receive(:total_time).and_return(0.24) + + expect(sampler.metrics[:total_time]).to receive(:set) + .with({}, 240) + .and_call_original + + sampler.sample + end + + it 'adds a metric containing garbage collection statistics' do + GC.stat.keys.each do |key| + expect(sampler.metrics[key]).to receive(:set).with({}, anything).and_call_original + end + + sampler.sample + end + end + + if Gitlab::Metrics.mri? + describe '#sample_objects' do + it 'adds a metric containing the amount of allocated objects' do + expect(sampler.metrics[:objects_total]).to receive(:set) + .with(include(class: anything), be > 0) + .at_least(:once) + .and_call_original + + sampler.sample + end + + it 'ignores classes without a name' do + expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 }) + + expect(sampler.metrics[:objects_total]).not_to receive(:set) + .with(include(class: 'object_counts'), anything) + + sampler.sample + end + end + end +end diff --git a/spec/lib/gitlab/metrics/unicorn_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb index dc0d1f2e940..771b633a2b9 100644 --- a/spec/lib/gitlab/metrics/unicorn_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/unicorn_sampler_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Metrics::UnicornSampler do +describe Gitlab::Metrics::Samplers::UnicornSampler do subject { described_class.new(1.second) } describe '#sample' do diff --git a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb index 0803ce42fac..6d69b5305d2 100644 --- a/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/sidekiq_middleware_spec.rb @@ -5,8 +5,8 @@ describe Gitlab::Metrics::SidekiqMiddleware do let(:message) { { 'args' => ['test'], 'enqueued_at' => Time.new(2016, 6, 23, 6, 59).to_f } } def run(worker, message) - expect(Gitlab::Metrics::Transaction).to receive(:new) - .with('TestWorker#perform') + expect(Gitlab::Metrics::BackgroundTransaction).to receive(:new) + .with(worker.class) .and_call_original expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set) @@ -18,21 +18,18 @@ describe Gitlab::Metrics::SidekiqMiddleware do end describe '#call' do - it 'tracks the transaction' do - worker = double(:worker, class: double(:class, name: 'TestWorker')) + let(:test_worker_class) { double(:class, name: 'TestWorker') } + let(:worker) { double(:worker, class: test_worker_class) } + it 'tracks the transaction' do run(worker, message) end it 'tracks the transaction (for messages without `enqueued_at`)' do - worker = double(:worker, class: double(:class, name: 'TestWorker')) - run(worker, {}) end it 'tracks any raised exceptions' do - worker = double(:worker, class: double(:class, name: 'TestWorker')) - expect_any_instance_of(Gitlab::Metrics::Transaction) .to receive(:run).and_raise(RuntimeError) @@ -45,18 +42,5 @@ describe Gitlab::Metrics::SidekiqMiddleware do expect { middleware.call(worker, message, :test) } .to raise_error(RuntimeError) end - - it 'tags the metrics accordingly' do - tags = { one: 1, two: 2 } - worker = double(:worker, class: double(:class, name: 'TestWorker')) - allow(worker).to receive(:metrics_tags).and_return(tags) - - tags.each do |tag, value| - expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:add_tag) - .with(tag, value) - end - - run(worker, message) - end end end diff --git a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb index e7b595405a8..eca75a4fac1 100644 --- a/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/action_view_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe Gitlab::Metrics::Subscribers::ActionView do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:env) { {} } + let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } let(:subscriber) { described_class.new } @@ -29,5 +30,13 @@ describe Gitlab::Metrics::Subscribers::ActionView do subscriber.render_template(event) end + + it 'observes view rendering time' do + expect(subscriber.send(:metric_view_rendering_duration_seconds)) + .to receive(:observe) + .with({ view: 'app/views/x.html.haml' }, 2.1) + + subscriber.render_template(event) + end end end diff --git a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb index ce6587e993f..9b3698fb4a8 100644 --- a/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/active_record_spec.rb @@ -1,11 +1,12 @@ require 'spec_helper' describe Gitlab::Metrics::Subscribers::ActiveRecord do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:env) { {} } + let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } let(:subscriber) { described_class.new } let(:event) do - double(:event, duration: 0.2, + double(:event, duration: 2, payload: { sql: 'SELECT * FROM users WHERE id = 10' }) end @@ -20,16 +21,24 @@ describe Gitlab::Metrics::Subscribers::ActiveRecord do end describe 'with a current transaction' do + it 'observes sql_duration metric' do + expect(subscriber).to receive(:current_transaction) + .at_least(:once) + .and_return(transaction) + expect(subscriber.send(:metric_sql_duration_seconds)).to receive(:observe).with({}, 0.002) + subscriber.sql(event) + end + it 'increments the :sql_duration value' do expect(subscriber).to receive(:current_transaction) .at_least(:once) .and_return(transaction) expect(transaction).to receive(:increment) - .with(:sql_duration, 0.2) + .with(:sql_duration, 2, false) expect(transaction).to receive(:increment) - .with(:sql_count, 1) + .with(:sql_count, 1, false) subscriber.sql(event) end diff --git a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb index f04dc8dcc02..58e28592cf9 100644 --- a/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb +++ b/spec/lib/gitlab/metrics/subscribers/rails_cache_spec.rb @@ -1,15 +1,16 @@ require 'spec_helper' describe Gitlab::Metrics::Subscribers::RailsCache do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:env) { {} } + let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } let(:subscriber) { described_class.new } let(:event) { double(:event, duration: 15.2) } describe '#cache_read' do it 'increments the cache_read duration' do - expect(subscriber).to receive(:increment) - .with(:cache_read, event.duration) + expect(subscriber).to receive(:observe) + .with(:read, event.duration) subscriber.cache_read(event) end @@ -17,7 +18,7 @@ describe Gitlab::Metrics::Subscribers::RailsCache do context 'with a transaction' do before do allow(subscriber).to receive(:current_transaction) - .and_return(transaction) + .and_return(transaction) end context 'with hit event' do @@ -25,9 +26,9 @@ describe Gitlab::Metrics::Subscribers::RailsCache do it 'increments the cache_read_hit count' do expect(transaction).to receive(:increment) - .with(:cache_read_hit_count, 1) + .with(:cache_read_hit_count, 1, false) expect(transaction).to receive(:increment) - .with(any_args).at_least(1) # Other calls + .with(any_args).at_least(1) # Other calls subscriber.cache_read(event) end @@ -37,7 +38,7 @@ describe Gitlab::Metrics::Subscribers::RailsCache do it 'does not increment cache read miss' do expect(transaction).not_to receive(:increment) - .with(:cache_read_hit_count, 1) + .with(:cache_read_hit_count, 1) subscriber.cache_read(event) end @@ -49,9 +50,15 @@ describe Gitlab::Metrics::Subscribers::RailsCache do it 'increments the cache_read_miss count' do expect(transaction).to receive(:increment) - .with(:cache_read_miss_count, 1) + .with(:cache_read_miss_count, 1, false) expect(transaction).to receive(:increment) - .with(any_args).at_least(1) # Other calls + .with(any_args).at_least(1) # Other calls + + subscriber.cache_read(event) + end + + it 'increments the cache_read_miss total' do + expect(subscriber.send(:metric_cache_misses_total)).to receive(:increment).with({}) subscriber.cache_read(event) end @@ -61,7 +68,13 @@ describe Gitlab::Metrics::Subscribers::RailsCache do it 'does not increment cache read miss' do expect(transaction).not_to receive(:increment) - .with(:cache_read_miss_count, 1) + .with(:cache_read_miss_count, 1) + + subscriber.cache_read(event) + end + + it 'does not increment cache_read_miss total' do + expect(subscriber.send(:metric_cache_misses_total)).not_to receive(:increment).with({}) subscriber.cache_read(event) end @@ -71,27 +84,27 @@ describe Gitlab::Metrics::Subscribers::RailsCache do end describe '#cache_write' do - it 'increments the cache_write duration' do - expect(subscriber).to receive(:increment) - .with(:cache_write, event.duration) + it 'observes write duration' do + expect(subscriber).to receive(:observe) + .with(:write, event.duration) subscriber.cache_write(event) end end describe '#cache_delete' do - it 'increments the cache_delete duration' do - expect(subscriber).to receive(:increment) - .with(:cache_delete, event.duration) + it 'observes delete duration' do + expect(subscriber).to receive(:observe) + .with(:delete, event.duration) subscriber.cache_delete(event) end end describe '#cache_exist?' do - it 'increments the cache_exists duration' do - expect(subscriber).to receive(:increment) - .with(:cache_exists, event.duration) + it 'observes the exists duration' do + expect(subscriber).to receive(:observe) + .with(:exists, event.duration) subscriber.cache_exist?(event) end @@ -109,12 +122,12 @@ describe Gitlab::Metrics::Subscribers::RailsCache do context 'with a transaction' do before do allow(subscriber).to receive(:current_transaction) - .and_return(transaction) + .and_return(transaction) end it 'increments the cache_read_hit count' do expect(transaction).to receive(:increment) - .with(:cache_read_hit_count, 1) + .with(:cache_read_hit_count, 1) subscriber.cache_fetch_hit(event) end @@ -133,47 +146,61 @@ describe Gitlab::Metrics::Subscribers::RailsCache do context 'with a transaction' do before do allow(subscriber).to receive(:current_transaction) - .and_return(transaction) + .and_return(transaction) end it 'increments the cache_fetch_miss count' do expect(transaction).to receive(:increment) - .with(:cache_read_miss_count, 1) + .with(:cache_read_miss_count, 1) + + subscriber.cache_generate(event) + end + + it 'increments the cache_read_miss total' do + expect(subscriber.send(:metric_cache_misses_total)).to receive(:increment).with({}) subscriber.cache_generate(event) end end end - describe '#increment' do + describe '#observe' do context 'without a transaction' do it 'returns' do expect(transaction).not_to receive(:increment) - subscriber.increment(:foo, 15.2) + subscriber.observe(:foo, 15.2) end end context 'with a transaction' do before do allow(subscriber).to receive(:current_transaction) - .and_return(transaction) + .and_return(transaction) end it 'increments the total and specific cache duration' do expect(transaction).to receive(:increment) - .with(:cache_duration, event.duration) + .with(:cache_duration, event.duration, false) expect(transaction).to receive(:increment) - .with(:cache_count, 1) + .with(:cache_count, 1, false) expect(transaction).to receive(:increment) - .with(:cache_delete_duration, event.duration) + .with(:cache_delete_duration, event.duration, false) expect(transaction).to receive(:increment) - .with(:cache_delete_count, 1) + .with(:cache_delete_count, 1, false) + + subscriber.observe(:delete, event.duration) + end + + it 'observes cache metric' do + expect(subscriber.send(:metric_cache_operation_duration_seconds)) + .to receive(:observe) + .with(transaction.labels.merge(operation: :delete), event.duration / 1000.0) - subscriber.increment(:cache_delete, event.duration) + subscriber.observe(:delete, event.duration) end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/web_transaction_spec.rb index 3779af81512..1d162f53a13 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/web_transaction_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' -describe Gitlab::Metrics::Transaction do - let(:transaction) { described_class.new } +describe Gitlab::Metrics::WebTransaction do + let(:env) { {} } + let(:transaction) { described_class.new(env) } describe '#duration' do it 'returns the duration of a transaction in seconds' do @@ -48,7 +49,7 @@ describe Gitlab::Metrics::Transaction do describe '#method_call_for' do it 'returns a MethodCall' do - method = transaction.method_call_for('Foo#bar') + method = transaction.method_call_for('Foo#bar', :Foo, '#bar') expect(method).to be_an_instance_of(Gitlab::Metrics::MethodCall) end @@ -85,14 +86,6 @@ describe Gitlab::Metrics::Transaction do end end - describe '#add_tag' do - it 'adds a tag' do - transaction.add_tag(:foo, 'bar') - - expect(transaction.tags).to eq({ foo: 'bar' }) - end - end - describe '#finish' do it 'tracks the transaction details and submits them to Sidekiq' do expect(transaction).to receive(:track_self) @@ -127,7 +120,7 @@ describe Gitlab::Metrics::Transaction do end it 'adds the action as a tag for every metric' do - transaction.action = 'Foo#bar' + allow(transaction).to receive(:labels).and_return(controller: 'Foo', action: 'bar') transaction.track_self hash = { @@ -144,7 +137,8 @@ describe Gitlab::Metrics::Transaction do end it 'does not add an action tag for events' do - transaction.action = 'Foo#bar' + allow(transaction).to receive(:labels).and_return(controller: 'Foo', action: 'bar') + transaction.add_event(:meow) hash = { @@ -161,6 +155,61 @@ describe Gitlab::Metrics::Transaction do end end + describe '#labels' do + context 'when request goes to Grape endpoint' do + before do + route = double(:route, request_method: 'GET', path: '/:version/projects/:id/archive(.:format)') + endpoint = double(:endpoint, route: route) + + env['api.endpoint'] = endpoint + end + it 'provides labels with the method and path of the route in the grape endpoint' do + expect(transaction.labels).to eq({ controller: 'Grape', action: 'GET /projects/:id/archive' }) + expect(transaction.action).to eq('Grape#GET /projects/:id/archive') + end + + it 'does not provide labels if route infos are missing' do + endpoint = double(:endpoint) + allow(endpoint).to receive(:route).and_raise + + env['api.endpoint'] = endpoint + + expect(transaction.labels).to eq({}) + expect(transaction.action).to be_nil + end + end + + context 'when request goes to ActionController' do + let(:content_type) { 'text/html' } + + before do + klass = double(:klass, name: 'TestController') + controller = double(:controller, class: klass, action_name: 'show', content_type: content_type) + + env['action_controller.instance'] = controller + end + + it 'tags a transaction with the name and action of a controller' do + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show' }) + expect(transaction.action).to eq('TestController#show') + end + + context 'when the response content type is not :html' do + let(:content_type) { 'application/json' } + + it 'appends the mime type to the transaction action' do + expect(transaction.labels).to eq({ controller: 'TestController', action: 'show.json' }) + expect(transaction.action).to eq('TestController#show.json') + end + end + end + + it 'returns no labels when no route information is present in env' do + expect(transaction.labels).to eq({}) + expect(transaction.action).to eq(nil) + end + end + describe '#add_event' do it 'adds a metric' do transaction.add_event(:meow) diff --git a/spec/lib/gitlab/metrics_spec.rb b/spec/lib/gitlab/metrics_spec.rb index 599b8807d8d..1619fbd88b1 100644 --- a/spec/lib/gitlab/metrics_spec.rb +++ b/spec/lib/gitlab/metrics_spec.rb @@ -115,7 +115,7 @@ describe Gitlab::Metrics do end context 'with a transaction' do - let(:transaction) { Gitlab::Metrics::Transaction.new } + let(:transaction) { Gitlab::Metrics::WebTransaction.new({}) } before do allow(described_class).to receive(:current_transaction) @@ -124,13 +124,13 @@ describe Gitlab::Metrics do it 'adds a metric to the current transaction' do expect(transaction).to receive(:increment) - .with('foo_real_time', a_kind_of(Numeric)) + .with('foo_real_time', a_kind_of(Numeric), false) expect(transaction).to receive(:increment) - .with('foo_cpu_time', a_kind_of(Numeric)) + .with('foo_cpu_time', a_kind_of(Numeric), false) expect(transaction).to receive(:increment) - .with('foo_call_count', 1) + .with('foo_call_count', 1, false) described_class.measure(:foo) { 10 } end @@ -143,31 +143,6 @@ describe Gitlab::Metrics do end end - describe '.tag_transaction' do - context 'without a transaction' do - it 'does nothing' do - expect_any_instance_of(Gitlab::Metrics::Transaction) - .not_to receive(:add_tag) - - described_class.tag_transaction(:foo, 'bar') - end - end - - context 'with a transaction' do - let(:transaction) { Gitlab::Metrics::Transaction.new } - - it 'adds the tag to the transaction' do - expect(described_class).to receive(:current_transaction) - .and_return(transaction) - - expect(transaction).to receive(:add_tag) - .with(:foo, 'bar') - - described_class.tag_transaction(:foo, 'bar') - end - end - end - describe '.action=' do context 'without a transaction' do it 'does nothing' do @@ -180,7 +155,7 @@ describe Gitlab::Metrics do context 'with a transaction' do it 'sets the action of a transaction' do - trans = Gitlab::Metrics::Transaction.new + trans = Gitlab::Metrics::WebTransaction.new({}) expect(described_class).to receive(:current_transaction) .and_return(trans) @@ -210,7 +185,7 @@ describe Gitlab::Metrics do context 'with a transaction' do it 'adds an event' do - transaction = Gitlab::Metrics::Transaction.new + transaction = Gitlab::Metrics::WebTransaction.new({}) expect(transaction).to receive(:add_event).with(:meow) @@ -224,7 +199,7 @@ describe Gitlab::Metrics do shared_examples 'prometheus metrics API' do describe '#counter' do - subject { described_class.counter(:couter, 'doc') } + subject { described_class.counter(:counter, 'doc') } describe '#increment' do it 'successfully calls #increment without arguments' do @@ -280,7 +255,7 @@ describe Gitlab::Metrics do it_behaves_like 'prometheus metrics API' describe '#null_metric' do - subject { described_class.provide_metric(:test) } + subject { described_class.send(:provide_metric, :test) } it { is_expected.to be_a(Gitlab::Metrics::NullMetric) } end @@ -321,7 +296,7 @@ describe Gitlab::Metrics do it_behaves_like 'prometheus metrics API' describe '#null_metric' do - subject { described_class.provide_metric(:test) } + subject { described_class.send(:provide_metric, :test) } it { is_expected.to be_nil } end diff --git a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb index 88107536c9e..14f2c3cb86f 100644 --- a/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb +++ b/spec/lib/gitlab/middleware/rails_queue_duration_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Middleware::RailsQueueDuration do let(:app) { double(:app) } let(:middleware) { described_class.new(app) } let(:env) { {} } - let(:transaction) { double(:transaction) } + let(:transaction) { Gitlab::Metrics::WebTransaction.new(env) } before do expect(app).to receive(:call).with(env).and_return('yay') @@ -30,6 +30,16 @@ describe Gitlab::Middleware::RailsQueueDuration do expect(transaction).to receive(:set).with(:rails_queue_duration, an_instance_of(Float)) expect(middleware.call(env)).to eq('yay') end + + it 'observes rails queue duration metrics and calls the app when the header is present' do + env['HTTP_GITLAB_WORKHORSE_PROXY_START'] = '2000000000' + + expect(middleware.send(:metric_rails_queue_duration_seconds)).to receive(:observe).with(transaction.labels, 1) + + Timecop.freeze(Time.at(3)) do + expect(middleware.call(env)).to eq('yay') + end + end end end end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index a7b65e94706..a4c1113ae37 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -60,9 +60,9 @@ describe Gitlab::UsageData do deploy_keys deployments environments - gcp_clusters - gcp_clusters_enabled - gcp_clusters_disabled + clusters + clusters_enabled + clusters_disabled in_review_folder groups issues diff --git a/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb new file mode 100644 index 00000000000..9f41534441b --- /dev/null +++ b/spec/migrations/migrate_gcp_clusters_to_new_clusters_architectures_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20171013104327_migrate_gcp_clusters_to_new_clusters_architectures.rb') + +describe MigrateGcpClustersToNewClustersArchitectures, :migration do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:service) { create(:kubernetes_service, project: project) } + + context 'when cluster is being created' do + let(:project_id) { project.id } + let(:user_id) { user.id } + let(:service_id) { service.id } + let(:status) { 2 } # creating + let(:gcp_cluster_size) { 1 } + let(:created_at) { "'2017-10-17 20:24:02'" } + let(:updated_at) { "'2017-10-17 20:28:44'" } + let(:enabled) { true } + let(:status_reason) { "''" } + let(:project_namespace) { "'sample-app'" } + let(:endpoint) { 'NULL' } + let(:ca_cert) { 'NULL' } + let(:encrypted_kubernetes_token) { 'NULL' } + let(:encrypted_kubernetes_token_iv) { 'NULL' } + let(:username) { 'NULL' } + let(:encrypted_password) { 'NULL' } + let(:encrypted_password_iv) { 'NULL' } + let(:gcp_project_id) { "'gcp_project_id'" } + let(:gcp_cluster_zone) { "'gcp_cluster_zone'" } + let(:gcp_cluster_name) { "'gcp_cluster_name'" } + let(:gcp_machine_type) { "'gcp_machine_type'" } + let(:gcp_operation_id) { 'NULL' } + let(:encrypted_gcp_token) { "'encrypted_gcp_token'" } + let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" } + + let(:cluster) { Clusters::Cluster.last } + let(:cluster_id) { cluster.id } + + before do + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO gcp_clusters (project_id, user_id, service_id, status, gcp_cluster_size, created_at, updated_at, enabled, status_reason, project_namespace, endpoint, ca_cert, encrypted_kubernetes_token, encrypted_kubernetes_token_iv, username, encrypted_password, encrypted_password_iv, gcp_project_id, gcp_cluster_zone, gcp_cluster_name, gcp_machine_type, gcp_operation_id, encrypted_gcp_token, encrypted_gcp_token_iv) + VALUES (#{project_id}, #{user_id}, #{service_id}, #{status}, #{gcp_cluster_size}, #{created_at}, #{updated_at}, #{enabled}, #{status_reason}, #{project_namespace}, #{endpoint}, #{ca_cert}, #{encrypted_kubernetes_token}, #{encrypted_kubernetes_token_iv}, #{username}, #{encrypted_password}, #{encrypted_password_iv}, #{gcp_project_id}, #{gcp_cluster_zone}, #{gcp_cluster_name}, #{gcp_machine_type}, #{gcp_operation_id}, #{encrypted_gcp_token}, #{encrypted_gcp_token_iv}); + SQL + end + + it 'correctly migrate to new clusters architectures' do + migrate! + + expect(Clusters::Cluster.count).to eq(1) + expect(Clusters::Project.count).to eq(1) + expect(Clusters::Providers::Gcp.count).to eq(1) + expect(Clusters::Platforms::Kubernetes.count).to eq(1) + + expect(cluster.user).to eq(user) + expect(cluster.enabled).to be_truthy + expect(cluster.name).to eq(gcp_cluster_name.delete!("'")) + expect(cluster.provider_type).to eq('gcp') + expect(cluster.platform_type).to eq('kubernetes') + + expect(cluster.project).to eq(project) + expect(project.cluster).to eq(cluster) + + expect(cluster.provider_gcp.cluster).to eq(cluster) + expect(cluster.provider_gcp.status).to eq(status) + expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason)) + expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id)) + expect(cluster.provider_gcp.zone).to eq(tr(gcp_cluster_zone)) + expect(cluster.provider_gcp.num_nodes).to eq(gcp_cluster_size) + expect(cluster.provider_gcp.machine_type).to eq(tr(gcp_machine_type)) + expect(cluster.provider_gcp.operation_id).to be_nil + expect(cluster.provider_gcp.endpoint).to be_nil + expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token)) + expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv)) + + expect(cluster.platform_kubernetes.cluster).to eq(cluster) + expect(cluster.platform_kubernetes.api_url).to be_nil + expect(cluster.platform_kubernetes.ca_cert).to be_nil + expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace)) + expect(cluster.platform_kubernetes.username).to be_nil + expect(cluster.platform_kubernetes.encrypted_password).to be_nil + expect(cluster.platform_kubernetes.encrypted_password_iv).to be_nil + expect(cluster.platform_kubernetes.encrypted_token).to be_nil + expect(cluster.platform_kubernetes.encrypted_token_iv).to be_nil + end + end + + context 'when cluster has been created' do + let(:project_id) { project.id } + let(:user_id) { user.id } + let(:service_id) { service.id } + let(:status) { 3 } # created + let(:gcp_cluster_size) { 1 } + let(:created_at) { "'2017-10-17 20:24:02'" } + let(:updated_at) { "'2017-10-17 20:28:44'" } + let(:enabled) { true } + let(:status_reason) { "'general error'" } + let(:project_namespace) { "'sample-app'" } + let(:endpoint) { "'111.111.111.111'" } + let(:ca_cert) { "'ca_cert'" } + let(:encrypted_kubernetes_token) { "'encrypted_kubernetes_token'" } + let(:encrypted_kubernetes_token_iv) { "'encrypted_kubernetes_token_iv'" } + let(:username) { "'username'" } + let(:encrypted_password) { "'encrypted_password'" } + let(:encrypted_password_iv) { "'encrypted_password_iv'" } + let(:gcp_project_id) { "'gcp_project_id'" } + let(:gcp_cluster_zone) { "'gcp_cluster_zone'" } + let(:gcp_cluster_name) { "'gcp_cluster_name'" } + let(:gcp_machine_type) { "'gcp_machine_type'" } + let(:gcp_operation_id) { "'gcp_operation_id'" } + let(:encrypted_gcp_token) { "'encrypted_gcp_token'" } + let(:encrypted_gcp_token_iv) { "'encrypted_gcp_token_iv'" } + + let(:cluster) { Clusters::Cluster.last } + let(:cluster_id) { cluster.id } + + before do + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO gcp_clusters (project_id, user_id, service_id, status, gcp_cluster_size, created_at, updated_at, enabled, status_reason, project_namespace, endpoint, ca_cert, encrypted_kubernetes_token, encrypted_kubernetes_token_iv, username, encrypted_password, encrypted_password_iv, gcp_project_id, gcp_cluster_zone, gcp_cluster_name, gcp_machine_type, gcp_operation_id, encrypted_gcp_token, encrypted_gcp_token_iv) + VALUES (#{project_id}, #{user_id}, #{service_id}, #{status}, #{gcp_cluster_size}, #{created_at}, #{updated_at}, #{enabled}, #{status_reason}, #{project_namespace}, #{endpoint}, #{ca_cert}, #{encrypted_kubernetes_token}, #{encrypted_kubernetes_token_iv}, #{username}, #{encrypted_password}, #{encrypted_password_iv}, #{gcp_project_id}, #{gcp_cluster_zone}, #{gcp_cluster_name}, #{gcp_machine_type}, #{gcp_operation_id}, #{encrypted_gcp_token}, #{encrypted_gcp_token_iv}); + SQL + end + + it 'correctly migrate to new clusters architectures' do + migrate! + + expect(Clusters::Cluster.count).to eq(1) + expect(Clusters::Project.count).to eq(1) + expect(Clusters::Providers::Gcp.count).to eq(1) + expect(Clusters::Platforms::Kubernetes.count).to eq(1) + + expect(cluster.user).to eq(user) + expect(cluster.enabled).to be_truthy + expect(cluster.name).to eq(tr(gcp_cluster_name)) + expect(cluster.provider_type).to eq('gcp') + expect(cluster.platform_type).to eq('kubernetes') + + expect(cluster.project).to eq(project) + expect(project.cluster).to eq(cluster) + + expect(cluster.provider_gcp.cluster).to eq(cluster) + expect(cluster.provider_gcp.status).to eq(status) + expect(cluster.provider_gcp.status_reason).to eq(tr(status_reason)) + expect(cluster.provider_gcp.gcp_project_id).to eq(tr(gcp_project_id)) + expect(cluster.provider_gcp.zone).to eq(tr(gcp_cluster_zone)) + expect(cluster.provider_gcp.num_nodes).to eq(gcp_cluster_size) + expect(cluster.provider_gcp.machine_type).to eq(tr(gcp_machine_type)) + expect(cluster.provider_gcp.operation_id).to eq(tr(gcp_operation_id)) + expect(cluster.provider_gcp.endpoint).to eq(tr(endpoint)) + expect(cluster.provider_gcp.encrypted_access_token).to eq(tr(encrypted_gcp_token)) + expect(cluster.provider_gcp.encrypted_access_token_iv).to eq(tr(encrypted_gcp_token_iv)) + + expect(cluster.platform_kubernetes.cluster).to eq(cluster) + expect(cluster.platform_kubernetes.api_url).to eq('https://' + tr(endpoint)) + expect(cluster.platform_kubernetes.ca_cert).to eq(tr(ca_cert)) + expect(cluster.platform_kubernetes.namespace).to eq(tr(project_namespace)) + expect(cluster.platform_kubernetes.username).to eq(tr(username)) + expect(cluster.platform_kubernetes.encrypted_password).to eq(tr(encrypted_password)) + expect(cluster.platform_kubernetes.encrypted_password_iv).to eq(tr(encrypted_password_iv)) + expect(cluster.platform_kubernetes.encrypted_token).to eq(tr(encrypted_kubernetes_token)) + expect(cluster.platform_kubernetes.encrypted_token_iv).to eq(tr(encrypted_kubernetes_token_iv)) + end + end + + def tr(s) + s.delete("'") + end +end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb new file mode 100644 index 00000000000..12123a3d753 --- /dev/null +++ b/spec/models/clusters/cluster_spec.rb @@ -0,0 +1,181 @@ +require 'spec_helper' + +describe Clusters::Cluster do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:projects) } + it { is_expected.to have_one(:provider_gcp) } + it { is_expected.to have_one(:platform_kubernetes) } + it { is_expected.to delegate_method(:status).to(:provider) } + it { is_expected.to delegate_method(:status_reason).to(:provider) } + it { is_expected.to delegate_method(:status_name).to(:provider) } + it { is_expected.to delegate_method(:on_creation?).to(:provider) } + it { is_expected.to delegate_method(:update_kubernetes_integration!).to(:platform) } + it { is_expected.to respond_to :project } + + describe '.enabled' do + subject { described_class.enabled } + + let!(:cluster) { create(:cluster, enabled: true) } + + before do + create(:cluster, enabled: false) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe '.disabled' do + subject { described_class.disabled } + + let!(:cluster) { create(:cluster, enabled: false) } + + before do + create(:cluster, enabled: true) + end + + it { is_expected.to contain_exactly(cluster) } + end + + describe 'validation' do + subject { cluster.valid? } + + context 'when validates name' do + context 'when provided by user' do + let!(:cluster) { build(:cluster, :provided_by_user, name: name) } + + context 'when name is empty' do + let(:name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when name is nil' do + let(:name) { nil } + + it { is_expected.to be_falsey } + end + + context 'when name is present' do + let(:name) { 'cluster-name-1' } + + it { is_expected.to be_truthy } + end + end + + context 'when provided by gcp' do + let!(:cluster) { build(:cluster, :provided_by_gcp, name: name) } + + context 'when name is shorter than 1' do + let(:name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when name is longer than 63' do + let(:name) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when name includes invalid character' do + let(:name) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when name is present' do + let(:name) { 'cluster-name-1' } + + it { is_expected.to be_truthy } + end + + context 'when record is persisted' do + let(:name) { 'cluster-name-1' } + + before do + cluster.save! + end + + context 'when name is changed' do + before do + cluster.name = 'new-cluster-name' + end + + it { is_expected.to be_falsey } + end + + context 'when name is same' do + before do + cluster.name = name + end + + it { is_expected.to be_truthy } + end + end + end + end + + context 'when validates restrict_modification' do + context 'when creation is on going' do + let!(:cluster) { create(:cluster, :providing_by_gcp) } + + it { expect(cluster.update(enabled: false)).to be_falsey } + end + + context 'when creation is done' do + let!(:cluster) { create(:cluster, :provided_by_gcp) } + + it { expect(cluster.update(enabled: false)).to be_truthy } + end + end + end + + describe '#provider' do + subject { cluster.provider } + + context 'when provider is gcp' do + let(:cluster) { create(:cluster, :provided_by_gcp) } + + it 'returns a provider' do + is_expected.to eq(cluster.provider_gcp) + expect(subject.class.name.deconstantize).to eq(Clusters::Providers.to_s) + end + end + + context 'when provider is user' do + let(:cluster) { create(:cluster, :provided_by_user) } + + it { is_expected.to be_nil } + end + end + + describe '#platform' do + subject { cluster.platform } + + context 'when platform is kubernetes' do + let(:cluster) { create(:cluster, :provided_by_user) } + + it 'returns a platform' do + is_expected.to eq(cluster.platform_kubernetes) + expect(subject.class.name.deconstantize).to eq(Clusters::Platforms.to_s) + end + end + end + + describe '#first_project' do + subject { cluster.first_project } + + context 'when cluster belongs to a project' do + let(:cluster) { create(:cluster, :project) } + let(:project) { Clusters::Project.find_by_cluster_id(cluster.id).project } + + it { is_expected.to eq(project) } + end + + context 'when cluster does not belong to projects' do + let(:cluster) { create(:cluster) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb new file mode 100644 index 00000000000..ed76be703a5 --- /dev/null +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching do + include KubernetesHelpers + include ReactiveCachingHelpers + + it { is_expected.to belong_to(:cluster) } + it { is_expected.to respond_to :ca_pem } + + describe 'before_validation' do + context 'when namespace includes upper case' do + let(:kubernetes) { create(:cluster_platform_kubernetes, :configured, namespace: namespace) } + let(:namespace) { 'ABC' } + + it 'converts to lower case' do + expect(kubernetes.namespace).to eq('abc') + end + end + end + + describe 'validation' do + subject { kubernetes.valid? } + + context 'when validates namespace' do + let(:kubernetes) { build(:cluster_platform_kubernetes, :configured, namespace: namespace) } + + context 'when namespace is blank' do + let(:namespace) { '' } + + it { is_expected.to be_truthy } + end + + context 'when namespace is longer than 63' do + let(:namespace) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when namespace includes invalid character' do + let(:namespace) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when namespace is vaild' do + let(:namespace) { 'namespace-123' } + + it { is_expected.to be_truthy } + end + end + + context 'when validates api_url' do + let(:kubernetes) { build(:cluster_platform_kubernetes, :configured) } + + before do + kubernetes.api_url = api_url + end + + context 'when api_url is invalid url' do + let(:api_url) { '!!!!!!' } + + it { expect(kubernetes.save).to be_falsey } + end + + context 'when api_url is nil' do + let(:api_url) { nil } + + it { expect(kubernetes.save).to be_falsey } + end + + context 'when api_url is valid url' do + let(:api_url) { 'https://111.111.111.111' } + + it { expect(kubernetes.save).to be_truthy } + end + end + + context 'when validates token' do + let(:kubernetes) { build(:cluster_platform_kubernetes, :configured) } + + before do + kubernetes.token = token + end + + context 'when token is nil' do + let(:token) { nil } + + it { expect(kubernetes.save).to be_falsey } + end + end + end + + describe 'after_save from Clusters::Cluster' do + context 'when platform_kubernetes is being cerated' do + let(:enabled) { true } + let(:project) { create(:project) } + let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, enabled: enabled, projects: [project]) } + let(:platform) { build(:cluster_platform_kubernetes, :configured) } + let(:provider) { build(:cluster_provider_gcp) } + let(:kubernetes_service) { project.kubernetes_service } + + it 'updates KubernetesService' do + cluster.save! + + expect(kubernetes_service.active).to eq(enabled) + expect(kubernetes_service.api_url).to eq(platform.api_url) + expect(kubernetes_service.namespace).to eq(platform.namespace) + expect(kubernetes_service.ca_pem).to eq(platform.ca_cert) + end + end + + context 'when platform_kubernetes has been created' do + let(:enabled) { false } + let!(:project) { create(:project) } + let!(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + let(:platform) { cluster.platform } + let(:kubernetes_service) { project.kubernetes_service } + + it 'updates KubernetesService' do + cluster.update(enabled: enabled) + + expect(kubernetes_service.active).to eq(enabled) + end + end + + context 'when kubernetes_service has been configured without cluster integration' do + let!(:project) { create(:project) } + let(:cluster) { build(:cluster, provider_type: :gcp, platform_type: :kubernetes, platform_kubernetes: platform, provider_gcp: provider, projects: [project]) } + let(:platform) { build(:cluster_platform_kubernetes, :configured, api_url: 'https://111.111.111.111') } + let(:provider) { build(:cluster_provider_gcp) } + + before do + create(:kubernetes_service, project: project) + end + + it 'raises an error' do + expect { cluster.save! }.to raise_error('Kubernetes service already configured') + end + end + end + + describe '#actual_namespace' do + subject { kubernetes.actual_namespace } + + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + let(:kubernetes) { create(:cluster_platform_kubernetes, :configured, namespace: namespace) } + + context 'when namespace is present' do + let(:namespace) { 'namespace-123' } + + it { is_expected.to eq(namespace) } + end + + context 'when namespace is not present' do + let(:namespace) { nil } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + end + + describe '.namespace_for_project' do + subject { described_class.namespace_for_project(project) } + + let(:project) { create(:project) } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + + describe '#default_namespace' do + subject { kubernetes.default_namespace } + + let(:kubernetes) { create(:cluster_platform_kubernetes, :configured) } + + context 'when cluster belongs to a project' do + let!(:cluster) { create(:cluster, :project, platform_kubernetes: kubernetes) } + let(:project) { cluster.project } + + it { is_expected.to eq("#{project.path}-#{project.id}") } + end + + context 'when cluster belongs to nothing' do + let!(:cluster) { create(:cluster, platform_kubernetes: kubernetes) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/clusters/project_spec.rb b/spec/models/clusters/project_spec.rb new file mode 100644 index 00000000000..7d75d6ab345 --- /dev/null +++ b/spec/models/clusters/project_spec.rb @@ -0,0 +1,6 @@ +require 'spec_helper' + +describe Clusters::Project do + it { is_expected.to belong_to(:cluster) } + it { is_expected.to belong_to(:project) } +end diff --git a/spec/models/clusters/providers/gcp_spec.rb b/spec/models/clusters/providers/gcp_spec.rb new file mode 100644 index 00000000000..ecd0a08c953 --- /dev/null +++ b/spec/models/clusters/providers/gcp_spec.rb @@ -0,0 +1,183 @@ +require 'spec_helper' + +describe Clusters::Providers::Gcp do + it { is_expected.to belong_to(:cluster) } + it { is_expected.to validate_presence_of(:zone) } + + describe 'default_value_for' do + let(:gcp) { build(:cluster_provider_gcp) } + + it "has default value" do + expect(gcp.zone).to eq('us-central1-a') + expect(gcp.num_nodes).to eq(3) + expect(gcp.machine_type).to eq('n1-standard-4') + end + end + + describe 'validation' do + subject { gcp.valid? } + + context 'when validates gcp_project_id' do + let(:gcp) { build(:cluster_provider_gcp, gcp_project_id: gcp_project_id) } + + context 'when gcp_project_id is shorter than 1' do + let(:gcp_project_id) { '' } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id is longer than 63' do + let(:gcp_project_id) { 'a' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id includes invalid character' do + let(:gcp_project_id) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + + context 'when gcp_project_id is valid' do + let(:gcp_project_id) { 'gcp-project-1' } + + it { is_expected.to be_truthy } + end + end + + context 'when validates num_nodes' do + let(:gcp) { build(:cluster_provider_gcp, num_nodes: num_nodes) } + + context 'when num_nodes is string' do + let(:num_nodes) { 'A3' } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is nil' do + let(:num_nodes) { nil } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is smaller than 1' do + let(:num_nodes) { 0 } + + it { is_expected.to be_falsey } + end + + context 'when num_nodes is valid' do + let(:num_nodes) { 3 } + + it { is_expected.to be_truthy } + end + end + end + + describe '#state_machine' do + context 'when any => [:created]' do + let(:gcp) { build(:cluster_provider_gcp, :creating) } + + before do + gcp.make_created + end + + it 'nullify access_token and operation_id' do + expect(gcp.access_token).to be_nil + expect(gcp.operation_id).to be_nil + expect(gcp).to be_created + end + end + + context 'when any => [:creating]' do + let(:gcp) { build(:cluster_provider_gcp) } + + context 'when operation_id is present' do + let(:operation_id) { 'operation-xxx' } + + before do + gcp.make_creating(operation_id) + end + + it 'sets operation_id' do + expect(gcp.operation_id).to eq(operation_id) + expect(gcp).to be_creating + end + end + + context 'when operation_id is nil' do + let(:operation_id) { nil } + + it 'raises an error' do + expect { gcp.make_creating(operation_id) } + .to raise_error('operation_id is required') + end + end + end + + context 'when any => [:errored]' do + let(:gcp) { build(:cluster_provider_gcp, :creating) } + let(:status_reason) { 'err msg' } + + it 'nullify access_token and operation_id' do + gcp.make_errored(status_reason) + + expect(gcp.access_token).to be_nil + expect(gcp.operation_id).to be_nil + expect(gcp.status_reason).to eq(status_reason) + expect(gcp).to be_errored + end + + context 'when status_reason is nil' do + let(:gcp) { build(:cluster_provider_gcp, :errored) } + + it 'does not set status_reason' do + gcp.make_errored(nil) + + expect(gcp.status_reason).not_to be_nil + end + end + end + end + + describe '#on_creation?' do + subject { gcp.on_creation? } + + context 'when status is creating' do + let(:gcp) { create(:cluster_provider_gcp, :creating) } + + it { is_expected.to be_truthy } + end + + context 'when status is created' do + let(:gcp) { create(:cluster_provider_gcp, :created) } + + it { is_expected.to be_falsey } + end + end + + describe '#api_client' do + subject { gcp.api_client } + + context 'when status is creating' do + let(:gcp) { build(:cluster_provider_gcp, :creating) } + + it 'returns Cloud Platform API clinet' do + expect(subject).to be_an_instance_of(GoogleApi::CloudPlatform::Client) + expect(subject.access_token).to eq(gcp.access_token) + end + end + + context 'when status is created' do + let(:gcp) { build(:cluster_provider_gcp, :created) } + + it { is_expected.to be_nil } + end + + context 'when status is errored' do + let(:gcp) { build(:cluster_provider_gcp, :errored) } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/concerns/ignorable_column_spec.rb b/spec/models/concerns/ignorable_column_spec.rb index dba9fe43327..b70f2331a0e 100644 --- a/spec/models/concerns/ignorable_column_spec.rb +++ b/spec/models/concerns/ignorable_column_spec.rb @@ -5,7 +5,11 @@ describe IgnorableColumn do Class.new do def self.columns # This method does not have access to "double" - [Struct.new(:name).new('id'), Struct.new(:name).new('title')] + [ + Struct.new(:name).new('id'), + Struct.new(:name).new('title'), + Struct.new(:name).new('date') + ] end end end @@ -18,7 +22,7 @@ describe IgnorableColumn do describe '.columns' do it 'returns the columns, excluding the ignored ones' do - model.ignore_column(:title) + model.ignore_column(:title, :date) expect(model.columns.map(&:name)).to eq(%w(id)) end @@ -30,9 +34,9 @@ describe IgnorableColumn do end it 'returns the names of the ignored columns' do - model.ignore_column(:title) + model.ignore_column(:title, :date) - expect(model.ignored_columns).to eq(Set.new(%w(title))) + expect(model.ignored_columns).to eq(Set.new(%w(title date))) end end end diff --git a/spec/models/gcp/cluster_spec.rb b/spec/models/gcp/cluster_spec.rb deleted file mode 100644 index 13811d67ba0..00000000000 --- a/spec/models/gcp/cluster_spec.rb +++ /dev/null @@ -1,264 +0,0 @@ -require 'spec_helper' - -describe Gcp::Cluster do - it { is_expected.to belong_to(:project) } - it { is_expected.to belong_to(:user) } - it { is_expected.to belong_to(:service) } - - it { is_expected.to validate_presence_of(:gcp_cluster_zone) } - - describe '.enabled' do - subject { described_class.enabled } - - let!(:cluster) { create(:gcp_cluster, enabled: true) } - - before do - create(:gcp_cluster, enabled: false) - end - - it { is_expected.to contain_exactly(cluster) } - end - - describe '.disabled' do - subject { described_class.disabled } - - let!(:cluster) { create(:gcp_cluster, enabled: false) } - - before do - create(:gcp_cluster, enabled: true) - end - - it { is_expected.to contain_exactly(cluster) } - end - - describe '#default_value_for' do - let(:cluster) { described_class.new } - - it { expect(cluster.gcp_cluster_zone).to eq('us-central1-a') } - it { expect(cluster.gcp_cluster_size).to eq(3) } - it { expect(cluster.gcp_machine_type).to eq('n1-standard-2') } - end - - describe '#validates' do - subject { cluster.valid? } - - context 'when validates gcp_project_id' do - let(:cluster) { build(:gcp_cluster, gcp_project_id: gcp_project_id) } - - context 'when valid' do - let(:gcp_project_id) { 'gcp-project-12345' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:gcp_project_id) { '' } - - it { is_expected.to be_falsey } - end - - context 'when too long' do - let(:gcp_project_id) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:gcp_project_id) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates gcp_cluster_name' do - let(:cluster) { build(:gcp_cluster, gcp_cluster_name: gcp_cluster_name) } - - context 'when valid' do - let(:gcp_cluster_name) { 'test-cluster' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:gcp_cluster_name) { '' } - - it { is_expected.to be_falsey } - end - - context 'when too long' do - let(:gcp_cluster_name) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:gcp_cluster_name) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates gcp_cluster_size' do - let(:cluster) { build(:gcp_cluster, gcp_cluster_size: gcp_cluster_size) } - - context 'when valid' do - let(:gcp_cluster_size) { 1 } - - it { is_expected.to be_truthy } - end - - context 'when zero' do - let(:gcp_cluster_size) { 0 } - - it { is_expected.to be_falsey } - end - end - - context 'when validates project_namespace' do - let(:cluster) { build(:gcp_cluster, project_namespace: project_namespace) } - - context 'when valid' do - let(:project_namespace) { 'default-namespace' } - - it { is_expected.to be_truthy } - end - - context 'when empty' do - let(:project_namespace) { '' } - - it { is_expected.to be_truthy } - end - - context 'when too long' do - let(:project_namespace) { 'A' * 64 } - - it { is_expected.to be_falsey } - end - - context 'when includes abnormal character' do - let(:project_namespace) { '!!!!!!' } - - it { is_expected.to be_falsey } - end - end - - context 'when validates restrict_modification' do - let(:cluster) { create(:gcp_cluster) } - - before do - cluster.make_creating! - end - - context 'when created' do - before do - cluster.make_created! - end - - it { is_expected.to be_truthy } - end - - context 'when creating' do - it { is_expected.to be_falsey } - end - end - end - - describe '#state_machine' do - let(:cluster) { build(:gcp_cluster) } - - context 'when transits to created state' do - before do - cluster.gcp_token = 'tmp' - cluster.gcp_operation_id = 'tmp' - cluster.make_created! - end - - it 'nullify gcp_token and gcp_operation_id' do - expect(cluster.gcp_token).to be_nil - expect(cluster.gcp_operation_id).to be_nil - expect(cluster).to be_created - end - end - - context 'when transits to errored state' do - let(:reason) { 'something wrong' } - - before do - cluster.make_errored!(reason) - end - - it 'sets status_reason' do - expect(cluster.status_reason).to eq(reason) - expect(cluster).to be_errored - end - end - end - - describe '#project_namespace_placeholder' do - subject { cluster.project_namespace_placeholder } - - let(:cluster) { create(:gcp_cluster) } - - it 'returns a placeholder' do - is_expected.to eq("#{cluster.project.path}-#{cluster.project.id}") - end - end - - describe '#on_creation?' do - subject { cluster.on_creation? } - - let(:cluster) { create(:gcp_cluster) } - - context 'when status is creating' do - before do - cluster.make_creating! - end - - it { is_expected.to be_truthy } - end - - context 'when status is created' do - before do - cluster.make_created! - end - - it { is_expected.to be_falsey } - end - end - - describe '#api_url' do - subject { cluster.api_url } - - let(:cluster) { create(:gcp_cluster, :created_on_gke) } - let(:api_url) { 'https://' + cluster.endpoint } - - it { is_expected.to eq(api_url) } - end - - describe '#restrict_modification' do - subject { cluster.restrict_modification } - - let(:cluster) { create(:gcp_cluster) } - - context 'when status is created' do - before do - cluster.make_created! - end - - it { is_expected.to be_truthy } - end - - context 'when status is creating' do - before do - cluster.make_creating! - end - - it { is_expected.to be_falsey } - - it 'sets error' do - is_expected.to be_falsey - expect(cluster.errors).not_to be_empty - end - end - end -end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 476a2697605..d022dae3476 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1755,39 +1755,12 @@ describe MergeRequest do end end - describe '#fetch_ref' do - it 'sets "ref_fetched" flag to true' do - subject.update!(ref_fetched: nil) + describe '#fetch_ref!' do + it 'fetches the ref correctly' do + expect { subject.target_project.repository.delete_refs(subject.ref_path) }.not_to raise_error - subject.fetch_ref - - expect(subject.reload.ref_fetched).to be_truthy - end - end - - describe '#ref_fetched?' do - it 'does not perform git operation when value is cached' do - subject.ref_fetched = true - - expect_any_instance_of(Repository).not_to receive(:ref_exists?) - expect(subject.ref_fetched?).to be_truthy - end - - it 'caches the value when ref exists but value is not cached' do - subject.update!(ref_fetched: nil) - allow_any_instance_of(Repository).to receive(:ref_exists?) - .and_return(true) - - expect(subject.ref_fetched?).to be_truthy - expect(subject.reload.ref_fetched).to be_truthy - end - - it 'returns false when ref does not exist' do - subject.update!(ref_fetched: nil) - allow_any_instance_of(Repository).to receive(:ref_exists?) - .and_return(false) - - expect(subject.ref_fetched?).to be_falsey + subject.fetch_ref! + expect(subject.target_project.repository.ref_exists?(subject.ref_path)).to be_truthy end end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 00de536a18b..7617e1f89b1 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -145,7 +145,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do let(:discovery_url) { 'https://kubernetes.example.com/api/v1' } before do - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) end context 'with path prefix in api_url' do @@ -153,7 +153,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do it 'tests with the prefix' do service.api_url = 'https://kubernetes.example.com/prefix' - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) expect(service.test[:success]).to be_truthy expect(WebMock).to have_requested(:get, discovery_url).once diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e8588975118..0e50909988b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -276,6 +276,12 @@ describe Project do expect(project).to be_valid end + + it 'allows a path ending in a period' do + project = build(:project, path: 'foo.') + + expect(project).to be_valid + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e0896d64c8f..d2f97009ad9 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -788,14 +788,16 @@ describe User do end it "creates external user by default" do - user = build(:user) + user = create(:user) expect(user.external).to be_truthy + expect(user.can_create_group).to be_falsey + expect(user.projects_limit).to be 0 end describe 'with default overrides' do it "creates a non-external user" do - user = build(:user, external: false) + user = create(:user, external: false) expect(user.external).to be_falsey end diff --git a/spec/policies/gcp/cluster_policy_spec.rb b/spec/policies/clusters/cluster_policy_spec.rb index e213aa3d557..4207f42b07f 100644 --- a/spec/policies/gcp/cluster_policy_spec.rb +++ b/spec/policies/clusters/cluster_policy_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' -describe Gcp::ClusterPolicy, :models do - set(:project) { create(:project) } - set(:cluster) { create(:gcp_cluster, project: project) } +describe Clusters::ClusterPolicy, :models do + let(:cluster) { create(:cluster, :project) } + let(:project) { cluster.project } let(:user) { create(:user) } let(:policy) { described_class.new(user, cluster) } diff --git a/spec/presenters/gcp/cluster_presenter_spec.rb b/spec/presenters/clusters/cluster_presenter_spec.rb index 8d86dc31582..48d4f3671c5 100644 --- a/spec/presenters/gcp/cluster_presenter_spec.rb +++ b/spec/presenters/clusters/cluster_presenter_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' -describe Gcp::ClusterPresenter do - let(:project) { create(:project) } - let(:cluster) { create(:gcp_cluster, project: project) } +describe Clusters::ClusterPresenter do + let(:cluster) { create(:cluster, :provided_by_gcp) } subject(:presenter) do described_class.new(cluster) @@ -22,14 +21,14 @@ describe Gcp::ClusterPresenter do end it 'forwards missing methods to cluster' do - expect(presenter.gcp_cluster_zone).to eq(cluster.gcp_cluster_zone) + expect(presenter.status).to eq(cluster.status) end end describe '#gke_cluster_url' do subject { described_class.new(cluster).gke_cluster_url } - it { is_expected.to include(cluster.gcp_cluster_zone) } - it { is_expected.to include(cluster.gcp_cluster_name) } + it { is_expected.to include(cluster.provider.zone) } + it { is_expected.to include(cluster.name) } end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 024cfe8b372..e16be3c46e1 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -623,8 +623,6 @@ describe API::MergeRequests do before do forked_project.add_reporter(user2) - - allow_any_instance_of(MergeRequest).to receive(:write_ref) end it "returns merge_request" do diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb index 26251b95680..91897e5ee01 100644 --- a/spec/requests/api/v3/merge_requests_spec.rb +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -319,8 +319,6 @@ describe API::MergeRequests do before do forked_project.add_reporter(user2) - - allow_any_instance_of(MergeRequest).to receive(:write_ref) end it "returns merge_request" do diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb index 2c7f49974f1..72f02131211 100644 --- a/spec/serializers/cluster_entity_spec.rb +++ b/spec/serializers/cluster_entity_spec.rb @@ -1,22 +1,38 @@ require 'spec_helper' describe ClusterEntity do - set(:cluster) { create(:gcp_cluster, :errored) } - let(:request) { double('request') } + describe '#as_json' do + subject { described_class.new(cluster).as_json } - let(:entity) do - described_class.new(cluster) - end + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } - describe '#as_json' do - subject { entity.as_json } + context 'when status is creating' do + let(:provider) { create(:cluster_provider_gcp, :creating) } - it 'contains status' do - expect(subject[:status]).to eq(:errored) + it 'has corresponded data' do + expect(subject[:status]).to eq(:creating) + expect(subject[:status_reason]).to be_nil + end + end + + context 'when status is errored' do + let(:provider) { create(:cluster_provider_gcp, :errored) } + + it 'has corresponded data' do + expect(subject[:status]).to eq(:errored) + expect(subject[:status_reason]).to eq(provider.status_reason) + end + end end - it 'contains status reason' do - expect(subject[:status_reason]).to eq('general error') + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } + + it 'has nil' do + expect(subject[:status]).to be_nil + expect(subject[:status_reason]).to be_nil + end end end end diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb index 1ac6784d28f..ff7d1789149 100644 --- a/spec/serializers/cluster_serializer_spec.rb +++ b/spec/serializers/cluster_serializer_spec.rb @@ -1,15 +1,20 @@ require 'spec_helper' describe ClusterSerializer do - let(:serializer) do - described_class.new - end - describe '#represent_status' do - subject { serializer.represent_status(resource) } + subject { described_class.new.represent_status(cluster) } + + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:cluster_provider_gcp, :errored) } + + it 'serializes only status' do + expect(subject.keys).to contain_exactly(:status, :status_reason) + end + end - context 'when represents only status' do - let(:resource) { create(:gcp_cluster, :errored) } + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } it 'serializes only status' do expect(subject.keys).to contain_exactly(:status, :status_reason) diff --git a/spec/services/ci/create_cluster_service_spec.rb b/spec/services/ci/create_cluster_service_spec.rb deleted file mode 100644 index 6e7398fbffa..00000000000 --- a/spec/services/ci/create_cluster_service_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -require 'spec_helper' - -describe Ci::CreateClusterService do - describe '#execute' do - let(:access_token) { 'xxx' } - let(:project) { create(:project) } - let(:user) { create(:user) } - let(:result) { described_class.new(project, user, params).execute(access_token) } - - context 'when correct params' do - let(:params) do - { - gcp_project_id: 'gcp-project', - gcp_cluster_name: 'test-cluster', - gcp_cluster_zone: 'us-central1-a', - gcp_cluster_size: 1 - } - end - - it 'creates a cluster object' do - expect(ClusterProvisionWorker).to receive(:perform_async) - expect { result }.to change { Gcp::Cluster.count }.by(1) - expect(result.gcp_project_id).to eq('gcp-project') - expect(result.gcp_cluster_name).to eq('test-cluster') - expect(result.gcp_cluster_zone).to eq('us-central1-a') - expect(result.gcp_cluster_size).to eq(1) - expect(result.gcp_token).to eq(access_token) - end - end - - context 'when invalid params' do - let(:params) do - { - gcp_project_id: 'gcp-project', - gcp_cluster_name: 'test-cluster', - gcp_cluster_zone: 'us-central1-a', - gcp_cluster_size: 'ABC' - } - end - - it 'returns an error' do - expect(ClusterProvisionWorker).not_to receive(:perform_async) - expect { result }.to change { Gcp::Cluster.count }.by(0) - end - end - end -end diff --git a/spec/services/ci/fetch_gcp_operation_service_spec.rb b/spec/services/ci/fetch_gcp_operation_service_spec.rb deleted file mode 100644 index 7792979c5cb..00000000000 --- a/spec/services/ci/fetch_gcp_operation_service_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' -require 'google/apis' - -describe Ci::FetchGcpOperationService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { double } - - context 'when suceeded' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_operations).and_return(operation) - end - - it 'fetch the gcp operaion' do - expect { |b| described_class.new.execute(cluster, &b) } - .to yield_with_args(operation) - end - end - - context 'when raises an error' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_operations).and_raise(error) - end - - it 'sets an error to cluster object' do - expect { |b| described_class.new.execute(cluster, &b) } - .not_to yield_with_args - expect(cluster.reload).to be_errored - end - end - end -end diff --git a/spec/services/ci/finalize_cluster_creation_service_spec.rb b/spec/services/ci/finalize_cluster_creation_service_spec.rb deleted file mode 100644 index def3709fdb4..00000000000 --- a/spec/services/ci/finalize_cluster_creation_service_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'spec_helper' - -describe Ci::FinalizeClusterCreationService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:result) { described_class.new.execute(cluster) } - - context 'when suceeded to get cluster from api' do - let(:gke_cluster) { double } - - before do - allow(gke_cluster).to receive(:endpoint).and_return('111.111.111.111') - allow(gke_cluster).to receive(:master_auth).and_return(spy) - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_get).and_return(gke_cluster) - end - - context 'when suceeded to get kubernetes token' do - let(:kubernetes_token) { 'abc' } - - before do - allow_any_instance_of(Ci::FetchKubernetesTokenService) - .to receive(:execute).and_return(kubernetes_token) - end - - it 'executes integration cluster' do - expect_any_instance_of(Ci::IntegrateClusterService).to receive(:execute) - described_class.new.execute(cluster) - end - end - - context 'when failed to get kubernetes token' do - before do - allow_any_instance_of(Ci::FetchKubernetesTokenService) - .to receive(:execute).and_return(nil) - end - - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - end - - context 'when failed to get cluster from api' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_get).and_raise(error) - end - - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - end -end diff --git a/spec/services/ci/integrate_cluster_service_spec.rb b/spec/services/ci/integrate_cluster_service_spec.rb deleted file mode 100644 index 3a79c205bd1..00000000000 --- a/spec/services/ci/integrate_cluster_service_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'spec_helper' - -describe Ci::IntegrateClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster, :custom_project_namespace) } - let(:endpoint) { '123.123.123.123' } - let(:ca_cert) { 'ca_cert_xxx' } - let(:token) { 'token_xxx' } - let(:username) { 'username_xxx' } - let(:password) { 'password_xxx' } - - before do - described_class - .new.execute(cluster, endpoint, ca_cert, token, username, password) - - cluster.reload - end - - context 'when correct params' do - it 'creates a cluster object' do - expect(cluster.endpoint).to eq(endpoint) - expect(cluster.ca_cert).to eq(ca_cert) - expect(cluster.kubernetes_token).to eq(token) - expect(cluster.username).to eq(username) - expect(cluster.password).to eq(password) - expect(cluster.service.active).to be_truthy - expect(cluster.service.api_url).to eq(cluster.api_url) - expect(cluster.service.ca_pem).to eq(ca_cert) - expect(cluster.service.namespace).to eq(cluster.project_namespace) - expect(cluster.service.token).to eq(token) - end - end - - context 'when invalid params' do - let(:endpoint) { nil } - - it 'sets an error to cluster object' do - expect(cluster).to be_errored - end - end - end -end diff --git a/spec/services/ci/provision_cluster_service_spec.rb b/spec/services/ci/provision_cluster_service_spec.rb deleted file mode 100644 index 5ce5c788314..00000000000 --- a/spec/services/ci/provision_cluster_service_spec.rb +++ /dev/null @@ -1,85 +0,0 @@ -require 'spec_helper' - -describe Ci::ProvisionClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { spy } - - shared_examples 'error' do - it 'sets an error to cluster object' do - described_class.new.execute(cluster) - - expect(cluster.reload).to be_errored - end - end - - context 'when suceeded to request provision' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create).and_return(operation) - end - - context 'when operation status is RUNNING' do - before do - allow(operation).to receive(:status).and_return('RUNNING') - end - - context 'when suceeded to parse gcp operation id' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return('operation-123') - end - - context 'when cluster status is scheduled' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return('operation-123') - end - - it 'schedules a worker for status minitoring' do - expect(WaitForClusterCreationWorker).to receive(:perform_in) - - described_class.new.execute(cluster) - end - end - - context 'when cluster status is creating' do - before do - cluster.make_creating! - end - - it_behaves_like 'error' - end - end - - context 'when failed to parse gcp operation id' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:parse_operation_id).and_return(nil) - end - - it_behaves_like 'error' - end - end - - context 'when operation status is others' do - before do - allow(operation).to receive(:status).and_return('others') - end - - it_behaves_like 'error' - end - end - - context 'when failed to request provision' do - let(:error) { Google::Apis::ServerError.new('a') } - - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create).and_raise(error) - end - - it_behaves_like 'error' - end - end -end diff --git a/spec/services/ci/update_cluster_service_spec.rb b/spec/services/ci/update_cluster_service_spec.rb deleted file mode 100644 index a289385b88f..00000000000 --- a/spec/services/ci/update_cluster_service_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -require 'spec_helper' - -describe Ci::UpdateClusterService do - describe '#execute' do - let(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service) } - - before do - described_class.new(cluster.project, cluster.user, params).execute(cluster) - - cluster.reload - end - - context 'when correct params' do - context 'when enabled is true' do - let(:params) { { 'enabled' => 'true' } } - - it 'enables cluster and overwrite kubernetes service' do - expect(cluster.enabled).to be_truthy - expect(cluster.service.active).to be_truthy - expect(cluster.service.api_url).to eq(cluster.api_url) - expect(cluster.service.ca_pem).to eq(cluster.ca_cert) - expect(cluster.service.namespace).to eq(cluster.project_namespace) - expect(cluster.service.token).to eq(cluster.kubernetes_token) - end - end - - context 'when enabled is false' do - let(:params) { { 'enabled' => 'false' } } - - it 'disables cluster and kubernetes service' do - expect(cluster.enabled).to be_falsy - expect(cluster.service.active).to be_falsy - end - end - end - end -end diff --git a/spec/services/clusters/create_service_spec.rb b/spec/services/clusters/create_service_spec.rb new file mode 100644 index 00000000000..5b6edb73beb --- /dev/null +++ b/spec/services/clusters/create_service_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Clusters::CreateService do + let(:access_token) { 'xxx' } + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:result) { described_class.new(project, user, params).execute(access_token) } + + context 'when provider is gcp' do + context 'when correct params' do + let(:params) do + { + name: 'test-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: 'gcp-project', + zone: 'us-central1-a', + num_nodes: 1, + machine_type: 'machine_type-a' + } + } + end + + it 'creates a cluster object and performs a worker' do + expect(ClusterProvisionWorker).to receive(:perform_async) + + expect { result } + .to change { Clusters::Cluster.count }.by(1) + .and change { Clusters::Providers::Gcp.count }.by(1) + + expect(result.name).to eq('test-cluster') + expect(result.user).to eq(user) + expect(result.project).to eq(project) + expect(result.provider.gcp_project_id).to eq('gcp-project') + expect(result.provider.zone).to eq('us-central1-a') + expect(result.provider.num_nodes).to eq(1) + expect(result.provider.machine_type).to eq('machine_type-a') + expect(result.provider.access_token).to eq(access_token) + expect(result.platform).to be_nil + end + end + + context 'when invalid params' do + let(:params) do + { + name: 'test-cluster', + provider_type: :gcp, + provider_gcp_attributes: { + gcp_project_id: '!!!!!!!', + zone: 'us-central1-a', + num_nodes: 1, + machine_type: 'machine_type-a' + } + } + end + + it 'returns an error' do + expect(ClusterProvisionWorker).not_to receive(:perform_async) + expect { result }.to change { Clusters::Cluster.count }.by(0) + expect(result.errors[:"provider_gcp.gcp_project_id"]).to be_present + end + end + end +end diff --git a/spec/services/clusters/gcp/fetch_operation_service_spec.rb b/spec/services/clusters/gcp/fetch_operation_service_spec.rb new file mode 100644 index 00000000000..e2fa93904c5 --- /dev/null +++ b/spec/services/clusters/gcp/fetch_operation_service_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Clusters::Gcp::FetchOperationService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:cluster_provider_gcp, :creating) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:operation_id) { provider.operation_id } + + shared_examples 'success' do + it 'yields' do + expect { |b| described_class.new.execute(provider, &b) } + .to yield_with_args + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + expect { |b| described_class.new.execute(provider, &b) } + .not_to yield_with_args + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to fetch operation' do + before do + stub_cloud_platform_get_zone_operation(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'success' + end + + context 'when Internal Server Error happened' do + before do + stub_cloud_platform_get_zone_operation_error(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb new file mode 100644 index 00000000000..0cf91307589 --- /dev/null +++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +describe Clusters::Gcp::FinalizeCreationService do + include GoogleApi::CloudPlatformHelpers + include KubernetesHelpers + + describe '#execute' do + let(:cluster) { create(:cluster, :project, :providing_by_gcp) } + let(:provider) { cluster.provider } + let(:platform) { cluster.platform } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:cluster_name) { cluster.name } + + shared_examples 'success' do + it 'configures provider and kubernetes' do + described_class.new.execute(provider) + + expect(provider).to be_created + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to fetch gke cluster info' do + let(:endpoint) { '111.111.111.111' } + let(:api_url) { 'https://' + endpoint } + let(:username) { 'sample-username' } + let(:password) { 'sample-password' } + + before do + stub_cloud_platform_get_zone_cluster( + gcp_project_id, zone, cluster_name, + { + endpoint: endpoint, + username: username, + password: password + } + ) + + stub_kubeclient_discover(api_url) + end + + context 'when suceeded to fetch kuberenetes token' do + let(:token) { 'sample-token' } + + before do + stub_kubeclient_get_secrets( + api_url, + { + token: Base64.encode64(token) + } ) + end + + it_behaves_like 'success' + + it 'has corresponded data' do + described_class.new.execute(provider) + cluster.reload + provider.reload + platform.reload + + expect(provider.endpoint).to eq(endpoint) + expect(platform.api_url).to eq(api_url) + expect(platform.ca_cert).to eq(Base64.decode64(load_sample_cert)) + expect(platform.username).to eq(username) + expect(platform.password).to eq(password) + expect(platform.token).to eq(token) + end + end + + context 'when default-token is not found' do + before do + stub_kubeclient_get_secrets(api_url, metadata_name: 'aaaa') + end + + it_behaves_like 'error' + end + + context 'when token is empty' do + before do + stub_kubeclient_get_secrets(api_url, token: '') + end + + it_behaves_like 'error' + end + + context 'when failed to fetch kuberenetes token' do + before do + stub_kubeclient_get_secrets_error(api_url) + end + + it_behaves_like 'error' + end + end + + context 'when failed to fetch gke cluster info' do + before do + stub_cloud_platform_get_zone_cluster_error(gcp_project_id, zone, cluster_name) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/provision_service_spec.rb b/spec/services/clusters/gcp/provision_service_spec.rb new file mode 100644 index 00000000000..f48afdc83b2 --- /dev/null +++ b/spec/services/clusters/gcp/provision_service_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe Clusters::Gcp::ProvisionService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:cluster_provider_gcp, :scheduled) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + + shared_examples 'success' do + it 'schedules a worker for status minitoring' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + + described_class.new.execute(provider) + + expect(provider.reload).to be_creating + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when suceeded to request provision' do + before do + stub_cloud_platform_create_cluster(gcp_project_id, zone) + end + + it_behaves_like 'success' + end + + context 'when operation status is unexpected' do + before do + stub_cloud_platform_create_cluster( + gcp_project_id, zone, + { + "status": 'unexpected' + } ) + end + + it_behaves_like 'error' + end + + context 'when selfLink is unexpected' do + before do + stub_cloud_platform_create_cluster( + gcp_project_id, zone, + { + "selfLink": 'unexpected' + }) + end + + it_behaves_like 'error' + end + + context 'when Internal Server Error happened' do + before do + stub_cloud_platform_create_cluster_error(gcp_project_id, zone) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/gcp/verify_provision_status_service_spec.rb b/spec/services/clusters/gcp/verify_provision_status_service_spec.rb new file mode 100644 index 00000000000..2ee2fa51f63 --- /dev/null +++ b/spec/services/clusters/gcp/verify_provision_status_service_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +describe Clusters::Gcp::VerifyProvisionStatusService do + include GoogleApi::CloudPlatformHelpers + + describe '#execute' do + let(:provider) { create(:cluster_provider_gcp, :creating) } + let(:gcp_project_id) { provider.gcp_project_id } + let(:zone) { provider.zone } + let(:operation_id) { provider.operation_id } + + shared_examples 'continue_creation' do + it 'schedules a worker for status minitoring' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + + described_class.new.execute(provider) + end + end + + shared_examples 'finalize_creation' do + it 'schedules a worker for status minitoring' do + expect_any_instance_of(Clusters::Gcp::FinalizeCreationService).to receive(:execute) + + described_class.new.execute(provider) + end + end + + shared_examples 'error' do + it 'sets an error to provider object' do + described_class.new.execute(provider) + + expect(provider.reload).to be_errored + end + end + + context 'when operation status is RUNNING' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'RUNNING', + "startTime": 1.minute.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'continue_creation' + + context 'when cluster creation time exceeds timeout' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'RUNNING', + "startTime": 30.minutes.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'error' + end + end + + context 'when operation status is PENDING' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'PENDING', + "startTime": 1.minute.ago.strftime("%FT%TZ") + } ) + end + + it_behaves_like 'continue_creation' + end + + context 'when operation status is DONE' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'DONE' + } ) + end + + it_behaves_like 'finalize_creation' + end + + context 'when operation status is unexpected' do + before do + stub_cloud_platform_get_zone_operation( + gcp_project_id, zone, operation_id, + { + "status": 'unexpected' + } ) + end + + it_behaves_like 'error' + end + + context 'when failed to get operation status' do + before do + stub_cloud_platform_get_zone_operation_error(gcp_project_id, zone, operation_id) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/clusters/update_service_spec.rb b/spec/services/clusters/update_service_spec.rb new file mode 100644 index 00000000000..2d91a21035d --- /dev/null +++ b/spec/services/clusters/update_service_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Clusters::UpdateService do + describe '#execute' do + subject { described_class.new(cluster.project, cluster.user, params).execute(cluster) } + + let(:cluster) { create(:cluster, :project, :provided_by_user) } + + context 'when correct params' do + context 'when enabled is true' do + let(:params) { { enabled: true } } + + it 'enables cluster' do + is_expected.to eq(true) + expect(cluster.enabled).to be_truthy + end + end + + context 'when enabled is false' do + let(:params) { { enabled: false } } + + it 'disables cluster' do + is_expected.to eq(true) + expect(cluster.enabled).to be_falsy + end + end + + context 'when namespace is specified' do + let(:params) do + { + platform_kubernetes_attributes: { + namespace: 'custom-namespace' + } + } + end + + it 'updates namespace' do + is_expected.to eq(true) + expect(cluster.platform.namespace).to eq('custom-namespace') + end + end + end + + context 'when invalid params' do + let(:params) do + { + platform_kubernetes_attributes: { + namespace: '!!!' + } + } + end + + it 'returns false' do + is_expected.to eq(false) + expect(cluster.errors[:"platform_kubernetes.namespace"]).to be_present + end + end + end +end diff --git a/spec/services/events/render_service_spec.rb b/spec/services/events/render_service_spec.rb new file mode 100644 index 00000000000..b4a4a44d07b --- /dev/null +++ b/spec/services/events/render_service_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Events::RenderService do + describe '#execute' do + let!(:note) { build(:note) } + let!(:event) { build(:event, target: note, project: note.project) } + let!(:user) { build(:user) } + + context 'when the request format is atom' do + it 'renders the note inside events' do + expect(Banzai::ObjectRenderer).to receive(:new) + .with(event.project, user, + only_path: false, + xhtml: true) + .and_call_original + + expect_any_instance_of(Banzai::ObjectRenderer) + .to receive(:render).with([note], :note) + + described_class.new(user).execute([event], atom_request: true) + end + end + + context 'when the request format is not atom' do + it 'renders the note inside events' do + expect(Banzai::ObjectRenderer).to receive(:new) + .with(event.project, user, {}) + .and_call_original + + expect_any_instance_of(Banzai::ObjectRenderer) + .to receive(:render).with([note], :note) + + described_class.new(user).execute([event], atom_request: false) + end + end + end +end diff --git a/spec/services/notes/render_service_spec.rb b/spec/services/notes/render_service_spec.rb new file mode 100644 index 00000000000..faac498037f --- /dev/null +++ b/spec/services/notes/render_service_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Notes::RenderService do + describe '#execute' do + it 'renders a Note' do + note = double(:note) + project = double(:project) + wiki = double(:wiki) + user = double(:user) + + expect(Banzai::ObjectRenderer).to receive(:new) + .with(project, user, + requested_path: 'foo', + project_wiki: wiki, + ref: 'bar', + only_path: nil, + xhtml: false) + .and_call_original + + expect_any_instance_of(Banzai::ObjectRenderer) + .to receive(:render).with([note], :note) + + described_class.new(user).execute([note], project, + requested_path: 'foo', + project_wiki: wiki, + ref: 'bar', + only_path: nil, + xhtml: false) + end + end +end diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb new file mode 100644 index 00000000000..dabf0db7666 --- /dev/null +++ b/spec/support/google_api/cloud_platform_helpers.rb @@ -0,0 +1,119 @@ +module GoogleApi + module CloudPlatformHelpers + def stub_google_api_validate_token + request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token' + request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.since.to_i.to_s + end + + def stub_google_api_expired_token + request.session[GoogleApi::CloudPlatform::Client.session_key_for_token] = 'token' + request.session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = 1.hour.ago.to_i.to_s + end + + def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, **options) + WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)) + .to_return(cloud_platform_response(cloud_platform_cluster_body(options))) + end + + def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id) + WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id)) + .to_return(status: [500, "Internal Server Error"]) + end + + def stub_cloud_platform_create_cluster(project_id, zone, **options) + WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone)) + .to_return(cloud_platform_response(cloud_platform_operation_body(options))) + end + + def stub_cloud_platform_create_cluster_error(project_id, zone) + WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone)) + .to_return(status: [500, "Internal Server Error"]) + end + + def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, **options) + WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id)) + .to_return(cloud_platform_response(cloud_platform_operation_body(options))) + end + + def stub_cloud_platform_get_zone_operation_error(project_id, zone, operation_id) + WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id)) + .to_return(status: [500, "Internal Server Error"]) + end + + def cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters/#{cluster_id}" + end + + def cloud_platform_create_cluster_url(project_id, zone) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/clusters" + end + + def cloud_platform_get_zone_operation_url(project_id, zone, operation_id) + "https://container.googleapis.com/v1/projects/#{project_id}/zones/#{zone}/operations/#{operation_id}" + end + + def cloud_platform_response(body) + { status: 200, headers: { 'Content-Type' => 'application/json' }, body: body.to_json } + end + + def load_sample_cert + pem_file = File.expand_path(Rails.root.join('spec/fixtures/clusters/sample_cert.pem')) + Base64.encode64(File.read(pem_file)) + end + + ## + # gcloud container clusters create + # https://cloud.google.com/container-engine/reference/rest/v1/projects.zones.clusters/create + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def cloud_platform_cluster_body(**options) + { + "name": options[:name] || 'string', + "description": options[:description] || 'string', + "initialNodeCount": options[:initialNodeCount] || 'number', + "masterAuth": { + "username": options[:username] || 'string', + "password": options[:password] || 'string', + "clusterCaCertificate": options[:clusterCaCertificate] || load_sample_cert, + "clientCertificate": options[:clientCertificate] || 'string', + "clientKey": options[:clientKey] || 'string' + }, + "loggingService": options[:loggingService] || 'string', + "monitoringService": options[:monitoringService] || 'string', + "network": options[:network] || 'string', + "clusterIpv4Cidr": options[:clusterIpv4Cidr] || 'string', + "subnetwork": options[:subnetwork] || 'string', + "enableKubernetesAlpha": options[:enableKubernetesAlpha] || 'boolean', + "labelFingerprint": options[:labelFingerprint] || 'string', + "selfLink": options[:selfLink] || 'string', + "zone": options[:zone] || 'string', + "endpoint": options[:endpoint] || 'string', + "initialClusterVersion": options[:initialClusterVersion] || 'string', + "currentMasterVersion": options[:currentMasterVersion] || 'string', + "currentNodeVersion": options[:currentNodeVersion] || 'string', + "createTime": options[:createTime] || 'string', + "status": options[:status] || 'RUNNING', + "statusMessage": options[:statusMessage] || 'string', + "nodeIpv4CidrSize": options[:nodeIpv4CidrSize] || 'number', + "servicesIpv4Cidr": options[:servicesIpv4Cidr] || 'string', + "currentNodeCount": options[:currentNodeCount] || 'number', + "expireTime": options[:expireTime] || 'string' + } + end + + def cloud_platform_operation_body(**options) + { + "name": options[:name] || 'operation-1234567891234-1234567', + "zone": options[:zone] || 'us-central1-a', + "operationType": options[:operationType] || 'CREATE_CLUSTER', + "status": options[:status] || 'PENDING', + "detail": options[:detail] || 'detail', + "statusMessage": options[:statusMessage] || '', + "selfLink": options[:selfLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/operations/operation-1234567891234-1234567', + "targetLink": options[:targetLink] || 'https://container.googleapis.com/v1/projects/123456789101/zones/us-central1-a/clusters/test-cluster', + "startTime": options[:startTime] || '2017-09-13T16:49:13.055601589Z', + "endTime": options[:endTime] || '' + } + end + end +end diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index c92f78b324c..e46b61b6461 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -9,22 +9,51 @@ module KubernetesHelpers kube_response(kube_pods_body) end - def stub_kubeclient_discover - WebMock.stub_request(:get, service.api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) + def stub_kubeclient_discover(api_url) + WebMock.stub_request(:get, api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) end def stub_kubeclient_pods(response = nil) - stub_kubeclient_discover + stub_kubeclient_discover(service.api_url) pods_url = service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods" WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response) end + def stub_kubeclient_get_secrets(api_url, **options) + WebMock.stub_request(:get, api_url + '/api/v1/secrets') + .to_return(kube_response(kube_v1_secrets_body(options))) + end + + def stub_kubeclient_get_secrets_error(api_url) + WebMock.stub_request(:get, api_url + '/api/v1/secrets') + .to_return(status: [404, "Internal Server Error"]) + end + + def kube_v1_secrets_body(**options) + { + "kind" => "SecretList", + "apiVersion": "v1", + "items" => [ + { + "metadata": { + "name": options[:metadata_name] || "default-token-1", + "namespace": "kube-system" + }, + "data": { + "token": options[:token] || Base64.encode64('token-sample-123') + } + } + ] + } + end + def kube_v1_discovery_body { "kind" => "APIResourceList", "resources" => [ - { "name" => "pods", "namespaced" => true, "kind" => "Pod" } + { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, + { "name" => "secrets", "namespaced" => true, "kind" => "Secret" } ] } end diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb deleted file mode 100644 index 08e1c5a728a..00000000000 --- a/spec/validators/dynamic_path_validator_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -require 'spec_helper' - -describe DynamicPathValidator do - let(:validator) { described_class.new(attributes: [:path]) } - - def expect_handles_invalid_utf8 - expect { yield('\255invalid') }.to be_falsey - end - - describe '.valid_user_path' do - it 'handles invalid utf8' do - expect(described_class.valid_user_path?("a\0weird\255path")).to be_falsey - end - end - - describe '.valid_group_path' do - it 'handles invalid utf8' do - expect(described_class.valid_group_path?("a\0weird\255path")).to be_falsey - end - end - - describe '.valid_project_path' do - it 'handles invalid utf8' do - expect(described_class.valid_project_path?("a\0weird\255path")).to be_falsey - end - end - - describe '#path_valid_for_record?' do - context 'for project' do - it 'calls valid_project_path?' do - project = build(:project, path: 'activity') - - expect(described_class).to receive(:valid_project_path?).with(project.full_path).and_call_original - - expect(validator.path_valid_for_record?(project, 'activity')).to be_truthy - end - end - - context 'for group' do - it 'calls valid_group_path?' do - group = build(:group, :nested, path: 'activity') - - expect(described_class).to receive(:valid_group_path?).with(group.full_path).and_call_original - - expect(validator.path_valid_for_record?(group, 'activity')).to be_falsey - end - end - - context 'for user' do - it 'calls valid_user_path?' do - user = build(:user, username: 'activity') - - expect(described_class).to receive(:valid_user_path?).with(user.full_path).and_call_original - - expect(validator.path_valid_for_record?(user, 'activity')).to be_truthy - end - end - - context 'for user namespace' do - it 'calls valid_user_path?' do - user = create(:user, username: 'activity') - namespace = user.namespace - - expect(described_class).to receive(:valid_user_path?).with(namespace.full_path).and_call_original - - expect(validator.path_valid_for_record?(namespace, 'activity')).to be_truthy - end - end - end - - describe '#validates_each' do - it 'adds a message when the path is not in the correct format' do - group = build(:group) - - validator.validate_each(group, :path, "Path with spaces, and comma's!") - - expect(group.errors[:path]).to include(Gitlab::PathRegex.namespace_format_message) - end - - it 'adds a message when the path is not in the correct format' do - group = build(:group, path: 'users') - - validator.validate_each(group, :path, 'users') - - expect(group.errors[:path]).to include('users is a reserved name') - end - - it 'updating to an invalid path is not allowed' do - project = create(:project) - project.path = 'update' - - validator.validate_each(project, :path, 'update') - - expect(project.errors[:path]).to include('update is a reserved name') - end - end -end diff --git a/spec/validators/namespace_path_validator_spec.rb b/spec/validators/namespace_path_validator_spec.rb new file mode 100644 index 00000000000..61e2845f35f --- /dev/null +++ b/spec/validators/namespace_path_validator_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe NamespacePathValidator do + let(:validator) { described_class.new(attributes: [:path]) } + + describe '.valid_path?' do + it 'handles invalid utf8' do + expect(described_class.valid_path?("a\0weird\255path")).to be_falsey + end + end + + describe '#validates_each' do + it 'adds a message when the path is not in the correct format' do + group = build(:group) + + validator.validate_each(group, :path, "Path with spaces, and comma's!") + + expect(group.errors[:path]).to include(Gitlab::PathRegex.namespace_format_message) + end + + it 'adds a message when the path is reserved when creating' do + group = build(:group, path: 'help') + + validator.validate_each(group, :path, 'help') + + expect(group.errors[:path]).to include('help is a reserved name') + end + + it 'adds a message when the path is reserved when updating' do + group = create(:group) + group.path = 'help' + + validator.validate_each(group, :path, 'help') + + expect(group.errors[:path]).to include('help is a reserved name') + end + end +end diff --git a/spec/validators/project_path_validator_spec.rb b/spec/validators/project_path_validator_spec.rb new file mode 100644 index 00000000000..8bb5e72dc22 --- /dev/null +++ b/spec/validators/project_path_validator_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe ProjectPathValidator do + let(:validator) { described_class.new(attributes: [:path]) } + + describe '.valid_path?' do + it 'handles invalid utf8' do + expect(described_class.valid_path?("a\0weird\255path")).to be_falsey + end + end + + describe '#validates_each' do + it 'adds a message when the path is not in the correct format' do + project = build(:project) + + validator.validate_each(project, :path, "Path with spaces, and comma's!") + + expect(project.errors[:path]).to include(Gitlab::PathRegex.project_path_format_message) + end + + it 'adds a message when the path is reserved when creating' do + project = build(:project, path: 'blob') + + validator.validate_each(project, :path, 'blob') + + expect(project.errors[:path]).to include('blob is a reserved name') + end + + it 'adds a message when the path is reserved when updating' do + project = create(:project) + project.path = 'blob' + + validator.validate_each(project, :path, 'blob') + + expect(project.errors[:path]).to include('blob is a reserved name') + end + end +end diff --git a/spec/validators/user_path_validator_spec.rb b/spec/validators/user_path_validator_spec.rb new file mode 100644 index 00000000000..a46089cc24f --- /dev/null +++ b/spec/validators/user_path_validator_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe UserPathValidator do + let(:validator) { described_class.new(attributes: [:username]) } + + describe '.valid_path?' do + it 'handles invalid utf8' do + expect(described_class.valid_path?("a\0weird\255path")).to be_falsey + end + end + + describe '#validates_each' do + it 'adds a message when the path is not in the correct format' do + user = build(:user) + + validator.validate_each(user, :username, "Path with spaces, and comma's!") + + expect(user.errors[:username]).to include(Gitlab::PathRegex.namespace_format_message) + end + + it 'adds a message when the path is reserved when creating' do + user = build(:user, username: 'help') + + validator.validate_each(user, :username, 'help') + + expect(user.errors[:username]).to include('help is a reserved name') + end + + it 'adds a message when the path is reserved when updating' do + user = create(:user) + user.username = 'help' + + validator.validate_each(user, :username, 'help') + + expect(user.errors[:username]).to include('help is a reserved name') + end + end +end diff --git a/spec/workers/cluster_provision_worker_spec.rb b/spec/workers/cluster_provision_worker_spec.rb index 11f208289db..8054ec11a48 100644 --- a/spec/workers/cluster_provision_worker_spec.rb +++ b/spec/workers/cluster_provision_worker_spec.rb @@ -2,11 +2,22 @@ require 'spec_helper' describe ClusterProvisionWorker do describe '#perform' do - context 'when cluster exists' do - let(:cluster) { create(:gcp_cluster) } + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:cluster_provider_gcp, :scheduled) } it 'provision a cluster' do - expect_any_instance_of(Ci::ProvisionClusterService).to receive(:execute) + expect_any_instance_of(Clusters::Gcp::ProvisionService).to receive(:execute) + + described_class.new.perform(cluster.id) + end + end + + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } + + it 'does not provision a cluster' do + expect_any_instance_of(Clusters::Gcp::ProvisionService).not_to receive(:execute) described_class.new.perform(cluster.id) end @@ -14,7 +25,7 @@ describe ClusterProvisionWorker do context 'when cluster does not exist' do it 'does not provision a cluster' do - expect_any_instance_of(Ci::ProvisionClusterService).not_to receive(:execute) + expect_any_instance_of(Clusters::Gcp::ProvisionService).not_to receive(:execute) described_class.new.perform(123) end diff --git a/spec/workers/wait_for_cluster_creation_worker_spec.rb b/spec/workers/wait_for_cluster_creation_worker_spec.rb index dcd4a3b9aec..0e92b298178 100644 --- a/spec/workers/wait_for_cluster_creation_worker_spec.rb +++ b/spec/workers/wait_for_cluster_creation_worker_spec.rb @@ -2,65 +2,32 @@ require 'spec_helper' describe WaitForClusterCreationWorker do describe '#perform' do - context 'when cluster exists' do - let(:cluster) { create(:gcp_cluster) } - let(:operation) { double } + context 'when provider type is gcp' do + let(:cluster) { create(:cluster, provider_type: :gcp, provider_gcp: provider) } + let(:provider) { create(:cluster_provider_gcp, :creating) } - before do - allow(operation).to receive(:status).and_return(status) - allow(operation).to receive(:start_time).and_return(1.minute.ago) - allow(operation).to receive(:status_message).and_return('error') - allow_any_instance_of(Ci::FetchGcpOperationService).to receive(:execute).and_yield(operation) - end - - context 'when operation status is RUNNING' do - let(:status) { 'RUNNING' } - - it 'reschedules worker' do - expect(described_class).to receive(:perform_in) - - described_class.new.perform(cluster.id) - end - - context 'when operation timeout' do - before do - allow(operation).to receive(:start_time).and_return(30.minutes.ago.utc) - end - - it 'sets an error message on cluster' do - described_class.new.perform(cluster.id) + it 'provision a cluster' do + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).to receive(:execute) - expect(cluster.reload).to be_errored - end - end - end - - context 'when operation status is DONE' do - let(:status) { 'DONE' } - - it 'finalizes cluster creation' do - expect_any_instance_of(Ci::FinalizeClusterCreationService).to receive(:execute) - - described_class.new.perform(cluster.id) - end + described_class.new.perform(cluster.id) end + end - context 'when operation status is others' do - let(:status) { 'others' } + context 'when provider type is user' do + let(:cluster) { create(:cluster, provider_type: :user) } - it 'sets an error message on cluster' do - described_class.new.perform(cluster.id) + it 'does not provision a cluster' do + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).not_to receive(:execute) - expect(cluster.reload).to be_errored - end + described_class.new.perform(cluster.id) end end context 'when cluster does not exist' do it 'does not provision a cluster' do - expect_any_instance_of(Ci::FetchGcpOperationService).not_to receive(:execute) + expect_any_instance_of(Clusters::Gcp::VerifyProvisionStatusService).not_to receive(:execute) - described_class.new.perform(1234) + described_class.new.perform(123) end end end |