diff --git a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts index c50d29ba521e7..d24da636b7dd3 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/type_registrations.test.ts @@ -59,6 +59,7 @@ const previouslyRegisteredTypes = [ 'infrastructure-monitoring-log-view', 'infrastructure-ui-source', 'ingest-agent-policies', + 'ingest-download-sources', 'ingest-outputs', 'ingest-package-policies', 'ingest_manager_settings', diff --git a/x-pack/plugins/fleet/common/constants/download_source.ts b/x-pack/plugins/fleet/common/constants/download_source.ts new file mode 100644 index 0000000000000..1b74c627e93f7 --- /dev/null +++ b/x-pack/plugins/fleet/common/constants/download_source.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const DEFAULT_DOWNLOAD_SOURCE = 'artifactory.elastic.co'; + +export const DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE = 'ingest-download-sources'; + +export const DEFAULT_DOWNLOAD_SOURCE_ID = 'fleet-default-download-source'; diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index ef8cb63f132b4..31bcd317a8052 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -16,6 +16,7 @@ export * from './output'; export * from './enrollment_api_key'; export * from './settings'; export * from './preconfiguration'; +export * from './download_source'; // TODO: This is the default `index.max_result_window` ES setting, which dictates // the maximum amount of results allowed to be returned from a search. It's possible diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index 33bf1b6f6b5b7..f9c36b986969e 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -15,6 +15,7 @@ export const DATA_STREAM_API_ROOT = `${API_ROOT}/data_streams`; export const PACKAGE_POLICY_API_ROOT = `${API_ROOT}/package_policies`; export const AGENT_POLICY_API_ROOT = `${API_ROOT}/agent_policies`; export const K8S_API_ROOT = `${API_ROOT}/kubernetes`; +export const DOWNLOAD_SOURCE_API_ROOT = `${API_ROOT}/agent_download_sources`; export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency'; @@ -151,3 +152,12 @@ export const PRECONFIGURATION_API_ROUTES = { RESET_PATTERN: `${INTERNAL_ROOT}/reset_preconfigured_agent_policies`, RESET_ONE_PATTERN: `${INTERNAL_ROOT}/reset_preconfigured_agent_policies/{agentPolicyId}`, }; + +// Agent download source routes +export const DOWNLOAD_SOURCE_API_ROUTES = { + LIST_PATTERN: `${API_ROOT}/agent_download_sources`, + INFO_PATTERN: `${API_ROOT}/agent_download_sources/{sourceId}`, + CREATE_PATTERN: `${API_ROOT}/agent_download_sources`, + UPDATE_PATTERN: `${API_ROOT}/agent_download_sources/{sourceId}`, + DELETE_PATTERN: `${API_ROOT}/agent_download_sources/{sourceId}`, +}; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index eb6f7626ea35f..5e4138a5b51c5 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3042,6 +3042,213 @@ ] } }, + "/agent_download_sources": { + "get": { + "summary": "Agent Download Sources", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/download_sources" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + } + } + } + } + } + } + }, + "operationId": "get-download-sources" + }, + "post": { + "summary": "Agent Download Sources", + "description": "Create a new agent download source", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/download_sources" + } + } + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "host": { + "type": "string" + } + }, + "required": [ + "name", + "host", + "is_default" + ] + } + } + } + }, + "operationId": "post-download-sources" + } + }, + "/agent_download_sources/{sourceId}": { + "get": { + "summary": "Agent Download Sources - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/download_sources" + } + }, + "required": [ + "item" + ] + } + } + } + } + }, + "operationId": "get-one-download-source" + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "sourceId", + "in": "path", + "required": true + } + ], + "delete": { + "summary": "Agent Download Sources - Delete", + "operationId": "delete-download-source", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + }, + "put": { + "summary": "Agent Download Sources - Update", + "operationId": "update-download-source", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "host": { + "type": "string" + } + }, + "required": [ + "name", + "is_default", + "host" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/download_sources" + } + }, + "required": [ + "item" + ] + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + } + }, "/logstash_api_keys": { "post": { "summary": "Generate Logstash API key", @@ -4572,6 +4779,30 @@ "name", "type" ] + }, + "download_sources": { + "title": "Download Source", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "host": { + "type": "string" + } + }, + "required": [ + "id", + "is_default", + "name", + "host" + ] } } }, diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 3793dfe7982dc..77218a60236dc 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -1872,6 +1872,135 @@ paths: - item parameters: - $ref: '#/components/parameters/kbn_xsrf' + /agent_download_sources: + get: + summary: Agent Download Sources + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/download_sources' + total: + type: integer + page: + type: integer + perPage: + type: integer + operationId: get-download-sources + post: + summary: Agent Download Sources + description: Create a new agent download source + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/download_sources' + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + is_default: + type: boolean + host: + type: string + required: + - name + - host + - is_default + operationId: post-download-sources + /agent_download_sources/{sourceId}: + get: + summary: Agent Download Sources - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/download_sources' + required: + - item + operationId: get-one-download-source + parameters: + - schema: + type: string + name: sourceId + in: path + required: true + delete: + summary: Agent Download Sources - Delete + operationId: delete-download-source + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + put: + summary: Agent Download Sources - Update + operationId: update-download-source + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + is_default: + type: boolean + host: + type: string + required: + - name + - is_default + - host + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/download_sources' + required: + - item + parameters: + - $ref: '#/components/parameters/kbn_xsrf' /logstash_api_keys: post: summary: Generate Logstash API key @@ -2894,5 +3023,22 @@ components: - is_default - name - type + download_sources: + title: Download Source + type: object + properties: + id: + type: string + is_default: + type: boolean + name: + type: string + host: + type: string + required: + - id + - is_default + - name + - host security: - basicAuth: [] diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/download_sources.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/download_sources.yaml new file mode 100644 index 0000000000000..d963e82089303 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/download_sources.yaml @@ -0,0 +1,16 @@ +title: Download Source +type: object +properties: + id: + type: string + is_default: + type: boolean + name: + type: string + host: + type: string +required: + - id + - is_default + - name + - host diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 275deb8e8b843..da5e4a00f58f4 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -108,6 +108,10 @@ paths: $ref: paths/outputs.yaml /outputs/{outputId}: $ref: paths/outputs@{output_id}.yaml + /agent_download_sources: + $ref: paths/agent_download_sources.yaml + /agent_download_sources/{sourceId}: + $ref: paths/agent_download_sources@{source_id}.yaml /logstash_api_keys: $ref: paths/logstash_api_keys.yaml components: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources.yaml new file mode 100644 index 0000000000000..a76eef05cae73 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources.yaml @@ -0,0 +1,55 @@ +get: + summary: Agent Download Sources + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: ../components/schemas/download_sources.yaml + total: + type: integer + page: + type: integer + perPage: + type: integer + operationId: get-download-sources +post: + summary: Agent Download Sources + description: 'Create a new agent download source' + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/download_sources.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + id: + type: string + name: + type: string + is_default: + type: boolean + host: + type: string + required: + - name + - host + - is_default + operationId: post-download-sources diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources@{source_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources@{source_id}.yaml new file mode 100644 index 0000000000000..c9b97018aa523 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_download_sources@{source_id}.yaml @@ -0,0 +1,72 @@ +get: + summary: Agent Download Sources - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/download_sources.yaml + required: + - item + operationId: get-one-download-source +parameters: + - schema: + type: string + name: sourceId + in: path + required: true +delete: + summary: Agent Download Sources - Delete + operationId: delete-download-source + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml +put: + summary: Agent Download Sources - Update + operationId: update-download-source + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + is_default: + type: boolean + host: + type: string + required: + - name + - is_default + - host + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/download_sources.yaml + required: + - item + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index c289ebf32fd16..269a0d606b0c8 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -28,6 +28,7 @@ export interface NewAgentPolicy { // Nullable to allow user to reset to default outputs data_output_id?: string | null; monitoring_output_id?: string | null; + download_source_id?: string | null; } export interface AgentPolicy extends Omit { diff --git a/x-pack/plugins/fleet/common/types/models/download_sources.ts b/x-pack/plugins/fleet/common/types/models/download_sources.ts new file mode 100644 index 0000000000000..1595965be7d1d --- /dev/null +++ b/x-pack/plugins/fleet/common/types/models/download_sources.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface DownloadSourceBase { + name: string; + host: string; + is_default: boolean; +} + +export type DownloadSource = DownloadSourceBase & { + id: string; +}; +export type DownloadSourceAttributes = DownloadSourceBase & { + source_id?: string; +}; diff --git a/x-pack/plugins/fleet/common/types/models/index.ts b/x-pack/plugins/fleet/common/types/models/index.ts index ded6b3d9ee59d..07becab0af3f3 100644 --- a/x-pack/plugins/fleet/common/types/models/index.ts +++ b/x-pack/plugins/fleet/common/types/models/index.ts @@ -15,3 +15,4 @@ export * from './package_spec'; export * from './enrollment_api_key'; export * from './settings'; export * from './preconfiguration'; +export * from './download_sources'; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/download_sources.ts b/x-pack/plugins/fleet/common/types/rest_spec/download_sources.ts new file mode 100644 index 0000000000000..13af84f372c2f --- /dev/null +++ b/x-pack/plugins/fleet/common/types/rest_spec/download_sources.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DownloadSourceBase } from '../models'; + +import type { ListResult } from './common'; + +export interface GetOneDownloadSourceResponse { + item: DownloadSourceBase; +} + +export interface DeleteDownloadSourceResponse { + id: string; +} + +export interface GetOneDownloadSourceRequest { + params: { + outputId: string; + }; +} + +export interface PutDownloadSourceRequest { + params: { + outputId: string; + }; + body: { + id: string; + name: string; + hosts: string; + is_default?: boolean; + }; +} + +export interface PostDownloadSourceRequest { + body: { + id: string; + name: string; + hosts: string; + is_default?: boolean; + }; +} + +export interface PutDownloadSourceResponse { + item: DownloadSourceBase; +} + +export type GetDownloadSourceResponse = ListResult; diff --git a/x-pack/plugins/fleet/common/types/rest_spec/index.ts b/x-pack/plugins/fleet/common/types/rest_spec/index.ts index 3ad6df3a97303..78b9f09f7f9f8 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/index.ts @@ -16,3 +16,4 @@ export * from './enrollment_api_key'; export * from './output'; export * from './settings'; export * from './app'; +export * from './download_sources'; diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 813f381ecc4fc..ae7e5456efa16 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -33,6 +33,8 @@ export { SETTINGS_API_ROUTES, APP_API_ROUTES, PRECONFIGURATION_API_ROUTES, + DOWNLOAD_SOURCE_API_ROOT, + DOWNLOAD_SOURCE_API_ROUTES, // Saved object types SO_SEARCH_LIMIT, AGENTS_PREFIX, @@ -55,6 +57,10 @@ export { PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, PRECONFIGURATION_LATEST_KEYWORD, AUTO_UPDATE_PACKAGES, + // Download sources + DEFAULT_DOWNLOAD_SOURCE, + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + DEFAULT_DOWNLOAD_SOURCE_ID, } from '../../common'; export { diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index 1d1892f620e93..f617bcb55f341 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -60,6 +60,7 @@ export class FleetUnauthorizedError extends IngestManagerError {} export class OutputUnauthorizedError extends IngestManagerError {} export class OutputInvalidError extends IngestManagerError {} export class OutputLicenceError extends IngestManagerError {} +export class DownloadSourceError extends IngestManagerError {} export class ArtifactsClientError extends IngestManagerError {} export class ArtifactsClientAccessDeniedError extends IngestManagerError { diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index ae4f6134e891f..961d318be2c59 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -67,6 +67,7 @@ import { registerSettingsRoutes, registerAppRoutes, registerPreconfigurationRoutes, + registerDownloadSourcesRoutes, registerHealthCheckRoutes, } from './routes'; @@ -367,6 +368,7 @@ export class FleetPlugin registerSettingsRoutes(fleetAuthzRouter); registerDataStreamRoutes(fleetAuthzRouter); registerPreconfigurationRoutes(fleetAuthzRouter); + registerDownloadSourcesRoutes(fleetAuthzRouter); registerHealthCheckRoutes(fleetAuthzRouter); // Conditional config routes diff --git a/x-pack/plugins/fleet/server/routes/download_source/handler.ts b/x-pack/plugins/fleet/server/routes/download_source/handler.ts new file mode 100644 index 0000000000000..878f19e655ed2 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/download_source/handler.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RequestHandler } from '@kbn/core/server'; +import type { TypeOf } from '@kbn/config-schema'; + +import type { + GetOneDownloadSourcesRequestSchema, + PutDownloadSourcesRequestSchema, + PostDownloadSourcesRequestSchema, + DeleteDownloadSourcesRequestSchema, +} from '../../types'; +import type { + GetOneDownloadSourceResponse, + DeleteDownloadSourceResponse, + PutDownloadSourceResponse, + GetDownloadSourceResponse, +} from '../../../common'; +import { downloadSourceService } from '../../services/download_source'; +import { defaultIngestErrorHandler } from '../../errors'; +import { agentPolicyService } from '../../services'; + +export const getDownloadSourcesHandler: RequestHandler = async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const downloadSources = await downloadSourceService.list(soClient); + + const body: GetDownloadSourceResponse = { + items: downloadSources.items, + page: downloadSources.page, + perPage: downloadSources.perPage, + total: downloadSources.total, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const getOneDownloadSourcesHandler: RequestHandler< + TypeOf +> = async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + const downloadSource = await downloadSourceService.get(soClient, request.params.sourceId); + + const body: GetOneDownloadSourceResponse = { + item: downloadSource, + }; + + return response.ok({ body }); + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { + return response.notFound({ + body: { message: `Download source ${request.params.sourceId} not found` }, + }); + } + + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const putDownloadSourcesHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const soClient = coreContext.savedObjects.client; + const esClient = coreContext.elasticsearch.client.asInternalUser; + try { + await downloadSourceService.update(soClient, request.params.sourceId, request.body); + const downloadSource = await downloadSourceService.get(soClient, request.params.sourceId); + if (downloadSource.is_default) { + await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); + } else { + await agentPolicyService.bumpAllAgentPoliciesForDownloadSource( + soClient, + esClient, + downloadSource.id + ); + } + + const body: PutDownloadSourceResponse = { + item: downloadSource, + }; + + return response.ok({ body }); + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { + return response.notFound({ + body: { message: `Download source ${request.params.sourceId} not found` }, + }); + } + + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const postDownloadSourcesHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const coreContext = await context.core; + const soClient = coreContext.savedObjects.client; + const esClient = coreContext.elasticsearch.client.asInternalUser; + try { + const { id, ...data } = request.body; + const downloadSource = await downloadSourceService.create(soClient, data, { id }); + if (downloadSource.is_default) { + await agentPolicyService.bumpAllAgentPolicies(soClient, esClient); + } + + const body: GetOneDownloadSourceResponse = { + item: downloadSource, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } +}; + +export const deleteDownloadSourcesHandler: RequestHandler< + TypeOf +> = async (context, request, response) => { + const soClient = (await context.core).savedObjects.client; + try { + await downloadSourceService.delete(soClient, request.params.sourceId); + + const body: DeleteDownloadSourceResponse = { + id: request.params.sourceId, + }; + + return response.ok({ body }); + } catch (error) { + if (error.isBoom && error.output.statusCode === 404) { + return response.notFound({ + body: { message: `Donwload source ${request.params.sourceId} not found` }, + }); + } + + return defaultIngestErrorHandler({ error, response }); + } +}; diff --git a/x-pack/plugins/fleet/server/routes/download_source/index.tsx b/x-pack/plugins/fleet/server/routes/download_source/index.tsx new file mode 100644 index 0000000000000..1c670bdf2b018 --- /dev/null +++ b/x-pack/plugins/fleet/server/routes/download_source/index.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DOWNLOAD_SOURCE_API_ROUTES } from '../../constants'; +import { + getDownloadSourcesRequestSchema, + GetOneDownloadSourcesRequestSchema, + PutDownloadSourcesRequestSchema, + PostDownloadSourcesRequestSchema, + DeleteDownloadSourcesRequestSchema, +} from '../../types'; +import type { FleetAuthzRouter } from '../security'; + +import { + getDownloadSourcesHandler, + getOneDownloadSourcesHandler, + putDownloadSourcesHandler, + postDownloadSourcesHandler, + deleteDownloadSourcesHandler, +} from './handler'; + +export const registerRoutes = (router: FleetAuthzRouter) => { + router.get( + { + path: DOWNLOAD_SOURCE_API_ROUTES.LIST_PATTERN, + validate: getDownloadSourcesRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getDownloadSourcesHandler + ); + router.get( + { + path: DOWNLOAD_SOURCE_API_ROUTES.INFO_PATTERN, + validate: GetOneDownloadSourcesRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + getOneDownloadSourcesHandler + ); + router.put( + { + path: DOWNLOAD_SOURCE_API_ROUTES.UPDATE_PATTERN, + validate: PutDownloadSourcesRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + putDownloadSourcesHandler + ); + + router.post( + { + path: DOWNLOAD_SOURCE_API_ROUTES.CREATE_PATTERN, + validate: PostDownloadSourcesRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + postDownloadSourcesHandler + ); + + router.delete( + { + path: DOWNLOAD_SOURCE_API_ROUTES.DELETE_PATTERN, + validate: DeleteDownloadSourcesRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + deleteDownloadSourcesHandler + ); +}; diff --git a/x-pack/plugins/fleet/server/routes/index.ts b/x-pack/plugins/fleet/server/routes/index.ts index b04e179a05dcf..24c0947a419f6 100644 --- a/x-pack/plugins/fleet/server/routes/index.ts +++ b/x-pack/plugins/fleet/server/routes/index.ts @@ -16,4 +16,5 @@ export { registerRoutes as registerOutputRoutes } from './output'; export { registerRoutes as registerSettingsRoutes } from './settings'; export { registerRoutes as registerAppRoutes } from './app'; export { registerRoutes as registerPreconfigurationRoutes } from './preconfiguration'; +export { registerRoutes as registerDownloadSourcesRoutes } from './download_source'; export { registerRoutes as registerHealthCheckRoutes } from './health_check'; diff --git a/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts index bd0ed690ec6fe..87cfe2c23fc9e 100644 --- a/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts +++ b/x-pack/plugins/fleet/server/routes/preconfiguration/handler.ts @@ -15,7 +15,11 @@ import type { PostResetOnePreconfiguredAgentPoliciesSchema, } from '../../types'; import { defaultIngestErrorHandler } from '../../errors'; -import { ensurePreconfiguredPackagesAndPolicies, outputService } from '../../services'; +import { + ensurePreconfiguredPackagesAndPolicies, + outputService, + downloadSourceService, +} from '../../services'; import { resetPreconfiguredAgentPolicies } from '../../services/preconfiguration/reset_agent_policies'; export const updatePreconfigurationHandler: FleetRequestHandler< @@ -28,6 +32,7 @@ export const updatePreconfigurationHandler: FleetRequestHandler< const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; const defaultOutput = await outputService.ensureDefaultOutput(soClient); + const defaultDownloadSource = await downloadSourceService.ensureDefault(soClient); const spaceId = fleetContext.spaceId; const { agentPolicies, packages } = request.body; @@ -38,6 +43,7 @@ export const updatePreconfigurationHandler: FleetRequestHandler< (agentPolicies as PreconfiguredAgentPolicy[]) ?? [], packages ?? [], defaultOutput, + defaultDownloadSource, spaceId ); return response.ok({ body }); diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 009cdef6aa771..6438c042bac7c 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -17,6 +17,7 @@ import { ASSETS_SAVED_OBJECT_TYPE, GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, PRECONFIGURATION_DELETION_RECORD_SAVED_OBJECT_TYPE, + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, } from '../constants'; import { @@ -298,6 +299,22 @@ const getSavedObjectTypes = ( }, }, }, + [DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE]: { + name: DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'agnostic', + management: { + importableAndExportable: false, + }, + mappings: { + properties: { + source_id: { type: 'keyword', index: false }, + name: { type: 'keyword' }, + is_default: { type: 'boolean' }, + host: { type: 'keyword' }, + }, + }, + }, }); export function registerSavedObjects( diff --git a/x-pack/plugins/fleet/server/services/agent_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policy.test.ts index a71180f235288..2d5bc0aa4f88a 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.test.ts @@ -25,6 +25,7 @@ import { getAgentsByKuery } from './agents'; import { packagePolicyService } from './package_policy'; import { appContextService } from './app_context'; import { outputService } from './output'; +import { downloadSourceService } from './download_source'; import { getFullAgentPolicy } from './agent_policies'; function getSavedObjectMock(agentPolicyAttributes: any) { @@ -60,6 +61,7 @@ function getSavedObjectMock(agentPolicyAttributes: any) { } jest.mock('./output'); +jest.mock('./download_source'); jest.mock('./agent_policy_update'); jest.mock('./agents'); jest.mock('./package_policy'); @@ -69,6 +71,9 @@ jest.mock('uuid/v5'); const mockedAppContextService = appContextService as jest.Mocked; const mockedOutputService = outputService as jest.Mocked; +const mockedDownloadSourceService = downloadSourceService as jest.Mocked< + typeof downloadSourceService +>; const mockedGetFullAgentPolicy = getFullAgentPolicy as jest.Mock< ReturnType >; @@ -257,6 +262,83 @@ describe('agent policy', () => { }); }); + describe('removeDefaultSourceFromAll', () => { + let mockedAgentPolicyServiceUpdate: jest.SpyInstance< + ReturnType + >; + beforeEach(() => { + mockedAgentPolicyServiceUpdate = jest + .spyOn(agentPolicyService, 'update') + .mockResolvedValue({} as any); + }); + + afterEach(() => { + mockedAgentPolicyServiceUpdate.mockRestore(); + }); + + it('should update policies using deleted download source host', async () => { + const soClient = savedObjectsClientMock.create(); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mockedDownloadSourceService.getDefaultDownloadSourceId.mockResolvedValue( + 'default-download-source-id' + ); + soClient.find.mockResolvedValue({ + saved_objects: [ + { + id: 'test-ds-1', + attributes: { + download_source_id: 'ds-id-1', + }, + }, + { + id: 'test-ds-2', + attributes: { + download_source_id: 'default-download-source-id', + }, + }, + ], + } as any); + + await agentPolicyService.removeDefaultSourceFromAll( + soClient, + esClient, + 'default-download-source-id' + ); + + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledTimes(2); + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'test-ds-1', + { download_source_id: 'ds-id-1' } + ); + expect(mockedAgentPolicyServiceUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'test-ds-2', + { download_source_id: null } + ); + }); + }); + + describe('bumpAllAgentPoliciesForDownloadSource', () => { + it('should call agentPolicyUpdateEventHandler with updated event once', async () => { + const soClient = getSavedObjectMock({ + revision: 1, + monitoring_enabled: ['metrics'], + }); + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + await agentPolicyService.bumpAllAgentPoliciesForDownloadSource( + soClient, + esClient, + 'test-id-1' + ); + + expect(agentPolicyUpdateEventHandler).toHaveBeenCalledTimes(1); + }); + }); + describe('update', () => { it('should update is_managed property, if given', async () => { // ignore unrelated unique name constraint diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 1e86354513e26..af69cd8713cf0 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -842,6 +842,79 @@ class AgentPolicyService { ): Promise { return getFullAgentPolicy(soClient, id, options); } + + /** + * Remove a download source from all agent policies that are using it, and replace the output by the default ones. + * @param soClient + * @param esClient + * @param downloadSourceId + */ + public async removeDefaultSourceFromAll( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + downloadSourceId: string + ) { + const agentPolicies = ( + await soClient.find({ + type: SAVED_OBJECT_TYPE, + fields: ['revision', 'download_source_id'], + searchFields: ['download_source_id'], + search: escapeSearchQueryPhrase(downloadSourceId), + perPage: SO_SEARCH_LIMIT, + }) + ).saved_objects.map((so) => ({ + id: so.id, + ...so.attributes, + })); + + if (agentPolicies.length > 0) { + await pMap( + agentPolicies, + (agentPolicy) => + this.update(soClient, esClient, agentPolicy.id, { + download_source_id: + agentPolicy.download_source_id === downloadSourceId + ? null + : agentPolicy.download_source_id, + }), + { + concurrency: 50, + } + ); + } + } + + public async bumpAllAgentPoliciesForDownloadSource( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient, + downloadSourceId: string, + options?: { user?: AuthenticatedUser } + ): Promise> { + const currentPolicies = await soClient.find({ + type: SAVED_OBJECT_TYPE, + fields: ['revision', 'download_source_id'], + searchFields: ['download_source_id'], + search: escapeSearchQueryPhrase(downloadSourceId), + perPage: SO_SEARCH_LIMIT, + }); + const bumpedPolicies = currentPolicies.saved_objects.map((policy) => { + policy.attributes = { + ...policy.attributes, + revision: policy.attributes.revision + 1, + updated_at: new Date().toISOString(), + updated_by: options?.user ? options.user.username : 'system', + }; + return policy; + }); + const res = await soClient.bulkUpdate(bumpedPolicies); + await pMap( + currentPolicies.saved_objects, + (policy) => this.triggerAgentPolicyUpdatedEvent(soClient, esClient, 'updated', policy.id), + { concurrency: 50 } + ); + + return res; + } } export const agentPolicyService = new AgentPolicyService(); diff --git a/x-pack/plugins/fleet/server/services/download_source.test.ts b/x-pack/plugins/fleet/server/services/download_source.test.ts new file mode 100644 index 0000000000000..07f450d2254e4 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/download_source.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; + +import type { DownloadSourceAttributes } from '../types'; + +import { DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE } from '../constants'; + +import { downloadSourceService } from './download_source'; +import { appContextService } from './app_context'; +import { agentPolicyService } from './agent_policy'; + +jest.mock('./app_context'); +jest.mock('./agent_policy'); + +const mockedAppContextService = appContextService as jest.Mocked; +const mockedAgentPolicyService = agentPolicyService as jest.Mocked; + +function mockDownloadSourceSO(id: string, attributes: any = {}) { + return { + id, + type: DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + references: [], + attributes: { + source_id: id, + ...attributes, + }, + }; +} + +function getMockedSoClient(options: { defaultDownloadSourceId?: string } = {}) { + const soClient = savedObjectsClientMock.create(); + + soClient.get.mockImplementation(async (type: string, id: string) => { + switch (id) { + case 'download-source-test': { + return mockDownloadSourceSO('download-source-test', { + is_default: false, + name: 'Test', + host: 'http://test.co', + }); + } + case 'existing-default-download-source': { + return mockDownloadSourceSO('existing-default-download-source', { + is_default: true, + name: 'Default host', + host: 'http://artifacts.co', + }); + } + default: + throw new Error('not found: ' + id); + } + }); + soClient.update.mockImplementation(async (type, id, data) => { + return { + id, + type, + attributes: {}, + references: [], + }; + }); + soClient.create.mockImplementation(async (type, data, createOptions) => { + return { + id: createOptions?.id || 'generated-id', + type, + attributes: {}, + references: [], + }; + }); + soClient.find.mockImplementation(async (findOptions) => { + if ( + options?.defaultDownloadSourceId && + findOptions.searchFields && + findOptions.searchFields.includes('is_default') && + findOptions.search === 'true' + ) { + return { + page: 1, + per_page: 10, + saved_objects: [ + { + score: 0, + ...(await soClient.get( + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + options.defaultDownloadSourceId + )), + }, + ], + total: 1, + }; + } + + return { + page: 1, + per_page: 10, + saved_objects: [], + total: 0, + }; + }); + mockedAppContextService.getInternalUserSOClient.mockReturnValue(soClient); + + return soClient; +} + +describe('Download Service Service', () => { + beforeEach(() => { + mockedAgentPolicyService.list.mockClear(); + mockedAgentPolicyService.hasAPMIntegration.mockClear(); + mockedAgentPolicyService.removeDefaultSourceFromAll.mockReset(); + mockedAppContextService.getInternalUserSOClient.mockReset(); + }); + describe('create', () => { + it('work with a predefined id', async () => { + const soClient = getMockedSoClient(); + + await downloadSourceService.create( + soClient, + { + host: 'http://test.co', + is_default: false, + name: 'Test', + }, + { id: 'download-source-test' } + ); + + expect(soClient.create).toBeCalled(); + + // ID should always be the same for a predefined id + expect(soClient.create.mock.calls[0][2]?.id).toEqual('download-source-test'); + expect((soClient.create.mock.calls[0][1] as DownloadSourceAttributes).source_id).toEqual( + 'download-source-test' + ); + }); + + it('should create a new default value if none exists before', async () => { + const soClient = getMockedSoClient(); + + await downloadSourceService.create( + soClient, + { + is_default: true, + name: 'Test', + host: 'http://test.co', + }, + { id: 'download-source-test' } + ); + + expect(soClient.update).not.toBeCalled(); + }); + + it('should update existing default download source when creating a new default one', async () => { + const soClient = getMockedSoClient({ + defaultDownloadSourceId: 'existing-default-download-source', + }); + + await downloadSourceService.create(soClient, { + is_default: true, + name: 'New default host', + host: 'http://test.co', + }); + + expect(soClient.update).toBeCalledTimes(1); + expect(soClient.update).toBeCalledWith( + expect.anything(), + 'existing-default-download-source', + { is_default: false } + ); + }); + }); + + describe('update', () => { + it('should update existing default value when updating a download source to become the default one', async () => { + const soClient = getMockedSoClient({ + defaultDownloadSourceId: 'existing-default-download-source', + }); + + await downloadSourceService.update(soClient, 'download-source-test', { + is_default: true, + name: 'New default', + host: 'http://test.co', + }); + + expect(soClient.update).toBeCalledWith( + expect.anything(), + 'existing-default-download-source', + { + is_default: false, + } + ); + expect(soClient.update).toBeCalledWith(expect.anything(), 'download-source-test', { + is_default: true, + name: 'New default', + host: 'http://test.co', + }); + }); + + it('should not update existing default when the download source is already the default one', async () => { + const soClient = getMockedSoClient({ + defaultDownloadSourceId: 'existing-default-download-source', + }); + + await downloadSourceService.update(soClient, 'existing-default-download-source', { + is_default: true, + name: 'Test', + host: 'http://test.co', + }); + + expect(soClient.update).toBeCalledTimes(1); + expect(soClient.update).toBeCalledWith( + expect.anything(), + 'existing-default-download-source', + { + is_default: true, + name: 'Test', + host: 'http://test.co', + } + ); + }); + }); + + describe('delete', () => { + it('Call removeDefaultSourceFromAll before deleting the value', async () => { + const soClient = getMockedSoClient(); + await downloadSourceService.delete(soClient, 'download-source-test'); + expect(mockedAgentPolicyService.removeDefaultSourceFromAll).toBeCalled(); + expect(soClient.delete).toBeCalled(); + }); + }); + + describe('get', () => { + it('works with a predefined id', async () => { + const soClient = getMockedSoClient(); + const downloadSource = await downloadSourceService.get(soClient, 'download-source-test'); + + expect(soClient.get).toHaveBeenCalledWith( + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + 'download-source-test' + ); + + expect(downloadSource.id).toEqual('download-source-test'); + }); + }); + + describe('getDefaultDownloadSourceId', () => { + it('works with a predefined id', async () => { + const soClient = getMockedSoClient({ + defaultDownloadSourceId: 'existing-default-download-source', + }); + const defaultId = await downloadSourceService.getDefaultDownloadSourceId(soClient); + + expect(defaultId).toEqual('existing-default-download-source'); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/download_source.ts b/x-pack/plugins/fleet/server/services/download_source.ts new file mode 100644 index 0000000000000..c189ac1a23019 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/download_source.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { SavedObjectsClientContract, SavedObject } from '@kbn/core/server'; + +import { + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + DEFAULT_DOWNLOAD_SOURCE, + DEFAULT_DOWNLOAD_SOURCE_ID, +} from '../constants'; + +import type { DownloadSource, DownloadSourceAttributes, DownloadSourceBase } from '../types'; +import { DownloadSourceError } from '../errors'; +import { SO_SEARCH_LIMIT } from '../../common'; + +import { agentPolicyService } from './agent_policy'; +import { appContextService } from './app_context'; + +function savedObjectToDownloadSource(so: SavedObject) { + const { source_id: sourceId, ...attributes } = so.attributes; + + return { + id: sourceId ?? so.id, + ...attributes, + }; +} + +class DownloadSourceService { + public async get(soClient: SavedObjectsClientContract, id: string): Promise { + const soResponse = await soClient.get( + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + id + ); + + if (soResponse.error) { + throw new Error(soResponse.error.message); + } + + return savedObjectToDownloadSource(soResponse); + } + + public async list(soClient: SavedObjectsClientContract) { + const downloadSources = await soClient.find({ + type: DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + page: 1, + perPage: SO_SEARCH_LIMIT, + sortField: 'is_default', + sortOrder: 'desc', + }); + + return { + items: downloadSources.saved_objects.map(savedObjectToDownloadSource), + total: downloadSources.total, + page: downloadSources.page, + perPage: downloadSources.per_page, + }; + } + + public async create( + soClient: SavedObjectsClientContract, + downloadSource: DownloadSourceBase, + options?: { id?: string } + ): Promise { + const data: DownloadSourceAttributes = downloadSource; + + // default should be only one + if (data.is_default) { + const defaultDownloadSourceId = await this.getDefaultDownloadSourceId(soClient); + + if (defaultDownloadSourceId) { + await this.update(soClient, defaultDownloadSourceId, { is_default: false }); + } + } + if (options?.id) { + data.source_id = options?.id; + } + + const newSo = await soClient.create( + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + data, + { + id: options?.id, + } + ); + return savedObjectToDownloadSource(newSo); + } + + // default should be only one + public async update( + soClient: SavedObjectsClientContract, + id: string, + newData: Partial + ) { + const updateData: Partial = newData; + + if (updateData.is_default) { + const defaultDownloadSourceId = await this.getDefaultDownloadSourceId(soClient); + + if (defaultDownloadSourceId && defaultDownloadSourceId !== id) { + await this.update(soClient, defaultDownloadSourceId, { is_default: false }); + } + } + const soResponse = await soClient.update( + DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + id, + updateData + ); + if (soResponse.error) { + throw new Error(soResponse.error.message); + } + } + + public async delete( + soClient: SavedObjectsClientContract, + id: string, + { fromPreconfiguration = false }: { fromPreconfiguration?: boolean } = { + fromPreconfiguration: false, + } + ) { + const targetDS = await this.get(soClient, id); + + if (targetDS.is_default) { + throw new DownloadSourceError(`Default Download source ${id} cannot be deleted.`); + } + await agentPolicyService.removeDefaultSourceFromAll( + soClient, + appContextService.getInternalUserESClient(), + id + ); + + return soClient.delete(DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, id); + } + + public async getDefaultDownloadSourceId(soClient: SavedObjectsClientContract) { + const results = await this._getDefaultDownloadSourceSO(soClient); + + if (!results.saved_objects.length) { + return null; + } + + return savedObjectToDownloadSource(results.saved_objects[0]).id; + } + + public async ensureDefault(soClient: SavedObjectsClientContract) { + const downloadSources = await this.list(soClient); + + const defaultDS = downloadSources.items.find((o) => o.is_default); + + if (!defaultDS) { + const newDefaultDS: DownloadSourceBase = { + name: 'default', + is_default: true, + host: DEFAULT_DOWNLOAD_SOURCE, + }; + + return await this.create(soClient, newDefaultDS, { + id: DEFAULT_DOWNLOAD_SOURCE_ID, + }); + } + + return defaultDS; + } + + private async _getDefaultDownloadSourceSO(soClient: SavedObjectsClientContract) { + return await soClient.find({ + type: DOWNLOAD_SOURCE_SAVED_OBJECT_TYPE, + searchFields: ['is_default'], + search: 'true', + }); + } +} + +export const downloadSourceService = new DownloadSourceService(); diff --git a/x-pack/plugins/fleet/server/services/index.ts b/x-pack/plugins/fleet/server/services/index.ts index 226d40cfd640a..a23a049bdc762 100644 --- a/x-pack/plugins/fleet/server/services/index.ts +++ b/x-pack/plugins/fleet/server/services/index.ts @@ -43,6 +43,7 @@ export type { AgentClient, AgentService } from './agents'; export { agentPolicyService } from './agent_policy'; export { packagePolicyService } from './package_policy'; export { outputService } from './output'; +export { downloadSourceService } from './download_source'; export { settingsService }; // Plugin services diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts index 1664215613cb3..c0b24f927d848 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.test.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.test.ts @@ -18,7 +18,7 @@ import type { PreconfiguredAgentPolicy, RegistrySearchResult, } from '../../common/types'; -import type { AgentPolicy, NewPackagePolicy, Output } from '../types'; +import type { AgentPolicy, NewPackagePolicy, Output, DownloadSource } from '../types'; import { AGENT_POLICY_SAVED_OBJECT_TYPE } from '../constants'; @@ -55,6 +55,12 @@ const mockDefaultOutput: Output = { type: 'elasticsearch', hosts: ['http://127.0.0.1:9201'], }; +const mockDefaultDownloadService: DownloadSource = { + id: 'ds-test-id', + is_default: true, + name: 'default download source host', + host: 'http://127.0.0.1:9201', +}; function getPutPreconfiguredPackagesMock() { const soClient = savedObjectsClientMock.create(); @@ -296,6 +302,7 @@ describe('policy preconfiguration', () => { [], [], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -314,6 +321,7 @@ describe('policy preconfiguration', () => { [], [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -344,6 +352,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -396,6 +405,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -446,6 +456,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -503,6 +514,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -523,6 +535,7 @@ describe('policy preconfiguration', () => { { name: 'test_package', version: '2.0.0' }, ], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ) ).rejects.toThrow( @@ -555,6 +568,7 @@ describe('policy preconfiguration', () => { policies, [{ name: 'test_package', version: '3.0.0' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ) ).rejects.toThrow( @@ -587,6 +601,7 @@ describe('policy preconfiguration', () => { policies, [{ name: 'CANNOT_MATCH', version: 'x.y.z' }], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ) ).rejects.toThrow( @@ -612,6 +627,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -638,6 +654,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -678,6 +695,7 @@ describe('policy preconfiguration', () => { ] as PreconfiguredAgentPolicy[], [], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); expect(spyAgentPolicyServiceUpdate).toBeCalled(); @@ -718,6 +736,7 @@ describe('policy preconfiguration', () => { [policy], [], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); expect(spyAgentPolicyServiceUpdate).not.toBeCalled(); @@ -766,6 +785,7 @@ describe('policy preconfiguration', () => { }, ], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -803,6 +823,7 @@ describe('policy preconfiguration', () => { }, ], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); @@ -848,6 +869,7 @@ describe('policy preconfiguration', () => { }, ], mockDefaultOutput, + mockDefaultDownloadService, DEFAULT_SPACE_ID ); diff --git a/x-pack/plugins/fleet/server/services/preconfiguration.ts b/x-pack/plugins/fleet/server/services/preconfiguration.ts index b425cc4ca4035..1be98d364e963 100644 --- a/x-pack/plugins/fleet/server/services/preconfiguration.ts +++ b/x-pack/plugins/fleet/server/services/preconfiguration.ts @@ -14,6 +14,7 @@ import type { AgentPolicy, Installation, Output, + DownloadSource, PreconfiguredAgentPolicy, PreconfiguredPackage, PreconfigurationError, @@ -45,6 +46,7 @@ export async function ensurePreconfiguredPackagesAndPolicies( policies: PreconfiguredAgentPolicy[] = [], packages: PreconfiguredPackage[] = [], defaultOutput: Output, + defaultDownloadSource: DownloadSource, spaceId: string ): Promise { const logger = appContextService.getLogger(); @@ -154,12 +156,17 @@ export async function ensurePreconfiguredPackagesAndPolicies( preconfiguredAgentPolicy, policy ); + + const newFields = { + defaultDownloadSourceId: defaultDownloadSource.id, + ...fields, + }; if (hasChanged) { const updatedPolicy = await agentPolicyService.update( soClient, esClient, String(preconfiguredAgentPolicy.id), - fields, + newFields, { force: true, } diff --git a/x-pack/plugins/fleet/server/services/setup.test.ts b/x-pack/plugins/fleet/server/services/setup.test.ts index c9dc4227ae48d..c7dd2b2657992 100644 --- a/x-pack/plugins/fleet/server/services/setup.test.ts +++ b/x-pack/plugins/fleet/server/services/setup.test.ts @@ -21,6 +21,7 @@ jest.mock('./preconfiguration'); jest.mock('./preconfiguration/outputs'); jest.mock('./settings'); jest.mock('./output'); +jest.mock('./download_source'); jest.mock('./epm/packages'); jest.mock('./managed_package_policies'); diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 50819d7a2640d..ac8aa17603d92 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -26,6 +26,7 @@ import { agentPolicyService } from './agent_policy'; import { ensurePreconfiguredPackagesAndPolicies } from './preconfiguration'; import { ensurePreconfiguredOutputs } from './preconfiguration/outputs'; import { outputService } from './output'; +import { downloadSourceService } from './download_source'; import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_keys'; import { settingsService } from '.'; @@ -76,6 +77,8 @@ async function createSetupSideEffects( const defaultOutput = await outputService.ensureDefaultOutput(soClient); + const defaultDownloadSource = await downloadSourceService.ensureDefault(soClient); + if (appContextService.getConfig()?.agentIdVerificationEnabled) { logger.debug('Setting up Fleet Elasticsearch assets'); await ensureFleetGlobalEsAssets(soClient, esClient); @@ -109,6 +112,7 @@ async function createSetupSideEffects( policies, packages, defaultOutput, + defaultDownloadSource, DEFAULT_SPACE_ID ); diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 10a00393f8075..1d83d4011115c 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -74,6 +74,9 @@ export type { FleetServerAgentAction, FleetServerPolicy, FullAgentPolicyInputStream, + DownloadSourceBase, + DownloadSource, + DownloadSourceAttributes, } from '../../common'; export { ElasticsearchAssetType, diff --git a/x-pack/plugins/fleet/server/types/models/download_sources.ts b/x-pack/plugins/fleet/server/types/models/download_sources.ts new file mode 100644 index 0000000000000..5d11419493660 --- /dev/null +++ b/x-pack/plugins/fleet/server/types/models/download_sources.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +const DownloadSourceBaseSchema = { + id: schema.maybe(schema.string()), + name: schema.string(), + host: schema.uri({ scheme: ['http', 'https'] }), + is_default: schema.boolean({ defaultValue: false }), +}; + +export const DownloadSourceSchema = schema.object({ ...DownloadSourceBaseSchema }); diff --git a/x-pack/plugins/fleet/server/types/models/index.ts b/x-pack/plugins/fleet/server/types/models/index.ts index d71c6b2e0d748..0a58538616f77 100644 --- a/x-pack/plugins/fleet/server/types/models/index.ts +++ b/x-pack/plugins/fleet/server/types/models/index.ts @@ -11,3 +11,4 @@ export * from './package_policy'; export * from './output'; export * from './enrollment_api_key'; export * from './preconfiguration'; +export * from './download_sources'; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/download_sources.ts b/x-pack/plugins/fleet/server/types/rest_spec/download_sources.ts new file mode 100644 index 0000000000000..039afbdc55aad --- /dev/null +++ b/x-pack/plugins/fleet/server/types/rest_spec/download_sources.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { DownloadSourceSchema } from '../models'; + +export const GetOneDownloadSourcesRequestSchema = { + params: schema.object({ + sourceId: schema.string(), + }), +}; + +export const getDownloadSourcesRequestSchema = {}; + +export const PostDownloadSourcesRequestSchema = { + body: DownloadSourceSchema, +}; + +export const PutDownloadSourcesRequestSchema = { + params: schema.object({ + sourceId: schema.string(), + }), + body: DownloadSourceSchema, +}; + +export const DeleteDownloadSourcesRequestSchema = { + params: schema.object({ + sourceId: schema.string(), + }), +}; diff --git a/x-pack/plugins/fleet/server/types/rest_spec/index.ts b/x-pack/plugins/fleet/server/types/rest_spec/index.ts index fe5dae4e39ed7..3bd19ddbeb921 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/index.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/index.ts @@ -16,3 +16,4 @@ export * from './preconfiguration'; export * from './settings'; export * from './setup'; export * from './check_permissions'; +export * from './download_sources'; diff --git a/x-pack/test/fleet_api_integration/apis/download_sources/crud.ts b/x-pack/test/fleet_api_integration/apis/download_sources/crud.ts new file mode 100644 index 0000000000000..0d0916be10171 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/download_sources/crud.ts @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; +import { setupFleetAndAgents } from '../agents/services'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('fleet_download_sources_crud', async function () { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + await esArchiver.load('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + }); + setupFleetAndAgents(providerContext); + + let defaultDownloadSourceId: string; + + before(async function () { + const { body: response } = await supertest + .get(`/api/fleet/agent_download_sources`) + .expect(200); + + const defaultDownloadSource = response.items.find((item: any) => item.is_default); + if (!defaultDownloadSource) { + throw new Error('default download source not set'); + } + defaultDownloadSourceId = defaultDownloadSource.id; + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/empty_kibana'); + await esArchiver.unload('x-pack/test/functional/es_archives/fleet/empty_fleet_server'); + }); + + describe('GET /agent_download_sources', () => { + it('should list the default download source host', async () => { + const { body: downloadSource } = await supertest + .get(`/api/fleet/agent_download_sources`) + .expect(200); + + expect(downloadSource.items[0]).to.eql({ + id: 'fleet-default-download-source', + name: 'default', + is_default: true, + host: 'artifactory.elastic.co', + }); + }); + }); + + describe('GET /agent_download_sources/{sourceId}', () => { + it('should return the requested download source host', async () => { + const { body: downloadSource } = await supertest + .get(`/api/fleet/agent_download_sources/${defaultDownloadSourceId}`) + .expect(200); + + expect(downloadSource).to.eql({ + item: { + id: 'fleet-default-download-source', + name: 'default', + is_default: true, + host: 'artifactory.elastic.co', + }, + }); + }); + }); + + describe('PUT /agent_download_sources/{sourceId}', () => { + it('should allow to update an existing download source', async function () { + await supertest + .put(`/api/fleet/agent_download_sources/${defaultDownloadSourceId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'new host1', + host: 'https://test.co:403', + is_default: false, + }) + .expect(200); + + const { + body: { item: downloadSource }, + } = await supertest + .get(`/api/fleet/agent_download_sources/${defaultDownloadSourceId}`) + .expect(200); + + expect(downloadSource.host).to.eql('https://test.co:403'); + }); + + it('should allow to update an existing download source', async function () { + await supertest + .put(`/api/fleet/agent_download_sources/${defaultDownloadSourceId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'new default host', + host: 'https://test.co', + is_default: true, + }) + .expect(200); + + await supertest.get(`/api/fleet/agent_download_sources`).expect(200); + }); + + it('should return a 404 when updating a non existing download source', async function () { + await supertest + .put(`/api/fleet/agent_download_sources/idonotexists`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'new host1', + host: 'https://test.co', + is_default: true, + }) + .expect(404); + }); + + it('should return a 400 when passing a host that is not a valid uri', async function () { + await supertest + .put(`/api/fleet/agent_download_sources/${defaultDownloadSourceId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'new host1', + host: 'not a valid uri', + is_default: true, + }) + .expect(400); + }); + }); + + describe('POST /agent_download_sources', () => { + it('should allow to create a new download source host', async function () { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'My download source', + host: 'http://test.fr:443', + is_default: false, + }) + .expect(200); + + const { id: _, ...itemWithoutId } = postResponse.item; + expect(itemWithoutId).to.eql({ + name: 'My download source', + host: 'http://test.fr:443', + is_default: false, + }); + }); + + it('should toggle default download source when creating a new default one', async function () { + await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default download source host 1', + host: 'https://test.co', + is_default: true, + }) + .expect(200); + + const { + body: { item: downloadSource2 }, + } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'default download source host 2', + host: 'https://test2.co', + is_default: true, + }) + .expect(200); + + const { + body: { items: downloadSources }, + } = await supertest.get(`/api/fleet/agent_download_sources`).expect(200); + + const defaultDownloadSource = downloadSources.filter((item: any) => item.is_default); + expect(defaultDownloadSource).to.have.length(1); + expect(defaultDownloadSource[0].id).eql(downloadSource2.id); + }); + + it('should return a 400 when passing a host that is not a valid uri', async function () { + await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'new host1', + host: 'not a valid uri', + is_default: true, + }) + .expect(400); + }); + }); + + describe('DELETE /agent_download_sources/{sourceId}', () => { + let sourceId: string; + let defaultDSIdToDelete: string; + + before(async () => { + const { body: postResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Download source to delete test', + host: 'https://test.co', + }) + .expect(200); + sourceId = postResponse.item.id; + + const { body: defaultDSPostResponse } = await supertest + .post(`/api/fleet/agent_download_sources`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Default download source to delete test', + host: 'https://test.co', + is_default: true, + }) + .expect(200); + defaultDSIdToDelete = defaultDSPostResponse.item.id; + }); + + it('should return a 400 when trying to delete a default download source host ', async function () { + await supertest + .delete(`/api/fleet/agent_download_sources/${defaultDSIdToDelete}`) + .set('kbn-xsrf', 'xxxx') + .expect(400); + }); + + it('should return a 404 when deleting a non existing entry ', async function () { + await supertest + .delete(`/api/fleet/agent_download_sources/idonotexists`) + .set('kbn-xsrf', 'xxxx') + .expect(404); + }); + + it('should allow to delete a download source value ', async function () { + const { body: deleteResponse } = await supertest + .delete(`/api/fleet/agent_download_sources/${sourceId}`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + expect(deleteResponse.id).to.eql(sourceId); + }); + }); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/download_sources/index.js b/x-pack/test/fleet_api_integration/apis/download_sources/index.js new file mode 100644 index 0000000000000..96d6fc841209b --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/download_sources/index.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export default function loadTests({ loadTestFile }) { + describe('Agent Download Source Endpoints', () => { + loadTestFile(require.resolve('./crud')); + }); +} diff --git a/x-pack/test/fleet_api_integration/apis/index.js b/x-pack/test/fleet_api_integration/apis/index.js index 4f9d2026bc531..a2be789b9a982 100644 --- a/x-pack/test/fleet_api_integration/apis/index.js +++ b/x-pack/test/fleet_api_integration/apis/index.js @@ -50,6 +50,9 @@ export default function ({ loadTestFile, getService }) { // Outputs loadTestFile(require.resolve('./outputs')); + // Download sources + loadTestFile(require.resolve('./download_sources')); + // Telemetry loadTestFile(require.resolve('./fleet_telemetry'));