Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/database/seeders/20220412122043-environments.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ module.exports = {
id: 'TDI59So3d',
created_at: new Date('2019-08-09 16:05:00'),
updated_at: new Date('2019-08-09 19:00:00'),
raw_configuration: JSON.stringify({
hosts: '["fake-flp3"]',
}),
},
// Error
{
Expand Down
10 changes: 10 additions & 0 deletions lib/domain/dtos/GetAllEnvironmentsDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ const FilterDto = Joi.object({
'string.base': 'Run numbers must be comma-separated numbers or ranges (e.g. 12,15-18)',
}).optional(),
created: FromToFilterDto,
rawConfiguration: Joi.object({
// Surrounding quotes are allowed for better UX, but are not part of the key
key: Joi.string().trim().max(256).replace(/^"(.*)"$/, '$1').optional(),

/*
* Strip surrounding quotes and unescape JSON escapes (e.g. [\"flp1\"] copied from the raw JSON becomes ["flp1"]), the use case
* then compares it to the unescaped value of the key, or to both forms when searching the whole configuration
*/
value: Joi.string().trim().max(1000).replace(/^"(.*)"$/, '$1').replace(/\\(["\\])/g, '$1').optional(),
}).or('key', 'value').optional(),
});

const QueryDto = Joi.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { FilterModel } from '../common/FilterModel.js';
import { RawTextFilterModel } from '../common/filters/RawTextFilterModel.js';

/**
* Model for filtering environments by raw configuration key-value pairs
*/
export class RawConfigurationFilterModel extends FilterModel {
/**
* Constructor
*/
constructor() {
super();

this._keyModel = new RawTextFilterModel();
this._valueModel = new RawTextFilterModel();

this._addSubmodel(this._keyModel);
this._addSubmodel(this._valueModel);
}

/**
* @inheritDoc
*/
reset() {
this._keyModel.reset();
this._valueModel.reset();
}

/**
* @inheritDoc
*/
get isEmpty() {
return this.key === '' && this.value === '';
}

/**
* Returns the normalized value as an object with key and value properties, each one only if filled (without key, the value is
* searched anywhere in the configuration)
*
* @return {object} the normalized filter value
*/
get normalized() {
const result = {};
if (this.key) {
result.key = this.key;
}
if (this.value) {
result.value = this.value;
}
return result;
}

/**
* @inheritDoc
*/
set normalized({ key = '', value = '' } = {}) {
this._keyModel.normalized = key;
this._valueModel.normalized = value;
}

/**
* Returns the key currently typed in the key input, trimmed and without surrounding quotes
*
* @return {string} the key
*/
get key() {
return this._keyModel.value.trim().replace(/^"(.*)"$/, '$1');
}

/**
* Returns the value currently typed in the value input, trimmed
*
* @return {string} the value
*/
get value() {
return this._valueModel.value.trim();
}

/**
* Returns the key filter model
*
* @return {RawTextFilterModel} the key filter model
*/
get keyModel() {
return this._keyModel;
}

/**
* Returns the value filter model
*
* @return {RawTextFilterModel} the value filter model
*/
get valueModel() {
return this._valueModel;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

import { h } from '/js/src/index.js';
import { rawTextFilter } from '../common/filters/rawTextFilter.js';

/**
* Renders a filter for the raw configuration of environments, allowing users to filter environments on a key, optionally with a value,
* or on a text found anywhere in the raw configuration
*
* @param {RawConfigurationFilterModel} rawConfigurationFilterModel the raw configuration filter model
* @return {Component} the filter
*/
export const rawConfigurationFilter = (rawConfigurationFilterModel) =>
h('.flex-row.g2', [
h(
'.flex-column.flex-grow',
// Bubble to update the value placeholder when a key is typed
{ oninput: () => rawConfigurationFilterModel.visualChange$.notify() },
[
rawTextFilter(
rawConfigurationFilterModel.keyModel,
{ id: 'rawConfiguration-key', placeholder: 'e.g. hosts' },
),
],
),
h('.flex-column.flex-grow', [
rawTextFilter(
rawConfigurationFilterModel.valueModel,
{
id: 'rawConfiguration-value',
placeholder: rawConfigurationFilterModel.key ? 'e.g. some-host' : 'Search anywhere in the configuration',
},
),
]),
]);
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { parseEnvironmentConfiguration } from '../format/parseEnvironmentConfigu
import { timeRangeFilter } from '../../../components/Filters/common/filters/timeRangeFilter.js';
import { checkboxes } from '../../../components/Filters/common/filters/checkboxFilter.js';
import { textInputFilter } from '../../../components/Filters/common/filters/textInputFilter.js';
import { rawConfigurationFilter } from '../../../components/Filters/EnvironmentsFilter/rawConfigurationFilter.js';

/**
* List of active columns for the environments overview
Expand Down Expand Up @@ -205,4 +206,19 @@ export const environmentsActiveColumns = {
: '-';
},
},
rawConfiguration: {
name: 'Raw Configuration',
visible: false,
filterTooltip: 'With a key: environments having this key, combined with a value environments having this key set to this value '
+ 'Without a key: environments whose configuration contains the given text anywhere in its raw configuration text',

/**
* Raw configuration filter component
*
* @param {object} environmentOverviewModel the overview model
* @param {FilteringModel} environmentOverviewModel.filteringModel the filtering model
* @return {Component} the filter component
*/
filter: ({ filteringModel }) => rawConfigurationFilter(filteringModel.get('rawConfiguration')),
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { TimeRangeInputModel } from '../../../components/Filters/common/filters/TimeRangeInputModel.js';
import { RawTextFilterModel } from '../../../components/Filters/common/filters/RawTextFilterModel.js';
import { RawConfigurationFilterModel } from '../../../components/Filters/EnvironmentsFilter/RawConfigurationFilterModel.js';
import { coloredEnvironmentStatusComponent } from '../ColoredEnvironmentStatusComponent.js';
import { StatusAcronym } from '../../../domain/enums/statusAcronym.mjs';
import { SelectionModel } from '../../../components/common/selection/SelectionModel.js';
Expand Down Expand Up @@ -43,6 +44,7 @@ export class EnvironmentOverviewModel extends FilterableOverviewPageModel {
})),
}),
ids: new RawTextFilterModel(),
rawConfiguration: new RawConfigurationFilterModel(),
},
);
}
Expand Down
48 changes: 48 additions & 0 deletions lib/usecases/environment/GetAllEnvironmentsUseCase.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ const { unpackNumberRange } = require('../../utilities/rangeUtils.js');
const { splitStringToStringsTrimmed } = require('../../utilities/stringUtils.js');
const { setTimeRangeQuery } = require('../../utilities/setTimeRangeQuery.js');

/**
* Escape the LIKE wildcards of a value, so that it is matched literally
*
* @param {string} value the value to escape
* @return {string} the escaped value
*/
const escapeLikeWildcards = (value) => value.replace(/[\\%_]/g, '\\$&');

/**
* Subquery to select the latest history item for each environment.
* It orders the history items by updatedAt DESC and selects to have the latests
Expand Down Expand Up @@ -82,6 +90,7 @@ class GetAllEnvironmentsUseCase {
statusHistory,
runNumbers: runNumbersExpression,
created,
rawConfiguration,
} = filter;

if (created) {
Expand Down Expand Up @@ -161,6 +170,45 @@ class GetAllEnvironmentsUseCase {
});
}
}

if (rawConfiguration) {
const { key: jsonKey, value: searchValue } = rawConfiguration;

if (jsonKey) {
// Quote the key so that any character (e.g. "-", spaces, "*") is treated literally in the JSON path
const jsonPath = `$."${jsonKey.replace(/[\\"]/g, '\\$&')}"`;

if (searchValue) {
// JSON_UNQUOTE gives the stored string without its JSON escaping
queryBuilder.literalWhere(
'JSON_UNQUOTE(JSON_EXTRACT(raw_configuration, :jsonPath)) LIKE :searchPattern',
{ jsonPath, searchPattern: `%${escapeLikeWildcards(searchValue)}%` },
);
} else {
queryBuilder.literalWhere(
'JSON_EXTRACT(raw_configuration, :jsonPath) IS NOT NULL',
{ jsonPath },
);
}
} else if (searchValue) {
/*
* Without key, search anywhere in the raw configuration text. Values are stored JSON-escaped (e.g. [\"flp1\"]), so
* match both the value as given and its JSON-escaped form, to find text typed naturally as well as copied from
* the stored configuration
*/
const plainPattern = `%${escapeLikeWildcards(searchValue)}%`;
const jsonEscapedPattern = `%${escapeLikeWildcards(JSON.stringify(searchValue).slice(1, -1))}%`;

if (plainPattern === jsonEscapedPattern) {
queryBuilder.literalWhere('raw_configuration LIKE :plainPattern', { plainPattern });
} else {
queryBuilder.literalWhere(
'(raw_configuration LIKE :plainPattern OR raw_configuration LIKE :jsonEscapedPattern)',
{ plainPattern, jsonEscapedPattern },
);
}
}
}
}

queryBuilder.include({ association: 'runs' });
Expand Down
78 changes: 78 additions & 0 deletions test/api/environments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,84 @@ module.exports = () => {
const rangeError = errors.find((err) => err.source.pointer === '/data/attributes/query/filter/runNumbers');
expect(rangeError.detail).to.equal('Invalid range: -105');
});

it('should successfully filter environments by a key in the raw configuration', async () => {
const response = await request(server).get('/api/environments?filter[rawConfiguration][key]=hosts');

expect(response.status).to.equal(200);
const environments = response.body.data;
expect(environments.length).to.be.equal(2);
expect(environments.map(({ id }) => id)).to.include.members(['CmCvjNbg', 'TDI59So3d']);
});

it('should return 400 if length of rawConfiguration key exceeds 256 characters', async () => {
const longKey = 'a'.repeat(257);
const response = await request(server).get(`/api/environments?filter[rawConfiguration][key]=${longKey}`);

expect(response.status).to.equal(400);
});

it('should return 400 if length of rawConfiguration value exceeds 1000 characters', async () => {
const longValue = 'a'.repeat(1001);
const response = await request(server)
.get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${longValue}`);

expect(response.status).to.equal(400);
});

it('should return the same results when filtering raw configuration value with escaped and plain quotes', async () => {
// Search with plain quotes: ["fake-flp3"]
const plainResponse = await request(server)
.get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${encodeURIComponent('["fake-flp3"]')}`);

// Search with escaped quotes: [\"fake-flp3\"]
const escapedResponse = await request(server)
.get(`/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=${encodeURIComponent('[\\"fake-flp3\\"]')}`);

expect(plainResponse.status).to.equal(200);
expect(escapedResponse.status).to.equal(200);

const plainEnvs = plainResponse.body.data;
const escapedEnvs = escapedResponse.body.data;

expect(plainEnvs.length).to.be.equal(1);
expect(plainEnvs[0].id).to.equal('TDI59So3d');

expect(escapedEnvs.length).to.be.equal(1);
expect(escapedEnvs[0].id).to.equal('TDI59So3d');
});

it('should filter raw configuration value with array brackets and quotes', async () => {
const response = await request(server)
.get('/api/environments?filter[rawConfiguration][key]=hosts&filter[rawConfiguration][value]=["fake-flp1", "fake-flp2"]');

expect(response.status).to.equal(200);
const environments = response.body.data;
expect(environments.length).to.be.equal(1);
expect(environments[0].id).to.equal('CmCvjNbg');
});

it('should search a raw configuration value without key anywhere in the configuration', async () => {
const response = await request(server).get('/api/environments?filter[rawConfiguration][value]=fake-flp3');

expect(response.status).to.equal(200);
expect(response.body.data.map(({ id }) => id)).to.have.members(['TDI59So3d']);
});

it('should find a raw configuration fragment copied from the environment details page', async () => {
// The details page displays values JSON-serialized, e.g. "[\"fake-flp1\", \"fake-flp2\"]"
const response = await request(server)
.get(`/api/environments?filter[rawConfiguration][value]=${encodeURIComponent('\\"fake-flp1\\", \\"fake-flp2')}`);

expect(response.status).to.equal(200);
expect(response.body.data.map(({ id }) => id)).to.have.members(['CmCvjNbg']);
});

it('should return 400 if the raw configuration filter has an empty key and no value', async () => {
const response = await request(server).get('/api/environments?filter[rawConfiguration][key]=');

expect(response.status).to.equal(400);
});
});
describe('POST /api/environments', () => {
it('should return 201 if valid data is provided', async () => {
Expand Down
Loading
Loading