From 96f74e6e7afac29228ca45507e3981ac5f4974e6 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 12 Mar 2020 18:52:06 -0400 Subject: [PATCH] [Ingest] Add Fleet & EPM features (#59376) * [EPM] Documentation of HTTP routes & TS types for Ingest (#48798) * Add beginning models and two routes for Ingest * Update types & models per discussion w/Ruflin Also reviewed data structures listed at https://docs.google.com/document/d/1IBR3f9dpHqJmXYEdg06WV34KSMd3g5k4aMGa4jde_Eg/edit# * Update: /policies always returns array. /policy returns single policy * Add pagination for /policy & /datasources. Uses per_page & page params * Add API metadata. Standardize policy_id param name. * Update descriptions to match Google Doc. Move use case to Policy. Disabled the '@typescript-eslint/array-type' rule because it was going around in circles. It didn't like Datasource[] or Array * Return to initial TS annotation for Arrays Remove the line disabling @typescript-eslint/array-type now that it's behaving normally again :shrug: * [EPM] Add directory structure for server/lib. (#50469) * Add directory structure for server/lib. * 'tests' seems to be more common than 'test' * Make CI happy * [EPM] Add basic documentation directory (#50478) * [EPM] Add basic documentation directory Having the doc directory around allows us to easily add docs from here on to document how EPM works. To run the docs build, use the following command from the kibana directory: ``` ../docs/build_docs --doc docs/epm/index.asciidoc --open ``` The above assumes that docs (https://github.com/elastic/docs) are checked out in the same directory as Kibana. With this change, the EPM docs build is not included yet in the overall docs build. For this adjustments to https://github.com/elastic/docs/blob/master/conf.yaml must be made. * [EPM] Add basic index template (#50471) This PR adds the very basic index template we will use for the packages. It contains all the basic settings and some examples. The examples will be remove as soon as we have an actual implementation with packages but for now is convenient to see if it is a valid package. This code is put into the lib directory as it does not tie directly into any handlers. It also adds an functional tests for loading a template. This means we have a way to check if a template is valid in Elasticsearch. Based on this we can check in the future all our generated templates for validity with Elasticsearch. To run the functional test, go to the Kibana x-pack directory. Start the first command: ``` node scripts/functional_tests_server.js --config test/epm_api_integration/config.ts ``` Keep the above running and switch to an other Terminal. Now run: ``` node scripts/functional_test_runner.js --config x-pack/test/epm_api_integration/config.ts ``` * 40752 rewrite ingest pipeline (#50627) * Add directory structure for server/lib. * 'tests' seems to be more common than 'test' * Make CI happy * Implement pipeline rewriting. * Add more testcases * For posterity (comment change) * Allow beats-style template delimiters * Be more succinct * Document better * Replace AssetType enum with union type (#50696) See https://github.com/elastic/kibana/pull/50609#discussion_r346080439 Discussed in Slack and agree to revert for now. Can track down the issues & restore later * Remove unnecessary await if we can return the promise (#50329) * Fix whitespace per figma comments. Closes #47348 (#47350) * Add fix & comment for TS 3.7.2 regression * [EPM] cleanup assets, filter assets for those currently supported (#50609) * cleanup assets, filter assets for those currently supported * removed unused type * fix type * add comment for better type * change type name to be more descriptive * hardcode image width for ie11 (#49796) * hardcode image width for ie11 * eslint * improve comment * add maxWidth * [EPM] useKibana hook & render in plugin.start (#50110) * plugin.start now does reactdom.render vs returning react element export plugin function from public/index * Move setClient call from plugin.start to plugin.setup * Use `useUiSetting$` from `useKibana` hooks * Fix broken app due to bad hooks usage Can't use useKibana outside a React component. Reverting to prior approach since it's still NP. Can revisit context usage in a followup PR * [EPM] Install package from detail view on button click (#50735) * Support basic "click button -> show spinner -> installed" install flow * Remove incorrect comments. Add TS return types to data functions. * [EPM] Use NP feature_catalogue.register (#50108) * Use NP feature_catalogue.register * Use type from NP plugin * fix linting * fix types * fix headers in Fleet * skipping test due to ES param change * Revert "skipping test due to ES param change" This reverts commit d05f20decfbfc4d91069816a6f8dfde26b5bd6bc. * remove type field * remove unused import * [EPM] Final(?) update from integrations_manager -> EPM (#50976) * Update (all remaining?) references from integrations_manager to EPM * Update path in i18n file * [EPM] update compatibility section (#50975) * change min max version to single range value * add elastic stack icon and change text * remove badge from version and use code font * remove euistyled * add back version component lost in merge * remove euiStyled * remove old file * Restore RequirementVersionRange type * Disable test for elasticsearch username. Temporary work around until we know how the stack wants to add the permissions we need. Either adding to the kibana user or creating a new user. * Revert "Disable test for elasticsearch username." This reverts commit f1020e4eab2ada5d854eacc44cdb6d5bd23c267f. * Disable test for elasticsearch username. Temporary work around until we know how the stack wants to add the permissions we need. Either adding to the kibana user or creating a new user. * Fix EPM typing issues in register feature * Fix typings after master merge * [EPM] CI fixes (#51284) * Initialize es in test. * Add it(), no es.init() * Clean up. * [EPM] add confirmation modal (#51172) * add confirmation modal, move install state to Header * update callout to use title * move components only used in detail view to detail dir * use better variable names * update to more descriptive variable names * Restore prior response vaulues for install & delete package (#51252) Discussed this with @skh https://github.com/elastic/kibana/pull/51112#commitcomment-35961413 & https://github.com/elastic/kibana/pull/51112#commitcomment-35970664 as well as in a video call Also added some TS type annotations for data fetching functions to make the contracts more explicit * [EPM] /package API only lists installable assets. Restore enums. (#51414) * API only shows installable assets. Server types to own file. Restore enums * Fix type imports * Only return installable asset types (kibana for now) * server/types now only has code from hapi which shouldn't go to client * Add more restricted TS types to DisplayAssets object * Flip order of arguments to Extract In these cases it still works the same, but looking at https://www.typescriptlang.org/docs/handbook/advanced-types.html the signature is `Extract` - Extract from `T` those types that are assignable to `U` so the larger set should be first * [Fleet] Enrollment api key UI (#51495) * Make button pretty in dark mode as well. (#51610) * [EPM] Add docs entry about registryUrl config (#51697) This documentation is at the moment mainly for internal use. I found myself searching for this URL several times in the code or PRs so I thought I rather add it to the docs for now. * [EPM] Remove encoding of Kibana objects as not needed anymore * [Fleet] Move agent status server side and API to get aggregated status for a policy (#51673) * [EPM] Add basic docs around install/delete API endpoint (#51728) This is mainly for internal usage at the moment to look up. * Ingest/policy (#51741) * wip policy * tweaks * tweaks * FIX TYPOS * WIP move policy => agent config conversion to fleet, WIP policy changed method * fix tests and bugs * updates tests and snaps * more fixes * use AGENT_POLLING_INTERVAL * cleanup and fix some formatting * Update x-pack/legacy/plugins/ingest/server/libs/datasources.ts Co-Authored-By: John Schulz * Update x-pack/legacy/plugins/ingest/server/libs/datasources.ts Co-Authored-By: John Schulz * Update x-pack/legacy/plugins/ingest/server/libs/outputs.ts Co-Authored-By: John Schulz * Update x-pack/legacy/plugins/fleet/server/libs/policy.ts Co-Authored-By: John Schulz * fix things broken by PR review suggestions * remove unused field * fix types * fix mappings * add datasource mappings * Fix mappings and remove get full policy from checkin * Fix ingest api integration tests * run es-lint to fix fleet * Fix typescript issues * [EPM] Track package install state and add toast notification (#51734) * add notifications from core to plugin * add package install state hook * fix type error * use toMountPoint helper to add jsx to notification * add warning notification to failed install * make notifications dependency explicit prop * move PackageInstall provider lower * add comment about InstallStatus type overlapping InstallationStatus * use InstallStatus type in InstallationButton component * fix type * [Ingest] Adds support for a working default output (#51841) * aadding config * add working settings to the default output * remove default username and password * update libs * [EPM] Add basics for creating the ILM setup (#50474) This contains the basic objects to setup ILM * Create index and alias with a write index * Get the policy The code does not contain any functional tests yet as it is still open on how to do it best. I suggest to get this in as a foundation and then iterate on top of it. * [EPM] Add datasource (ingest pipeline) from package (#51851) ## Summary This mixes a few concerns but I think it's worth it to show the parts working together. Take a look at the individual commits for a better separation of features. This adds - the `/datasource/install/{pkgkey}` endpoint which installs ingest pipelines from a package into ES and saves a reference to them in the EPM state Saved Object - Connects the "Add datasource" button in the successful installation Toast to the new API - Adds a toast notification to inform the user the datasource was added correctly - Adds a "Delete Package" button on the details page so we can uninstall a package while we're waiting for the separate view which allows deletes - b99eda6 Pushes logic that was in the detail view into `InstallationButton`. This consolidates the logic in one component (or one component & the existing hook) and, iiic, means we can put `` on any view and get the same behavior I'm marking this as a normal PR so people can merge if they wish ![add-datasource-delete-package-small](https://user-images.githubusercontent.com/57655/69775686-7fb39280-1167-11ea-8d41-e2b8a02252a1.gif) * [EPM] Add basic processing of fields.yml file (#51148) The fields.yml is used to generate the Elasticsearch template and Kibana index pattern. This PR adds a very basic implementation of processing the fields.yml and then create an Elasticsearch template out of it. The only fields that are supported at the moment are keyword fields, more will be added as a follow up. The testing was implemented with a golden file. The output from the method is compared to a json file. If the input is changed or the method is changed, it is possible to regenerate the files with the `-generate` flag as following: ``` node scripts/jest ./legacy/plugins/epm/server/lib/template/template.test.ts -generate ``` This will allow us to quickly test many inputs / outputs in the future, make adjustments to the existing files and generate the new outputs. We then can compare it in the diff it the changes make sense. * [EPM] Create basic implementation to merge input template and dataset manifest (#51803) * [EPM] Create basic implementation to merge input template and dataset manifest With this code it is possible to take an input template for the agent and merge it with the config variables from the dataset manifest file. Currently only the name and the default value are merged. Later on we must implement to be able to pass user configured variables to it and make decision based on OS selection. Closing https://github.com/elastic/kibana/issues/51794 * [EPM] Refactoring of lib structure (#51885) This refactors the structure of lib. As so far all the lib parts are related to assets in the package, it is organised the same way with the same structure. Each directory has its own tests directory if it needs one. This makes it possible to (almost) not need relative paths for tests. * [EPM] Allow to read files from fields directory (#51958) This change allows to also extract files from the `fields` directory. Previously this was not possible because it always assumed a service must be there. * [EPM] Install Elasticsearch Index Template for data source (#51878) This installs the Elasticsearch index template for each dataset in a package. For now the names are hardcoded based on package key and dataset name but will be more dynamic later on when we pass the full dataset information. The dataset extractions is a bit "hacky" at the moment and we should get a full implementation of dataset at a later stage and replace this code. * [Fleet] Policy list, details, create, edit UIs (#51950) * Set up simple policies list view * Adjust spec to return single policy * Set up simple policy details page * Add demo stats/chart to policy details * Add description string * Initial setup of policy form and create policy UI * Policy create/edit form; integrate policy list api * Integrate create policy api * Integrate policy detail, agent status, and policy edit APIs; adjust policy list api integration in agent enrollment * Fix edit policy mock meta * Fix policy list search bar * PR and linting fixes; use typings from ingest plugin * Fix i18n * [EPM] Add datasource saved object type (#51871) ## Summary This PR makes a few assumptions, and contains a lot of refactoring. It might be beneficial to look at the resulting directory structure under `server` first to get the (new) big picture. Assumptions: - our API deals with several concerns, for now these are packages and datasources - we manage our own HTTP API endpoints for these concerns (in particular, don't use the ingest plugin for that) - we manage (for now) the Kibana saved object in which datasources are saved. importing and calling methods from the ingest plugin to do that down the road will (hopefully) be a manageable change This led to the following decisions: - the code is separated into subdirectories by concern, containing all the route handlers and tightly coupled code - for now, these directories are in `server/packages` and `server/datasources`. I'm tempted to move them into `server/api/{packages,datasources}` but wanted to limit the amount of refactoring in one PR - shared code lives in `server/lib` - some code from `server/packages` has been almost duplicated to handle saving to Datasource saved objects, some has been refactored and is used from both places. The deduplication needs further improvement - maybe `server/registry` should also move under `server/lib` (but see above, I'm trying to not move everything around all at once) Testing: * Please note that this is a breaking change because the saved object type for package information has also been renamed. You'll need to start with a fresh `.kibana-*` index. Restarting `yarn es snapshot` (withouth specifying a data directory) should do the trick. * Package installation should still work, e.g. with a GET request to `http://localhost:5601/api/epm/package/coredns-1.0.1`. The saved objects for packages can be inspected with a GET request to `http://localhost:5601/api/saved_objects/epm-package/$PKG_KEY`, e.g. `http://localhost:5601/api/saved_objects/epm-package/coredns-1.0.1` * Datasource creation should still work, e.g. with a GET request to `http://localhost:5601/api/epm/datasource/install/coredns-1.0.1`. The saved objects for datasources can be inspected with a GET to `http://localhost:5601/api/saved_objects/epm-datasource/$PKG_KEY`, e.g. `http://localhost:5601/api/saved_objects/epm-datasource/coredns-1.0.1` * [Fleet] Expose policy during agent checkin (#51968) * [EPM] Add /epr prefix to the tar.gz download path (#51881) The registry slightly changed the .tar.gz path because of download stats reason. This adjusts for it. See https://github.com/elastic/package-registry/pull/169 * [EPM] Move template installation to lib and add asset helper (#52049) * [EPM] Move template installation to lib and add asset helper All the logic related to the installation of the templates for a package should be inside the template library folder. This moves the logic into this folder. A few refactorings were made to simplify installation: * Introduction of DataSet interface: This interface is needed to extract the data sets inside a package and install one template per data set. * Pass package instead of package key to installation process: Passing the package instead of the package key means fetching of package information is decoupled from the installation process and abstracted. This separates the two concerns and should simplify testing. * getAsssets method: The getAssets methods works on top of the package object to extract asset paths. It is inspired by get_objects methods but supports passing a package and a dataset. Currently one problem with testing that exists is that to fetch the content of an asset is not decoupled yet. * [EPM] Reduce data source to one type (#52061) Between Fleet / Ingest / EPM there had been several interface definitions of Datasource and the related types. This reduces it to one place for the definition. The same applies to the policy definition. The goal of this is that from now on we all rely on the same definition. If we make changes, we make them in all parts of the code. In this PR is only the minimal change needed to get us all on one interface. Further changes will be needed that we all rely on the same saved objects etc. * add export command * revert 2 more files to rely on export * revert imports * Fix types for Datasource Saved Object * merge in master * fix type check * Run VSCode's organize imports on EPM files (#52234) Learned about it on Slack from https://twitter.com/ryanchenkie/status/1201883268527927301 Blog at https://code.visualstudio.com/updates/v1_23#_run-code-actions-on-save Basically does the order we've been loosely following (3rd party, then relative) & alphabetic by location and variable name. It's not customizable but it's reasonable and, afaict, consistent. * [EPM] More realistic datasource SO. Error if package not installed. (#52229) * Move cache 'hack' into getAssetsData * p -> pkg. package is reserved. pkgkey is used in many places * Remove unnecessary type cast * Clarify reasons behind asset path manipulation * Return the Datasource; not the Saved Object. * Use real values from package in fake datasource SO * Error if /datasource/install before /package/install ``` > curl --user elastic:changeme localhost:5601/api/epm/datasource/install/coredns-1.0.1 { "statusCode": 403, "error": "Forbidden", "message": "coredns-1.0.1 is not installed" } > curl --user elastic:changeme localhost:5601/api/epm/install/coredns-1.0.1 [ { "id": "53aa1f70-443e-11e9-8548-ab7fbe04f038", "type": "dashboard" }, { "id": "Metricbeat-CoreDNS-Dashboard-ecs", "type": "dashboard" }, { "id": "75743f70-443c-11e9-8548-ab7fbe04f038", "type": "visualization" }, { "id": "36e08510-53c4-11e9-b466-9be470bbd327-ecs", "type": "visualization" }, { "id": "277fc650-67a9-11e9-a534-715561d0bf42", "type": "visualization" }, { "id": "cfde7fb0-443d-11e9-8548-ab7fbe04f038", "type": "visualization" }, { "id": "a19df590-53c4-11e9-b466-9be470bbd327-ecs", "type": "visualization" }, { "id": "a58345f0-7298-11e9-b0d0-414c3011ddbb", "type": "visualization" }, { "id": "9dc640e0-4432-11e9-8548-ab7fbe04f038", "type": "visualization" }, { "id": "3ad75810-4429-11e9-8548-ab7fbe04f038", "type": "visualization" }, { "id": "57c74300-7308-11e9-b0d0-414c3011ddbb", "type": "visualization" }, { "id": "27da53f0-53d5-11e9-b466-9be470bbd327-ecs", "type": "visualization" }, { "id": "86177430-728d-11e9-b0d0-414c3011ddbb", "type": "visualization" }, { "id": "4804eaa0-7315-11e9-b0d0-414c3011ddbb", "type": "visualization" } ] > curl --user elastic:changeme localhost:5601/api/epm/datasource/install/coredns-1.0.1 [ { "id": "coredns_1_0_1_dataset_log_elasticsearch_ingest_pipeline_pipeline_plaintext_json", "type": "ingest-pipeline" }, { "id": "coredns_1_0_1_dataset_log_elasticsearch_ingest_pipeline_pipeline_json_json", "type": "ingest-pipeline" }, { "id": "coredns_1_0_1_dataset_log_elasticsearch_ingest_pipeline_pipeline_entry_json", "type": "ingest-pipeline" } ] ``` * fix duplicated imports * [EPM] Move golden files generation over to jest snapshot (#52203) * [EPM] Move golden files generation over to jest snapshot I initially used my own implementation to write the generated files. It runs out jest has a feature to write snapshots which simplifies the code a lot. I added a loop with an additional test file so in the future we can just keep adding test files without having to modify the test code. To updated the snapshots, the param `-u` has to be used: ``` node scripts/jest legacy/plugins/epm/server/lib/fields/field.test.ts -u ``` * [EPM] Create metrics-* and logs-* Kibana index pattern (#52277) This creates the very basic Kibana index patterns metrics-* and logs-* for Kibana. At the moment it is overwritten every time. We need to change this in the future to take the fields from all installed data sources and regenerate it. * [EPM] Create helper for elasticsearch asset names (#52265) Most of the Elasticsearch assets have the same base name. This creates a helper to get the base name for the assets. In case we decide to change the base name in the future, we can change it in one place. * fix tests and destructing * [EPM] Update Registry types. Prevent errors installing certain datasources. (#52285) * Update RegistryPackage type * Use download key from EPR to fetch archive * Fix errors caused by correcting the Registry types. The issues were largely that some Registry types like `title, `datasets` and `assets` where marked as required, but are actually optional. This highlighted area in the code were we relied on them always being present. We added to the issue by wrapping Registry types in `Required` which made those items which were correctly listed as optional, required for EPM code. Updated EPM types to reflect the largely pass-through nature of the EPM types. There are two properties which we ensure are in every EPM response, those were put into their own (unexported) type. Confirm by trying to add a datasource to a package which has no datasources, like apache-1.0.1 or system-2.0.1. In `feature-ingest` and the earlier version of this PR, the `/datasource/install` call returns a 500. In this PR it succeeds. * [EPM] Add setup of default ILM policies (#52272) This creates two ILM policies: logs-default and metrics-default. These are the default ILM policies used. Currently the policy content is hardcoded in the code but should be fetched from the base package in the future. The setup happens as part of the datasource installation. When a data source is installed it is a good time to check if the assets are there but we might extract this to a better place in the future. * [EPM] 52075 add data source first page (#52320) * Update RegistryPackage type * add first page of add data source * fix for ie11 flex min width bug * remove toDetailViewRelative * remove unneeded spread * Update TS type names for EPR search results (#52512) * `RegistryList -> RegistrySearchResults` * `RegistryListItem -> RegistrySearchResult` * Restore import sort order from #52234 (#52548) Many of the changes from #52234 were lost. Presumably due to PR(s) merging which were based on branches which had the previous unsorted order. * [EPM] Replace wildcard export (#52554) * PackageNotInstalledError -> packages/index.ts * pkgToPkgKey -> registry/index.ts (will convert existing `${name}-${version}` instances later) * Replace export * from packages. There's an argument that the import sites should be updated to import from `packages/get`, `packages/install`, etc but that can wait for a later PR. * [EPM] Reduce usage of epm-package SavedObject (#52576) * Delete existing Installation type. Rename InstallationAttributes to Installation * Reduce usage of EPM SO. Add getInstallation(). Replaced two calls of getInstallationObject() with getInstallation(). Two less places with knowledge of SO internals. Lots of potential improvements for EPM TS types remain (refactoring/removing Installable, etc), but this is a good incremental step, IMO * [EPM] Fix missing export link (#52628) Without it, things break. I am surprised CI did not catch this. * [EPM] Cleanup ILM loading (#52632) Before the check for the ILM policy to exist triggered an exception. With this change it is a normal response also if the policy does not exist yet. A follow up issue will be created in Elasticsearch to get a HEAD request for this available. * [EPM] Switch to staging URL for registry (#52626) The old cluster with the registry will be removed as soon as this is merged. * [EPM] Use Dataset interface to generate template (#52255) This will make sure we have to pass much feature params and can fully rely on the datasource object to create names for assets. * [Fleet] Use agent events to compute agent health (#52513) * [EPM] Data source integration tests (#52542) * Add fixtures for data source integration test. * Move test setup to beforeEach * Add test for datasource creation * Handle pipelines in yml format. * Make integration test for adding a data source pass. * Use EPR staging URL with CDN. (#52776) See https://github.com/elastic/kibana/pull/52626#pullrequestreview-330622868 * [EPM] Add Data Source page updates (#52705) * remove dupe type RegistryPackage * change switches to checkboxes, use datasets to create checkboxes, add some local form state * update types * [EPM] redirect after package install (#52771) * add callback after successful installation and redirect * add temp data sources tab content to access add data source page * remove assets tab for mvp * hide data sources link and redirect from data sources tab if package not installed * change callback name * remove commented out assets logic * add redirect to hook * fix type * Use ingest datasource api (#52964) Incremental change. Uses HTTP API for datasource creation. Will do follow-up PR which uses JS function instead * Remove duplicate fetchInfo & installTemplates I think this was from a bad merge, but pretty sure we don't want these functions called twice in the same function * WIP. Pushing so others can see * Improve correctness/flexibility of absolute URL * Disable datasource test & template installation * [Ingest] Data source APIs (#52448) * Clean up ingest imports and remove unneeded mock_spec files * Initial pass at datasources lib and API endpoints * Add add/remove datasource to/from policy API endpoints * Add datasource contract tests and related policy contract tests; update snapshots * Fix tests * Fix tests again * Fix tests 3 * Adjust routes, PR feedback * modify epm createDatasource endpoint to use user data (#52971) * change epm/datasource/install/{pkg} to POST, send user data to endpoint, install pipelines and templates based on user selected datasets * change test to post for installing a datasource * change some names and types around * delete request.headers['transfer-encoding'] being passed through from epm request * [EPM] Don't share CreateFakeDatasource type (#53068) It's not shared between client & server so it doesn't need to be in common. Also, it imports server code which would try to bring server types to the client. It's types so they're compiled away but it's important to keep common to what's truly common. Breaking this separation is why we thought enums broke the client. A lint rule just landed in master to prevent this. * [EPM] Index template generation fixes (#53104) * Only add keyword type field to mappings. * Index template installation * Handle empty fields definition files * Re-enable index template installation * [Fleet] Assign/Unassign data source from policy UI (#53058) * Add index files to export various modules; normalize imports * Clean up unused files; extract datasources table component from policy details page * Expose http client to frontend libs; remove unused types; import ES UI's useRequest lib * Adjust shape of rest api adapter interface to better match with rest of kibana; remove unused node adapter; change per_page param to perPage in agent events route * Initial pass at assign data sources flyout * Initial pass at unassigning data sources from policy * Make data sources table searchable by package values * Fix enrollment key lib for rest adapter param changes * Fix imports and types * `yarn.lock` changes after bootstrapping * [EPM] Implement getConfig for dataset (#53261) * [EPM] Implement getConfig for dataset * Implements a getConfig method on a dataset object. * Build the configuration for each dataset in a package. * construct and save streams into datasource saved object * [EPM] Fix template installation (#53272) As dataset.package was not set, the installed templates contained undefined in the template name. This changes fixes this. * [EM] Refactor ingest pipeline installation (#53309) * Refactor ingest pipeline installation * Only install index templates for requested datasets * Add index.default_pipeline to index template * Hook up pipeline rewriting * Add correct types. * change POST create datasources path (#53165) * change POST create datasources path * remove pkgkey from params * Fix creation of a data source with a custom ID (#53537) * [Ingest] Return associated policy IDs in data source info (#53350) * Return number of policies from data source, surface in assign data source UI * Update snapshots * [EPM]: Assign data source to policy in UI (#53597) * Let ES generate source ids. Refactor along the way. * Datasource.id isn't optional. It's just missing before we send to Ingest * Delete EPM's mapping of datasources saved object. Ingest handles that. * Keep datasource object-related work in constructDatasource * Move asset installation into own function. Keep entry point high-level. * More descriptive (less ambiguous) names for these two functions * Use enum values from Ingest instead of plain strings * Limit the 'type' key of references to known asset types. * Update variable names to clarify that we're merging arrays of references * Use [].flat instead .reduce + .concat to avoid error on empty arrays. * Pass PackageInfo value directly to component vs pulling off n properties * Name handlers/options based on the data, not the UI element * Populate policy combo box based on values from Ingest policy API * Mark Dataset.vars as optional. * Add TODOs * Add commands to run API tests to README (#53847) * Limit functions to 3 params max. Update those which used more (#53848) * [EPM] Code in 'common' directories shouldn't import server code (#53854) * [Fleet] Code in 'common' directories shouldn't import server code (#53938) * [Fleet] Remove server code from common folder in fleet * [Fleet] Fix typescript issues after master merge * [EPM] Fix typescript issues after master merge * Fix eslint issues * Fix typescript issues after merge * Fix merge master missing line * Fix merge conflict * [Fleet] Fix registration of Ingest management section (#54065) * Fix registration of Ingest management section * Fix i18n key * [Fleet] Remove server code from common folder in ingest (#53969) * [Fleet] Connect fleet to policy change update (#53201) * [Fleet] Send created event when a policy is created * [Fleet] updated created event when a policy is created * [Fleet] Send deleted event when a policy is deleted * [Fleet] Rename output.url => output.hosts (#54258) * [Ingest] Remove policies UI (#54308) * Remove meta field UI from policy add/edit form * Initial pass at policy bulk+single delete UI and API * Adjust policy links from agent list and detail pages so that links are only active if policy exists * Add delete policy UI to policy detail page * Disable policy delete button for default policy * Commit updated kbn-pm artifact. CI is failing with messages like 14:52:28 ERROR: 'yarn kbn run build -i @kbn/pm' caused changes to the following files: 14:52:28 14:52:28 packages/kbn-pm/dist/index.js Following advice from https://elastic.slack.com/archives/C0D8P2XK5/p1570032166063400 and running/committing build * Update kbn/pm package Signed-off-by: Tyler Smalley * [EPM] create logs metrics index patterns (#54037) * fixes bug in for loop returning too early and not looping through all yaml files creating incomplete index template, move loading yaml files to own function, other cleanup * use reduce in place of for loop * basic functionality for creating index patterns * separate logs and metrics index patterns * dedupe fields * adjust flattenFields to rename nested fields with parent name path * some tests * use yml files for tests * add awaits as part of installing the package * optimize loading of yaml files * fix typo * change type packageName to package * update tests to use all files from the beginning * fix type errors * fix test * Use dataset.package from registry https://github.com/elastic/kibana/pull/54037#pullrequestreview-340362812 * Form validation on add datasource page. (#53920) * [Ingest] Add support for policy `label` field (#54413) * Allow `label` field in policy APIs, update UIs to support `label` field * PR review changes, typo fixes, update tests * [Fleet] Fix api key creation (#54498) * [Fleet] Move agent acks to his own endpoint (#54401) * [Ingest] Fix MaxListenersExceededWarning during kibana boot (#54745) * [Fleet] Create a default api key for the default output (#54658) * [EPM] update index pattern fields (#54862) * add DatasetType type * move loadFieldsFromYaml to fields * add logic to determine field values * update tests * add back accidentally removed readFromDocValues * remove DatasetType and add IndexPatternType * rename dedup to dedupe * group tests * use null coalescing operator * Fix typing issues * [Fleet] Support agent event subtype STOPPING (#55231) * [EPM] Prevent double submit when creating data sources Update installationRequested state after submit (#55100) * [EPM] handle alias fields when creating kibana index pattern (#55254) * add alias support, update flattenFields to handle alias copying, update and add tests * update snapshot * update findFieldByPath to return undefined if not leaf node * remove temporary alias type from map * [EPM] handle multi fields when creating index patterns (#55554) * handle mult_fields * move nested function out * [Fleet] Fleet/spec docs (#55619) * [EPM] Start to document definitions (#55361) This is a first stab at creating a place where we define the terms we use across ingest management. This is not complete but defines a place where we can add all the future defintions. * [EPM] Indexing strategy docs (#55301) * [EPM] Indexing strategy docs This documentation is to start documenting our new indexing strategy in a public place and have it versioned. This will allow us to share the current state of the indexing strategy more easily in a single place, track it when updated and also already have it ready for our future users to look it up and understand the benefits of it. * update typos * fix one more typo * apply review feedback * skip fields that are disabled (#55735) * [Fleet] Fix fleet typing issues after merging master * [EPM] Create fieldFormatMap in kibana index pattern (#55892) * add support for fieldFormatMap * output params must be camel case * fix case * add url_template param * [Fleet] Use user from saved object to create apiKeys (#55458) * [EPM] Document package upgrade behaviour (#56138) This PR adds more detailed documentation on what should happen when a package is upgraded. * Remove some explicit typing to pass type checks. This abstraction will be removed in the single plugin going into master. * remove readFromDocValues (#56227) * Add symlink to yarn.lock to fix(?) CI https://github.com/elastic/kibana/pull/56443/checks?check_run_id=418123781 failed saying EPM directory "MUST have a 'yarn.lock' symlink" Seems to have originated with https://github.com/elastic/kibana/pull/55440 Following example from other legacy/plugins/* in that PR * Like 441d9ed, but correct * [Ingest] Convert `ingest` plugin to new platform `ingest_manager` plugin (#56262) * Seed Ingest Manager as a new NP plugin * Add contexts for core, deps, and config. Begin routing and nav UI * Export NP ready request from top-level es_ui_shared/public/ * Add nav styling w/ theming, add useRequest hook * Set up license and config server-side services; add test routes * Move most types and constants into /common * Initial pass at: * data stream and agent config models * data stream routes and schemas * Initial pass at agent config api route handlers * Change plugin id to camel case * Fix circular schema dependency, add security as optional plugin * Create appContext service, use request user info in agent config routes + libs * Create default agent config * Add default output host config, output typings, and create default output and its api key * Move saved object mapping to new plugin * Change data streams -> datasources * Add legacy plugin to bootstrap mappings * Adjust fleet's ingest dependencies * Disable policies UI in Fleet * Adjust EPM's ingest dependencies * Adjust ingest manager base API route * Adjust fleet's client side ingest dependencies * Remove more ingest dependencies from fleet * REMOVE MOST OF LEGACY INGEST PLUGIN * Add section for agent configs in UI nav * Allow useRequest and sendRequest consumers to specify typing for response * Initial pass at porting over agent config list UI * Port over agent config creation * Port over delete agent config functionality * Fix app routing * Port over fleet setup routes * Adjust fleet's ingest dependencies * Make fleet happy path work, skip some tests (MESSY! :)) * Remove policy list UI code from fleet * Change useRequestResponse error type * Add missing agent config schemas and hooks * Fix type check issues * Register IM under management * Fix type issues as a result of changes to use/sendRequest interfaces * Make all ingest saved objects *not* space-aware * Fix i18n path * Fix app categories import * Fix datasource package assets schema (array of asset objects) * Seed Ingest Manager privileges to fix tests * Change `features` to optional plugin instead of required * Fix security privileges tests * Fix feature test * Fix duplicate enrollment key created for default agent config * Fix fleet agent enrollment by catching agent config 404 * PR feedback * [Fleet] Detailed docs of fleet <-> agents interactions (#56212) * [EPM] update index patterns on install/uninstall of package (#56591) * create index patterns functionality on install/uninstall of package * update snapshots * [Fleet] Generate an ES api key per agent per output (#56637) * [Fleet] Remove unused enrollement rules (#56753) * remove files related to creating data source (#56745) * Fix typing issues after mergin master * Fix api key authentication after master merge * [EPM] NP Migration: Move server files and route handlers to ingest_manager (#56854) * initial pass moving registry and categories endpoint * moves all needed server files and gets list endpoint working * add route list validation schema * remove epm config * use config to get the registryUrl * clean up registry url * gets file endpoint working * add info endpoint * get install package endpoint working * support uninstall package endpoint * add API response types * move epm types to models * move AssetType to IngestAsset type from ingest plugin * remove redundant export * update epm_api_integration tests to new endpoint paths in ingest manager * fix imports * [Ingest Management] Change indexing from {type}-{namespace}-{dataset} to {type}-{dataset}-{namespace} (#56132) Currently we have the indexing strategy defined as `{type}-{namespace}-{dataset}`. In this PR I propose to change this to `{type}-{dataset}-{namespace}`. As all 3 fields are constant keyword fields, the orders does not matter in most cases. The reason I propose this change is to better align the name of indices with the name of the other assets: * ingest pipeline: {type}-{dataset} * index template: {type}-{dataset} * Index pattern: {type}-{dataset}-* * alias name: {type}-{dataset}-{namespace} This makes it easier to remember the asset names conventions (at least for me). It makes a difference when specifying security per namespace: To lock down security, previously it is `/(logs|metrics)-prod-$/` and becomes `/(logs|metrics)-[^-]+-prod-$/`. In any case, we should help / assist the user to get this right. * [EPM] Remove epm plugin and directory (#57309) * delete server files, move over epm saved object schemas and mapping, stop epm plugin loading in xpack * updated yarn * change to updated name * remove epm dir, copy readme to ingest_manager * move package.json over and update yarn.lock * update package name * add yarn.lock symlink * fix yarn.lock symlink * remove epm from security privileges map * remove epm from feature endpoint and i18nrc * [Fleet] Move fleet to the new platform and to ingest_manager plugin (#56803) * [Fleet] NP migration public part (#57567) * [EPM] EPM to new plugin, UI part (#56882) * Move EPM home / list view over to ingest-manager * Use react-router-dom in epm section. * WIP: add package detail view. * Use correct route. * Only import needed types to public * Remove obsolete file. * Import type correctly * Revert "Remove obsolete file." This reverts commit 4b061102ebc62b49e7d1291060405ea8d23a3a8a. * Routes are still needed, fix them. * Import types correctly * More type import fixes. * update get categories hook * remove no longer used getCategories function * get list packages hook working * delete routes.tsx, cleanup links * add the usePackageInstall hook * replace rest of api calls with use/send request * remove tmp_routes * bring over breadcrumbs * remove comments and get styles working * get ride side col loading * temp type fix * remove useCore * add assets * remove comment * add public directory to legacy ingest_manager and update asset path * Fix PackageInfo type. Use for API & UI vs a saved object type. The `as PackageInfo` cast was required because the pipeline was typed as returning `Installed | NotInstalled` which are saved object response. Updating that to PackageInfo allows the `as` to be removed but revealed an incompatibility between the `assets` properties of RegistryPackage and PackageInfo ``` Types of property 'assets' are incompatible. Type 'Record<"kibana", Record>' is missing the following properties from type 'string[]': length, pop, push, concat, and 28 more. ``` It seems the `RegistryPackage & PackageAdditions` didn't cause the PackageAdditions.assets to replace the RegistryPackage.assets property. I changed the definition of PackageInfo to do what I initially thought it was doing. See comments in models/epm.ts for more about how the new type is constructed. * remove comment * fix paths * fix public paths * fix path * remove ui types file * fix types Co-authored-by: Sandra Gonzales Co-authored-by: John Schulz * [Fleet] AgentEvent change agent_id and remove data (#57818) * Remove legacy `ingest` plugin completely (#58056) Co-authored-by: Elastic Machine * WIP. 1 type error (but >1 bugs) remaining. * Add `callCluster` accessors for using ES vs appContext.getClusterClient() * Undo (?) changes to kbn-pm/dist/index.js * Run scripts/build_renovate_config for @types/tar * Replace data w/ agent_id in server schema * Different way to declare a saved object type * Use a more specific path to the agent script * Replace data with agent_id for agent checkin * Restore internalSavedObjectsClient in app context * Use project & HTTP TS types in scripts/dev_agent * Remove ingestManager from FTR features The plugin is disabled by default and not currently running the FTR tests. I believe we'll add this back when we restore the EPM integration tests. * Move more variables to common/{constants,types} * Remove ingestManager from default expected features * Enable conditional routes. Adjust integration tests EPM routes currently return a 500 for these tests. For now they `.expect(500)` when enabled and `.expect(404)` when disabled. We can look into the issue and get them to `.expect(200)` in later tests. * Replace React.FC with React.FunctionComponent following new repo pattern * Enable Fleet & Ingest FTR tests * Remove duplicate *Response entries from server/types * Update README instructions for CLI flags. Rearrange sections (#58363) * Add instructions for CLI flags. Rearrange sections Also added some more information about the plugin behavior with links to relevant code * Add instructions for Ingest & Fleet FTR tests * Restore search to EPM list page * [Fleet] Allow to configure CA sha for kibana and elasticsearch (#58186) * [Ingest] Adjust saved object mappings and rename policy -> config (#58670) * Replace all reference to (agent) "policy"/"policies" with "config"/"configs" * Adjust output and datasource saved object mappings * Update schemas and types to match SO changes * Fix type check * Adjust default output object * Fix property names in tests * Move installing of index templates and ingest pipelines to package installation (#58619) * update template and pipeline asset names, install on package install * fix package install error handling messaging * save references to installations to package saved object * add epm.enabled flag for epm functional test runner * don't add suffix to pipeline entry * [Fleet] Add a schema of all of our saved objects (#58769) * [Fleet] Do not use default id for saved objects that need to be encrypted (#57876) * Adding events for index pattern generation (#58908) Co-authored-by: Elastic Machine * Fix yarn.lock after merge * [Fleet] create agent config before enabling fleet user (#59166) * support for top level elasticsearch assets installation (#58869) * add support for ilm policy installation * check if ilm policy exists * handle prebuilt index templates, update tests * cleanup * update type in install * fix installing index templates to create multiple ones for inheritance * [Fleet] Create default output while creating default config (#59223) * Use the new definitions from package-registry#176 (#59311) * [Fleet] Remove our custom API key authentication (#59212) * [Ingest] Full agent config schema & API (#59262) * Add schema for full agent config and business logic to convert SO agent config -> full agent config * Adjust output properties in full agent config * Whitelist full agent config output fields * [Fleet] UI Agent enrollment flyout (#58524) * Use POST vs GET for EPM install & remove (#59367) Co-authored-by: Elastic Machine * install default packages during setup (#59330) * install default packages during setup * check if package is already installed * [Ingest] Updates to Agent Configuration List UI (#59374) - Added search bar - Sync of columns to design - Actions per row in popup menu - Connect pagination and per-page count to API request - Support for `kuery` url param * Update docs from /api/:section to /api/ingest_manager/:section (#59422) * [EPM] Add Streams TS type to mirror EPR's (#59446) * Add Streams TS type in EPM to mirror EPR's Follows the changes add in https://github.com/elastic/package-registry/pull/230/files#diff-7dea786222588c32c19238bffffee9c2 * Add RegistryPackage.datasources * Add more detailed shape for Registry Vars * Don't code in modify src/plugins/management See PR convo at https://github.com/elastic/kibana/pull/59376/files/c47975535f72e41b0f9a70e678454aac15927db6#r389042975 * [Ingest] Add agent counts to each agent config output of `/agent_configs` API (#59552) * Added agent counts to getAgentConfigsHandler * Show agent counts on Agent Config List * [Fleet] Agent list header (#59487) * [EPM] Use /packages & /packages/{pkgkey} (#59550) * Use /packages & /packages/{pkgkey} * Update paths in skipped tests * Docs use /packages vs /package/{install,delete} Some copy & code sample changes. Co-authored-by: Elastic Machine * [Ingest] Design sync for Agent Configuration Create Flyout (#59479) * Match form to design * Support URI route param to open flyout * [Ingest] Create data source step 1 & 2 (#59590) * Add Error and PackageIcon components * Add create datasource layout+navigation, and select package step * Add description field to datasource, remove assets and description from datasource package * Add temporary datasources typing for EPM package info * Initial pass at configure datasources step. Stream vars only (no input vars yet) * First pass at input vars config; separate components * Fix issue with adding more than one datasource to a config * Add shell review step; save datasouce * Remove assign/unassign datasource from agent config details UI, replace with add datasource buttons * Remove actions column from datasource table * Initial pass at create datasource from package * Move package to config service to /common, add tests * Rename VarsEntry to RegistryVarsEntry, add datasets and datasources to RegistrySearchResult definition * Add typings to create datasource flow * Add real count of agents to select agent config list * Ensure the necessary package is installed at time of datasource creation * Use lowercase pkgkey for consistency * Update EPM file path to use /packages (#59693) * fixed header padding (#59711) * [EPM] Use icons from packages, if present (#59765) * [Ingest] Create data source step 3 (#59822) * Make app setup loading state prettier * Add review step of datasource wizard * Change name to ID in agent config datasources field * Fix types * Add stored datasource to agent datasource unit tests * Adjustment of registry typings and which registry copy fields to show to sync with elastic/package-registry#242 * Fix `multi` vars not populating with array: elastic/kibana#59724 * Account for if a stream is enabled by default from registry package definition: elastic/kibana#59724 * Adjust tests to account for last two commits * Fix review page back link * Fix d'oh typo * [Ingest] Agent Config Details header and sub tabs navigation (#59783) - Syncs Agent Config Details header to design - Includes sub navigation tabs connected to route URL - Agent Config List Create data source row action enabled * [Fleet] update agent list UI (#59685) * [Fleet] ensure default packages are added to the default config (#59759) * [Fleet] fix output rename api_token => api_key (#60001) * [Ingest] Address #59376 feedback (#59961) * Disable create/destroy CTAs if no write capability Use `core.application.capabilities.ingestManager.write` to test user permissions * Add -all & -read tags for HTTP routes * Update test .expect() to match description * Add useCapabilities hook. Fix two issues with hiding/disabling CTA. Co-authored-by: Elastic Machine * Missed one in e12a8ad8a4 * Use package icon as default when no other can be found (#60025) * Remove duplicate xpack setting FTR tests failing to start ES with error ``` ERROR ERROR: setting [xpack.security.authc.api_key.enabled] already set, saw [true] and [true] ``` https://github.com/elastic/kibana/pull/59376/checks?check_run_id=503930031 & https://github.com/elastic/kibana/pull/59376/checks?check_run_id=503975576 etc It appears the xpack.security.authc.api_key.enabled flag was recently added to master in another part, so remove our instance of the setting to prevent the error * Update EPM file tests to use /packages/{pkgkey} These should have failed when the routes were changed. Will go back and see what happened. * [Ingest] Add `revision` to agent configs & data sources (#59848) * Add revision to agent config and datasource saved objects, add delete datasource service and datasource * Add revision to full agent config output * PR feedback Co-authored-by: Sonja Krause-Harder Co-authored-by: Nicolas Ruflin Co-authored-by: Sandra Gonzales Co-authored-by: Matt Apperson Co-authored-by: Nicolas Chaulet Co-authored-by: Nicolas Chaulet Co-authored-by: Matt Apperson Co-authored-by: Jen Huang Co-authored-by: Brian Seeders Co-authored-by: Tyler Smalley Co-authored-by: Elastic Machine Co-authored-by: neptunian Co-authored-by: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Co-authored-by: Henry --- docs/epm/index.asciidoc | 146 ++ package.json | 2 + renovate.json5 | 16 + src/test_utils/kbn_server.ts | 2 +- x-pack/legacy/plugins/ingest_manager/index.ts | 5 + x-pack/package.json | 4 +- x-pack/plugins/ingest_manager/README.md | 87 +- .../ingest_manager/common/constants/agent.ts | 16 + .../common/constants/agent_config.ts | 7 +- .../common/constants/enrollment_api_key.ts | 7 + .../ingest_manager/common/constants/epm.ts | 8 + .../ingest_manager/common/constants/index.ts | 3 + .../ingest_manager/common/constants/output.ts | 6 +- .../ingest_manager/common/constants/plugin.ts | 1 - .../ingest_manager/common/constants/routes.ts | 37 +- .../common/services/agent_status.ts | 27 + .../datasource_to_agent_datasource.test.ts | 115 ++ .../datasource_to_agent_datasource.ts | 51 + .../ingest_manager/common/services/index.ts | 5 + .../common/services/package_to_config.test.ts | 252 +++ .../common/services/package_to_config.ts | 69 + .../ingest_manager/common/services/routes.ts | 32 +- .../ingest_manager/common/types/index.ts | 16 +- .../common/types/models/agent.ts | 77 + .../common/types/models/agent_config.ts | 49 +- .../common/types/models/datasource.ts | 61 +- .../common/types/models/enrollment_api_key.ts | 23 + .../ingest_manager/common/types/models/epm.ts | 265 +++ .../common/types/models/index.ts | 4 + .../common/types/models/output.ts | 16 +- .../common/types/rest_spec/agent.ts | 142 ++ .../common/types/rest_spec/agent_config.ts | 35 +- .../common/types/rest_spec/common.ts | 4 +- .../common/types/rest_spec/datasource.ts | 25 +- .../types/rest_spec/enrollment_api_key.ts | 59 + .../common/types/rest_spec/epm.ts | 75 + .../common/types/rest_spec/fleet_setup.ts | 4 +- .../common/types/rest_spec/index.ts | 4 + .../common/types/rest_spec/install_script.ts | 11 + .../dev_docs/actions_and_events.md | 47 + .../dev_docs/api/agents_acks.md | 37 + .../dev_docs/api/agents_checkin.md | 47 + .../dev_docs/api/agents_enroll.md | 79 + .../dev_docs/api/agents_list.md | 22 + .../dev_docs/api/agents_unenroll.md | 40 + .../ingest_manager/dev_docs/api_keys.md | 15 + .../dev_docs/fleet_agent_communication.md | 32 + .../fleet_agents_interactions_detailed.md | 49 + .../dev_docs/schema/agent_checkin.mml | 37 + .../dev_docs/schema/agent_checkin.png | Bin 0 -> 96703 bytes .../dev_docs/schema/agent_enroll.mml | 17 + .../dev_docs/schema/agent_enroll.png | Bin 0 -> 84785 bytes .../dev_docs/schema/saved_objects.mml | 82 + .../dev_docs/schema/saved_objects.png | Bin 0 -> 92818 bytes x-pack/plugins/ingest_manager/package.json | 11 + .../ingest_manager/components/error.tsx | 18 + .../ingest_manager/components/header.tsx | 4 +- .../ingest_manager/components/index.ts | 1 + .../ingest_manager/components/search_bar.tsx | 162 ++ .../ingest_manager/constants/index.ts | 13 +- .../ingest_manager/hooks/index.ts | 7 +- .../ingest_manager/hooks/use_capabilities.ts | 12 + .../ingest_manager/hooks/use_deps.ts | 19 +- .../ingest_manager/hooks/use_input.ts | 24 + .../ingest_manager/hooks/use_pagination.tsx | 2 +- .../hooks/use_request/agent_config.ts | 20 +- .../hooks/use_request/agents.ts | 61 + .../hooks/use_request/datasource.ts | 16 + .../hooks/use_request/enrollment_api_keys.ts | 27 + .../ingest_manager/hooks/use_request/epm.ts | 66 + .../ingest_manager/hooks/use_request/index.ts | 6 +- .../ingest_manager/hooks/use_request/setup.ts | 15 + .../hooks/use_request/use_request.ts | 4 +- .../ingest_manager/hooks/use_url_params.ts | 28 + .../applications/ingest_manager/index.tsx | 83 +- .../ingest_manager/layouts/default.tsx | 4 +- .../ingest_manager/layouts/index.tsx | 1 + .../ingest_manager/layouts/with_header.tsx | 5 +- .../ingest_manager/layouts/without_header.tsx | 28 + .../components/config_delete_provider.tsx | 4 +- .../agent_config/components/config_form.tsx | 215 ++- .../sections/agent_config/components/index.ts | 1 + .../components/linked_agent_count.tsx | 31 + .../components/datasource_input_config.tsx | 136 ++ .../components/datasource_input_panel.tsx | 178 ++ .../datasource_input_stream_config.tsx | 132 ++ .../components/datasource_input_var_field.tsx | 48 + .../components/index.ts | 7 + .../components/layout.tsx | 123 ++ .../components/navigation.tsx | 85 + .../create_datasource_page/constants.ts | 18 + .../create_datasource_page/index.tsx | 267 +++ .../step_configure_datasource.tsx | 289 +++ .../create_datasource_page/step_review.tsx | 189 ++ .../step_select_config.tsx | 259 +++ .../step_select_package.tsx | 210 +++ .../create_datasource_page/types.ts | 8 + .../details_page/components/config_form.tsx | 94 + .../components/datasources_table.tsx | 131 ++ .../details_page/components/donut_chart.tsx | 65 + .../details_page/components/edit_config.tsx | 135 ++ .../details_page/components/index.ts | 8 + .../agent_config/details_page/constants.ts | 10 + .../agent_config/details_page/hooks/index.ts | 7 + .../details_page/hooks/use_agent_status.tsx | 36 + .../details_page/hooks/use_config.tsx | 12 + .../details_page/hooks/use_details_uri.ts | 32 + .../agent_config/details_page/index.tsx | 365 ++++ .../sections/agent_config/index.tsx | 8 + .../list_page/components/create_config.tsx | 20 +- .../sections/agent_config/list_page/index.tsx | 405 +++-- .../epm/components/assets_facet_group.tsx | 102 ++ .../sections/epm/components/icon_panel.tsx | 31 + .../sections/epm/components/index.ts | 7 + .../epm/components/nav_button_back.tsx | 19 + .../sections/epm/components/package_card.tsx | 47 + .../sections/epm/components/package_icon.tsx | 26 + .../epm/components/package_list_grid.tsx | 63 + .../sections/epm/components/requirements.tsx | 57 + .../sections/epm/components/version.tsx | 22 + .../ingest_manager/sections/epm/constants.tsx | 44 + .../sections/epm/hooks/index.tsx | 15 + .../sections/epm/hooks/use_breadcrumbs.tsx | 13 + .../sections/epm/hooks/use_links.tsx | 61 + .../epm/hooks/use_package_install.tsx | 142 ++ .../ingest_manager/sections/epm/index.tsx | 86 +- .../screens/detail/confirm_package_delete.tsx | 32 + .../detail/confirm_package_install.tsx | 36 + .../sections/epm/screens/detail/content.tsx | 83 + .../epm/screens/detail/content_collapse.tsx | 96 + .../epm/screens/detail/data_sources_panel.tsx | 40 + .../sections/epm/screens/detail/header.tsx | 81 + .../sections/epm/screens/detail/index.tsx | 82 + .../screens/detail/installation_button.tsx | 97 ++ .../sections/epm/screens/detail/layout.tsx | 37 + .../epm/screens/detail/markdown_renderers.tsx | 70 + .../epm/screens/detail/overview_panel.tsx | 21 + .../sections/epm/screens/detail/readme.tsx | 67 + .../epm/screens/detail/screenshots.tsx | 77 + .../epm/screens/detail/side_nav_links.tsx | 50 + .../epm/screens/home/category_facets.tsx | 36 + .../sections/epm/screens/home/header.tsx | 54 + .../sections/epm/screens/home/hooks.tsx | 47 + .../sections/epm/screens/home/index.tsx | 139 ++ .../epm/screens/home/search_packages.tsx | 33 + .../epm/screens/home/search_results.tsx | 33 + .../components/agent_events_table.tsx | 154 ++ .../components/details_section.tsx | 157 ++ .../agent_details_page/components/index.ts | 7 + .../components/metadata_flyout.tsx | 76 + .../components/metadata_form.tsx | 160 ++ .../fleet/agent_details_page/hooks/index.ts | 6 + .../agent_details_page/hooks/use_agent.tsx | 12 + .../fleet/agent_details_page/index.tsx | 67 + .../agent_enrollment_flyout/index.tsx | 77 + .../agent_enrollment_flyout/instructions.tsx | 126 ++ .../agent_enrollment_flyout/key_selection.tsx | 205 +++ .../components/donut_chart.tsx | 65 + .../confirm_delete_modal.tsx | 46 + .../create_api_key_form.tsx | 94 + .../components/enrollment_api_keys/hooks.tsx | 40 + .../components/enrollment_api_keys/index.tsx | 152 ++ .../enrollment_instructions/index.tsx | 8 + .../enrollment_instructions/manual/index.tsx | 36 + .../enrollment_instructions/shell/index.tsx | 90 + .../fleet/agent_list_page/components/index.ts | 6 + .../sections/fleet/agent_list_page/index.scss | 6 + .../sections/fleet/agent_list_page/index.tsx | 729 ++++++++ .../fleet/components/agent_health.tsx | 105 ++ .../components/agent_unenroll_provider.tsx | 184 ++ .../sections/fleet/components/index.tsx | 9 + .../sections/fleet/components/loading.tsx | 16 + .../components/navigation/child_routes.tsx | 38 + .../components/navigation/connected_link.tsx | 40 + .../error_pages/components/no_data_layout.tsx | 35 + .../fleet/error_pages/enforce_security.tsx | 26 + .../fleet/error_pages/invalid_license.tsx | 27 + .../sections/fleet/error_pages/no_access.tsx | 27 + .../ingest_manager/sections/fleet/index.tsx | 84 +- .../sections/fleet/setup_page/index.tsx | 80 + .../ingest_manager/services/index.ts | 13 +- .../ingest_manager/types/index.ts | 61 +- .../plugins/ingest_manager/public/plugin.ts | 17 +- .../ingest_manager/scripts/dev_agent/index.js | 8 + .../scripts/dev_agent/script.ts | 134 ++ .../plugins/ingest_manager/scripts/readme.md | 8 + .../ingest_manager/server/constants/index.ts | 16 +- x-pack/plugins/ingest_manager/server/index.ts | 18 +- .../server/integration_tests/router.test.ts | 25 +- .../plugins/ingest_manager/server/plugin.ts | 80 +- .../server/routes/agent/handlers.ts | 411 +++++ .../server/routes/agent/index.ts | 135 ++ .../server/routes/agent_config/handlers.ts | 61 +- .../server/routes/agent_config/index.ts | 22 +- .../server/routes/datasource/handlers.ts | 32 +- .../server/routes/datasource/index.ts | 12 +- .../routes/enrollment_api_key/handler.ts | 112 ++ .../server/routes/enrollment_api_key/index.ts | 57 + .../server/routes/epm/handlers.ts | 163 ++ .../ingest_manager/server/routes/epm/index.ts | 67 +- .../ingest_manager/server/routes/index.ts | 5 +- .../server/routes/install_script/index.ts | 44 + .../routes/{fleet_setup => setup}/handlers.ts | 35 +- .../routes/{fleet_setup => setup}/index.ts | 27 +- .../ingest_manager/server/saved_objects.ts | 118 +- .../server/services/agent_config.ts | 185 +- .../server/services/agent_config_update.ts | 30 + .../server/services/agents/acks.ts | 28 + .../server/services/agents/checkin.ts | 162 ++ .../server/services/agents/crud.ts | 156 ++ .../server/services/agents/enroll.ts | 84 + .../server/services/agents/events.ts | 45 + .../server/services/agents/index.ts | 14 + .../server/services/agents/saved_objects.ts | 26 + .../server/services/agents/status.ts | 95 + .../server/services/agents/unenroll.ts | 35 + .../server/services/agents/update.ts | 59 + .../services/api_keys/enrollment_api_key.ts | 128 ++ .../server/services/api_keys/index.ts | 112 ++ .../server/services/api_keys/security.ts | 70 + .../server/services/app_context.ts | 10 + .../ingest_manager/server/services/config.ts | 37 + .../server/services/datasource.ts | 52 +- .../server/services/epm/agent/agent.test.ts | 32 + .../server/services/epm/agent/agent.ts | 23 + .../epm/agent/tests/input.generated.yaml | 5 + .../server/services/epm/agent/tests/input.yml | 7 + .../services/epm/agent/tests/manifest.yml | 20 + .../services/epm/elasticsearch/ilm/install.ts | 48 + .../services/epm/elasticsearch/index.test.ts | 22 + .../services/epm/elasticsearch/index.ts | 15 + .../ingest_pipeline/ingest_pipelines.test.ts | 134 ++ .../elasticsearch/ingest_pipeline/install.ts | 195 +++ .../tests/ingest_pipeline_template.json | 101 ++ .../ingest_pipelines/no_replacement.json | 49 + .../tests/ingest_pipelines/no_replacement.yml | 51 + .../ingest_pipelines/real_input_beats.json | 101 ++ .../ingest_pipelines/real_input_beats.yml | 113 ++ .../ingest_pipelines/real_input_standard.json | 101 ++ .../ingest_pipelines/real_input_standard.yml | 113 ++ .../tests/ingest_pipelines/real_output.json | 101 ++ .../tests/ingest_pipelines/real_output.yml | 113 ++ .../__snapshots__/template.test.ts.snap | 79 + .../epm/elasticsearch/template/install.ts | 120 ++ .../elasticsearch/template/template.test.ts | 42 + .../epm/elasticsearch/template/template.ts | 128 ++ .../fields/__snapshots__/field.test.ts.snap | 101 ++ .../server/services/epm/fields/field.test.ts | 35 + .../server/services/epm/fields/field.ts | 113 ++ .../server/services/epm/fields/tests/base.yml | 7 + .../epm/fields/tests/coredns.logs.yml | 51 + .../__snapshots__/install.test.ts.snap | 1326 ++++++++++++++ .../epm/kibana/index_pattern/install.test.ts | 311 ++++ .../epm/kibana/index_pattern/install.ts | 338 ++++ .../index_pattern/tests/coredns.logs.yml | 71 + .../index_pattern/tests/nginx.access.ecs.yml | 112 ++ .../index_pattern/tests/nginx.error.ecs.yml | 112 ++ .../index_pattern/tests/nginx.fields.yml | 118 ++ .../services/epm/packages/assets.test.ts | 66 + .../server/services/epm/packages/assets.ts | 72 + .../server/services/epm/packages/get.ts | 128 ++ .../services/epm/packages/get_objects.ts | 72 + .../server/services/epm/packages/index.ts | 53 + .../server/services/epm/packages/install.ts | 199 +++ .../server/services/epm/packages/remove.ts | 59 + .../server/services/epm/registry/cache.ts | 10 + .../server/services/epm/registry/extract.ts | 32 + .../services/epm/registry/index.test.ts | 50 + .../server/services/epm/registry/index.ts | 180 ++ .../server/services/epm/registry/requests.ts | 31 + .../server/services/epm/registry/streams.ts | 30 + .../server/services/install_script/index.ts | 18 + .../install_script/install_templates/macos.ts | 15 + .../install_script/install_templates/types.ts | 7 + .../ingest_manager/server/services/license.ts | 38 + .../ingest_manager/server/services/output.ts | 103 +- .../ingest_manager/server/services/setup.ts | 87 + .../ingest_manager/server/types/index.tsx | 51 +- .../server/types/models/agent.ts | 49 + .../server/types/models/agent_config.ts | 14 +- .../server/types/models/datasource.ts | 43 +- .../server/types/models/enrollment_api_key.ts | 25 + .../server/types/models/index.ts | 2 + .../server/types/models/output.ts | 11 +- .../server/types/rest_spec/agent.ts | 96 + .../server/types/rest_spec/agent_config.ts | 36 +- .../server/types/rest_spec/datasource.ts | 7 +- .../types/rest_spec/enrollment_api_key.ts | 35 + .../server/types/rest_spec/epm.ts | 37 + .../server/types/rest_spec/index.ts | 6 +- .../server/types/rest_spec/install_script.ts | 13 + x-pack/plugins/ingest_manager/yarn.lock | 1 + .../apis/features/features/features.ts | 1 + .../api_integration/apis/fleet/agents/acks.ts | 79 + .../apis/fleet/agents/checkin.ts | 105 ++ .../apis/fleet/agents/enroll.ts | 108 ++ .../apis/fleet/agents/events.ts | 36 + .../apis/fleet/agents/services.ts | 35 + .../apis/fleet/delete_agent.ts | 96 + .../apis/fleet/enrollment_api_keys/crud.ts | 83 + .../test/api_integration/apis/fleet/index.js | 19 + .../api_integration/apis/fleet/install.ts | 25 + .../api_integration/apis/fleet/list_agent.ts | 101 ++ .../apis/fleet/unenroll_agent.ts | 77 + x-pack/test/api_integration/apis/index.js | 2 + .../test/api_integration/apis/ingest/index.js | 11 + .../api_integration/apis/ingest/policies.ts | 63 + .../apis/security/privileges.ts | 1 + x-pack/test/api_integration/config.js | 2 + x-pack/test/epm_api_integration/apis/file.ts | 151 ++ .../packages/epr/yamlpipeline_1.0.0.tar.gz | Bin 0 -> 1996 bytes .../packages/package/yamlpipeline_1.0.0 | 32 + x-pack/test/epm_api_integration/apis/ilm.ts | 40 + x-pack/test/epm_api_integration/apis/index.js | 15 + x-pack/test/epm_api_integration/apis/list.ts | 124 ++ .../apis/mock_http_server.d.ts | 9 + .../test/epm_api_integration/apis/template.ts | 44 + x-pack/test/epm_api_integration/config.ts | 36 + .../es_archives/fleet/agents/data.json | 147 ++ .../es_archives/fleet/agents/mappings.json | 1549 +++++++++++++++++ .../es_archives/ingest/policies/data.json | 59 + .../es_archives/ingest/policies/mappings.json | 1545 ++++++++++++++++ .../example.contract.test.ts.snap | 105 ++ .../test_utils/jest/contract_tests/servers.ts | 13 +- yarn.lock | 125 +- 325 files changed, 24540 insertions(+), 764 deletions(-) create mode 100644 docs/epm/index.asciidoc create mode 100644 x-pack/plugins/ingest_manager/common/constants/agent.ts create mode 100644 x-pack/plugins/ingest_manager/common/constants/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/common/constants/epm.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/agent_status.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts create mode 100644 x-pack/plugins/ingest_manager/common/services/package_to_config.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/models/agent.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/models/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/models/epm.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/rest_spec/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts create mode 100644 x-pack/plugins/ingest_manager/common/types/rest_spec/install_script.ts create mode 100644 x-pack/plugins/ingest_manager/dev_docs/actions_and_events.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api/agents_acks.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api/agents_checkin.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api/agents_enroll.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api/agents_list.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api/agents_unenroll.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/api_keys.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/fleet_agent_communication.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/fleet_agents_interactions_detailed.md create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.mml create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.png create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.mml create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.png create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.mml create mode 100644 x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.png create mode 100644 x-pack/plugins/ingest_manager/package.json create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/error.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_capabilities.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_input.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_url_params.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/without_header.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_var_field.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/constants.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_agent_status.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_config.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icon_panel.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/requirements.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/version.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_breadcrumbs.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_links.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content_collapse.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/markdown_renderers.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/overview_panel.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/readme.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/category_facets.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/hooks.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_packages.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/use_agent.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/donut_chart.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/confirm_delete_modal.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/create_api_key_form.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/hooks.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/loading.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/child_routes.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/connected_link.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/components/no_data_layout.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/enforce_security.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/invalid_license.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/no_access.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx create mode 100644 x-pack/plugins/ingest_manager/scripts/dev_agent/index.js create mode 100644 x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts create mode 100644 x-pack/plugins/ingest_manager/scripts/readme.md create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/agent/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/handler.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts create mode 100644 x-pack/plugins/ingest_manager/server/routes/install_script/index.ts rename x-pack/plugins/ingest_manager/server/routes/{fleet_setup => setup}/handlers.ts (58%) rename x-pack/plugins/ingest_manager/server/routes/{fleet_setup => setup}/index.ts (51%) create mode 100644 x-pack/plugins/ingest_manager/server/services/agent_config_update.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/acks.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/checkin.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/crud.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/enroll.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/events.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/status.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/update.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/api_keys/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/api_keys/security.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/config.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ilm/install.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/ingest_pipelines.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipeline_template.json create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.json create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.json create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.json create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.json create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/fields/tests/coredns.logs.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/coredns.logs.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.access.ecs.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.error.ecs.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/epm/registry/streams.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/install_script/index.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/license.ts create mode 100644 x-pack/plugins/ingest_manager/server/services/setup.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/models/agent.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/models/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/rest_spec/enrollment_api_key.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts create mode 100644 x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts create mode 120000 x-pack/plugins/ingest_manager/yarn.lock create mode 100644 x-pack/test/api_integration/apis/fleet/agents/acks.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/checkin.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/enroll.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/events.ts create mode 100644 x-pack/test/api_integration/apis/fleet/agents/services.ts create mode 100644 x-pack/test/api_integration/apis/fleet/delete_agent.ts create mode 100644 x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts create mode 100644 x-pack/test/api_integration/apis/fleet/index.js create mode 100644 x-pack/test/api_integration/apis/fleet/install.ts create mode 100644 x-pack/test/api_integration/apis/fleet/list_agent.ts create mode 100644 x-pack/test/api_integration/apis/fleet/unenroll_agent.ts create mode 100644 x-pack/test/api_integration/apis/ingest/index.js create mode 100644 x-pack/test/api_integration/apis/ingest/policies.ts create mode 100644 x-pack/test/epm_api_integration/apis/file.ts create mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz create mode 100644 x-pack/test/epm_api_integration/apis/fixtures/packages/package/yamlpipeline_1.0.0 create mode 100644 x-pack/test/epm_api_integration/apis/ilm.ts create mode 100644 x-pack/test/epm_api_integration/apis/index.js create mode 100644 x-pack/test/epm_api_integration/apis/list.ts create mode 100644 x-pack/test/epm_api_integration/apis/mock_http_server.d.ts create mode 100644 x-pack/test/epm_api_integration/apis/template.ts create mode 100644 x-pack/test/epm_api_integration/config.ts create mode 100644 x-pack/test/functional/es_archives/fleet/agents/data.json create mode 100644 x-pack/test/functional/es_archives/fleet/agents/mappings.json create mode 100644 x-pack/test/functional/es_archives/ingest/policies/data.json create mode 100644 x-pack/test/functional/es_archives/ingest/policies/mappings.json diff --git a/docs/epm/index.asciidoc b/docs/epm/index.asciidoc new file mode 100644 index 0000000000000..46d45b85690e3 --- /dev/null +++ b/docs/epm/index.asciidoc @@ -0,0 +1,146 @@ +[role="xpack"] +[[epm]] +== Elastic Package Manager + +These are the docs for the Elastic Package Manager (EPM). + + +=== Configuration + +The Elastic Package Manager by default access `epr.elastic.co` to retrieve the package. The url can be configured with: + +``` +xpack.epm.registryUrl: 'http://localhost:8080' +``` + +=== API + +The Package Manager offers an API. Here an example on how they can be used. + +List installed packages: + +``` +curl localhost:5601/api/ingest_manager/epm/packages +``` + +Install a package: + +``` +curl -X POST localhost:5601/api/ingest_manager/epm/packages/iptables-1.0.4 +``` + +Delete a package: + +``` +curl -X DELETE localhost:5601/api/ingest_manager/epm/packages/iptables-1.0.4 +``` + +=== Definitions + +This section is to define terms used across ingest management. + +==== Elastic Agent +A single, unified agent that users can deploy to hosts or containers. It controls which data is collected from the host or containers and where the data is sent. It will run Beats, Endpoint or other monitoring programs as needed. It can operate standalone or pull a configuration policy from Fleet. + +==== Namespace +A user-specified string that will be used to part of the index name in Elasticsearch. It helps users identify logs coming from a specific environment (like prod or test), an application, or other identifiers. + +==== Package + +A package contains all the assets for the Elastic Stack. A more detailed definition of a package can be found under https://github.com/elastic/package-registry . + + +== Indexing Strategy + +Ingest Management enforces an indexing strategy to allow the system to automically detect indices and run queries on it. In short the indexing strategy looks as following: + +``` +{type}-{dataset}-{namespace} +``` + +The `{type}` can be `logs` or `metrics`. The `{namespace}` is the part where the user can use free form. The only two requirement are that it has only characters allowed in an Elasticsearch index name and does NOT contain a `-`. The `dataset` is defined by the data that is indexed. The same requirements as for the namespace apply. It is expected that the fields for type, namespace and dataset are part of each event and are constant keywords. + +Note: More `{type}`s might be added in the future like `apm` and `endpoint`. + +This indexing strategy has a few advantages: + +* Each index contains only the fields which are relevant for the dataset. This leads to more dense indices and better field completion. +* ILM policies can be applied per namespace per dataset. +* Rollups can be specified per namespace per dataset. +* Having the namespace user configurable makes setting security permissions possible. +* Having a global metrics and logs template, allows to create new indices on demand which still follow the convention. This is common in the case of k8s as an example. +* Constant keywords allow to narrow down the indices we need to access for querying very efficiently. This is especially relevant in environments which a large number of indices or with indices on slower nodes. + +=== Ingest Pipeline + +The ingest pipelines for a specific dataset will have the following naming scheme: + +``` +{type}-{dataset}-{package.version} +``` + +As an example, the ingest pipeline for the Nginx access logs is called `logs-nginx.access-3.4.1`. The same ingest pipeline is used for all namespaces. It is possible that a dataset has multiple ingest pipelines in which case a suffix is added to the name. + +The version is included in each pipeline to allow upgrades. The pipeline itself is listed in the index template and is automatically applied at ingest time. + +=== Templates & ILM Policies + +To make the above strategy possible, alias templates are required. For each type there is a basic alias template with a default ILM policy. These default templates apply to all indices which follow the indexing strategy and do not have a more specific dataset alias template. + +The `metrics` and `logs` alias template contain all the basic fields from ECS. + +Each type template contains an ILM policy. Modifying this default ILM policy will affect all data covered by the default templates. + +The templates for a dataset are called as following: + +``` +{type}-{dataset} +``` + +The pattern used inside the index template is `{type}-{dataset}-*` to match all namespaces. + +=== Defaults + +If the Elastic Agent is used to ingest data and only the type is specified, `default` for the namespace is used and `generic` for the dataset. + +=== Data filtering + +Filtering for data in queries for example in visualizations or dashboards should always be done on the constant keyword fields. Visualizations needing data for the nginx.access dataset should query on `type:logs AND dataset:nginx.access`. As these are constant keywords the prefiltering is very efficient. + +=== Security permissions + +Security permissions can be set on different levels. To set special permissions for the access on the prod namespace an index pattern as below can be used: + +``` +/(logs|metrics)-[^-]+-prod-$/ +``` + +To set specific permissions on the logs index, the following can be used: + +``` +/^(logs|metrics)-.*/ +``` + +Todo: The above queries need to be tested. + + + +== Package Manager + +=== Package Upgrades + +When upgrading a package between a bugfix or a minor version, no breaking changes should happen. Upgrading a package has the following effect: + +* Removal of existing dashboards +* Installation of new dashboards +* Write new ingest pipelines with the version +* Write new Elasticsearch alias templates +* Trigger a rollover for all the affected indices + +The new ingest pipeline is expected to still work with the data coming from older configurations. In most cases this means some of the fields can be missing. For this to work, each event must contain the version of config / package it is coming from to make such a decision. + +In case of a breaking change in the data structure, the new ingest pipeline is also expected to deal with this change. In case there are breaking changes which cannot be dealt with in an ingest pipeline, a new package has to be created. + +Each package lists its minimal required agent version. In case there are agents enrolled with an older version, the user is notified to upgrade these agents as otherwise the new configs cannot be rolled out. + + diff --git a/package.json b/package.json index 1f8973de3d22a..3d1faf3bc3478 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", + "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", "angular": "^1.7.9", @@ -455,6 +456,7 @@ "listr": "^0.14.1", "load-grunt-config": "^3.0.1", "mocha": "^6.2.2", + "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", diff --git a/renovate.json5 b/renovate.json5 index ca2cd2e6bcd93..e4836537df703 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -431,6 +431,14 @@ '@types/jquery', ], }, + { + groupSlug: 'js-search', + groupName: 'js-search related packages', + packageNames: [ + 'js-search', + '@types/js-search', + ], + }, { groupSlug: 'js-yaml', groupName: 'js-yaml related packages', @@ -877,6 +885,14 @@ '@types/supertest-as-promised', ], }, + { + groupSlug: 'tar', + groupName: 'tar related packages', + packageNames: [ + 'tar', + '@types/tar', + ], + }, { groupSlug: 'tar-fs', groupName: 'tar-fs related packages', diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index e1b4a823e7e87..f4c3ecd8243ce 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -252,7 +252,7 @@ export function createTestServers({ return { startES: async () => { - await es.start(); + await es.start(get(settings, 'es.esArgs', [])); if (['gold', 'trial'].includes(license)) { await setupUsers({ diff --git a/x-pack/legacy/plugins/ingest_manager/index.ts b/x-pack/legacy/plugins/ingest_manager/index.ts index 7ed5599b234a3..47c6478f66471 100644 --- a/x-pack/legacy/plugins/ingest_manager/index.ts +++ b/x-pack/legacy/plugins/ingest_manager/index.ts @@ -9,6 +9,7 @@ import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_CONFIG_SAVED_OBJECT_TYPE, DATASOURCE_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, } from '../../../plugins/ingest_manager/server'; // TODO https://github.com/elastic/kibana/issues/46373 @@ -34,6 +35,10 @@ export function ingestManager(kibana: any) { isNamespaceAgnostic: true, // indexPattern: INDEX_NAMES.INGEST, }, + [PACKAGES_SAVED_OBJECT_TYPE]: { + isNamespaceAgnostic: true, + // indexPattern: INDEX_NAMES.INGEST, + }, }, mappings: savedObjectMappings, }, diff --git a/x-pack/package.json b/x-pack/package.json index 11068bcccf561..4047a825184b8 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -33,7 +33,7 @@ "@kbn/plugin-helpers": "9.0.2", "@kbn/test": "1.0.0", "@kbn/utility-types": "1.0.0", - "@mattapperson/slapshot": "1.4.0", + "@mattapperson/slapshot": "1.4.3", "@storybook/addon-actions": "^5.2.6", "@storybook/addon-console": "^1.2.1", "@storybook/addon-knobs": "^5.2.6", @@ -69,6 +69,7 @@ "@types/history": "^4.7.3", "@types/jest": "24.0.19", "@types/joi": "^13.4.2", + "@types/js-search": "^1.4.0", "@types/js-yaml": "^3.11.1", "@types/jsdom": "^12.2.4", "@types/json-stable-stringify": "^1.0.32", @@ -258,6 +259,7 @@ "isbinaryfile": "4.0.2", "joi": "^13.5.2", "jquery": "^3.4.1", + "js-search": "^1.4.3", "js-yaml": "3.13.1", "json-stable-stringify": "^1.0.1", "jsonwebtoken": "^8.5.1", diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index 60c2a457a2806..241138880780f 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -1,20 +1,81 @@ # Ingest Manager +## Plugin + - No features enabled by default. See the TypeScript type for the [the available plugin configuration options](https://github.com/elastic/kibana/blob/feature-ingest/x-pack/plugins/ingest_manager/common/types/index.ts#L9-L19) + - Setting `xpack.ingestManager.enabled=true` is required to enable the plugin. It adds the `DATASOURCE_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts) + - Adding `--xpack.ingestManager.epm.enabled=true` will add the EPM API & UI + - Adding `--xpack.ingestManager.fleet.enabled=true` will add the Fleet API & UI + - [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133) + - [Integration tests](server/integration_tests/router.test.ts) + - Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features. -## Getting started -See the Kibana docs for [how to set up your dev environment](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#setting-up-your-development-environment), [run Elasticsearch](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-elasticsearch), and [start Kibana](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-kibana). +## Development -One common workflow is: +### Getting started +See the Kibana docs for [how to set up your dev environment](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#setting-up-your-development-environment), [run Elasticsearch](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-elasticsearch), and [start Kibana](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-kibana) - 1. `yarn es snapshot` - 1. In another shell: `yarn start --xpack.ingestManager.enabled=true` (or set in `config.yml`) -## HTTP API - 1. Nothing by default. If `xpack.ingestManager.enabled=true`, it adds the `DATASOURCE_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts) - 1. [Integration tests](../../test/api_integration/apis/ingest_manager/endpoints.ts) - 1. In later versions the EPM and Fleet routes will be added when their flags are enabled. See the [currently disabled logic to add those routes](https://github.com/jfsiii/kibana/blob/feature-ingest-manager/x-pack/plugins/ingest_manager/server/plugin.ts#L86-L90). +One common development workflow is: + - Start Elasticsearch in one shell + ``` + yarn es snapshot -E xpack.security.authc.api_key.enabled=true + ``` + - Start Kibana in another shell + ``` + yarn start --xpack.ingestManager.enabled=true --xpack.ingestManager.epm.enabled=true --xpack.ingestManager.fleet.enabled=true + ``` -## Plugin architecture -Follows the `common`, `server`, `public` structure from the [Architecture Style Guide -](https://github.com/elastic/kibana/blob/master/style_guides/architecture_style_guide.md#file-and-folder-structure). +This plugin follows the `common`, `server`, `public` structure from the [Architecture Style Guide +](https://github.com/elastic/kibana/blob/master/style_guides/architecture_style_guide.md#file-and-folder-structure). We also follow the pattern of developing feature branches under your personal fork of Kibana. -We use New Platform approach (structure, APIs, etc) where possible. There's a `kibana.json` manifest, and the server uses the `server/{index,plugin}.ts` approach from [`MIGRATION.md`](https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md#architecture). \ No newline at end of file +### API Tests +#### Ingest & Fleet + 1. In one terminal, change to the `x-pack` directory and start the test server with + ``` + node scripts/functional_tests_server.js --config test/api_integration/config.ts + ``` + + 1. in a second terminal, run the tests from the Kibana root directory with + ``` + node scripts/functional_test_runner.js --config x-pack/test/api_integration/config.ts + ``` +#### EPM + 1. In one terminal, change to the `x-pack` directory and start the test server with + ``` + node scripts/functional_tests_server.js --config test/epm_api_integration/config.ts + ``` + + 1. in a second terminal, run the tests from the Kibana root directory with + ``` + node scripts/functional_test_runner.js --config x-pack/test/epm_api_integration/config.ts + ``` + + ### Staying up-to-date with `master` + While we're developing in the `feature-ingest` feature branch, here's is more information on keeping up to date with upstream kibana. + +
+ merge upstream master into feature-ingest + +```bash +## checkout feature branch to your fork +git checkout -B feature-ingest origin/feature-ingest + +## make sure your feature branch is current with upstream feature branch +git pull upstream feature-ingest + +## pull in changes from upstream master +git pull upstream master + +## push changes to your remote +git push origin + +# /!\ Open a DRAFT PR /!\ +# Normal PRs will re-notify authors of commits already merged +# Draft PR will trigger CI run. Once CI is green ... +# /!\ DO NOT USE THE GITHUB UI TO MERGE THE PR /!\ + +## push your changes to upstream feature branch from the terminal; not GitHub UI +git push upstream +``` +
+ +See https://github.com/elastic/kibana/pull/37950 for an example. diff --git a/x-pack/plugins/ingest_manager/common/constants/agent.ts b/x-pack/plugins/ingest_manager/common/constants/agent.ts new file mode 100644 index 0000000000000..fe6f7f57e2899 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/constants/agent.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const AGENT_SAVED_OBJECT_TYPE = 'agents'; + +export const AGENT_EVENT_SAVED_OBJECT_TYPE = 'agent_events'; + +export const AGENT_TYPE_PERMANENT = 'PERMANENT'; +export const AGENT_TYPE_EPHEMERAL = 'EPHEMERAL'; +export const AGENT_TYPE_TEMPORARY = 'TEMPORARY'; + +export const AGENT_POLLING_THRESHOLD_MS = 30000; +export const AGENT_POLLING_INTERVAL = 1000; diff --git a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts index d0854d6ffeec7..337022e552278 100644 --- a/x-pack/plugins/ingest_manager/common/constants/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/constants/agent_config.ts @@ -3,16 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { AgentConfigStatus } from '../types'; +import { AgentConfigStatus, DefaultPackages } from '../types'; export const AGENT_CONFIG_SAVED_OBJECT_TYPE = 'agent_configs'; -export const DEFAULT_AGENT_CONFIG_ID = 'default'; - export const DEFAULT_AGENT_CONFIG = { name: 'Default config', namespace: 'default', description: 'Default agent configuration created by Kibana', status: AgentConfigStatus.Active, datasources: [], + is_default: true, }; + +export const DEFAULT_AGENT_CONFIGS_PACKAGES = [DefaultPackages.system]; diff --git a/x-pack/plugins/ingest_manager/common/constants/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/common/constants/enrollment_api_key.ts new file mode 100644 index 0000000000000..f4a4bcde2f393 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/constants/enrollment_api_key.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE = 'enrollment_api_keys'; diff --git a/x-pack/plugins/ingest_manager/common/constants/epm.ts b/x-pack/plugins/ingest_manager/common/constants/epm.ts new file mode 100644 index 0000000000000..eb72c28e7bf39 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/constants/epm.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PACKAGES_SAVED_OBJECT_TYPE = 'epm-package'; +export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; diff --git a/x-pack/plugins/ingest_manager/common/constants/index.ts b/x-pack/plugins/ingest_manager/common/constants/index.ts index aa3b204be4889..45d315e6d5664 100644 --- a/x-pack/plugins/ingest_manager/common/constants/index.ts +++ b/x-pack/plugins/ingest_manager/common/constants/index.ts @@ -6,6 +6,9 @@ export * from './plugin'; export * from './routes'; +export * from './agent'; export * from './agent_config'; export * from './datasource'; +export * from './epm'; export * from './output'; +export * from './enrollment_api_key'; diff --git a/x-pack/plugins/ingest_manager/common/constants/output.ts b/x-pack/plugins/ingest_manager/common/constants/output.ts index e0262d0ca811c..6060a2b63fc8e 100644 --- a/x-pack/plugins/ingest_manager/common/constants/output.ts +++ b/x-pack/plugins/ingest_manager/common/constants/output.ts @@ -7,12 +7,10 @@ import { OutputType } from '../types'; export const OUTPUT_SAVED_OBJECT_TYPE = 'outputs'; -export const DEFAULT_OUTPUT_ID = 'default'; - export const DEFAULT_OUTPUT = { - name: DEFAULT_OUTPUT_ID, + name: 'default', + is_default: true, type: OutputType.Elasticsearch, hosts: [''], - ingest_pipeline: DEFAULT_OUTPUT_ID, api_key: '', }; diff --git a/x-pack/plugins/ingest_manager/common/constants/plugin.ts b/x-pack/plugins/ingest_manager/common/constants/plugin.ts index 7922e6cadfa28..c2390bb433953 100644 --- a/x-pack/plugins/ingest_manager/common/constants/plugin.ts +++ b/x-pack/plugins/ingest_manager/common/constants/plugin.ts @@ -3,5 +3,4 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - export const PLUGIN_ID = 'ingestManager'; diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index efd6ef17ba05b..1dc98f9bc8947 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -11,11 +11,14 @@ export const EPM_API_ROOT = `${API_ROOT}/epm`; export const FLEET_API_ROOT = `${API_ROOT}/fleet`; // EPM API routes +const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; +const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgkey}`; export const EPM_API_ROUTES = { - LIST_PATTERN: `${EPM_API_ROOT}/list`, - INFO_PATTERN: `${EPM_API_ROOT}/package/{pkgkey}`, - INSTALL_PATTERN: `${EPM_API_ROOT}/install/{pkgkey}`, - DELETE_PATTERN: `${EPM_API_ROOT}/delete/{pkgkey}`, + LIST_PATTERN: EPM_PACKAGES_MANY, + INFO_PATTERN: EPM_PACKAGES_ONE, + INSTALL_PATTERN: EPM_PACKAGES_ONE, + DELETE_PATTERN: EPM_PACKAGES_ONE, + FILEPATH_PATTERN: `${EPM_PACKAGES_ONE}/{filePath*}`, CATEGORIES_PATTERN: `${EPM_API_ROOT}/categories`, }; @@ -35,6 +38,28 @@ export const AGENT_CONFIG_API_ROUTES = { CREATE_PATTERN: `${AGENT_CONFIG_API_ROOT}`, UPDATE_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}`, DELETE_PATTERN: `${AGENT_CONFIG_API_ROOT}/delete`, + FULL_INFO_PATTERN: `${AGENT_CONFIG_API_ROOT}/{agentConfigId}/full`, +}; + +// Agent API routes +export const AGENT_API_ROUTES = { + LIST_PATTERN: `${FLEET_API_ROOT}/agents`, + INFO_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}`, + UPDATE_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}`, + DELETE_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}`, + EVENTS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/events`, + CHECKIN_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/checkin`, + ACKS_PATTERN: `${FLEET_API_ROOT}/agents/{agentId}/acks`, + ENROLL_PATTERN: `${FLEET_API_ROOT}/agents/enroll`, + UNENROLL_PATTERN: `${FLEET_API_ROOT}/agents/unenroll`, + STATUS_PATTERN: `${FLEET_API_ROOT}/agent-status`, +}; + +export const ENROLLMENT_API_KEY_ROUTES = { + CREATE_PATTERN: `${FLEET_API_ROOT}/enrollment-api-keys`, + LIST_PATTERN: `${FLEET_API_ROOT}/enrollment-api-keys`, + INFO_PATTERN: `${FLEET_API_ROOT}/enrollment-api-keys/{keyId}`, + DELETE_PATTERN: `${FLEET_API_ROOT}/enrollment-api-keys/{keyId}`, }; // Fleet setup API routes @@ -42,3 +67,7 @@ export const FLEET_SETUP_API_ROUTES = { INFO_PATTERN: `${FLEET_API_ROOT}/setup`, CREATE_PATTERN: `${FLEET_API_ROOT}/setup`, }; + +export const SETUP_API_ROUTE = `${API_ROOT}/setup`; + +export const INSTALL_SCRIPT_API_ROUTES = `${FLEET_API_ROOT}/install/{osType}`; diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts new file mode 100644 index 0000000000000..7bbac55f11937 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AGENT_TYPE_TEMPORARY, + AGENT_POLLING_THRESHOLD_MS, + AGENT_TYPE_PERMANENT, +} from '../constants'; + +export function buildKueryForOnlineAgents() { + return `agents.last_checkin >= now-${(3 * AGENT_POLLING_THRESHOLD_MS) / 1000}s`; +} + +export function buildKueryForOfflineAgents() { + return `agents.type:${AGENT_TYPE_TEMPORARY} AND agents.last_checkin < now-${(3 * + AGENT_POLLING_THRESHOLD_MS) / + 1000}s`; +} + +export function buildKueryForErrorAgents() { + return `agents.type:${AGENT_TYPE_PERMANENT} AND agents.last_checkin < now-${(4 * + AGENT_POLLING_THRESHOLD_MS) / + 1000}s`; +} diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts new file mode 100644 index 0000000000000..9201cdcb6bbac --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { NewDatasource } from '../types'; +import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; + +describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { + const mockDatasource: NewDatasource = { + name: 'mock-datasource', + description: '', + config_id: '', + enabled: true, + output_id: '', + namespace: 'default', + inputs: [], + }; + + const mockInput = { + type: 'test-logs', + enabled: true, + streams: [ + { + id: 'test-logs-foo', + enabled: true, + dataset: 'foo', + config: { fooVar: 'foo-value', fooVar2: [1, 2] }, + }, + { + id: 'test-logs-bar', + enabled: false, + dataset: 'bar', + config: { barVar: 'bar-value', barVar2: [1, 2] }, + }, + ], + }; + + it('returns agent datasource config for datasource with no inputs', () => { + expect(storedDatasourceToAgentDatasource(mockDatasource)).toEqual({ + id: 'mock-datasource', + namespace: 'default', + enabled: true, + use_output: 'default', + inputs: [], + }); + + expect( + storedDatasourceToAgentDatasource({ + ...mockDatasource, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + }) + ).toEqual({ + id: 'mock-datasource', + namespace: 'default', + enabled: true, + use_output: 'default', + package: { + name: 'mock-package', + version: '0.0.0', + }, + inputs: [], + }); + }); + + it('returns agent datasource config with flattened stream configs', () => { + expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({ + id: 'mock-datasource', + namespace: 'default', + enabled: true, + use_output: 'default', + inputs: [ + { + type: 'test-logs', + enabled: true, + streams: [ + { + id: 'test-logs-foo', + enabled: true, + dataset: 'foo', + fooVar: 'foo-value', + fooVar2: [1, 2], + }, + { + id: 'test-logs-bar', + enabled: false, + dataset: 'bar', + barVar: 'bar-value', + barVar2: [1, 2], + }, + ], + }, + ], + }); + }); + + it('returns agent datasource config without disabled inputs', () => { + expect( + storedDatasourceToAgentDatasource({ + ...mockDatasource, + inputs: [{ ...mockInput, enabled: false }], + }) + ).toEqual({ + id: 'mock-datasource', + namespace: 'default', + enabled: true, + use_output: 'default', + inputs: [], + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts new file mode 100644 index 0000000000000..57627fa60fe43 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { Datasource, NewDatasource, FullAgentConfigDatasource } from '../types'; +import { DEFAULT_OUTPUT } from '../constants'; + +export const storedDatasourceToAgentDatasource = ( + datasource: Datasource | NewDatasource +): FullAgentConfigDatasource => { + const { name, namespace, enabled, package: pkg, inputs } = datasource; + const fullDatasource: FullAgentConfigDatasource = { + id: name, + namespace, + enabled, + use_output: DEFAULT_OUTPUT.name, // TODO: hardcoded to default output for now + inputs: inputs + .filter(input => input.enabled) + .map(input => ({ + ...input, + streams: input.streams.map(stream => { + if (stream.config) { + const fullStream = { + ...stream, + ...Object.entries(stream.config).reduce((acc, [configName, configValue]) => { + if (configValue !== undefined) { + acc[configName] = configValue; + } + return acc; + }, {} as { [key: string]: any }), + }; + delete fullStream.config; + return fullStream; + } else { + const fullStream = { ...stream }; + return fullStream; + } + }), + })), + }; + + if (pkg) { + fullDatasource.package = { + name: pkg.name, + version: pkg.version, + }; + } + + return fullDatasource; +}; diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index 1b3ae4706e3a7..7d1013cf1feb6 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -3,4 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import * as AgentStatusKueryHelper from './agent_status'; + export * from './routes'; +export { packageToConfigDatasourceInputs } from './package_to_config'; +export { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; +export { AgentStatusKueryHelper }; diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts new file mode 100644 index 0000000000000..a4a2eb6001495 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PackageInfo, InstallationStatus } from '../types'; +import { packageToConfigDatasourceInputs } from './package_to_config'; + +describe('Ingest Manager - packageToConfigDatasourceInputs', () => { + const mockPackage: PackageInfo = { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + description: 'description', + type: 'mock', + categories: [], + requirement: { kibana: { versions: '' }, elasticsearch: { versions: '' } }, + format_version: '', + download: '', + path: '', + assets: { + kibana: { + dashboard: [], + visualization: [], + search: [], + 'index-pattern': [], + }, + }, + status: InstallationStatus.notInstalled, + }; + + it('returns empty array for packages with no datasources', () => { + expect(packageToConfigDatasourceInputs(mockPackage)).toEqual([]); + expect(packageToConfigDatasourceInputs({ ...mockPackage, datasources: [] })).toEqual([]); + }); + + it('returns empty array for packages a datasource but no inputs', () => { + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [{ inputs: [] }], + } as unknown) as PackageInfo) + ).toEqual([]); + }); + + it('returns inputs with no streams for packages with no streams', () => { + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [{ inputs: [{ type: 'foo' }] }], + } as unknown) as PackageInfo) + ).toEqual([{ type: 'foo', enabled: true, streams: [] }]); + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [{ inputs: [{ type: 'foo' }, { type: 'bar' }] }], + } as unknown) as PackageInfo) + ).toEqual([ + { type: 'foo', enabled: true, streams: [] }, + { type: 'bar', enabled: true, streams: [] }, + ]); + }); + + it('returns inputs with streams for packages with streams', () => { + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [ + { + inputs: [ + { type: 'foo', streams: [{ dataset: 'foo' }] }, + { type: 'bar', streams: [{ dataset: 'bar' }, { dataset: 'bar2' }] }, + ], + }, + ], + } as unknown) as PackageInfo) + ).toEqual([ + { + type: 'foo', + enabled: true, + streams: [{ id: 'foo-foo', enabled: true, dataset: 'foo', config: {} }], + }, + { + type: 'bar', + enabled: true, + streams: [ + { id: 'bar-bar', enabled: true, dataset: 'bar', config: {} }, + { id: 'bar-bar2', enabled: true, dataset: 'bar2', config: {} }, + ], + }, + ]); + }); + + it('returns inputs with streams configurations for packages with stream vars', () => { + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [ + { + inputs: [ + { + type: 'foo', + streams: [ + { dataset: 'foo', vars: [{ default: 'foo-var-value', name: 'var-name' }] }, + ], + }, + { + type: 'bar', + streams: [ + { dataset: 'bar', vars: [{ default: 'bar-var-value', name: 'var-name' }] }, + { dataset: 'bar2', vars: [{ default: 'bar2-var-value', name: 'var-name' }] }, + ], + }, + ], + }, + ], + } as unknown) as PackageInfo) + ).toEqual([ + { + type: 'foo', + enabled: true, + streams: [ + { id: 'foo-foo', enabled: true, dataset: 'foo', config: { 'var-name': 'foo-var-value' } }, + ], + }, + { + type: 'bar', + enabled: true, + streams: [ + { id: 'bar-bar', enabled: true, dataset: 'bar', config: { 'var-name': 'bar-var-value' } }, + { + id: 'bar-bar2', + enabled: true, + dataset: 'bar2', + config: { 'var-name': 'bar2-var-value' }, + }, + ], + }, + ]); + }); + + it('returns inputs with streams configurations for packages with stream and input vars', () => { + expect( + packageToConfigDatasourceInputs(({ + ...mockPackage, + datasources: [ + { + inputs: [ + { + type: 'foo', + vars: [ + { default: 'foo-input-var-value', name: 'foo-input-var-name' }, + { default: 'foo-input2-var-value', name: 'foo-input2-var-name' }, + { name: 'foo-input3-var-name' }, + ], + streams: [ + { dataset: 'foo', vars: [{ default: 'foo-var-value', name: 'var-name' }] }, + ], + }, + { + type: 'bar', + vars: [ + { default: ['value1', 'value2'], name: 'bar-input-var-name' }, + { default: 123456, name: 'bar-input2-var-name' }, + ], + streams: [ + { dataset: 'bar', vars: [{ default: 'bar-var-value', name: 'var-name' }] }, + { dataset: 'bar2', vars: [{ default: 'bar2-var-value', name: 'var-name' }] }, + ], + }, + { + type: 'with-disabled-streams', + streams: [ + { + dataset: 'disabled', + enabled: false, + vars: [{ multi: true, name: 'var-name' }], + }, + { dataset: 'disabled2', enabled: false }, + ], + }, + ], + }, + ], + } as unknown) as PackageInfo) + ).toEqual([ + { + type: 'foo', + enabled: true, + streams: [ + { + id: 'foo-foo', + enabled: true, + dataset: 'foo', + config: { + 'var-name': 'foo-var-value', + 'foo-input-var-name': 'foo-input-var-value', + 'foo-input2-var-name': 'foo-input2-var-value', + 'foo-input3-var-name': undefined, + }, + }, + ], + }, + { + type: 'bar', + enabled: true, + streams: [ + { + id: 'bar-bar', + enabled: true, + dataset: 'bar', + config: { + 'var-name': 'bar-var-value', + 'bar-input-var-name': ['value1', 'value2'], + 'bar-input2-var-name': 123456, + }, + }, + { + id: 'bar-bar2', + enabled: true, + dataset: 'bar2', + config: { + 'var-name': 'bar2-var-value', + 'bar-input-var-name': ['value1', 'value2'], + 'bar-input2-var-name': 123456, + }, + }, + ], + }, + { + type: 'with-disabled-streams', + enabled: false, + streams: [ + { + id: 'with-disabled-streams-disabled', + enabled: false, + dataset: 'disabled', + config: { + 'var-name': [], + }, + }, + { + id: 'with-disabled-streams-disabled2', + enabled: false, + dataset: 'disabled2', + config: {}, + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts new file mode 100644 index 0000000000000..311a0a0fceddd --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + PackageInfo, + RegistryDatasource, + RegistryVarsEntry, + Datasource, + DatasourceInput, + DatasourceInputStream, +} from '../types'; + +/* + * This service creates a datasource inputs definition from defaults provided in package info + */ +export const packageToConfigDatasourceInputs = (packageInfo: PackageInfo): Datasource['inputs'] => { + const inputs: Datasource['inputs'] = []; + + // Assume package will only ever ship one datasource for now + const packageDatasource: RegistryDatasource | null = + packageInfo.datasources && packageInfo.datasources[0] ? packageInfo.datasources[0] : null; + + // Create datasource input property + if (packageDatasource?.inputs?.length) { + // Map each package datasource input to agent config datasource input + packageDatasource.inputs.forEach(packageInput => { + // Map each package input stream into datasource input stream + const streams: DatasourceInputStream[] = packageInput.streams + ? packageInput.streams.map(packageStream => { + // Copy input vars into each stream's vars + const streamVars: RegistryVarsEntry[] = [ + ...(packageInput.vars || []), + ...(packageStream.vars || []), + ]; + const streamConfig = {}; + const streamVarsReducer = ( + configObject: DatasourceInputStream['config'], + streamVar: RegistryVarsEntry + ): DatasourceInputStream['config'] => { + if (!streamVar.default && streamVar.multi) { + configObject![streamVar.name] = []; + } else { + configObject![streamVar.name] = streamVar.default; + } + return configObject; + }; + return { + id: `${packageInput.type}-${packageStream.dataset}`, + enabled: packageStream.enabled === false ? false : true, + dataset: packageStream.dataset, + config: streamVars.reduce(streamVarsReducer, streamConfig), + }; + }) + : []; + + const input: DatasourceInput = { + type: packageInput.type, + enabled: streams.length ? !!streams.find(stream => stream.enabled) : true, + streams, + }; + + inputs.push(input); + }); + } + + return inputs; +}; diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index bcd1646fe1f0c..7ad3944096a5f 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -8,6 +8,10 @@ import { EPM_API_ROUTES, DATASOURCE_API_ROUTES, AGENT_CONFIG_API_ROUTES, + FLEET_SETUP_API_ROUTES, + AGENT_API_ROUTES, + ENROLLMENT_API_KEY_ROUTES, + SETUP_API_ROUTE, } from '../constants'; export const epmRouteService = { @@ -24,7 +28,7 @@ export const epmRouteService = { }, getFilePath: (filePath: string) => { - return `${EPM_API_ROOT}${filePath}`; + return `${EPM_API_ROOT}${filePath.replace('/package', '/packages')}`; }, getInstallPath: (pkgkey: string) => { @@ -75,3 +79,29 @@ export const agentConfigRouteService = { return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN; }, }; + +export const fleetSetupRouteService = { + getFleetSetupPath: () => FLEET_SETUP_API_ROUTES.INFO_PATTERN, + postFleetSetupPath: () => FLEET_SETUP_API_ROUTES.CREATE_PATTERN, +}; + +export const agentRouteService = { + getInfoPath: (agentId: string) => AGENT_API_ROUTES.INFO_PATTERN.replace('{agentId}', agentId), + getUpdatePath: (agentId: string) => AGENT_API_ROUTES.UPDATE_PATTERN.replace('{agentId}', agentId), + getEventsPath: (agentId: string) => AGENT_API_ROUTES.EVENTS_PATTERN.replace('{agentId}', agentId), + getUnenrollPath: () => AGENT_API_ROUTES.UNENROLL_PATTERN, + getListPath: () => AGENT_API_ROUTES.LIST_PATTERN, + getStatusPath: () => AGENT_API_ROUTES.STATUS_PATTERN, +}; + +export const enrollmentAPIKeyRouteService = { + getListPath: () => ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN, + getCreatePath: () => ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN, + getInfoPath: (keyId: string) => ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN.replace('{keyId}', keyId), + getDeletePath: (keyId: string) => + ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN.replace('{keyId}', keyId), +}; + +export const setupRouteService = { + getSetupPath: () => SETUP_API_ROUTE, +}; diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index 4abb1b659f036..42f7a9333118e 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - export * from './models'; export * from './rest_spec'; @@ -16,5 +15,20 @@ export interface IngestManagerConfigType { fleet: { enabled: boolean; defaultOutputHost: string; + kibana: { + host?: string; + ca_sha256?: string; + }; + elasticsearch: { + host?: string; + ca_sha256?: string; + }; }; } + +// Calling Object.entries(PackagesGroupedByStatus) gave `status: string` +// which causes a "string is not assignable to type InstallationStatus` error +// see https://github.com/Microsoft/TypeScript/issues/20322 +// and https://github.com/Microsoft/TypeScript/pull/12253#issuecomment-263132208 +// and https://github.com/Microsoft/TypeScript/issues/21826#issuecomment-479851685 +export const entries = Object.entries as (o: T) => Array<[keyof T, T[keyof T]]>; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts new file mode 100644 index 0000000000000..a0575c71d3aba --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectAttributes } from '../../../../../../src/core/public'; +import { AGENT_TYPE_EPHEMERAL, AGENT_TYPE_PERMANENT, AGENT_TYPE_TEMPORARY } from '../../constants'; + +export type AgentType = + | typeof AGENT_TYPE_EPHEMERAL + | typeof AGENT_TYPE_PERMANENT + | typeof AGENT_TYPE_TEMPORARY; + +export type AgentStatus = 'offline' | 'error' | 'online' | 'inactive' | 'warning'; + +export interface AgentAction extends SavedObjectAttributes { + type: 'CONFIG_CHANGE' | 'DATA_DUMP' | 'RESUME' | 'PAUSE'; + id: string; + created_at: string; + data?: string; + sent_at?: string; +} + +export interface AgentEvent { + type: 'STATE' | 'ERROR' | 'ACTION_RESULT' | 'ACTION'; + subtype: // State + | 'RUNNING' + | 'STARTING' + | 'IN_PROGRESS' + | 'CONFIG' + | 'FAILED' + | 'STOPPING' + | 'STOPPED' + // Action results + | 'DATA_DUMP' + // Actions + | 'ACKNOWLEDGED' + | 'UNKNOWN'; + timestamp: string; + message: string; + payload?: any; + agent_id: string; + action_id?: string; + config_id?: string; + stream_id?: string; +} + +export interface AgentEventSOAttributes extends AgentEvent, SavedObjectAttributes {} + +interface AgentBase { + type: AgentType; + active: boolean; + enrolled_at: string; + shared_id?: string; + access_api_key_id?: string; + default_api_key?: string; + config_id?: string; + last_checkin?: string; + config_updated_at?: string; + actions: AgentAction[]; +} + +export interface Agent extends AgentBase { + id: string; + current_error_events: AgentEvent[]; + user_provided_metadata: Record; + local_metadata: Record; + access_api_key?: string; + status?: string; +} + +export interface AgentSOAttributes extends AgentBase, SavedObjectAttributes { + user_provided_metadata: string; + local_metadata: string; + current_error_events?: string; +} diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index 1cc8b32afe3c1..c63e496273ada 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -4,29 +4,58 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DatasourceSchema } from './datasource'; +import { SavedObjectAttributes } from '../../../../../../src/core/public'; +import { + Datasource, + DatasourcePackage, + DatasourceInput, + DatasourceInputStream, +} from './datasource'; +import { Output } from './output'; export enum AgentConfigStatus { Active = 'active', Inactive = 'inactive', } -interface AgentConfigBaseSchema { +export interface NewAgentConfig { name: string; - namespace: string; + namespace?: string; description?: string; + is_default?: boolean; } -export type NewAgentConfigSchema = AgentConfigBaseSchema; - -export type AgentConfigSchema = AgentConfigBaseSchema & { +export interface AgentConfig extends NewAgentConfig, SavedObjectAttributes { id: string; status: AgentConfigStatus; - datasources: Array; + datasources: string[] | Datasource[]; updated_on: string; updated_by: string; -}; + revision: number; +} -export type NewAgentConfig = NewAgentConfigSchema; +export type FullAgentConfigDatasource = Pick & { + id: string; + package?: Pick; + use_output: string; + inputs: Array< + Omit & { + streams: Array< + Omit & { + [key: string]: any; + } + >; + } + >; +}; -export type AgentConfig = AgentConfigSchema; +export interface FullAgentConfig { + id: string; + outputs: { + [key: string]: Pick & { + [key: string]: any; + }; + }; + datasources: FullAgentConfigDatasource[]; + revision?: number; +} diff --git a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts index f28037845c7f7..3503bbdcd40e3 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/datasource.ts @@ -4,40 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -interface DatasourceBaseSchema { +export interface DatasourcePackage { name: string; - namespace: string; - read_alias: string; - agent_config_id: string; - package: { - assets: Array<{ - id: string; - type: string; - }>; - description: string; - name: string; - title: string; - version: string; - }; - streams: Array<{ - config: Record; - input: { - type: string; - config: Record; - fields: Array>; - ilm_policy: string; - index_template: string; - ingest_pipelines: string[]; - }; - output_id: string; - processors: string[]; - }>; + title: string; + version: string; } -export type NewDatasourceSchema = DatasourceBaseSchema; +export interface DatasourceInputStream { + id: string; + enabled: boolean; + dataset: string; + processors?: string[]; + config?: Record; +} -export type DatasourceSchema = DatasourceBaseSchema & { id: string }; +export interface DatasourceInput { + type: string; + enabled: boolean; + processors?: string[]; + streams: DatasourceInputStream[]; +} -export type NewDatasource = NewDatasourceSchema; +export interface NewDatasource { + name: string; + description?: string; + namespace?: string; + config_id: string; + enabled: boolean; + package?: DatasourcePackage; + output_id: string; + inputs: DatasourceInput[]; +} -export type Datasource = DatasourceSchema; +export type Datasource = NewDatasource & { + id: string; + revision: number; +}; diff --git a/x-pack/plugins/ingest_manager/common/types/models/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/common/types/models/enrollment_api_key.ts new file mode 100644 index 0000000000000..35cb851a72933 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/models/enrollment_api_key.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectAttributes } from '../../../../../../src/core/public'; + +export interface EnrollmentAPIKey { + id: string; + api_key_id: string; + api_key: string; + name?: string; + active: boolean; + config_id?: string; +} + +export interface EnrollmentAPIKeySOAttributes extends SavedObjectAttributes { + api_key_id: string; + api_key: string; + name?: string; + active: boolean; + config_id?: string; +} diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts new file mode 100644 index 0000000000000..a1a39444c3b50 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Follow pattern from https://github.com/elastic/kibana/pull/52447 +// TODO: Update when https://github.com/elastic/kibana/issues/53021 is closed +import { + SavedObject, + SavedObjectAttributes, + SavedObjectReference, +} from '../../../../../../src/core/public'; + +export enum InstallationStatus { + installed = 'installed', + notInstalled = 'not_installed', +} +export enum InstallStatus { + installed = 'installed', + notInstalled = 'not_installed', + installing = 'installing', + uninstalling = 'uninstalling', +} + +export type DetailViewPanelName = 'overview' | 'data-sources'; +export type ServiceName = 'kibana' | 'elasticsearch'; +export type AssetType = KibanaAssetType | ElasticsearchAssetType | AgentAssetType; + +export enum KibanaAssetType { + dashboard = 'dashboard', + visualization = 'visualization', + search = 'search', + indexPattern = 'index-pattern', +} + +export enum ElasticsearchAssetType { + ingestPipeline = 'ingest-pipeline', + indexTemplate = 'index-template', + ilmPolicy = 'ilm-policy', +} + +export enum AgentAssetType { + input = 'input', +} + +// from /package/{name} +// type Package struct at https://github.com/elastic/package-registry/blob/master/util/package.go +// https://github.com/elastic/package-registry/blob/master/docs/api/package.json +export interface RegistryPackage { + name: string; + title?: string; + version: string; + readme?: string; + description: string; + type: string; + categories: string[]; + requirement: RequirementsByServiceName; + screenshots?: RegistryImage[]; + icons?: RegistryImage[]; + assets?: string[]; + internal?: boolean; + format_version: string; + datasets?: Dataset[]; + datasources?: RegistryDatasource[]; + download: string; + path: string; +} + +interface RegistryImage { + // https://github.com/elastic/package-registry/blob/master/util/package.go#L74 + // says src is potentially missing but I couldn't find any examples + // it seems like src should be required. How can you have an image with no reference to the content? + src: string; + title?: string; + size?: string; + type?: string; +} +export interface RegistryDatasource { + name: string; + title: string; + description: string; + inputs: RegistryInput[]; +} + +export interface RegistryInput { + type: string; + title: string; + description?: string; + vars?: RegistryVarsEntry[]; + streams: RegistryStream[]; +} + +export interface RegistryStream { + input: string; + dataset: string; + title: string; + description?: string; + enabled?: boolean; + vars?: RegistryVarsEntry[]; +} + +export type RequirementVersion = string; +export type RequirementVersionRange = string; +export interface ServiceRequirements { + versions: RequirementVersionRange; +} + +// Registry's response types +// from /search +// https://github.com/elastic/package-registry/blob/master/docs/api/search.json +export type RegistrySearchResults = RegistrySearchResult[]; +// from getPackageOutput at https://github.com/elastic/package-registry/blob/master/search.go +export type RegistrySearchResult = Pick< + RegistryPackage, + | 'name' + | 'title' + | 'version' + | 'description' + | 'type' + | 'icons' + | 'internal' + | 'download' + | 'path' + | 'datasets' + | 'datasources' +>; + +export type ScreenshotItem = RegistryImage; + +// from /categories +// https://github.com/elastic/package-registry/blob/master/docs/api/categories.json +export type CategorySummaryList = CategorySummaryItem[]; +export type CategoryId = string; +export interface CategorySummaryItem { + id: CategoryId; + title: string; + count: number; +} + +export type RequirementsByServiceName = Record; +export interface AssetParts { + pkgkey: string; + dataset?: string; + service: ServiceName; + type: AssetType; + file: string; +} +export type AssetTypeToParts = KibanaAssetTypeToParts & ElasticsearchAssetTypeToParts; +export type AssetsGroupedByServiceByType = Record< + Extract, + KibanaAssetTypeToParts +>; +// & Record, ElasticsearchAssetTypeToParts>; + +export type KibanaAssetParts = AssetParts & { + service: Extract; + type: KibanaAssetType; +}; + +export type ElasticsearchAssetParts = AssetParts & { + service: Extract; + type: ElasticsearchAssetType; +}; + +export type KibanaAssetTypeToParts = Record; +export type ElasticsearchAssetTypeToParts = Record< + ElasticsearchAssetType, + ElasticsearchAssetParts[] +>; + +export interface Dataset { + title: string; + path: string; + id: string; + release: string; + ingest_pipeline: string; + vars?: RegistryVarsEntry[]; + type: string; + streams?: RegistryStream[]; + package: string; +} + +// EPR types this as `[]map[string]interface{}` +// which means the official/possible type is Record +// but we effectively only see this shape +export interface RegistryVarsEntry { + name: string; + title?: string; + description?: string; + type: string; + required?: boolean; + multi?: boolean; + default?: string | string[]; + os?: { + [key: string]: { + default: string | string[]; + }; + }; +} + +// some properties are optional in Registry responses but required in EPM +// internal until we need them +interface PackageAdditions { + title: string; + assets: AssetsGroupedByServiceByType; +} + +// Managers public HTTP response types +export type PackageList = PackageListItem[]; + +export type PackageListItem = Installable; +export type PackagesGroupedByStatus = Record; +export type PackageInfo = Installable< + // remove the properties we'll be altering/replacing from the base type + Omit & + // now add our replacement definitions + PackageAdditions +>; + +export interface Installation extends SavedObjectAttributes { + installed: AssetReference[]; + name: string; + version: string; +} + +export type Installable = Installed | NotInstalled; + +export type Installed = T & { + status: InstallationStatus.installed; + savedObject: SavedObject; +}; + +export type NotInstalled = T & { + status: InstallationStatus.notInstalled; +}; + +export type AssetReference = Pick & { + type: AssetType | IngestAssetType; +}; + +/** + * Types of assets which can be installed/removed + */ +export enum IngestAssetType { + DataFrameTransform = 'data-frame-transform', + IlmPolicy = 'ilm-policy', + IndexTemplate = 'index-template', + IngestPipeline = 'ingest-pipeline', + MlJob = 'ml-job', + RollupJob = 'rollup-job', +} + +export enum DefaultPackages { + base = 'base', + system = 'system', +} + +export interface IndexTemplate { + order: number; + index_patterns: string[]; + settings: any; + mappings: object; + aliases: object; +} diff --git a/x-pack/plugins/ingest_manager/common/types/models/index.ts b/x-pack/plugins/ingest_manager/common/types/models/index.ts index 959dfe1d937b9..579b510e52daa 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/index.ts @@ -3,6 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +export * from './agent'; export * from './agent_config'; export * from './datasource'; export * from './output'; +export * from './epm'; +export * from './enrollment_api_key'; diff --git a/x-pack/plugins/ingest_manager/common/types/models/output.ts b/x-pack/plugins/ingest_manager/common/types/models/output.ts index 5f96fe33b5e16..cedf5e81f3cb6 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/output.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/output.ts @@ -8,26 +8,18 @@ export enum OutputType { Elasticsearch = 'elasticsearch', } -interface OutputBaseSchema { +export interface NewOutput { + is_default: boolean; name: string; type: OutputType; - username?: string; - password?: string; - index_name?: string; - ingest_pipeline?: string; hosts?: string[]; + ca_sha256?: string; api_key?: string; admin_username?: string; admin_password?: string; config?: Record; } -export type NewOutputSchema = OutputBaseSchema; - -export type OutputSchema = OutputBaseSchema & { +export type Output = NewOutput & { id: string; }; - -export type NewOutput = NewOutputSchema; - -export type Output = OutputSchema; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts new file mode 100644 index 0000000000000..af919d973b7d9 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Agent, AgentAction, AgentEvent, AgentStatus, AgentType } from '../models'; + +export interface GetAgentsRequest { + query: { + page: number; + perPage: number; + kuery?: string; + showInactive: boolean; + }; +} + +export interface GetAgentsResponse { + list: Agent[]; + total: number; + page: number; + perPage: number; + success: boolean; +} + +export interface GetOneAgentRequest { + params: { + agentId: string; + }; +} + +export interface GetOneAgentResponse { + item: Agent; + success: boolean; +} + +export interface PostAgentCheckinRequest { + params: { + agentId: string; + }; + body: { + local_metadata?: Record; + events?: AgentEvent[]; + }; +} + +export interface PostAgentCheckinResponse { + action: string; + success: boolean; + actions: AgentAction[]; +} + +export interface PostAgentEnrollRequest { + body: { + type: AgentType; + shared_id?: string; + metadata: { + local: Record; + user_provided: Record; + }; + }; +} + +export interface PostAgentEnrollResponse { + action: string; + success: boolean; + item: Agent & { status: AgentStatus }; +} + +export interface PostAgentAcksRequest { + body: { + action_ids: string[]; + }; + params: { + agentId: string; + }; +} + +export interface PostAgentUnenrollRequest { + body: { kuery: string } | { ids: string[] }; +} + +export interface PostAgentUnenrollResponse { + results: Array<{ + success: boolean; + error?: any; + id: string; + action: string; + }>; + success: boolean; +} + +export interface GetOneAgentEventsRequest { + params: { + agentId: string; + }; + query: { + page: number; + perPage: number; + kuery?: string; + }; +} + +export interface GetOneAgentEventsResponse { + list: AgentEvent[]; + total: number; + page: number; + perPage: number; + success: boolean; +} + +export interface DeleteAgentRequest { + params: { + agentId: string; + }; +} + +export interface UpdateAgentRequest { + params: { + agentId: string; + }; + body: { + user_provided_metadata: Record; + }; +} + +export interface GetAgentStatusRequest { + query: { + configId: string; + }; +} + +export interface GetAgentStatusResponse { + success: boolean; + results: { + events: number; + total: number; + online: number; + error: number; + offline: number; + }; +} diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts index 5d281b03260db..89d548d11dadb 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/agent_config.ts @@ -3,22 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { AgentConfig, NewAgentConfigSchema } from '../models'; -import { ListWithKuerySchema } from './common'; +import { AgentConfig, NewAgentConfig, FullAgentConfig } from '../models'; +import { ListWithKuery } from './common'; -export interface GetAgentConfigsRequestSchema { - query: ListWithKuerySchema; +export interface GetAgentConfigsRequest { + query: ListWithKuery; } +export type GetAgentConfigsResponseItem = AgentConfig & { agents?: number }; + export interface GetAgentConfigsResponse { - items: AgentConfig[]; + items: GetAgentConfigsResponseItem[]; total: number; page: number; perPage: number; success: boolean; } -export interface GetOneAgentConfigRequestSchema { +export interface GetOneAgentConfigRequest { params: { agentConfigId: string; }; @@ -29,8 +31,8 @@ export interface GetOneAgentConfigResponse { success: boolean; } -export interface CreateAgentConfigRequestSchema { - body: NewAgentConfigSchema; +export interface CreateAgentConfigRequest { + body: NewAgentConfig; } export interface CreateAgentConfigResponse { @@ -38,8 +40,8 @@ export interface CreateAgentConfigResponse { success: boolean; } -export type UpdateAgentConfigRequestSchema = GetOneAgentConfigRequestSchema & { - body: NewAgentConfigSchema; +export type UpdateAgentConfigRequest = GetOneAgentConfigRequest & { + body: NewAgentConfig; }; export interface UpdateAgentConfigResponse { @@ -47,7 +49,7 @@ export interface UpdateAgentConfigResponse { success: boolean; } -export interface DeleteAgentConfigsRequestSchema { +export interface DeleteAgentConfigsRequest { body: { agentConfigIds: string[]; }; @@ -57,3 +59,14 @@ export type DeleteAgentConfigsResponse = Array<{ id: string; success: boolean; }>; + +export interface GetFullAgentConfigRequest { + params: { + agentConfigId: string; + }; +} + +export interface GetFullAgentConfigResponse { + item: FullAgentConfig; + success: boolean; +} diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts index d247933d4011f..c52471ccfb4f5 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/common.ts @@ -4,10 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export interface ListWithKuerySchema { +export interface ListWithKuery { page: number; perPage: number; kuery?: string; } - -export type ListWithKuery = ListWithKuerySchema; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts index 78859f2008005..f630602503f0a 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/datasource.ts @@ -3,28 +3,33 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { NewDatasourceSchema } from '../models'; -import { ListWithKuerySchema } from './common'; +import { Datasource, NewDatasource } from '../models'; +import { ListWithKuery } from './common'; -export interface GetDatasourcesRequestSchema { - query: ListWithKuerySchema; +export interface GetDatasourcesRequest { + query: ListWithKuery; } -export interface GetOneDatasourceRequestSchema { +export interface GetOneDatasourceRequest { params: { datasourceId: string; }; } -export interface CreateDatasourceRequestSchema { - body: NewDatasourceSchema; +export interface CreateDatasourceRequest { + body: NewDatasource; } -export type UpdateDatasourceRequestSchema = GetOneDatasourceRequestSchema & { - body: NewDatasourceSchema; +export interface CreateDatasourceResponse { + item: Datasource; + success: boolean; +} + +export type UpdateDatasourceRequest = GetOneDatasourceRequest & { + body: NewDatasource; }; -export interface DeleteDatasourcesRequestSchema { +export interface DeleteDatasourcesRequest { body: { datasourceIds: string[]; }; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/enrollment_api_key.ts new file mode 100644 index 0000000000000..851e6571c0dd2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/enrollment_api_key.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EnrollmentAPIKey } from '../models'; + +export interface GetEnrollmentAPIKeysRequest { + query: { + page: number; + perPage: number; + kuery?: string; + }; +} + +export interface GetEnrollmentAPIKeysResponse { + list: EnrollmentAPIKey[]; + total: number; + page: number; + perPage: number; + success: boolean; +} + +export interface GetOneEnrollmentAPIKeyRequest { + params: { + keyId: string; + }; +} + +export interface GetOneEnrollmentAPIKeyResponse { + item: EnrollmentAPIKey; + success: boolean; +} + +export interface DeleteEnrollmentAPIKeyRequest { + params: { + keyId: string; + }; +} + +export interface DeleteEnrollmentAPIKeyResponse { + action: string; + success: boolean; +} + +export interface PostEnrollmentAPIKeyRequest { + body: { + name?: string; + config_id: string; + expiration?: string; + }; +} + +export interface PostEnrollmentAPIKeyResponse { + action: string; + item: EnrollmentAPIKey; + success: boolean; +} diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts new file mode 100644 index 0000000000000..5ac7fe9e2779b --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AssetReference, + CategorySummaryList, + Installable, + RegistryPackage, + PackageInfo, +} from '../models/epm'; + +export interface GetCategoriesResponse { + response: CategorySummaryList; + success: boolean; +} +export interface GetPackagesRequest { + query: { + category?: string; + }; +} + +export interface GetPackagesResponse { + response: Array< + Installable< + Pick< + RegistryPackage, + 'name' | 'title' | 'version' | 'description' | 'type' | 'icons' | 'download' | 'path' + > + > + >; + success: boolean; +} + +export interface GetFileRequest { + params: { + pkgkey: string; + filePath: string; + }; +} + +export interface GetInfoRequest { + params: { + pkgkey: string; + }; +} + +export interface GetInfoResponse { + response: PackageInfo; + success: boolean; +} + +export interface InstallPackageRequest { + params: { + pkgkey: string; + }; +} + +export interface InstallPackageResponse { + response: AssetReference[]; + success: boolean; +} + +export interface DeletePackageRequest { + params: { + pkgkey: string; + }; +} + +export interface DeletePackageResponse { + response: AssetReference[]; + success: boolean; +} diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts index 926021baab0ef..2eda4f187dafa 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/fleet_setup.ts @@ -5,9 +5,9 @@ */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GetFleetSetupRequestSchema {} +export interface GetFleetSetupRequest {} -export interface CreateFleetSetupRequestSchema { +export interface CreateFleetSetupRequest { body: { admin_username: string; admin_password: string; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts index 7d0d7e67f2db0..abe1bc8e3eddb 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/index.ts @@ -5,5 +5,9 @@ */ export * from './common'; export * from './datasource'; +export * from './agent'; export * from './agent_config'; export * from './fleet_setup'; +export * from './epm'; +export * from './enrollment_api_key'; +export * from './install_script'; diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/install_script.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/install_script.ts new file mode 100644 index 0000000000000..0b0b0945d652e --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/install_script.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface InstallScriptRequest { + params: { + osType: 'macos'; + }; +} diff --git a/x-pack/plugins/ingest_manager/dev_docs/actions_and_events.md b/x-pack/plugins/ingest_manager/dev_docs/actions_and_events.md new file mode 100644 index 0000000000000..b41cdc221c51a --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/actions_and_events.md @@ -0,0 +1,47 @@ +## Agent Fleet: actions protocol + +Agent is using `actions` and `events` to comunicate with fleet during checkin. + +## Actions + +Action are returned to the agent during the checkin [see](./api/agents_checkin) +Agent should aknowledge actions they received using `POST /agents/{agentId}/acks` API. + +### POLICY_CHANGE + +This action is send when a new policy is available, the policy is available under the `data` field. + +```js +{ + "type": "POLICY_CHANGE", + "id": "action_id_1", + "data": { + "policy": { + "id": "config_id", + "outputs": { + "default": { + "api_key": "slfhsdlfhjjkshfkjh:sdfsdfsdfsdf", + "id": "default", + "name": "Default", + "type": "elasticsearch", + "hosts": ["https://localhost:9200"], + } + }, + "streams": [ + { + "metricsets": [ + "container", + "cpu" + ], + "id": "string", + "type": "etc", + "output": { + "use_output": "default" + } + } + ] + } + } + }] +} +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api/agents_acks.md b/x-pack/plugins/ingest_manager/dev_docs/api/agents_acks.md new file mode 100644 index 0000000000000..d5bf4d2c2e236 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api/agents_acks.md @@ -0,0 +1,37 @@ +# Fleet agent acks API + +Agent acks +Acknowledge actions received during checkin + +## Request + +`POST /api/ingest_manager/fleet/agents/{agentId}/acks` + +## Headers + +- `Authorization` (Required, string) A valid fleet access api key.. + +## Request body + +- `action_ids` (Required, array) An array of action id that the agent received. + +## Response code + +- `200` Indicates a successful call. + +## Example + +```js +POST /api/ingest_manager/fleet/agents/a4937110-e53e-11e9-934f-47a8e38a522c/acks +Authorization: ApiKey VALID_ACCESS_API_KEY +{ + "action_ids": ["action-1", "action-2"] +} +``` + +```js +{ + "action": "acks", + "success": true, +} +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api/agents_checkin.md b/x-pack/plugins/ingest_manager/dev_docs/api/agents_checkin.md new file mode 100644 index 0000000000000..aa3e4b1335ecd --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api/agents_checkin.md @@ -0,0 +1,47 @@ +# Fleet agent checkin API + +Agent checkin +Report current state of a Fleet agent. + +## Request + +`POST /api/ingest_manager/fleet/agents/{agentId}/checkin` + +## Headers + +- `Authorization` (Required, string) A valid fleet access api key.. + +## Request body + +- `events` (Required, array) An array of events with the properties `type`, `subtype`, `message`, `timestamp`, `payload`, and `agent_id`. + +- `local_metadata` (Optional, object) An object that contains the local metadata for an agent. The metadata is a dictionary of strings (example: `{ "os": "macos" }`). + +## Response code + +- `200` Indicates a successful call. + +## Example + +```js +POST /api/ingest_manager/fleet/agents/a4937110-e53e-11e9-934f-47a8e38a522c/checkin +Authorization: ApiKey VALID_ACCESS_API_KEY +{ + "events": [{ + "type": "STATE", + "subtype": "STARTING", + "message": "state changed from STOPPED to STARTING", + "timestamp": "2019-10-01T13:42:54.323Z", + "payload": {}, + "agent_id": "a4937110-e53e-11e9-934f-47a8e38a522c" + }] +} +``` + +```js +{ + "action": "checkin", + "success": true, + "actions": [] +} +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api/agents_enroll.md b/x-pack/plugins/ingest_manager/dev_docs/api/agents_enroll.md new file mode 100644 index 0000000000000..304ce733b7dcd --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api/agents_enroll.md @@ -0,0 +1,79 @@ +# Enroll Fleet agent API + +Enroll agent + +## Request + +`POST /api/ingest_manager/fleet/agents/enroll` + +## Headers + +- `Authorization` (Required, string) a valid enrollemnt api key. + +## Request body + +- `type` (Required, string) Agent type should be one of `EPHEMERAL`, `TEMPORARY`, `PERMANENT` +- `shared_id` (Optional, string) An ID for the agent. +- `metadata` (Optional, object) Objects with `local` and `user_provided` properties that contain the metadata for an agent. The metadata is a dictionary of strings (example: `"local": { "os": "macos" }`). + +## Response code + +`200` Indicates a successful call. +`400` For an invalid request. +`401` For an invalid api key. + +## Example + +```js +POST /api/ingest_manager/fleet/agents/enroll +Authorization: ApiKey VALID_API_KEY +{ + "type": "PERMANENT", + "metadata": { + "local": { "os": "macos"}, + "userProvided": { "region": "us-east"} + } +} +``` + +The API returns the following: + +```js +{ + "action": "created", + "success": true, + "item": { + "id": "a4937110-e53e-11e9-934f-47a8e38a522c", + "active": true, + "config_id": "default", + "type": "PERMANENT", + "enrolled_at": "2019-10-02T18:01:22.337Z", + "user_provided_metadata": {}, + "local_metadata": {}, + "actions": [], + "access_api_key": "ACCESS_API_KEY" + } +} +``` + +## Expected errors + +The API will return a response with a `401` status code and an error if the enrollment apiKey is invalid like this: + +```js +{ + "statusCode": 401, + "error": "Unauthorized", + "message": "Enrollment apiKey is not valid: Enrollement api key does not exists or is not active" +} +``` + +The API will return a response with a `400` status code and an error if you enroll an agent with the same `shared_id` than an already active agent: + +```js +{ + "statusCode": 400, + "error": "BadRequest", + "message": "Impossible to enroll an already active agent" +} +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api/agents_list.md b/x-pack/plugins/ingest_manager/dev_docs/api/agents_list.md new file mode 100644 index 0000000000000..38f80a8bdc022 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api/agents_list.md @@ -0,0 +1,22 @@ +# Fleet agent listing API + +## Request + +`GET /api/ingest_manager/fleet/agents` + +## Query + +- `showInactive` (Optional, boolean) Show inactive agents (default to false) +- `kuery` (Optional, string) Filter using kibana query language +- `page` (Optional, number) +- `perPage` (Optional, number) + +## Response code + +- `200` Indicates a successful call. + +## Example + +```js +GET /api/ingest_manager/fleet/agents?kuery=agents.last_checkin:2019-10-01T13:42:54.323Z +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api/agents_unenroll.md b/x-pack/plugins/ingest_manager/dev_docs/api/agents_unenroll.md new file mode 100644 index 0000000000000..13b0aadd7689d --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api/agents_unenroll.md @@ -0,0 +1,40 @@ +# Enroll Fleet agent API + +Unenroll an agent + +## Request + +`POST /api/ingest_manager/fleet/agents/unenroll` + +## Request body + +- `ids` (Optional, string) An list of agent id to unenroll. +- `kuery` (Optional, string) a kibana query to search for agent to unenroll. + +> Note: one and only of this keys should be present: + +## Response code + +`200` Indicates a successful call. + +## Example + +```js +POST /api/ingest_manager/fleet/agents/enroll +{ + "ids": ['agent1'], +} +``` + +The API returns the following: + +```js +{ + "results": [{ + "success":true, + "id":"agent1", + "action":"unenrolled" + }], + "success":true +} +``` diff --git a/x-pack/plugins/ingest_manager/dev_docs/api_keys.md b/x-pack/plugins/ingest_manager/dev_docs/api_keys.md new file mode 100644 index 0000000000000..95d7ba1963531 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/api_keys.md @@ -0,0 +1,15 @@ +# Fleet tokens + +Fleet uses 3 types of API Keys: + +1. Enrollment API Keys - A long lived token with optional rules around assignment of policy when enrolling. It is used to enroll N agents. + +2. Access API Keys - Generated during enrollment and hidden from the user. This token is used to communicate with Kibana and is unique to each agent. This allows a single agent to be revoked without affecting other agents or their data ingestion ability. + +3. Output API Keys - This is used by the agent to ship data to ES. At the moment this is one token per unique output cluster per policy due to the scale needed from ES tokens not currently being supported. Once ES can accept the levels of scale needed, we would like to move to one token per agent. + +### FAQ + +- Can't we work on solving some of these issues and thus make this even easier? + +Yes, and we plan to. This is the first phase of how this will work, and we plan to reduce complexity over time. Because we have automated most of the complexity, all the user will notice is shorter and shorter tokens. diff --git a/x-pack/plugins/ingest_manager/dev_docs/fleet_agent_communication.md b/x-pack/plugins/ingest_manager/dev_docs/fleet_agent_communication.md new file mode 100644 index 0000000000000..8430983dc4e1d --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/fleet_agent_communication.md @@ -0,0 +1,32 @@ +# Fleet <> Agent communication protocal + +1. Makes request to the [`agent/enroll` endpoint](/docs/api/fleet.asciidoc) using the [enrollment API key](api_keys.md) as a barrier token, the policy ID being enrolled to, and the type of the agent. + +2. Fleet verifies the Enrollment API key is valid. And returns back a unique [access API key](api_keys.md). + +This Auth API key is created to work only for the assigned policy. +The auth API key is assigned to the combination of agent and policy, and the policy can be swapped out dynamically without creating a new auth API key. + +3. The agent now "checks in" with Fleet. + +The agent uses the access token to post its current event queue to [`agent/checkin`](/docs/api/fleet.asciidoc). The endpoint will return the agent's assigned policy and an array of actions for the agent or its software to run. +The agent continues posting events and receiving updated policy changes every 30 sec or via polling settings in the policy. + +4. The agent takes the returned policy and array of actions and first reloads any policy changes. It then runs any/all actions starting at index 0. + +### If an agent / host is compromised + +1. The user via the UI or API invalidates an agent's auth API key in Fleet by "unenrolling" an agent. + +2. At the time of the agent's next checkin, auth will fail resulting in a 403 error. + +3. The agent will stop polling and delete the locally cached policy. + +4. It is **/strongly/** recommended that if an agent is compromised, the outputs used on the given agent delete their ES access tokens, and regenerate them. + +To re-enable the agent, it must be re-enrolled. Permanent and temporary agents maintain state in Fleet. If one is re-enrolled a new auth token is generated and the agent is able to resume as it was. If this is not desired, the agent will be listed in a disabled state (`active: false`) and from the details screen it can be deleted. + +### If an enrollment token is compromised + +Fleet only supports a single active enrollment token at a time. If one becomes compromised, it is canceled and regenerated. +The singular enrollment token helps to reduce complexity, and also helps to reenforce to users that this token is an "admin" token in that it has a great deal of power, thus should be kept secret/safe. diff --git a/x-pack/plugins/ingest_manager/dev_docs/fleet_agents_interactions_detailed.md b/x-pack/plugins/ingest_manager/dev_docs/fleet_agents_interactions_detailed.md new file mode 100644 index 0000000000000..ac7005063da9d --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/fleet_agents_interactions_detailed.md @@ -0,0 +1,49 @@ +# Fleet <-> Agent Interactions + +## Agent enrollment and checkin + +Fleet workflow: + +- an agent enroll to fleet using an enrollmentAPiKey +- Every n seconds agent is polling the checkin API to send events and check for new configuration + +### Agent enrollment + +An agent must enroll using the REST Api provided by fleet. +When an agent enroll Fleet: + +- verify the API Key is a valid ES API key +- retrieve the Saved Object (SO) associated to this api key id (this SO contains the configuration|policy id) +- create an ES ApiKey unique to the agent for accessing kibana during checkin +- create an ES ApiKey per output to send logs and metrics to the output +- Save the new agent in a SO with keys encrypted inside the agent SO object + +![](schema/agent_enroll.png) + +### Agent checkin + +Agent are going to poll the checkin API to send events and check for new configration. To checkin agent are going to use the REST Api provided by fleet. + +When an agent checkin fleet: + +- verify the access API Key is a valid ES API key +- retrieve the agent (SO associated to this api key id) +- Insert events SO +- If the Agent configuration has been updated since last checkin + - generate the agent config + - Create the missing API key for agent -> ES communication +- Save the new agent (with last checkin date) in a SavedObject with keys encrypted inside the agent + +![](schema/agent_checkin.png) + +### Agent acknowledgement + +This is really similar to the checkin (same auth mecanism) and it's used for agent to acknowlege action received during checkin. + +An agent can acknowledge one or multiple actions by calling `POST /api/ingest_manager/fleet/agents/{agentId}/acks` + +## Other interactions + +### Agent Configuration update + +When a configuration is updated, every SO agent running this configuration is updated with a timestamp of the latest config. diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.mml b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.mml new file mode 100644 index 0000000000000..a5332c50ab947 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.mml @@ -0,0 +1,37 @@ +sequenceDiagram + participant Agent + participant Fleet API + participant SavedObjects + participant Elasticsearch + + Agent->>Fleet API: checkin(
accessAPIKey, events, updatedMetadata
) + rect rgba(191, 223, 255, .2) + Note over Fleet API,Elasticsearch: Authenticate the agent + Fleet API->>Elasticsearch: GET /_security/privileges + Fleet API->>SavedObjects: getAgent(apiKeyId) + Note right of SavedObjects: encrypted SO here + end + + alt If configuration updated since last checkin + Fleet API->>SavedObjects: getAgentConfiguration(configId) + + opt If there is not API Key for default output + Fleet API->>Elasticsearch: createAgentESApiKey() + end + end + + rect rgba(191, 223, 255, .2) + Note over Fleet API,Elasticsearch: Process agent events
(going to move to the agent directly) + Fleet API->>SavedObjects: createAgentEvents(events) + end + + + + rect rgba(191, 223, 255, .2) + Note over Fleet API,Elasticsearch: Update agent + Fleet API->>SavedObjects: updateAgent(metadata, checkinAt) + + end + + + Fleet API->>Agent: actions|agent config diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.png b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_checkin.png new file mode 100644 index 0000000000000000000000000000000000000000..cfb068b509b3a3d1216786249cad23256708ff08 GIT binary patch literal 96703 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 z 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 literal 0 HcmV?d00001 diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.mml b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.mml new file mode 100644 index 0000000000000..8b8acbdcc68f2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.mml @@ -0,0 +1,17 @@ +sequenceDiagram + participant Agent + participant Fleet API + participant SavedObjects + participant Elasticsearch + + Agent->>Fleet API: enroll(enrollmentAPIKey) + + rect rgba(191, 223, 255, 0.2) + Note over Fleet API,Elasticsearch: Verify API Key + Fleet API->>Elasticsearch: GET /\_security/privileges + Fleet API->>SavedObjects: getEnrollmentApiKey(apiKeyId) + end + + + + Fleet API->>Agent: success diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.png b/x-pack/plugins/ingest_manager/dev_docs/schema/agent_enroll.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc0cdabb4aed33c74bd143dde93fcf8f1cf68d3 GIT binary patch literal 84785 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 lB<}(vZtE^5({|C-KSJ(gm literal 0 HcmV?d00001 diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.mml b/x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.mml new file mode 100644 index 0000000000000..bb6dbc1037201 --- /dev/null +++ b/x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.mml @@ -0,0 +1,82 @@ +classDiagram + agent_configs "1" -- "*" datasources + agent_configs "1" -- "*" enrollment_api_keys + agent_configs "1" -- "*" agents : is used + agent_configs "*" -- "*" outputs + agents "1" -- "*" agent_events + agents "1" -- "*" agent_events + package "1" -- "*" datasources + + class package { + installed + } + + class agents { + status + access_api_key_id + config_id + last_checkin + local_metadata + user_provided_metadata + actions // Encrypted contains new agent config + default_api_key // Encrypted + + } + + class agent_events { + type + subtype + agent_id + action_id + config_id + stream_id + timestamp + message + payload + data + } + + class agent_configs { + datasources // datasource ids + name + namespace + description + status + } + + class datasources { + name + namespace + config_id + enabled + package + output_id + // Inputs + inputs.type + inputs.enabled + inputs.processors + // Inputs streams + inputs.streams.id + inputs.streams.enabled + inputs.streams.dataset + inputs.streams.processors + inputs.streams.config + } + + class enrollment_api_keys { + config_id + api_key_id + api_key // Encrypted + } + + + class outputs { + id + hosts + ca_sha256 + config + // Encrypted - user to create API keys + admin_username + admin_password + } + diff --git a/x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.png b/x-pack/plugins/ingest_manager/dev_docs/schema/saved_objects.png new file mode 100644 index 0000000000000000000000000000000000000000..dcfc6d33eae99d70ef1c0e203f1a2cde5a0cf7ae GIT binary patch literal 92818 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 R+u*+&((+eRFW-6m{{h)Nzli_< literal 0 HcmV?d00001 diff --git a/x-pack/plugins/ingest_manager/package.json b/x-pack/plugins/ingest_manager/package.json new file mode 100644 index 0000000000000..56b7df6b56852 --- /dev/null +++ b/x-pack/plugins/ingest_manager/package.json @@ -0,0 +1,11 @@ +{ + "author": "Elastic", + "name": "ingest-manager", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "dependencies": { + "react-markdown": "^4.2.2" + } + } + \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/error.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/error.tsx new file mode 100644 index 0000000000000..b9659016fec09 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/error.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; + +export const Error: React.FunctionComponent<{ + title: JSX.Element; + error: Error | string; +}> = ({ title, error }) => { + return ( + +

{typeof error === 'string' ? error : error.message}

+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx index 0936b5dcfed10..c87a77320d3f7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/header.tsx @@ -18,6 +18,8 @@ const Wrapper = styled.div` margin-left: auto; margin-right: auto; padding-top: ${props => props.theme.eui.paddingSizes.xl}; + padding-left: ${props => props.theme.eui.paddingSizes.m}; + padding-right: ${props => props.theme.eui.paddingSizes.m}; `; const Tabs = styled(EuiTabs)` @@ -36,7 +38,7 @@ export interface HeaderProps { export const Header: React.FC = ({ leftColumn, rightColumn, tabs }) => ( - + {leftColumn ? {leftColumn} : null} {rightColumn ? {rightColumn} : null} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts index b6bb29462c569..5551bff2c8bde 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ export { Loading } from './loading'; +export { Error } from './error'; export { Header, HeaderProps } from './header'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx new file mode 100644 index 0000000000000..41c24dadba068 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/search_bar.tsx @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { IFieldType } from 'src/plugins/data/public'; +// @ts-ignore +import { EuiSuggest, EuiSuggestItemProps } from '@elastic/eui'; +import { useDebounce } from '../hooks'; +import { useStartDeps } from '../hooks/use_deps'; +import { INDEX_NAME } from '../constants'; + +const DEBOUNCE_SEARCH_MS = 150; +const HIDDEN_FIELDS = ['agents.actions']; + +interface Suggestion { + label: string; + description: string; + value: string; + type: { + color: string; + iconType: string; + }; + start: number; + end: number; +} + +interface Props { + value: string; + fieldPrefix: string; + onChange: (newValue: string) => void; +} + +export const SearchBar: React.FunctionComponent = ({ value, fieldPrefix, onChange }) => { + const { suggestions } = useSuggestions(fieldPrefix, value); + + // TODO fix type when correctly typed in EUI + const onAutocompleteClick = (suggestion: any) => { + onChange( + [value.slice(0, suggestion.start), suggestion.value, value.slice(suggestion.end, -1)].join('') + ); + }; + // TODO fix type when correctly typed in EUI + const onChangeSearch = (e: any) => { + onChange(e.value); + }; + + return ( + { + return { + ...suggestion, + // For type + onClick: () => {}, + }; + })} + /> + ); +}; + +function transformSuggestionType(type: string): { iconType: string; color: string } { + switch (type) { + case 'field': + return { iconType: 'kqlField', color: 'tint4' }; + case 'value': + return { iconType: 'kqlValue', color: 'tint0' }; + case 'conjunction': + return { iconType: 'kqlSelector', color: 'tint3' }; + case 'operator': + return { iconType: 'kqlOperand', color: 'tint1' }; + default: + return { iconType: 'kqlOther', color: 'tint1' }; + } +} + +function useSuggestions(fieldPrefix: string, search: string) { + const { data } = useStartDeps(); + + const debouncedSearch = useDebounce(search, DEBOUNCE_SEARCH_MS); + const [suggestions, setSuggestions] = useState([]); + + const fetchSuggestions = async () => { + try { + const res = (await data.indexPatterns.getFieldsForWildcard({ + pattern: INDEX_NAME, + })) as IFieldType[]; + + if (!data || !data.autocomplete) { + throw new Error('Missing data plugin'); + } + const query = debouncedSearch || ''; + // @ts-ignore + const esSuggestions = ( + await data.autocomplete.getQuerySuggestions({ + language: 'kuery', + indexPatterns: [ + { + title: INDEX_NAME, + fields: res, + }, + ], + boolFilter: [], + query, + selectionStart: query.length, + selectionEnd: query.length, + }) + ) + .filter(suggestion => { + if (suggestion.type === 'conjunction') { + return true; + } + if (suggestion.type === 'value') { + return true; + } + if (suggestion.type === 'operator') { + return true; + } + + if (fieldPrefix && suggestion.text.startsWith(fieldPrefix)) { + for (const hiddenField of HIDDEN_FIELDS) { + if (suggestion.text.startsWith(hiddenField)) { + return false; + } + } + return true; + } + + return false; + }) + .map((suggestion: any) => ({ + label: suggestion.text, + description: suggestion.description || '', + type: transformSuggestionType(suggestion.type), + start: suggestion.start, + end: suggestion.end, + value: suggestion.text, + })); + + setSuggestions(esSuggestions); + } catch (err) { + setSuggestions([]); + } + }; + + useEffect(() => { + fetchSuggestions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch]); + + return { + suggestions, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts index 1af39a60455e0..1ac5bef629fde 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/constants/index.ts @@ -3,16 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -export { - PLUGIN_ID, - EPM_API_ROUTES, - DEFAULT_AGENT_CONFIG_ID, - AGENT_CONFIG_SAVED_OBJECT_TYPE, -} from '../../../../common'; +export { PLUGIN_ID, EPM_API_ROUTES, AGENT_CONFIG_SAVED_OBJECT_TYPE } from '../../../../common'; export const BASE_PATH = '/app/ingestManager'; export const EPM_PATH = '/epm'; +export const EPM_DETAIL_VIEW_PATH = `${EPM_PATH}/detail/:pkgkey/:panel?`; export const AGENT_CONFIG_PATH = '/configs'; export const AGENT_CONFIG_DETAILS_PATH = `${AGENT_CONFIG_PATH}/`; export const FLEET_PATH = '/fleet'; +export const FLEET_AGENTS_PATH = `${FLEET_PATH}/agents`; +export const FLEET_AGENT_DETAIL_PATH = `${FLEET_AGENTS_PATH}/`; + +export const INDEX_NAME = '.kibana'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts index a224b599c13af..5e0695bd3e305 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/index.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export { useCapabilities } from './use_capabilities'; export { useCore, CoreContext } from './use_core'; export { useConfig, ConfigContext } from './use_config'; -export { useDeps, DepsContext } from './use_deps'; +export { useSetupDeps, useStartDeps, DepsContext } from './use_deps'; export { useLink } from './use_link'; -export { usePagination } from './use_pagination'; +export { usePagination, Pagination } from './use_pagination'; export { useDebounce } from './use_debounce'; export * from './use_request'; +export * from './use_input'; +export * from './use_url_params'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_capabilities.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_capabilities.ts new file mode 100644 index 0000000000000..0a16c4a62a7d1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_capabilities.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCore } from './'; + +export function useCapabilities() { + const core = useCore(); + return core.application.capabilities.ingestManager; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts index a2e2f278930e3..25e4ee8fca43c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_deps.ts @@ -5,14 +5,25 @@ */ import React, { useContext } from 'react'; -import { IngestManagerSetupDeps } from '../../../plugin'; +import { IngestManagerSetupDeps, IngestManagerStartDeps } from '../../../plugin'; -export const DepsContext = React.createContext(null); +export const DepsContext = React.createContext<{ + setup: IngestManagerSetupDeps; + start: IngestManagerStartDeps; +} | null>(null); -export function useDeps() { +export function useSetupDeps() { const deps = useContext(DepsContext); if (deps === null) { throw new Error('DepsContext not initialized'); } - return deps; + return deps.setup; +} + +export function useStartDeps() { + const deps = useContext(DepsContext); + if (deps === null) { + throw new Error('StartDepsContext not initialized'); + } + return deps.start; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_input.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_input.ts new file mode 100644 index 0000000000000..4aa0ad7155d2f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_input.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +export function useInput(defaultValue = '') { + const [value, setValue] = React.useState(defaultValue); + + return { + value, + props: { + onChange: (e: React.ChangeEvent) => { + setValue(e.target.value); + }, + value, + }, + clear: () => { + setValue(''); + }, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx index ae0352a33b2ff..699bba3c62f97 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_pagination.tsx @@ -20,6 +20,6 @@ export function usePagination() { return { pagination, setPagination, - pageSizeOptions: 20, + pageSizeOptions: [5, 20, 50], }; } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index 389909e1d99ef..c2981aee42ad3 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -3,18 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { HttpFetchQuery } from 'kibana/public'; import { useRequest, sendRequest } from './use_request'; import { agentConfigRouteService } from '../../services'; import { GetAgentConfigsResponse, GetOneAgentConfigResponse, - CreateAgentConfigRequestSchema, + CreateAgentConfigRequest, CreateAgentConfigResponse, - UpdateAgentConfigRequestSchema, + UpdateAgentConfigRequest, UpdateAgentConfigResponse, - DeleteAgentConfigsRequestSchema, + DeleteAgentConfigsRequest, DeleteAgentConfigsResponse, } from '../../types'; @@ -33,7 +32,14 @@ export const useGetOneAgentConfig = (agentConfigId: string) => { }); }; -export const sendCreateAgentConfig = (body: CreateAgentConfigRequestSchema['body']) => { +export const sendGetOneAgentConfig = (agentConfigId: string) => { + return sendRequest({ + path: agentConfigRouteService.getInfoPath(agentConfigId), + method: 'get', + }); +}; + +export const sendCreateAgentConfig = (body: CreateAgentConfigRequest['body']) => { return sendRequest({ path: agentConfigRouteService.getCreatePath(), method: 'post', @@ -43,7 +49,7 @@ export const sendCreateAgentConfig = (body: CreateAgentConfigRequestSchema['body export const sendUpdateAgentConfig = ( agentConfigId: string, - body: UpdateAgentConfigRequestSchema['body'] + body: UpdateAgentConfigRequest['body'] ) => { return sendRequest({ path: agentConfigRouteService.getUpdatePath(agentConfigId), @@ -52,7 +58,7 @@ export const sendUpdateAgentConfig = ( }); }; -export const sendDeleteAgentConfigs = (body: DeleteAgentConfigsRequestSchema['body']) => { +export const sendDeleteAgentConfigs = (body: DeleteAgentConfigsRequest['body']) => { return sendRequest({ path: agentConfigRouteService.getDeletePath(), method: 'post', diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts new file mode 100644 index 0000000000000..f08b950e71ea8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agents.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRequest, UseRequestConfig, sendRequest } from './use_request'; +import { agentRouteService } from '../../services'; +import { + GetOneAgentResponse, + GetOneAgentEventsResponse, + GetOneAgentEventsRequest, + GetAgentsRequest, + GetAgentsResponse, + GetAgentStatusRequest, + GetAgentStatusResponse, +} from '../../types'; + +type RequestOptions = Pick, 'pollIntervalMs'>; + +export function useGetOneAgent(agentId: string, options?: RequestOptions) { + return useRequest({ + path: agentRouteService.getInfoPath(agentId), + method: 'get', + ...options, + }); +} + +export function useGetOneAgentEvents( + agentId: string, + query: GetOneAgentEventsRequest['query'], + options?: RequestOptions +) { + return useRequest({ + path: agentRouteService.getEventsPath(agentId), + method: 'get', + query, + ...options, + }); +} + +export function useGetAgents(query: GetAgentsRequest['query'], options?: RequestOptions) { + return useRequest({ + method: 'get', + path: agentRouteService.getListPath(), + query, + ...options, + }); +} + +export function sendGetAgentStatus( + query: GetAgentStatusRequest['query'], + options?: RequestOptions +) { + return sendRequest({ + method: 'get', + path: agentRouteService.getStatusPath(), + query, + ...options, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts new file mode 100644 index 0000000000000..60fbb9f0d2afa --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/datasource.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { sendRequest } from './use_request'; +import { datasourceRouteService } from '../../services'; +import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types'; + +export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => { + return sendRequest({ + path: datasourceRouteService.getCreatePath(), + method: 'post', + body: JSON.stringify(body), + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts new file mode 100644 index 0000000000000..2640f36423a0c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRequest, UseRequestConfig } from './use_request'; +import { enrollmentAPIKeyRouteService } from '../../services'; +import { GetOneEnrollmentAPIKeyResponse, GetEnrollmentAPIKeysResponse } from '../../types'; + +type RequestOptions = Pick, 'pollIntervalMs'>; + +export function useGetOneEnrollmentAPIKey(keyId: string, options?: RequestOptions) { + return useRequest({ + method: 'get', + path: enrollmentAPIKeyRouteService.getInfoPath(keyId), + ...options, + }); +} + +export function useGetEnrollmentAPIKeys(options?: RequestOptions) { + return useRequest({ + method: 'get', + path: enrollmentAPIKeyRouteService.getListPath(), + ...options, + }); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts new file mode 100644 index 0000000000000..02865ffe6fb1a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/epm.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpFetchQuery } from 'kibana/public'; +import { useRequest, sendRequest } from './use_request'; +import { epmRouteService } from '../../services'; +import { + GetCategoriesResponse, + GetPackagesResponse, + GetInfoResponse, + InstallPackageResponse, + DeletePackageResponse, +} from '../../types'; + +export const useGetCategories = () => { + return useRequest({ + path: epmRouteService.getCategoriesPath(), + method: 'get', + }); +}; + +export const useGetPackages = (query: HttpFetchQuery = {}) => { + return useRequest({ + path: epmRouteService.getListPath(), + method: 'get', + query, + }); +}; + +export const useGetPackageInfoByKey = (pkgkey: string) => { + return useRequest({ + path: epmRouteService.getInfoPath(pkgkey), + method: 'get', + }); +}; + +export const sendGetPackageInfoByKey = (pkgkey: string) => { + return sendRequest({ + path: epmRouteService.getInfoPath(pkgkey), + method: 'get', + }); +}; + +export const sendGetFileByPath = (filePath: string) => { + return sendRequest({ + path: epmRouteService.getFilePath(filePath), + method: 'get', + }); +}; + +export const sendInstallPackage = (pkgkey: string) => { + return sendRequest({ + path: epmRouteService.getInstallPath(pkgkey), + method: 'post', + }); +}; + +export const sendRemovePackage = (pkgkey: string) => { + return sendRequest({ + path: epmRouteService.getRemovePath(pkgkey), + method: 'delete', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts index 68d67080d90ba..5014049407e65 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/index.ts @@ -3,5 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { setHttpClient, sendRequest } from './use_request'; +export { setHttpClient, sendRequest, useRequest } from './use_request'; export * from './agent_config'; +export * from './datasource'; +export * from './agents'; +export * from './enrollment_api_keys'; +export * from './epm'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts new file mode 100644 index 0000000000000..04fdf9f66948f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/setup.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sendRequest } from './use_request'; +import { setupRouteService } from '../../services'; + +export const sendSetup = () => { + return sendRequest({ + path: setupRouteService.getSetupPath(), + method: 'post', + }); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts index 12b4d0bdf7df6..4b434bd1a149e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/use_request.ts @@ -7,13 +7,15 @@ import { HttpSetup } from 'kibana/public'; import { SendRequestConfig, SendRequestResponse, - UseRequestConfig, + UseRequestConfig as _UseRequestConfig, sendRequest as _sendRequest, useRequest as _useRequest, } from '../../../../../../../../src/plugins/es_ui_shared/public'; let httpClient: HttpSetup; +export type UseRequestConfig = _UseRequestConfig; + export const setHttpClient = (client: HttpSetup) => { httpClient = client; }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_url_params.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_url_params.ts new file mode 100644 index 0000000000000..817d2dad88f0a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_url_params.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useLocation } from 'react-router-dom'; +import { parse, stringify } from 'query-string'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * Parses `search` params and returns an object with them along with a `toUrlParams` function + * that allows being able to retrieve a stringified version of an object (default is the + * `urlParams` that was parsed) for use in the url. + * Object will be recreated every time `search` changes. + */ +export function useUrlParams() { + const { search } = useLocation(); + const [urlParams, setUrlParams] = useState(() => parse(search)); + const toUrlParams = useCallback((params = urlParams) => stringify(params), [urlParams]); + useEffect(() => { + setUrlParams(parse(search)); + }, [search]); + return { + urlParams, + toUrlParams, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 935eb42d0347e..9a85358a2a69c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -3,18 +3,26 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; import { useObservable } from 'react-use'; import { HashRouter as Router, Redirect, Switch, Route, RouteProps } from 'react-router-dom'; -import { CoreStart, AppMountParameters } from 'kibana/public'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiErrorBoundary } from '@elastic/eui'; +import { CoreStart, AppMountParameters } from 'kibana/public'; import { EuiThemeProvider } from '../../../../../legacy/common/eui_styled_components'; -import { IngestManagerSetupDeps, IngestManagerConfigType } from '../../plugin'; +import { + IngestManagerSetupDeps, + IngestManagerConfigType, + IngestManagerStartDeps, +} from '../../plugin'; import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from './constants'; -import { DefaultLayout } from './layouts'; +import { DefaultLayout, WithoutHeaderLayout } from './layouts'; +import { Loading, Error } from './components'; import { IngestManagerOverview, EPMApp, AgentConfigApp, FleetApp } from './sections'; import { CoreContext, DepsContext, ConfigContext, setHttpClient, useConfig } from './hooks'; +import { PackageInstallProvider } from './sections/epm/hooks'; +import { sendSetup } from './hooks/use_request/setup'; export interface ProtectedRouteProps extends RouteProps { isAllowed?: boolean; @@ -32,6 +40,50 @@ export const ProtectedRoute: React.FunctionComponent = ({ const IngestManagerRoutes = ({ ...rest }) => { const { epm, fleet } = useConfig(); + const [isInitialized, setIsInitialized] = useState(false); + const [initializationError, setInitializationError] = useState(null); + + useEffect(() => { + (async () => { + setIsInitialized(false); + setInitializationError(null); + try { + const res = await sendSetup(); + if (res.error) { + setInitializationError(res.error); + } + } catch (err) { + setInitializationError(err); + } + setIsInitialized(true); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!isInitialized || initializationError) { + return ( + + + + {initializationError ? ( + + } + error={initializationError} + /> + ) : ( + + )} + + + + ); + } + return ( @@ -66,22 +118,26 @@ const IngestManagerRoutes = ({ ...rest }) => { const IngestManagerApp = ({ basepath, coreStart, - deps, + setupDeps, + startDeps, config, }: { basepath: string; coreStart: CoreStart; - deps: IngestManagerSetupDeps; + setupDeps: IngestManagerSetupDeps; + startDeps: IngestManagerStartDeps; config: IngestManagerConfigType; }) => { const isDarkMode = useObservable(coreStart.uiSettings.get$('theme:darkMode')); return ( - + - + + + @@ -93,12 +149,19 @@ const IngestManagerApp = ({ export function renderApp( coreStart: CoreStart, { element, appBasePath }: AppMountParameters, - deps: IngestManagerSetupDeps, + setupDeps: IngestManagerSetupDeps, + startDeps: IngestManagerStartDeps, config: IngestManagerConfigType ) { setHttpClient(coreStart.http); ReactDOM.render( - , + , element ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx index f99d1bfe50026..8ec2d2ec03b35 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx @@ -12,7 +12,7 @@ import { useLink, useConfig } from '../hooks'; import { EPM_PATH, FLEET_PATH, AGENT_CONFIG_PATH } from '../constants'; interface Props { - section: Section; + section?: Section; children?: React.ReactNode; } @@ -43,7 +43,7 @@ export const DefaultLayout: React.FunctionComponent = ({ section, childre - + = ({ children, ...rest }) => ( +export const WithHeaderLayout: React.FC = ({ restrictWidth, children, ...rest }) => (
- + {children} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/without_header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/without_header.tsx new file mode 100644 index 0000000000000..cad98c5a0a7e1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/without_header.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; + +const Page = styled(EuiPage)` + background: ${props => props.theme.eui.euiColorEmptyShade}; +`; + +interface Props { + restrictWidth?: number; + children?: React.ReactNode; +} + +export const WithoutHeaderLayout: React.FC = ({ restrictWidth, children }) => ( + + + + + {children} + + + +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx index 6f51415a562a3..b18349e078766 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_delete_provider.tsx @@ -119,10 +119,10 @@ export const AgentConfigDeleteProvider: React.FunctionComponent = ({ chil } setIsLoadingAgentsCount(true); const { data } = await sendRequest<{ total: number }>({ - path: `/api/fleet/agents`, + path: `/api/ingest_manager/fleet/agents`, method: 'get', query: { - kuery: `agents.policy_id : (${agentConfigsToCheck.join(' or ')})`, + kuery: `agents.config_id : (${agentConfigsToCheck.join(' or ')})`, }, }); setAgentsCount(data?.total || 0); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx index 5a25dc8bc92b5..c1c9ce507c92c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_form.tsx @@ -4,15 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; -import { EuiFieldText, EuiForm, EuiFormRow } from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { + EuiAccordion, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + EuiSwitch, + EuiText, + EuiComboBox, + EuiIconTip, +} from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; import { NewAgentConfig } from '../../../types'; interface ValidationResults { [key: string]: JSX.Element[]; } +const StyledEuiAccordion = styled(EuiAccordion)` + .ingest-active-button { + color: ${props => props.theme.eui.euiColorPrimary}}; + } +`; + export const agentConfigFormValidation = ( agentConfig: Partial ): ValidationResults => { @@ -42,55 +63,185 @@ export const AgentConfigForm: React.FunctionComponent = ({ validation, }) => { const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); - const fields: Array<{ name: 'name' | 'description' | 'namespace'; label: JSX.Element }> = [ - { - name: 'name', - label: ( - - ), - }, - { - name: 'description', - label: ( - - ), - }, - { - name: 'namespace', - label: ( - - ), - }, - ]; + const [showNamespace, setShowNamespace] = useState(false); + const fields: Array<{ + name: 'name' | 'description' | 'namespace'; + label: JSX.Element; + placeholder: string; + }> = useMemo(() => { + return [ + { + name: 'name', + label: ( + + ), + placeholder: i18n.translate('xpack.ingestManager.agentConfigForm.nameFieldPlaceholder', { + defaultMessage: 'Choose a name', + }), + }, + { + name: 'description', + label: ( + + ), + placeholder: i18n.translate( + 'xpack.ingestManager.agentConfigForm.descriptionFieldPlaceholder', + { + defaultMessage: 'How will this configuration be used?', + } + ), + }, + ]; + }, []); return ( - {fields.map(({ name, label }) => { + {fields.map(({ name, label, placeholder }) => { return ( updateAgentConfig({ [name]: e.target.value })} isInvalid={Boolean(touchedFields[name] && validation[name])} onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })} + placeholder={placeholder} /> ); })} + + + + } + > + + {' '} + + + } + checked={true} + onChange={() => { + // FIXME: enable collection of system metrics - see: https://github.com/elastic/kibana/issues/59564 + }} + /> + + + + + } + buttonClassName="ingest-active-button" + > + + + + +

+ +

+
+ + + + +
+ + + } + checked={showNamespace} + onChange={() => { + setShowNamespace(!showNamespace); + if (showNamespace) { + updateAgentConfig({ namespace: '' }); + } + }} + /> + {showNamespace && ( + <> + + + { + updateAgentConfig({ namespace: value }); + }} + onChange={selectedOptions => { + updateAgentConfig({ + namespace: (selectedOptions.length ? selectedOptions[0] : '') as string, + }); + }} + isInvalid={Boolean(touchedFields.namespace && validation.namespace)} + onBlur={() => setTouchedFields({ ...touchedFields, namespace: true })} + /> + + + )} + +
+
); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts index d838221cd844e..a0fdc656dd7ed 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/index.ts @@ -6,3 +6,4 @@ export { AgentConfigForm, agentConfigFormValidation } from './config_form'; export { AgentConfigDeleteProvider } from './config_delete_provider'; +export { LinkedAgentCount } from './linked_agent_count'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx new file mode 100644 index 0000000000000..ec66108c60f68 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/linked_agent_count.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiLink } from '@elastic/eui'; +import { useLink } from '../../../hooks'; +import { FLEET_AGENTS_PATH } from '../../../constants'; + +export const LinkedAgentCount = memo<{ count: number; agentConfigId: string }>( + ({ count, agentConfigId }) => { + const FLEET_URI = useLink(FLEET_AGENTS_PATH); + const displayValue = ( + + ); + return count > 0 ? ( + + {displayValue} + + ) : ( + displayValue + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx new file mode 100644 index 0000000000000..39f2f048ab88d --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_config.tsx @@ -0,0 +1,136 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiTitle, +} from '@elastic/eui'; +import { DatasourceInput, RegistryVarsEntry } from '../../../../types'; +import { DatasourceInputVarField } from './datasource_input_var_field'; + +export const DatasourceInputConfig: React.FunctionComponent<{ + packageInputVars?: RegistryVarsEntry[]; + datasourceInput: DatasourceInput; + updateDatasourceInput: (updatedInput: Partial) => void; +}> = ({ packageInputVars, datasourceInput, updateDatasourceInput }) => { + // Showing advanced options toggle state + const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); + + const requiredVars: RegistryVarsEntry[] = []; + const advancedVars: RegistryVarsEntry[] = []; + + if (packageInputVars) { + packageInputVars.forEach(varDef => { + if (varDef.required && !varDef.default) { + requiredVars.push(varDef); + } else { + advancedVars.push(varDef); + } + }); + } + + return ( + + + +

+ +

+
+ + +

+ +

+
+
+ + + {requiredVars.map(varDef => { + const varName = varDef.name; + const value = datasourceInput.streams[0].config![varName]; + return ( + + { + updateDatasourceInput({ + streams: datasourceInput.streams.map(stream => ({ + ...stream, + config: { + ...stream.config, + [varName]: newValue, + }, + })), + }); + }} + /> + + ); + })} + {advancedVars.length ? ( + + + {/* Wrapper div to prevent button from going full width */} +
+ setIsShowingAdvanced(!isShowingAdvanced)} + flush="left" + > + + +
+
+ {isShowingAdvanced + ? advancedVars.map(varDef => { + const varName = varDef.name; + const value = datasourceInput.streams[0].config![varName]; + return ( + + { + updateDatasourceInput({ + streams: datasourceInput.streams.map(stream => ({ + ...stream, + config: { + ...stream.config, + [varName]: newValue, + }, + })), + }); + }} + /> + + ); + }) + : null} +
+ ) : null} +
+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx new file mode 100644 index 0000000000000..74b08f48df12d --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_panel.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, Fragment } from 'react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiTextColor, + EuiButtonIcon, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; +import { DatasourceInput, DatasourceInputStream, RegistryInput } from '../../../../types'; +import { DatasourceInputConfig } from './datasource_input_config'; +import { DatasourceInputStreamConfig } from './datasource_input_stream_config'; + +const FlushHorizontalRule = styled(EuiHorizontalRule)` + margin-left: -${props => props.theme.eui.paddingSizes.m}; + margin-right: -${props => props.theme.eui.paddingSizes.m}; + width: auto; +`; + +export const DatasourceInputPanel: React.FunctionComponent<{ + packageInput: RegistryInput; + datasourceInput: DatasourceInput; + updateDatasourceInput: (updatedInput: Partial) => void; +}> = ({ packageInput, datasourceInput, updateDatasourceInput }) => { + // Showing streams toggle state + const [isShowingStreams, setIsShowingStreams] = useState(false); + + return ( + + {/* Header / input-level toggle */} + + + +

{packageInput.title || packageInput.type}

+ + } + checked={datasourceInput.enabled} + onChange={e => { + const enabled = e.target.checked; + updateDatasourceInput({ + enabled, + streams: datasourceInput.streams.map(stream => ({ + ...stream, + enabled, + })), + }); + }} + /> +
+ + + + + + + {datasourceInput.streams.filter(stream => stream.enabled).length} + + + ), + total: packageInput.streams.length, + }} + /> + + + + setIsShowingStreams(!isShowingStreams)} + color="text" + aria-label={ + isShowingStreams + ? i18n.translate( + 'xpack.ingestManager.createDatasource.stepConfigure.hideStreamsAriaLabel', + { + defaultMessage: 'Hide {type} streams', + values: { + type: packageInput.type, + }, + } + ) + : i18n.translate( + 'xpack.ingestManager.createDatasource.stepConfigure.showStreamsAriaLabel', + { + defaultMessage: 'Show {type} streams', + values: { + type: packageInput.type, + }, + } + ) + } + /> + + + +
+ + {/* Header rule break */} + {isShowingStreams ? : null} + + {/* Input level configuration */} + {isShowingStreams && packageInput.vars && packageInput.vars.length ? ( + + + + + ) : null} + + {/* Per-stream configuration */} + {isShowingStreams ? ( + + {packageInput.streams.map(packageInputStream => { + const datasourceInputStream = datasourceInput.streams.find( + stream => stream.dataset === packageInputStream.dataset + ); + return datasourceInputStream ? ( + + ) => { + const indexOfUpdatedStream = datasourceInput.streams.findIndex( + stream => stream.dataset === packageInputStream.dataset + ); + const newStreams = [...datasourceInput.streams]; + newStreams[indexOfUpdatedStream] = { + ...newStreams[indexOfUpdatedStream], + ...updatedStream, + }; + + const updatedInput: Partial = { + streams: newStreams, + }; + + // Update input enabled state if needed + if (!datasourceInput.enabled && updatedStream.enabled) { + updatedInput.enabled = true; + } else if ( + datasourceInput.enabled && + !newStreams.find(stream => stream.enabled) + ) { + updatedInput.enabled = false; + } + + updateDatasourceInput(updatedInput); + }} + /> + + + + ) : null; + })} + + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx new file mode 100644 index 0000000000000..e4b138932cb53 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_stream_config.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, Fragment } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiSpacer, + EuiButtonEmpty, +} from '@elastic/eui'; +import { DatasourceInputStream, RegistryStream, RegistryVarsEntry } from '../../../../types'; +import { DatasourceInputVarField } from './datasource_input_var_field'; + +export const DatasourceInputStreamConfig: React.FunctionComponent<{ + packageInputStream: RegistryStream; + datasourceInputStream: DatasourceInputStream; + updateDatasourceInputStream: (updatedStream: Partial) => void; +}> = ({ packageInputStream, datasourceInputStream, updateDatasourceInputStream }) => { + // Showing advanced options toggle state + const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); + + const requiredVars: RegistryVarsEntry[] = []; + const advancedVars: RegistryVarsEntry[] = []; + + if (packageInputStream.vars && packageInputStream.vars.length) { + packageInputStream.vars.forEach(varDef => { + if (varDef.required && !varDef.default) { + requiredVars.push(varDef); + } else { + advancedVars.push(varDef); + } + }); + } + + return ( + + + { + const enabled = e.target.checked; + updateDatasourceInputStream({ + enabled, + }); + }} + /> + {packageInputStream.description ? ( + + + + + + + ) : null} + + + + {requiredVars.map(varDef => { + const varName = varDef.name; + const value = datasourceInputStream.config![varName]; + return ( + + { + updateDatasourceInputStream({ + config: { + ...datasourceInputStream.config, + [varName]: newValue, + }, + }); + }} + /> + + ); + })} + {advancedVars.length ? ( + + + {/* Wrapper div to prevent button from going full width */} +
+ setIsShowingAdvanced(!isShowingAdvanced)} + flush="left" + > + + +
+
+ {isShowingAdvanced + ? advancedVars.map(varDef => { + const varName = varDef.name; + const value = datasourceInputStream.config![varName]; + return ( + + { + updateDatasourceInputStream({ + config: { + ...datasourceInputStream.config, + [varName]: newValue, + }, + }); + }} + /> + + ); + }) + : null} +
+ ) : null} +
+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_var_field.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_var_field.tsx new file mode 100644 index 0000000000000..4186b6a488f54 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/datasource_input_var_field.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiFieldText, EuiComboBox, EuiText } from '@elastic/eui'; +import { RegistryVarsEntry } from '../../../../types'; + +export const DatasourceInputVarField: React.FunctionComponent<{ + varDef: RegistryVarsEntry; + value: any; + onChange: (newValue: any) => void; +}> = ({ varDef, value, onChange }) => { + return ( + + + + ) : null + } + helpText={} + > + {varDef.multi ? ( + ({ label: val }))} + onCreateOption={(newVal: any) => { + onChange([...value, newVal]); + }} + onChange={(newVals: any[]) => { + onChange(newVals.map(val => val.label)); + }} + /> + ) : ( + onChange(e.target.value)} /> + )} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts new file mode 100644 index 0000000000000..e5f18e1449d1b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { CreateDatasourcePageLayout } from './layout'; +export { DatasourceInputPanel } from './datasource_input_panel'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx new file mode 100644 index 0000000000000..c063155c571d2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/layout.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiButtonEmpty, + EuiSpacer, +} from '@elastic/eui'; +import { WithHeaderLayout } from '../../../../layouts'; +import { AgentConfig, PackageInfo } from '../../../../types'; +import { PackageIcon } from '../../../epm/components'; +import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; +import { CreateDatasourceStepsNavigation } from './navigation'; + +export const CreateDatasourcePageLayout: React.FunctionComponent<{ + from: CreateDatasourceFrom; + basePath: string; + cancelUrl: string; + maxStep: CreateDatasourceStep | ''; + currentStep: CreateDatasourceStep; + agentConfig?: AgentConfig; + packageInfo?: PackageInfo; + restrictWidth?: number; +}> = ({ + from, + basePath, + cancelUrl, + maxStep, + currentStep, + agentConfig, + packageInfo, + restrictWidth, + children, +}) => { + return ( + + + + + + + + +

+ +

+
+
+ + + + {agentConfig || from === 'config' ? ( + + + + + + + {agentConfig?.name || '-'} + + + + ) : null} + {packageInfo || from === 'package' ? ( + + + + + + + + + + + + {packageInfo?.title || packageInfo?.name || '-'} + + + + + + ) : null} + + + + } + rightColumn={ + + } + > + {children} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx new file mode 100644 index 0000000000000..099a7a83caa10 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/components/navigation.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import styled from 'styled-components'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { EuiStepsHorizontal } from '@elastic/eui'; +import { CreateDatasourceFrom, CreateDatasourceStep } from '../types'; +import { WeightedCreateDatasourceSteps, CREATE_DATASOURCE_STEP_PATHS } from '../constants'; + +const StepsHorizontal = styled(EuiStepsHorizontal)` + background: none; +`; + +export const CreateDatasourceStepsNavigation: React.FunctionComponent<{ + from: CreateDatasourceFrom; + basePath: string; + maxStep: CreateDatasourceStep | ''; + currentStep: CreateDatasourceStep; +}> = ({ from, basePath, maxStep, currentStep }) => { + const history = useHistory(); + + const steps = [ + from === 'config' + ? { + title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectPackageLabel', { + defaultMessage: 'Select package', + }), + isSelected: currentStep === 'selectPackage', + isComplete: + WeightedCreateDatasourceSteps.indexOf('selectPackage') <= + WeightedCreateDatasourceSteps.indexOf(maxStep), + onClick: () => { + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`); + }, + } + : { + title: i18n.translate('xpack.ingestManager.createDatasource.stepSelectConfigLabel', { + defaultMessage: 'Select configuration', + }), + isSelected: currentStep === 'selectConfig', + isComplete: + WeightedCreateDatasourceSteps.indexOf('selectConfig') <= + WeightedCreateDatasourceSteps.indexOf(maxStep), + onClick: () => { + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`); + }, + }, + { + title: i18n.translate('xpack.ingestManager.createDatasource.stepConfigureDatasourceLabel', { + defaultMessage: 'Configure data source', + }), + isSelected: currentStep === 'configure', + isComplete: + WeightedCreateDatasourceSteps.indexOf('configure') <= + WeightedCreateDatasourceSteps.indexOf(maxStep), + disabled: + WeightedCreateDatasourceSteps.indexOf(maxStep) < + WeightedCreateDatasourceSteps.indexOf('configure') - 1, + onClick: () => { + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); + }, + }, + { + title: i18n.translate('xpack.ingestManager.createDatasource.stepReviewLabel', { + defaultMessage: 'Review', + }), + isSelected: currentStep === 'review', + isComplete: + WeightedCreateDatasourceSteps.indexOf('review') <= + WeightedCreateDatasourceSteps.indexOf(maxStep), + disabled: + WeightedCreateDatasourceSteps.indexOf(maxStep) < + WeightedCreateDatasourceSteps.indexOf('review') - 1, + onClick: () => { + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.review}`); + }, + }, + ]; + + return ; +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts new file mode 100644 index 0000000000000..eea18179560a1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export const WeightedCreateDatasourceSteps = [ + 'selectConfig', + 'selectPackage', + 'configure', + 'review', +]; + +export const CREATE_DATASOURCE_STEP_PATHS = { + selectConfig: '/select-config', + selectPackage: '/select-package', + configure: '/configure', + review: '/review', +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx new file mode 100644 index 0000000000000..23d0f3317a667 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/index.tsx @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + useRouteMatch, + HashRouter as Router, + Switch, + Route, + Redirect, + useHistory, +} from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; +import { AgentConfig, PackageInfo, NewDatasource } from '../../../types'; +import { useLink, sendCreateDatasource } from '../../../hooks'; +import { useLinks as useEPMLinks } from '../../epm/hooks'; +import { CreateDatasourcePageLayout } from './components'; +import { CreateDatasourceFrom, CreateDatasourceStep } from './types'; +import { CREATE_DATASOURCE_STEP_PATHS } from './constants'; +import { StepSelectPackage } from './step_select_package'; +import { StepSelectConfig } from './step_select_config'; +import { StepConfigureDatasource } from './step_configure_datasource'; +import { StepReviewDatasource } from './step_review'; + +export const CreateDatasourcePage: React.FunctionComponent = () => { + const { + params: { configId, pkgkey }, + path: matchPath, + url: basePath, + } = useRouteMatch(); + const history = useHistory(); + const from: CreateDatasourceFrom = configId ? 'config' : 'package'; + const [maxStep, setMaxStep] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + // Agent config and package info states + const [agentConfig, setAgentConfig] = useState(); + const [packageInfo, setPackageInfo] = useState(); + + // New datasource state + const [datasource, setDatasource] = useState({ + name: '', + description: '', + config_id: '', + enabled: true, + output_id: '', // TODO: Blank for now as we only support default output + inputs: [], + }); + + // Update package info method + const updatePackageInfo = (updatedPackageInfo: PackageInfo | undefined) => { + if (updatedPackageInfo) { + setPackageInfo(updatedPackageInfo); + } else { + setPackageInfo(undefined); + setMaxStep(''); + } + + // eslint-disable-next-line no-console + console.debug('Package info updated', updatedPackageInfo); + }; + + // Update agent config method + const updateAgentConfig = (updatedAgentConfig: AgentConfig | undefined) => { + if (updatedAgentConfig) { + setAgentConfig(updatedAgentConfig); + } else { + setAgentConfig(undefined); + setMaxStep(''); + } + + // eslint-disable-next-line no-console + console.debug('Agent config updated', updatedAgentConfig); + }; + + // Update datasource method + const updateDatasource = (updatedFields: Partial) => { + const newDatasource = { + ...datasource, + ...updatedFields, + }; + setDatasource(newDatasource); + + // eslint-disable-next-line no-console + console.debug('Datasource updated', newDatasource); + }; + + // Cancel url + const CONFIG_URL = useLink( + `${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}` + ); + const PACKAGE_URL = useEPMLinks().toDetailView({ + name: (pkgkey || '-').split('-')[0], + version: (pkgkey || '-').split('-')[1], + }); + const cancelUrl = from === 'config' ? CONFIG_URL : PACKAGE_URL; + + // Redirect to first step + const redirectToFirstStep = + from === 'config' ? ( + + ) : ( + + ); + + // Url to first and second steps + const SELECT_PACKAGE_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectPackage}`); + const SELECT_CONFIG_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.selectConfig}`); + const CONFIGURE_DATASOURCE_URL = useLink(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); + const firstStepUrl = from === 'config' ? SELECT_PACKAGE_URL : SELECT_CONFIG_URL; + const secondStepUrl = CONFIGURE_DATASOURCE_URL; + + // Redirect to second step + const redirectToSecondStep = ( + + ); + + // Save datasource + const saveDatasource = async () => { + setIsSaving(true); + const result = await sendCreateDatasource(datasource); + setIsSaving(false); + return result; + }; + + const layoutProps = { + from, + basePath, + cancelUrl, + maxStep, + agentConfig, + packageInfo, + restrictWidth: 770, + }; + + return ( + + + {/* Redirect to first step from `/` */} + {from === 'config' ? ( + + ) : ( + + )} + + {/* First step, either render select package or select config depending on entry */} + {from === 'config' ? ( + + + { + setMaxStep('selectPackage'); + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); + }} + /> + + + ) : ( + + + { + setMaxStep('selectConfig'); + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.configure}`); + }} + /> + + + )} + + {/* Second step to configure data source, redirect to first step if agent config */} + {/* or package info isn't defined (i.e. after full page reload) */} + + + {!agentConfig || !packageInfo ? ( + redirectToFirstStep + ) : ( + + {from === 'config' ? ( + + ) : ( + + )} + + } + cancelUrl={cancelUrl} + onNext={() => { + setMaxStep('configure'); + history.push(`${basePath}${CREATE_DATASOURCE_STEP_PATHS.review}`); + }} + /> + )} + + + + {/* Third step to review, redirect to second step if data source name is missing */} + {/* (i.e. after full page reload) */} + + + {!agentConfig || !datasource.name ? ( + redirectToSecondStep + ) : ( + + + + } + onSubmit={async () => { + const { error } = await saveDatasource(); + if (!error) { + history.push( + `${AGENT_CONFIG_DETAILS_PATH}${agentConfig ? agentConfig.id : configId}` + ); + } else { + // TODO: Handle save datasource error + } + }} + /> + )} + + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx new file mode 100644 index 0000000000000..484ea3f1d94a0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_configure_datasource.tsx @@ -0,0 +1,289 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect, useState, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiSteps, + EuiPanel, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButtonEmpty, + EuiSpacer, + EuiEmptyPrompt, + EuiText, + EuiButton, + EuiComboBox, +} from '@elastic/eui'; +import { + AgentConfig, + PackageInfo, + Datasource, + NewDatasource, + DatasourceInput, +} from '../../../types'; +import { packageToConfigDatasourceInputs } from '../../../services'; +import { DatasourceInputPanel } from './components'; + +export const StepConfigureDatasource: React.FunctionComponent<{ + agentConfig: AgentConfig; + packageInfo: PackageInfo; + datasource: NewDatasource; + updateDatasource: (fields: Partial) => void; + backLink: JSX.Element; + cancelUrl: string; + onNext: () => void; +}> = ({ agentConfig, packageInfo, datasource, updateDatasource, backLink, cancelUrl, onNext }) => { + // Form show/hide states + const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState(false); + + // Update datasource's package and config info + useEffect(() => { + const dsPackage = datasource.package; + const currentPkgKey = dsPackage ? `${dsPackage.name}-${dsPackage.version}` : ''; + const pkgKey = `${packageInfo.name}-${packageInfo.version}`; + + // If package has changed, create shell datasource with input&stream values based on package info + if (currentPkgKey !== pkgKey) { + // Existing datasources on the agent config using the package name, retrieve highest number appended to datasource name + const dsPackageNamePattern = new RegExp(`${packageInfo.name}-(\\d+)`); + const dsWithMatchingNames = (agentConfig.datasources as Datasource[]) + .filter(ds => Boolean(ds.name.match(dsPackageNamePattern))) + .map(ds => parseInt(ds.name.match(dsPackageNamePattern)![1], 10)) + .sort(); + + updateDatasource({ + name: `${packageInfo.name}-${ + dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1 + }`, + package: { + name: packageInfo.name, + title: packageInfo.title, + version: packageInfo.version, + }, + inputs: packageToConfigDatasourceInputs(packageInfo), + }); + } + + // If agent config has changed, update datasource's config ID and namespace + if (datasource.config_id !== agentConfig.id) { + updateDatasource({ + config_id: agentConfig.id, + namespace: agentConfig.namespace, + }); + } + }, [datasource.package, datasource.config_id, agentConfig, packageInfo, updateDatasource]); + + // Step A, define datasource + const DefineDatasource = ( + + + + + } + > + + updateDatasource({ + name: e.target.value, + }) + } + /> + + + + + } + labelAppend={ + + + + } + > + + updateDatasource({ + description: e.target.value, + }) + } + /> + + + + + setIsShowingAdvancedDefine(!isShowingAdvancedDefine)} + > + + + {/* Todo: Populate list of existing namespaces */} + {isShowingAdvancedDefine ? ( + + + + + + } + > + { + updateDatasource({ + namespace: newNamespace, + }); + }} + onChange={(newNamespaces: Array<{ label: string }>) => { + updateDatasource({ + namespace: newNamespaces.length ? newNamespaces[0].label : '', + }); + }} + /> + + + + + ) : null} + + ); + + // Step B, configure inputs (and their streams) + // Assume packages only export one datasource for now + const ConfigureInputs = + packageInfo.datasources && packageInfo.datasources[0] ? ( + + {packageInfo.datasources[0].inputs.map(packageInput => { + const datasourceInput = datasource.inputs.find(input => input.type === packageInput.type); + return datasourceInput ? ( + + ) => { + const indexOfUpdatedInput = datasource.inputs.findIndex( + input => input.type === packageInput.type + ); + const newInputs = [...datasource.inputs]; + newInputs[indexOfUpdatedInput] = { + ...newInputs[indexOfUpdatedInput], + ...updatedInput, + }; + updateDatasource({ + inputs: newInputs, + }); + }} + /> + + ) : null; + })} + + ) : ( + + +

+ +

+ + } + /> +
+ ); + + return ( + + + + + + {backLink} + + + + + + + + + + + + + + + + onNext()}> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx new file mode 100644 index 0000000000000..355bf2febdf5f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_review.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiTitle, + EuiCallOut, + EuiText, + EuiCheckbox, + EuiTabbedContent, + EuiCodeBlock, + EuiSpacer, +} from '@elastic/eui'; +import { dump } from 'js-yaml'; +import { NewDatasource, AgentConfig } from '../../../types'; +import { useConfig, sendGetAgentStatus } from '../../../hooks'; +import { storedDatasourceToAgentDatasource } from '../../../services'; + +export const StepReviewDatasource: React.FunctionComponent<{ + agentConfig: AgentConfig; + datasource: NewDatasource; + backLink: JSX.Element; + cancelUrl: string; + onSubmit: () => void; + isSubmitLoading: boolean; +}> = ({ agentConfig, datasource, backLink, cancelUrl, onSubmit, isSubmitLoading }) => { + // Agent count info states + const [agentCount, setAgentCount] = useState(0); + const [agentCountChecked, setAgentCountChecked] = useState(false); + + // Config information + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + + // Retrieve agent count + useEffect(() => { + const getAgentCount = async () => { + const { data } = await sendGetAgentStatus({ configId: agentConfig.id }); + if (data?.results.total) { + setAgentCount(data.results.total); + } + }; + + if (isFleetEnabled) { + getAgentCount(); + } + }, [agentConfig.id, isFleetEnabled]); + + const showAgentDisclaimer = isFleetEnabled && agentCount; + const fullAgentDatasource = storedDatasourceToAgentDatasource(datasource); + + return ( + + + + + +

+ +

+
+
+ {backLink} +
+
+ + {/* Agents affected warning */} + {showAgentDisclaimer ? ( + + + } + > + +

+ {agentConfig.name}, + }} + /> +

+
+
+
+ ) : null} + + {/* Preview and YAML view */} + {/* TODO: Implement preview tab */} + + + + + {dump(fullAgentDatasource)} + + + ), + }, + ]} + /> + + + {/* Confirm agents affected */} + {showAgentDisclaimer ? ( + + + + +

+ +

+
+
+ + + } + checked={agentCountChecked} + onChange={e => setAgentCountChecked(e.target.checked)} + /> + +
+
+ ) : null} + + + + + + + + + + onSubmit()} + isLoading={isSubmitLoading} + disabled={isSubmitLoading || Boolean(showAgentDisclaimer && !agentCountChecked)} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx new file mode 100644 index 0000000000000..2ddfc170069a1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_config.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect, useState, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSelectable, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import { Error } from '../../../components'; +import { AGENT_CONFIG_PATH } from '../../../constants'; +import { useCapabilities, useLink } from '../../../hooks'; +import { AgentConfig, PackageInfo, GetAgentConfigsResponseItem } from '../../../types'; +import { useGetPackageInfoByKey, useGetAgentConfigs, sendGetOneAgentConfig } from '../../../hooks'; + +export const StepSelectConfig: React.FunctionComponent<{ + pkgkey: string; + updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; + agentConfig: AgentConfig | undefined; + updateAgentConfig: (config: AgentConfig | undefined) => void; + cancelUrl: string; + onNext: () => void; +}> = ({ pkgkey, updatePackageInfo, agentConfig, updateAgentConfig, cancelUrl, onNext }) => { + const hasWriteCapabilites = useCapabilities().write; + // Selected config state + const [selectedConfigId, setSelectedConfigId] = useState( + agentConfig ? agentConfig.id : undefined + ); + const [selectedConfigLoading, setSelectedConfigLoading] = useState(false); + const [selectedConfigError, setSelectedConfigError] = useState(); + + // Todo: replace with create agent config flyout + const CREATE_NEW_CONFIG_URI = useLink(AGENT_CONFIG_PATH); + + // Fetch package info + const { data: packageInfoData, error: packageInfoError } = useGetPackageInfoByKey(pkgkey); + + // Fetch agent configs info + const { + data: agentConfigsData, + error: agentConfigsError, + isLoading: isAgentConfigsLoading, + } = useGetAgentConfigs(); + const agentConfigs = agentConfigsData?.items || []; + const agentConfigsById = agentConfigs.reduce( + (acc: { [key: string]: GetAgentConfigsResponseItem }, config) => { + acc[config.id] = config; + return acc; + }, + {} + ); + + // Update parent package state + useEffect(() => { + if (packageInfoData && packageInfoData.response) { + updatePackageInfo(packageInfoData.response); + } + }, [packageInfoData, updatePackageInfo]); + + // Update parent selected agent config state + useEffect(() => { + const fetchAgentConfigInfo = async () => { + if (selectedConfigId) { + setSelectedConfigLoading(true); + const { data, error } = await sendGetOneAgentConfig(selectedConfigId); + setSelectedConfigLoading(false); + if (error) { + setSelectedConfigError(error); + updateAgentConfig(undefined); + } else if (data && data.item) { + setSelectedConfigError(undefined); + updateAgentConfig(data.item); + } + } else { + setSelectedConfigError(undefined); + updateAgentConfig(undefined); + } + }; + if (!agentConfig || selectedConfigId !== agentConfig.id) { + fetchAgentConfigInfo(); + } + }, [selectedConfigId, agentConfig, updateAgentConfig]); + + // Display package error if there is one + if (packageInfoError) { + return ( + + } + error={packageInfoError} + /> + ); + } + + // Display agent configs list error if there is one + if (agentConfigsError) { + return ( + + } + error={agentConfigsError} + /> + ); + } + + return ( + + + + + +

+ +

+
+
+ + + + + +
+
+ + { + return { + label: name, + key: id, + checked: selectedConfigId === id ? 'on' : undefined, + }; + })} + renderOption={option => ( + + {option.label} + + + {agentConfigsById[option.key!].description} + + + + + + + + + )} + listProps={{ + bordered: true, + }} + searchProps={{ + placeholder: i18n.translate( + 'xpack.ingestManager.createDatasource.StepSelectConfig.filterAgentConfigsInputPlaceholder', + { + defaultMessage: 'Search for agent configurations', + } + ), + }} + height={240} + onChange={options => { + const selectedOption = options.find(option => option.checked === 'on'); + if (selectedOption) { + setSelectedConfigId(selectedOption.key); + } else { + setSelectedConfigId(undefined); + } + }} + > + {(list, search) => ( + + {search} + + {list} + + )} + + + {/* Display selected agent config error if there is one */} + {selectedConfigError ? ( + + + } + error={selectedConfigError} + /> + + ) : null} + + + + + + + + + onNext()} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx new file mode 100644 index 0000000000000..f90e7f0ab0460 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/step_select_package.tsx @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useEffect, useState, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiSelectable, + EuiSpacer, +} from '@elastic/eui'; +import { Error } from '../../../components'; +import { AgentConfig, PackageInfo } from '../../../types'; +import { useGetOneAgentConfig, useGetPackages, sendGetPackageInfoByKey } from '../../../hooks'; +import { PackageIcon } from '../../epm/components'; + +export const StepSelectPackage: React.FunctionComponent<{ + agentConfigId: string; + updateAgentConfig: (config: AgentConfig | undefined) => void; + packageInfo?: PackageInfo; + updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; + cancelUrl: string; + onNext: () => void; +}> = ({ agentConfigId, updateAgentConfig, packageInfo, updatePackageInfo, cancelUrl, onNext }) => { + // Selected package state + const [selectedPkgKey, setSelectedPkgKey] = useState( + packageInfo ? `${packageInfo.name}-${packageInfo.version}` : undefined + ); + const [selectedPkgLoading, setSelectedPkgLoading] = useState(false); + const [selectedPkgError, setSelectedPkgError] = useState(); + + // Fetch agent config info + const { data: agentConfigData, error: agentConfigError } = useGetOneAgentConfig(agentConfigId); + + // Fetch packages info + const { + data: packagesData, + error: packagesError, + isLoading: isPackagesLoading, + } = useGetPackages(); + const packages = packagesData?.response || []; + + // Update parent agent config state + useEffect(() => { + if (agentConfigData && agentConfigData.item) { + updateAgentConfig(agentConfigData.item); + } + }, [agentConfigData, updateAgentConfig]); + + // Update parent selected package state + useEffect(() => { + const fetchPackageInfo = async () => { + if (selectedPkgKey) { + setSelectedPkgLoading(true); + const { data, error } = await sendGetPackageInfoByKey(selectedPkgKey); + setSelectedPkgLoading(false); + if (error) { + setSelectedPkgError(error); + updatePackageInfo(undefined); + } else if (data && data.response) { + setSelectedPkgError(undefined); + updatePackageInfo(data.response); + } + } else { + setSelectedPkgError(undefined); + updatePackageInfo(undefined); + } + }; + if (!packageInfo || selectedPkgKey !== `${packageInfo.name}-${packageInfo.version}`) { + fetchPackageInfo(); + } + }, [selectedPkgKey, packageInfo, updatePackageInfo]); + + // Display agent config error if there is one + if (agentConfigError) { + return ( + + } + error={agentConfigError} + /> + ); + } + + // Display packages list error if there is one + if (packagesError) { + return ( + + } + error={packagesError} + /> + ); + } + + return ( + + + +

+ +

+
+
+ + { + const pkgkey = `${name}-${version}`; + return { + label: title || name, + key: pkgkey, + prepend: , + checked: selectedPkgKey === pkgkey ? 'on' : undefined, + }; + })} + listProps={{ + bordered: true, + }} + searchProps={{ + placeholder: i18n.translate( + 'xpack.ingestManager.createDatasource.stepSelectPackage.filterPackagesInputPlaceholder', + { + defaultMessage: 'Search for packages', + } + ), + }} + height={240} + onChange={options => { + const selectedOption = options.find(option => option.checked === 'on'); + if (selectedOption) { + setSelectedPkgKey(selectedOption.key); + } else { + setSelectedPkgKey(undefined); + } + }} + > + {(list, search) => ( + + {search} + + {list} + + )} + + + {/* Display selected package error if there is one */} + {selectedPkgError ? ( + + + } + error={selectedPkgError} + /> + + ) : null} + + + + + + + + + onNext()} + > + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts new file mode 100644 index 0000000000000..bd05be2d8a558 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_datasource_page/types.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type CreateDatasourceFrom = 'package' | 'config'; +export type CreateDatasourceStep = 'selectConfig' | 'selectPackage' | 'configure' | 'review'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx new file mode 100644 index 0000000000000..c4f8d944ceb14 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/config_form.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiFieldText, EuiForm, EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../types'; + +interface ValidationResults { + [key: string]: JSX.Element[]; +} + +export const configFormValidation = (config: Partial): ValidationResults => { + const errors: ValidationResults = {}; + + if (!config.name?.trim()) { + errors.name = [ + , + ]; + } + + return errors; +}; + +interface Props { + config: Partial; + updateConfig: (u: Partial) => void; + validation: ValidationResults; +} + +export const ConfigForm: React.FunctionComponent = ({ + config, + updateConfig, + validation, +}) => { + const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); + const fields: Array<{ name: 'name' | 'description' | 'namespace'; label: JSX.Element }> = [ + { + name: 'name', + label: ( + + ), + }, + { + name: 'description', + label: ( + + ), + }, + { + name: 'namespace', + label: ( + + ), + }, + ]; + + return ( + + {fields.map(({ name, label }) => { + return ( + + updateConfig({ [name]: e.target.value })} + isInvalid={Boolean(touchedFields[name] && validation[name])} + onBlur={() => setTouchedFields({ ...touchedFields, [name]: true })} + /> + + ); + })} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx new file mode 100644 index 0000000000000..3c982747e1d22 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/datasources_table.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiInMemoryTable, EuiInMemoryTableProps, EuiBadge } from '@elastic/eui'; +import { Datasource } from '../../../../types'; + +type DatasourceWithConfig = Datasource & { configs?: string[] }; + +interface InMemoryDatasource { + id: string; + name: string; + streams: number; + packageName?: string; + packageTitle?: string; + packageVersion?: string; + configs: number; +} + +interface Props { + datasources?: DatasourceWithConfig[]; + withConfigsCount?: boolean; + loading?: EuiInMemoryTableProps['loading']; + message?: EuiInMemoryTableProps['message']; + search?: EuiInMemoryTableProps['search']; + selection?: EuiInMemoryTableProps['selection']; + isSelectable?: EuiInMemoryTableProps['isSelectable']; +} + +export const DatasourcesTable: React.FunctionComponent = ( + { datasources: originalDatasources, withConfigsCount, ...rest } = { + datasources: [], + withConfigsCount: false, + } +) => { + // Flatten some values so that they can be searched via in-memory table search + const datasources = + originalDatasources?.map(({ id, name, inputs, package: datasourcePackage, configs }) => ({ + id, + name, + streams: inputs.reduce( + (streamsCount, input) => + streamsCount + + (input.enabled ? input.streams.filter(stream => stream.enabled).length : 0), + 0 + ), + packageName: datasourcePackage?.name, + packageTitle: datasourcePackage?.title, + packageVersion: datasourcePackage?.version, + configs: configs?.length || 0, + })) || []; + + const columns: EuiInMemoryTableProps['columns'] = [ + { + field: 'name', + name: i18n.translate('xpack.ingestManager.configDetails.datasourcesTable.nameColumnTitle', { + defaultMessage: 'Name', + }), + }, + { + field: 'packageTitle', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.packageNameColumnTitle', + { + defaultMessage: 'Package', + } + ), + }, + { + field: 'packageVersion', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.packageVersionColumnTitle', + { + defaultMessage: 'Version', + } + ), + }, + { + field: 'streams', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.streamsCountColumnTitle', + { + defaultMessage: 'Streams', + } + ), + }, + ]; + + if (withConfigsCount) { + columns.splice(columns.length - 1, 0, { + field: 'configs', + name: i18n.translate( + 'xpack.ingestManager.configDetails.datasourcesTable.configsColumnTitle', + { + defaultMessage: 'Configs', + } + ), + render: (configs: number) => { + return configs === 0 ? ( + + + + ) : ( + configs + ); + }, + }); + } + + return ( + + itemId="id" + items={datasources || ([] as InMemoryDatasource[])} + columns={columns} + sorting={{ + sort: { + field: 'name', + direction: 'asc', + }, + }} + {...rest} + /> + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx new file mode 100644 index 0000000000000..408ccc6e951f6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/donut_chart.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef } from 'react'; +import d3 from 'd3'; +import { EuiFlexItem } from '@elastic/eui'; + +interface DonutChartProps { + data: { + [key: string]: number; + }; + height: number; + width: number; +} + +export const DonutChart = ({ height, width, data }: DonutChartProps) => { + const chartElement = useRef(null); + + useEffect(() => { + if (chartElement.current !== null) { + // we must remove any existing paths before painting + d3.selectAll('g').remove(); + const svgElement = d3 + .select(chartElement.current) + .append('g') + .attr('transform', `translate(${width / 2}, ${height / 2})`); + const color = d3.scale + .ordinal() + // @ts-ignore + .domain(data) + .range(['#017D73', '#98A2B3', '#BD271E']); + const pieGenerator = d3.layout + .pie() + .value(({ value }: any) => value) + // these start/end angles will reverse the direction of the pie, + // which matches our design + .startAngle(2 * Math.PI) + .endAngle(0); + + svgElement + .selectAll('g') + // @ts-ignore + .data(pieGenerator(d3.entries(data))) + .enter() + .append('path') + .attr( + 'd', + // @ts-ignore attr does not expect a param of type Arc but it behaves as desired + d3.svg + .arc() + .innerRadius(width * 0.28) + .outerRadius(Math.min(width, height) / 2 - 10) + ) + .attr('fill', (d: any) => color(d.data.key)); + } + }, [data, height, width]); + return ( + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx new file mode 100644 index 0000000000000..65eb86d7d871f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/edit_config.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useCore, sendRequest } from '../../../../hooks'; +import { agentConfigRouteService } from '../../../../services'; +import { AgentConfig } from '../../../../types'; +import { ConfigForm, configFormValidation } from './config_form'; + +interface Props { + agentConfig: AgentConfig; + onClose: () => void; +} + +export const EditConfigFlyout: React.FunctionComponent = ({ + agentConfig: originalAgentConfig, + onClose, +}) => { + const { notifications } = useCore(); + const [config, setConfig] = useState>({ + name: originalAgentConfig.name, + description: originalAgentConfig.description, + }); + const [isLoading, setIsLoading] = useState(false); + const updateConfig = (updatedFields: Partial) => { + setConfig({ + ...config, + ...updatedFields, + }); + }; + const validation = configFormValidation(config); + + const header = ( + + +

+ +

+
+
+ ); + + const body = ( + + + + ); + + const footer = ( + + + + + + + + + 0} + onClick={async () => { + setIsLoading(true); + try { + const { error } = await sendRequest({ + path: agentConfigRouteService.getUpdatePath(originalAgentConfig.id), + method: 'put', + body: JSON.stringify(config), + }); + if (!error) { + notifications.toasts.addSuccess( + i18n.translate('xpack.ingestManager.editConfig.successNotificationTitle', { + defaultMessage: "Agent config '{name}' updated", + values: { name: config.name }, + }) + ); + } else { + notifications.toasts.addDanger( + error + ? error.message + : i18n.translate('xpack.ingestManager.editConfig.errorNotificationTitle', { + defaultMessage: 'Unable to update agent config', + }) + ); + } + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.editConfig.errorNotificationTitle', { + defaultMessage: 'Unable to update agent config', + }) + ); + } + setIsLoading(false); + onClose(); + }} + > + + + + + + ); + + return ( + + {header} + {body} + {footer} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts new file mode 100644 index 0000000000000..51834268ffa5b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { DatasourcesTable } from './datasources_table'; +export { DonutChart } from './donut_chart'; +export { EditConfigFlyout } from './edit_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/constants.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/constants.ts new file mode 100644 index 0000000000000..787791f985c7d --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; + +export const DETAILS_ROUTER_PATH = `${AGENT_CONFIG_DETAILS_PATH}:configId`; +export const DETAILS_ROUTER_SUB_PATH = `${DETAILS_ROUTER_PATH}/:tabId`; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts new file mode 100644 index 0000000000000..19be93676a734 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { useGetAgentStatus, AgentStatusRefreshContext } from './use_agent_status'; +export { ConfigRefreshContext } from './use_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_agent_status.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_agent_status.tsx new file mode 100644 index 0000000000000..214deb81f535c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_agent_status.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { useRequest } from '../../../../hooks'; +import { GetAgentStatusResponse } from '../../../../types'; +import { agentRouteService } from '../../../../services'; +import { UseRequestConfig } from '../../../../hooks/use_request/use_request'; + +type RequestOptions = Pick, 'pollIntervalMs'>; + +export function useGetAgentStatus(configId?: string, options?: RequestOptions) { + const agentStatusRequest = useRequest({ + path: agentRouteService.getStatusPath(), + query: { + configId, + }, + method: 'get', + ...options, + }); + + return { + isLoading: agentStatusRequest.isLoading, + data: agentStatusRequest.data, + error: agentStatusRequest.error, + refreshAgentStatus: () => agentStatusRequest.sendRequest, + }; +} + +export const AgentStatusRefreshContext = React.createContext({ refresh: () => {} }); + +export function useAgentStatusRefresh() { + return React.useContext(AgentStatusRefreshContext).refresh; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_config.tsx new file mode 100644 index 0000000000000..b61986e7abb4f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_config.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +export const ConfigRefreshContext = React.createContext({ refresh: () => {} }); + +export function useConfigRefresh() { + return React.useContext(ConfigRefreshContext).refresh; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts new file mode 100644 index 0000000000000..df43d8e908e41 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/hooks/use_details_uri.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; +import { generatePath } from 'react-router-dom'; +import { useLink } from '../../../../hooks'; +import { AGENT_CONFIG_PATH } from '../../../../constants'; +import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from '../constants'; + +export const useDetailsUri = (configId: string) => { + const BASE_URI = useLink(''); + return useMemo(() => { + const AGENT_CONFIG_DETAILS = `${BASE_URI}${generatePath(DETAILS_ROUTER_PATH, { configId })}`; + + return { + ADD_DATASOURCE: `${AGENT_CONFIG_DETAILS}/add-datasource`, + AGENT_CONFIG_LIST: `${BASE_URI}${AGENT_CONFIG_PATH}`, + AGENT_CONFIG_DETAILS, + AGENT_CONFIG_DETAILS_YAML: `${BASE_URI}${generatePath(DETAILS_ROUTER_SUB_PATH, { + configId, + tabId: 'yaml', + })}`, + AGENT_CONFIG_DETAILS_SETTINGS: `${BASE_URI}${generatePath(DETAILS_ROUTER_SUB_PATH, { + configId, + tabId: 'settings', + })}`, + }; + }, [BASE_URI, configId]); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx new file mode 100644 index 0000000000000..6f72977cb333f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -0,0 +1,365 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, memo, useCallback, useMemo, useState } from 'react'; +import { Redirect, useRouteMatch, Switch, Route } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, + EuiText, + EuiSpacer, + EuiTitle, + EuiButton, + EuiButtonEmpty, + EuiEmptyPrompt, + EuiI18nNumber, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { Props as EuiTabProps } from '@elastic/eui/src/components/tabs/tab'; +import styled from 'styled-components'; +import { useCapabilities, useGetOneAgentConfig } from '../../../hooks'; +import { Datasource } from '../../../types'; +import { Loading } from '../../../components'; +import { WithHeaderLayout } from '../../../layouts'; +import { ConfigRefreshContext, useGetAgentStatus, AgentStatusRefreshContext } from './hooks'; +import { DatasourcesTable, EditConfigFlyout } from './components'; +import { LinkedAgentCount } from '../components'; +import { useDetailsUri } from './hooks/use_details_uri'; +import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from './constants'; + +const Divider = styled.div` + width: 0; + height: 100%; + border-left: ${props => props.theme.eui.euiBorderThin}; +`; + +export const AgentConfigDetailsPage = memo(() => { + return ( + + + + + + + + + ); +}); + +export const AgentConfigDetailsLayout: React.FunctionComponent = () => { + const { + params: { configId, tabId = '' }, + } = useRouteMatch<{ configId: string; tabId?: string }>(); + const hasWriteCapabilites = useCapabilities().write; + const agentConfigRequest = useGetOneAgentConfig(configId); + const agentConfig = agentConfigRequest.data ? agentConfigRequest.data.item : null; + const { isLoading, error, sendRequest: refreshAgentConfig } = agentConfigRequest; + const [redirectToAgentConfigList] = useState(false); + const agentStatusRequest = useGetAgentStatus(configId); + const { refreshAgentStatus } = agentStatusRequest; + const agentStatus = agentStatusRequest.data?.results; + const URI = useDetailsUri(configId); + + // Flyout states + const [isEditConfigFlyoutOpen, setIsEditConfigFlyoutOpen] = useState(false); + + const refreshData = useCallback(() => { + refreshAgentConfig(); + refreshAgentStatus(); + }, [refreshAgentConfig, refreshAgentStatus]); + + const headerLeftContent = useMemo( + () => ( + + + + + +
+ + + +
+ +

+ {(agentConfig && agentConfig.name) || ( + + )} +

+
+
+
+ {agentConfig && agentConfig.description ? ( + + + + {agentConfig.description} + + + ) : null} +
+
+ +
+ ), + [URI.AGENT_CONFIG_LIST, agentConfig, configId] + ); + + const headerRightContent = useMemo( + () => ( + + {[ + { + label: i18n.translate('xpack.ingestManager.configDetails.summary.revision', { + defaultMessage: 'Revision', + }), + content: '999', // FIXME: implement version - see: https://github.com/elastic/kibana/issues/56750 + }, + { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.configDetails.summary.datasources', { + defaultMessage: 'Data sources', + }), + content: ( + + ), + }, + { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.configDetails.summary.usedBy', { + defaultMessage: 'Used by', + }), + content: ( + + ), + }, + { isDivider: true }, + { + label: i18n.translate('xpack.ingestManager.configDetails.summary.lastUpdated', { + defaultMessage: 'Last updated on', + }), + content: + (agentConfig && ( + + )) || + '', + }, + ].map((item, index) => ( + + {item.isDivider ?? false ? ( + + ) : ( + + {item.label} + {item.content} + + )} + + ))} + + ), + [agentConfig, agentStatus] + ); + + const headerTabs = useMemo(() => { + return [ + { + id: 'datasources', + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.datasouces', { + defaultMessage: 'Data sources', + }), + href: URI.AGENT_CONFIG_DETAILS, + isSelected: tabId === '', + }, + { + id: 'yaml', + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.yamlFile', { + defaultMessage: 'YAML File', + }), + href: URI.AGENT_CONFIG_DETAILS_YAML, + isSelected: tabId === 'yaml', + }, + { + id: 'settings', + name: i18n.translate('xpack.ingestManager.configDetails.subTabs.settings', { + defaultMessage: 'Settings', + }), + href: URI.AGENT_CONFIG_DETAILS_SETTINGS, + isSelected: tabId === 'settings', + }, + ]; + }, [ + URI.AGENT_CONFIG_DETAILS, + URI.AGENT_CONFIG_DETAILS_SETTINGS, + URI.AGENT_CONFIG_DETAILS_YAML, + tabId, + ]); + + if (redirectToAgentConfigList) { + return ; + } + + if (isLoading) { + return ; + } + + if (error) { + return ( + + +

+ {error.message} +

+
+
+ ); + } + + if (!agentConfig) { + return ( + + + + ); + } + + return ( + + + + {isEditConfigFlyoutOpen ? ( + { + setIsEditConfigFlyoutOpen(false); + refreshData(); + }} + agentConfig={agentConfig} + /> + ) : null} + + + { + // TODO: YAML implementation tracked via https://github.com/elastic/kibana/issues/57958 + return
YAML placeholder
; + }} + /> + { + // TODO: Settings implementation tracked via: https://github.com/elastic/kibana/issues/57959 + return
Settings placeholder
; + }} + /> + { + return ( + + + + } + actions={ + + + + } + /> + ) : null + } + search={{ + toolsRight: [ + + + , + ], + box: { + incremental: true, + schema: true, + }, + }} + isSelectable={false} + /> + ); + }} + /> +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx index c80c4496198be..71ada155373bf 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/index.tsx @@ -6,10 +6,18 @@ import React from 'react'; import { HashRouter as Router, Switch, Route } from 'react-router-dom'; import { AgentConfigListPage } from './list_page'; +import { AgentConfigDetailsPage } from './details_page'; +import { CreateDatasourcePage } from './create_datasource_page'; export const AgentConfigApp: React.FunctionComponent = () => ( + + + + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index c6fea7b22bcd1..2373d6ad2ad17 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -16,9 +16,10 @@ import { EuiFlexItem, EuiButtonEmpty, EuiButton, + EuiText, } from '@elastic/eui'; import { NewAgentConfig } from '../../../../types'; -import { useCore, sendCreateAgentConfig } from '../../../../hooks'; +import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks'; import { AgentConfigForm, agentConfigFormValidation } from '../../components'; interface Props { @@ -27,11 +28,12 @@ interface Props { export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => { const { notifications } = useCore(); - + const hasWriteCapabilites = useCapabilities().write; const [agentConfig, setAgentConfig] = useState({ name: '', description: '', namespace: '', + is_default: undefined, }); const [isLoading, setIsLoading] = useState(false); const validation = agentConfigFormValidation(agentConfig); @@ -53,10 +55,16 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos

+ + + ); @@ -85,7 +93,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos 0} + isDisabled={!hasWriteCapabilites || isLoading || Object.keys(validation).length > 0} onClick={async () => { setIsLoading(true); try { @@ -125,7 +133,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos > @@ -134,7 +142,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos ); return ( - + {header} {body} {footer} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index ef5a38d486901..35915fab6f143 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'; import { EuiSpacer, EuiText, @@ -11,21 +11,45 @@ import { EuiFlexItem, EuiButton, EuiEmptyPrompt, - // @ts-ignore - EuiSearchBar, EuiBasicTable, EuiLink, - EuiBadge, + EuiTableActionsColumnType, + EuiTableFieldDataColumnType, + EuiTextColor, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; +import styled from 'styled-components'; +import { useHistory } from 'react-router-dom'; import { AgentConfig } from '../../../types'; -import { DEFAULT_AGENT_CONFIG_ID, AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; +import { + AGENT_CONFIG_DETAILS_PATH, + AGENT_CONFIG_SAVED_OBJECT_TYPE, + AGENT_CONFIG_PATH, +} from '../../../constants'; import { WithHeaderLayout } from '../../../layouts'; -// import { SearchBar } from '../../../components'; -import { useGetAgentConfigs, usePagination, useLink } from '../../../hooks'; +import { + useCapabilities, + useGetAgentConfigs, + usePagination, + useLink, + useConfig, + useUrlParams, +} from '../../../hooks'; import { AgentConfigDeleteProvider } from '../components'; import { CreateAgentConfigFlyout } from './components'; +import { SearchBar } from '../../../components/search_bar'; +import { LinkedAgentCount } from '../components'; + +const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); const AgentConfigListPageLayout: React.FunctionComponent = ({ children }) => ( ( ); +const DangerEuiContextMenuItem = styled(EuiContextMenuItem)` + color: ${props => props.theme.eui.textColors.danger}; +`; + +const RowActions = React.memo<{ config: AgentConfig; onDelete: () => void }>( + ({ config, onDelete }) => { + const hasWriteCapabilites = useCapabilities().write; + const DETAILS_URI = useLink(`${AGENT_CONFIG_DETAILS_PATH}${config.id}`); + const ADD_DATASOURCE_URI = `${DETAILS_URI}/add-datasource`; + + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + , + + + + , + + + + , + + + {deleteAgentConfigsPrompt => { + return ( + deleteAgentConfigsPrompt([config.id], onDelete)} + > + + + ); + }} + , + ]} + /> + + ); + } +); + export const AgentConfigListPage: React.FunctionComponent<{}> = () => { - // Create agent config flyout state - const [isCreateAgentConfigFlyoutOpen, setIsCreateAgentConfigFlyoutOpen] = useState( - false - ); + // Config information + const hasWriteCapabilites = useCapabilities().write; + const { + fleet: { enabled: isFleetEnabled }, + } = useConfig(); + + // Base URL paths + const DETAILS_URI = useLink(AGENT_CONFIG_DETAILS_PATH); // Table and search states const [search, setSearch] = useState(''); - const { pagination, setPagination } = usePagination(); + const { pagination, pageSizeOptions, setPagination } = usePagination(); const [selectedAgentConfigs, setSelectedAgentConfigs] = useState([]); + const history = useHistory(); + const { urlParams, toUrlParams } = useUrlParams(); + const isCreateAgentConfigFlyoutOpen = 'create' in urlParams; + const setIsCreateAgentConfigFlyoutOpen = useCallback( + (isOpen: boolean) => { + if (isOpen !== isCreateAgentConfigFlyoutOpen) { + if (isOpen) { + history.push(`${AGENT_CONFIG_PATH}?${toUrlParams({ ...urlParams, create: null })}`); + } else { + const { create, ...params } = urlParams; + history.push(`${AGENT_CONFIG_PATH}?${toUrlParams(params)}`); + } + } + }, + [history, isCreateAgentConfigFlyoutOpen, toUrlParams, urlParams] + ); // Fetch agent configs - const { isLoading, data: agentConfigData, sendRequest } = useGetAgentConfigs(); + const { isLoading, data: agentConfigData, sendRequest } = useGetAgentConfigs({ + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: search, + }); - // Base path for config details - const DETAILS_URI = useLink(AGENT_CONFIG_DETAILS_PATH); + // If `kuery` url param changes trigger a search + useEffect(() => { + const kuery = Array.isArray(urlParams.kuery) + ? urlParams.kuery[urlParams.kuery.length - 1] + : urlParams.kuery ?? ''; + if (kuery !== search) { + setSearch(kuery); + } + }, [search, urlParams]); // Some configs retrieved, set up table props - const columns = [ - { - field: 'name', - name: i18n.translate('xpack.ingestManager.agentConfigList.nameColumnTitle', { - defaultMessage: 'Name', - }), - render: (name: string, agentConfig: AgentConfig) => name || agentConfig.id, - }, - { - field: 'namespace', - name: i18n.translate('xpack.ingestManager.agentConfigList.namespaceColumnTitle', { - defaultMessage: 'Namespace', - }), - render: (namespace: string) => (namespace ? {namespace} : null), - }, - { - field: 'description', - name: i18n.translate('xpack.ingestManager.agentConfigList.descriptionColumnTitle', { - defaultMessage: 'Description', - }), - }, - { - field: 'datasources', - name: i18n.translate('xpack.ingestManager.agentConfigList.datasourcesCountColumnTitle', { - defaultMessage: 'Datasources', - }), - render: (datasources: AgentConfig['datasources']) => (datasources ? datasources.length : 0), - }, - { - name: i18n.translate('xpack.ingestManager.agentConfigList.actionsColumnTitle', { - defaultMessage: 'Actions', - }), - actions: [ - { - render: ({ id }: AgentConfig) => { - return ( - + const columns = useMemo(() => { + const cols: Array< + EuiTableFieldDataColumnType | EuiTableActionsColumnType + > = [ + { + field: 'name', + name: i18n.translate('xpack.ingestManager.agentConfigList.nameColumnTitle', { + defaultMessage: 'Name', + }), + width: '20%', + // FIXME: use version once available - see: https://github.com/elastic/kibana/issues/56750 + render: (name: string, agentConfig: AgentConfig) => ( + + + + {name || agentConfig.id} + + + + - - ); + + + + ), + }, + { + field: 'description', + name: i18n.translate('xpack.ingestManager.agentConfigList.descriptionColumnTitle', { + defaultMessage: 'Description', + }), + width: '35%', + truncateText: true, + render: (description: AgentConfig['description']) => ( + + {description} + + ), + }, + { + field: 'updated_on', + name: i18n.translate('xpack.ingestManager.agentConfigList.updatedOnColumnTitle', { + defaultMessage: 'Last updated on', + }), + render: (date: AgentConfig['updated_on']) => ( + + ), + }, + { + field: 'agents', + name: i18n.translate('xpack.ingestManager.agentConfigList.agentsColumnTitle', { + defaultMessage: 'Agents', + }), + dataType: 'number', + render: (agents: number, config: AgentConfig) => ( + + ), + }, + { + field: 'datasources', + name: i18n.translate('xpack.ingestManager.agentConfigList.datasourcesCountColumnTitle', { + defaultMessage: 'Data sources', + }), + dataType: 'number', + render: (datasources: AgentConfig['datasources']) => (datasources ? datasources.length : 0), + }, + { + name: i18n.translate('xpack.ingestManager.agentConfigList.actionsColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (config: AgentConfig) => ( + sendRequest()} /> + ), }, - }, - ], - width: '100px', - }, - ]; + ], + }, + ]; - const emptyPrompt = ( - - - - } - actions={ - setIsCreateAgentConfigFlyoutOpen(true)} - > - - - } - /> + // If Fleet is not enabled, then remove the `agents` column + if (!isFleetEnabled) { + return cols.filter(col => ('field' in col ? col.field !== 'agents' : true)); + } + + return cols; + }, [DETAILS_URI, isFleetEnabled, sendRequest]); + + const createAgentConfigButton = useMemo( + () => ( + setIsCreateAgentConfigFlyoutOpen(true)} + > + + + ), + [hasWriteCapabilites, setIsCreateAgentConfigFlyoutOpen] + ); + + const emptyPrompt = useMemo( + () => ( + + + + } + actions={hasWriteCapabilites ?? createAgentConfigButton} + /> + ), + [hasWriteCapabilites, createAgentConfigButton] ); return ( @@ -191,50 +382,40 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { ) : null} - {/* { - setPagination({ - ...pagination, - currentPage: 1, - }); - setSearch(newSearch); - }} - fieldPrefix={AGENT_CONFIG_SAVED_OBJECT_TYPE} - /> */} + { + setPagination({ + ...pagination, + currentPage: 1, + }); + setSearch(newSearch); + }} + fieldPrefix={AGENT_CONFIG_SAVED_OBJECT_TYPE} + /> - sendRequest()}> + sendRequest()}> - - setIsCreateAgentConfigFlyoutOpen(true)} - > - - - + {createAgentConfigButton} - ) : !search.trim() && agentConfigData?.total === 0 ? ( + ) : !search.trim() && (agentConfigData?.total ?? 0) === 0 ? ( emptyPrompt ) : ( = () => { columns={columns} isSelectable={true} selection={{ - selectable: (agentConfig: AgentConfig) => agentConfig.id !== DEFAULT_AGENT_CONFIG_ID, + selectable: (agentConfig: AgentConfig) => !agentConfig.is_default, onSelectionChange: (newSelectedAgentConfigs: AgentConfig[]) => { setSelectedAgentConfigs(newSelectedAgentConfigs); }, @@ -267,6 +448,7 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { pageIndex: pagination.currentPage - 1, pageSize: pagination.pageSize, totalItemCount: agentConfigData ? agentConfigData.total : 0, + pageSizeOptions, }} onChange={({ page }: { page: { index: number; size: number } }) => { const newPagination = { @@ -275,7 +457,6 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { pageSize: page.size, }; setPagination(newPagination); - sendRequest(); // todo: fix this to send pagination options }} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx new file mode 100644 index 0000000000000..219896dd27ef7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/assets_facet_group.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiFacetButton, + EuiFacetGroup, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { + AssetsGroupedByServiceByType, + AssetTypeToParts, + KibanaAssetType, + entries, +} from '../../../types'; +import { + AssetIcons, + AssetTitleMap, + DisplayedAssets, + ServiceIcons, + ServiceTitleMap, +} from '../constants'; + +export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByType }) { + const FirstHeaderRow = styled(EuiFlexGroup)` + padding: 0 0 ${props => props.theme.eui.paddingSizes.m} 0; + `; + + const HeaderRow = styled(EuiFlexGroup)` + padding: ${props => props.theme.eui.paddingSizes.m} 0; + `; + + const FacetGroup = styled(EuiFacetGroup)` + flex-grow: 0; + `; + + return ( + + {entries(assets).map(([service, typeToParts], index) => { + const Header = index === 0 ? FirstHeaderRow : HeaderRow; + // filter out assets we are not going to display + const filteredTypes: AssetTypeToParts = entries(typeToParts).reduce( + (acc: any, [asset, value]) => { + if (DisplayedAssets[service].includes(asset)) acc[asset] = value; + return acc; + }, + {} + ); + return ( + +
+ + + + + + + +

{ServiceTitleMap[service]} Assets

+
+
+
+
+ + + {entries(filteredTypes).map(([_type, parts]) => { + const type = _type as KibanaAssetType; + // only kibana assets have icons + const iconType = type in AssetIcons && AssetIcons[type]; + const iconNode = iconType ? : ''; + const FacetButton = styled(EuiFacetButton)` + padding: '${props => props.theme.eui.paddingSizes.xs} 0'; + height: 'unset'; + `; + return ( + {}} + > + {AssetTitleMap[type]} + + ); + })} + +
+ ); + })} +
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icon_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icon_panel.tsx new file mode 100644 index 0000000000000..7ce386ed56f5f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/icon_panel.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiPanel, IconType } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +export function IconPanel({ iconType }: { iconType: IconType }) { + const Panel = styled(EuiPanel)` + /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ + &&& { + position: absolute; + text-align: center; + vertical-align: middle; + padding: ${props => props.theme.eui.spacerSizes.xl}; + svg { + height: ${props => props.theme.eui.euiKeyPadMenuSize}; + width: ${props => props.theme.eui.euiKeyPadMenuSize}; + } + } + `; + + return ( + + + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts new file mode 100644 index 0000000000000..2cb940e2ff40c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { PackageIcon } from './package_icon'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx new file mode 100644 index 0000000000000..0c01bb72b339a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/nav_button_back.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiButtonEmpty } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +export function NavButtonBack({ href, text }: { href: string; text: string }) { + const ButtonEmpty = styled(EuiButtonEmpty)` + margin-right: ${props => props.theme.eui.spacerSizes.xl}; + `; + return ( + + {text} + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx new file mode 100644 index 0000000000000..d1d7cfc180cad --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import styled from 'styled-components'; +import { EuiCard } from '@elastic/eui'; +import { PackageInfo, PackageListItem } from '../../../types'; +import { useLinks } from '../hooks'; +import { PackageIcon } from './package_icon'; + +export interface BadgeProps { + showInstalledBadge?: boolean; +} + +type PackageCardProps = (PackageListItem | PackageInfo) & BadgeProps; + +// adding the `href` causes EuiCard to use a `a` instead of a `button` +// `a` tags use `euiLinkColor` which results in blueish Badge text +const Card = styled(EuiCard)` + color: inherit; +`; + +export function PackageCard({ + description, + name, + title, + version, + showInstalledBadge, + status, + icons, +}: PackageCardProps) { + const { toDetailView } = useLinks(); + const url = toDetailView({ name, version }); + + return ( + } + href={url} + /> + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx new file mode 100644 index 0000000000000..dd2f46adc3188 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_icon.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui'; +import { PackageInfo, PackageListItem } from '../../../types'; +import { useLinks } from '../hooks'; + +type Package = PackageInfo | PackageListItem; + +export const PackageIcon: React.FunctionComponent<{ + packageName: string; + icons?: Package['icons']; +} & Omit> = ({ packageName, icons, ...euiIconProps }) => { + const { toImage } = useLinks(); + // try to find a logo in EUI + const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`); + const svgIcons = icons?.filter(icon => icon.type === 'image/svg+xml'); + const localIcon = svgIcons && Array.isArray(svgIcons) && svgIcons[0]; + const pathToLocal = localIcon && toImage(localIcon.src); + const euiIconType = pathToLocal || euiLogoIcon || 'package'; + + return ; +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx new file mode 100644 index 0000000000000..34e1763c44255 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_list_grid.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { Fragment, ReactNode } from 'react'; +import { PackageList } from '../../../types'; +import { BadgeProps, PackageCard } from './package_card'; + +type ListProps = { + controls?: ReactNode; + title: string; + list: PackageList; +} & BadgeProps; + +export function PackageListGrid({ controls, title, list, showInstalledBadge }: ListProps) { + const controlsContent = ; + const gridContent = ; + + return ( + + {controlsContent} + {gridContent} + + ); +} + +interface ControlsColumnProps { + controls: ReactNode; + title: string; +} + +function ControlsColumn({ controls, title }: ControlsColumnProps) { + return ( + + +

{title}

+
+ + + {controls} + + +
+ ); +} + +type GridColumnProps = { + list: PackageList; +} & BadgeProps; + +function GridColumn({ list }: GridColumnProps) { + return ( + + {list.map(item => ( + + + + ))} + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/requirements.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/requirements.tsx new file mode 100644 index 0000000000000..f60d2d83ed45e --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/requirements.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiTextColor, EuiTitle } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { RequirementsByServiceName, entries } from '../../../types'; +import { ServiceTitleMap } from '../constants'; +import { Version } from './version'; + +export interface RequirementsProps { + requirements: RequirementsByServiceName; +} + +const FlexGroup = styled(EuiFlexGroup)` + padding: 0 0 ${props => props.theme.eui.paddingSizes.m} 0; + margin: 0; +`; +const StyledVersion = styled(Version)` + font-size: ${props => props.theme.eui.euiFontSizeXS}; +`; + +export function Requirements(props: RequirementsProps) { + const { requirements } = props; + + return ( + + + + + + + + +

Elastic Stack Compatibility

+
+
+
+
+ {entries(requirements).map(([service, requirement]) => ( + + + + {ServiceTitleMap[service]}: + + + + + + + ))} +
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/version.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/version.tsx new file mode 100644 index 0000000000000..537f6201dea06 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/version.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { RequirementVersion } from '../../../types'; + +const CodeText = styled.span` + font-family: ${props => props.theme.eui.euiCodeFontFamily}; +`; +export function Version({ + className, + version, +}: { + className?: string; + version: RequirementVersion; +}) { + return {version}; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx new file mode 100644 index 0000000000000..3a6dfe4a87daf --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/constants.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IconType } from '@elastic/eui'; +import { AssetType, ElasticsearchAssetType, KibanaAssetType, ServiceName } from '../../types'; + +// only allow Kibana assets for the kibana key, ES asssets for elasticsearch, etc +type ServiceNameToAssetTypes = Record, KibanaAssetType[]> & + Record, ElasticsearchAssetType[]>; + +export const DisplayedAssets: ServiceNameToAssetTypes = { + kibana: Object.values(KibanaAssetType), + elasticsearch: Object.values(ElasticsearchAssetType), +}; + +export const AssetTitleMap: Record = { + dashboard: 'Dashboard', + 'ilm-policy': 'ILM Policy', + 'ingest-pipeline': 'Ingest Pipeline', + 'index-pattern': 'Index Pattern', + 'index-template': 'Index Template', + search: 'Saved Search', + visualization: 'Visualization', + input: 'Agent input', +}; + +export const ServiceTitleMap: Record = { + elasticsearch: 'Elasticsearch', + kibana: 'Kibana', +}; + +export const AssetIcons: Record = { + dashboard: 'dashboardApp', + 'index-pattern': 'indexPatternApp', + search: 'searchProfilerApp', + visualization: 'visualizeApp', +}; + +export const ServiceIcons: Record = { + elasticsearch: 'logoElasticsearch', + kibana: 'logoKibana', +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx new file mode 100644 index 0000000000000..589ce5f5dbd25 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/index.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// export { useBreadcrumbs } from './use_breadcrumbs'; +export { useLinks } from './use_links'; +export { + PackageInstallProvider, + useDeletePackage, + useGetPackageInstallStatus, + useInstallPackage, + useSetPackageInstallStatus, +} from './use_package_install'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_breadcrumbs.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_breadcrumbs.tsx new file mode 100644 index 0000000000000..6222d346432c3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_breadcrumbs.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ChromeBreadcrumb } from '../../../../../../../../../src/core/public'; +import { useCore } from '../../../hooks'; + +export function useBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]) { + const { chrome } = useCore(); + return chrome.setBreadcrumbs(newBreadcrumbs); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_links.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_links.tsx new file mode 100644 index 0000000000000..d4ed3624a6e68 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_links.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { generatePath } from 'react-router-dom'; +import { useCore } from '../../../hooks/use_core'; +import { PLUGIN_ID } from '../../../constants'; +import { epmRouteService } from '../../../services'; +import { DetailViewPanelName } from '../../../types'; +import { BASE_PATH, EPM_PATH, EPM_DETAIL_VIEW_PATH } from '../../../constants'; + +// TODO: get this from server/packages/handlers.ts (move elsewhere?) +// seems like part of the name@version change +interface DetailParams { + name: string; + version: string; + panel?: DetailViewPanelName; + withAppRoot?: boolean; +} + +const removeRelativePath = (relativePath: string): string => + new URL(relativePath, 'http://example.com').pathname; + +export function useLinks() { + const { http } = useCore(); + function appRoot(path: string) { + // include '#' because we're using HashRouter + return http.basePath.prepend(BASE_PATH + '#' + path); + } + + return { + toAssets: (path: string) => + http.basePath.prepend( + `/plugins/${PLUGIN_ID}/applications/ingest_manager/sections/epm/assets/${path}` + ), + toImage: (path: string) => http.basePath.prepend(epmRouteService.getFilePath(path)), + toRelativeImage: ({ + path, + packageName, + version, + }: { + path: string; + packageName: string; + version: string; + }) => { + const imagePath = removeRelativePath(path); + const pkgkey = `${packageName}-${version}`; + const filePath = `${epmRouteService.getInfoPath(pkgkey)}/${imagePath}`; + return http.basePath.prepend(filePath); + }, + toListView: () => appRoot(EPM_PATH), + toDetailView: ({ name, version, panel, withAppRoot = true }: DetailParams) => { + // panel is optional, but `generatePath` won't accept `path: undefined` + // so use this to pass `{ pkgkey }` or `{ pkgkey, panel }` + const params = Object.assign({ pkgkey: `${name}-${version}` }, panel ? { panel } : {}); + const path = generatePath(EPM_DETAIL_VIEW_PATH, params); + return withAppRoot ? appRoot(path) : path; + }, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx new file mode 100644 index 0000000000000..537a2616f1786 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/hooks/use_package_install.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import createContainer from 'constate'; +import React, { useCallback, useState } from 'react'; +import { NotificationsStart } from 'src/core/public'; +import { useLinks } from '.'; +import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { PackageInfo } from '../../../types'; +import { sendInstallPackage, sendRemovePackage } from '../../../hooks'; +import { InstallStatus } from '../../../types'; + +interface PackagesInstall { + [key: string]: PackageInstallItem; +} + +interface PackageInstallItem { + status: InstallStatus; +} + +type InstallPackageProps = Pick; + +function usePackageInstall({ notifications }: { notifications: NotificationsStart }) { + const [packages, setPackage] = useState({}); + const { toDetailView } = useLinks(); + + const setPackageInstallStatus = useCallback( + ({ name, status }: { name: PackageInfo['name']; status: InstallStatus }) => { + setPackage((prev: PackagesInstall) => ({ + ...prev, + [name]: { status }, + })); + }, + [] + ); + + const installPackage = useCallback( + async ({ name, version, title }: InstallPackageProps) => { + setPackageInstallStatus({ name, status: InstallStatus.installing }); + const pkgkey = `${name}-${version}`; + + const res = await sendInstallPackage(pkgkey); + if (res.error) { + setPackageInstallStatus({ name, status: InstallStatus.notInstalled }); + notifications.toasts.addWarning({ + title: `Failed to install ${title} package`, + text: + 'Something went wrong while trying to install this package. Please try again later.', + iconType: 'alert', + }); + } else { + setPackageInstallStatus({ name, status: InstallStatus.installed }); + const SuccessMsg =

Successfully installed {name}

; + + notifications.toasts.addSuccess({ + title: `Installed ${title} package`, + text: toMountPoint(SuccessMsg), + }); + + // TODO: this should probably live somewhere else and use , + // this hook could return the request state and a component could + // use that state. the component should be able to unsubscribe to prevent memory leaks + const packageUrl = toDetailView({ name, version }); + const dataSourcesUrl = toDetailView({ + name, + version, + panel: 'data-sources', + withAppRoot: false, + }); + if (window.location.href.includes(packageUrl)) window.location.hash = dataSourcesUrl; + } + }, + [notifications.toasts, setPackageInstallStatus, toDetailView] + ); + + const getPackageInstallStatus = useCallback( + (pkg: string): InstallStatus => { + return packages[pkg].status; + }, + [packages] + ); + + const deletePackage = useCallback( + async ({ name, version, title }: Pick) => { + setPackageInstallStatus({ name, status: InstallStatus.uninstalling }); + const pkgkey = `${name}-${version}`; + + const res = await sendRemovePackage(pkgkey); + if (res.error) { + setPackageInstallStatus({ name, status: InstallStatus.installed }); + notifications.toasts.addWarning({ + title: `Failed to delete ${title} package`, + text: 'Something went wrong while trying to delete this package. Please try again later.', + iconType: 'alert', + }); + } else { + setPackageInstallStatus({ name, status: InstallStatus.notInstalled }); + + const SuccessMsg =

Successfully deleted {title}

; + + notifications.toasts.addSuccess({ + title: `Deleted ${title} package`, + text: toMountPoint(SuccessMsg), + }); + + const packageUrl = toDetailView({ name, version }); + const dataSourcesUrl = toDetailView({ + name, + version, + panel: 'data-sources', + }); + if (window.location.href.includes(packageUrl)) window.location.href = dataSourcesUrl; + } + }, + [notifications.toasts, setPackageInstallStatus, toDetailView] + ); + + return { + packages, + installPackage, + setPackageInstallStatus, + getPackageInstallStatus, + deletePackage, + }; +} + +export const [ + PackageInstallProvider, + useInstallPackage, + useSetPackageInstallStatus, + useGetPackageInstallStatus, + useDeletePackage, +] = createContainer( + usePackageInstall, + value => value.installPackage, + value => value.setPackageInstallStatus, + value => value.getPackageInstallStatus, + value => value.deletePackage +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx index 777c2353226c4..b8dd08eb46a54 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/index.tsx @@ -5,74 +5,28 @@ */ import React from 'react'; -import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiImage } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { PLUGIN_ID } from '../../constants'; -import { WithHeaderLayout } from '../../layouts'; -import { useConfig, useCore } from '../../hooks'; - -const ImageWrapper = styled.div` - margin-bottom: -62px; -`; +import { HashRouter as Router, Switch, Route } from 'react-router-dom'; +import { useConfig } from '../../hooks'; +import { CreateDatasourcePage } from '../agent_config/create_datasource_page'; +import { Home } from './screens/home'; +import { Detail } from './screens/detail'; export const EPMApp: React.FunctionComponent = () => { const { epm } = useConfig(); - const { http } = useCore(); - - if (!epm.enabled) { - return null; - } - return ( - - - -

- -

-
-
- - -

- -

-
-
- - } - rightColumn={ - - - - } - tabs={[ - { - id: 'all_packages', - name: 'All packages', - isSelected: true, - }, - { - id: 'installed_packages', - name: 'Installed packages', - }, - ]} - > - hello world - fleet app -
- ); + return epm.enabled ? ( + + + + + + + + + + + + + + ) : null; }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx new file mode 100644 index 0000000000000..2b3be04ac476b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_delete.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiCallOut, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import React from 'react'; + +interface ConfirmPackageDeleteProps { + onCancel: () => void; + onConfirm: () => void; + packageName: string; + numOfAssets: number; +} +export const ConfirmPackageDelete = (props: ConfirmPackageDeleteProps) => { + const { onCancel, onConfirm, packageName, numOfAssets } = props; + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx new file mode 100644 index 0000000000000..137d9cf226b4d --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/confirm_package_install.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +interface ConfirmPackageInstallProps { + onCancel: () => void; + onConfirm: () => void; + packageName: string; + numOfAssets: number; +} +export const ConfirmPackageInstall = (props: ConfirmPackageInstallProps) => { + const { onCancel, onConfirm, packageName, numOfAssets } = props; + return ( + + + + +

+ and will only be accessible to users who have permission to view this Space. Elasticsearch + assets are installed globally and will be accessible to all Kibana users. +

+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx new file mode 100644 index 0000000000000..384cbbeed378e --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import { DEFAULT_PANEL, DetailParams } from '.'; +import { PackageInfo } from '../../../../types'; +import { AssetsFacetGroup } from '../../components/assets_facet_group'; +import { Requirements } from '../../components/requirements'; +import { CenterColumn, LeftColumn, RightColumn } from './layout'; +import { OverviewPanel } from './overview_panel'; +import { SideNavLinks } from './side_nav_links'; +import { DataSourcesPanel } from './data_sources_panel'; + +type ContentProps = PackageInfo & Pick & { hasIconPanel: boolean }; +export function Content(props: ContentProps) { + const { hasIconPanel, name, panel, version } = props; + const SideNavColumn = hasIconPanel + ? styled(LeftColumn)` + /* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */ + &&& { + margin-top: 77px; + } + ` + : LeftColumn; + + // fixes IE11 problem with nested flex items + const ContentFlexGroup = styled(EuiFlexGroup)` + flex: 0 0 auto !important; + `; + return ( + + + + + + + + + + + + ); +} + +type ContentPanelProps = PackageInfo & Pick; +export function ContentPanel(props: ContentPanelProps) { + const { panel, name, version } = props; + switch (panel) { + case 'data-sources': + return ; + case 'overview': + default: + return ; + } +} + +type RightColumnContentProps = PackageInfo & Pick; +function RightColumnContent(props: RightColumnContentProps) { + const { assets, requirement, panel } = props; + switch (panel) { + case 'overview': + return ( + + + + + + + + + + + + ); + default: + return ; + } +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content_collapse.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content_collapse.tsx new file mode 100644 index 0000000000000..9d5614debb42b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/content_collapse.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiButton, EuiButtonEmpty, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import React, { Fragment, useCallback, useLayoutEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +const BottomFade = styled.div` + width: 100%; + background: ${props => + `linear-gradient(${props.theme.eui.euiColorEmptyShade}00 0%, ${props.theme.eui.euiColorEmptyShade} 100%)`}; + margin-top: -${props => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px; + height: ${props => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px; + position: absolute; +`; +const ContentCollapseContainer = styled.div` + position: relative; +`; +const CollapseButtonContainer = styled.div` + display: inline-block; + background-color: ${props => props.theme.eui.euiColorEmptyShade}; + position: absolute; + left: 50%; + transform: translateX(-50%); + top: ${props => parseInt(props.theme.eui.euiButtonHeight, 10) / 2}px; +`; +const CollapseButtonTop = styled(EuiButtonEmpty)` + float: right; +`; + +const CollapseButton = ({ + open, + toggleCollapse, +}: { + open: boolean; + toggleCollapse: () => void; +}) => { + return ( +
+ + + + + {open ? 'Collapse' : 'Read more'} + + +
+ ); +}; + +export const ContentCollapse = ({ children }: { children: React.ReactNode }) => { + const [open, setOpen] = useState(false); + const [height, setHeight] = useState('auto'); + const [collapsible, setCollapsible] = useState(true); + const contentEl = useRef(null); + const collapsedHeight = 360; + + // if content is too small, don't collapse + useLayoutEffect( + () => + contentEl.current && contentEl.current.clientHeight < collapsedHeight + ? setCollapsible(false) + : setHeight(collapsedHeight), + [] + ); + + const clickOpen = useCallback(() => { + setOpen(!open); + }, [open]); + + return ( + + {collapsible ? ( + +
+ {open && ( + + Collapse + + )} + {children} +
+ {!open && } + +
+ ) : ( +
{children}
+ )} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx new file mode 100644 index 0000000000000..fa3245aec02c5 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/data_sources_panel.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { EuiTitle } from '@elastic/eui'; +import { Redirect } from 'react-router-dom'; +import { useLinks, useGetPackageInstallStatus } from '../../hooks'; +import { InstallStatus } from '../../../../types'; + +interface DataSourcesPanelProps { + name: string; + version: string; +} +export const DataSourcesPanel = ({ name, version }: DataSourcesPanelProps) => { + const { toDetailView } = useLinks(); + const getPackageInstallStatus = useGetPackageInstallStatus(); + const packageInstallStatus = getPackageInstallStatus(name); + // if they arrive at this page and the package is not installed, send them to overview + // this happens if they arrive with a direct url or they uninstall while on this tab + if (packageInstallStatus !== InstallStatus.installed) + return ( + + ); + return ( + + + Data Sources + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx new file mode 100644 index 0000000000000..5a51515d49486 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/header.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiTitle, IconType, EuiButton } from '@elastic/eui'; +import { PackageInfo } from '../../../../types'; +import { EPM_PATH } from '../../../../constants'; +import { useCapabilities, useLink } from '../../../../hooks'; +import { IconPanel } from '../../components/icon_panel'; +import { NavButtonBack } from '../../components/nav_button_back'; +import { Version } from '../../components/version'; +import { useLinks } from '../../hooks'; +import { CenterColumn, LeftColumn, RightColumn } from './layout'; + +const FullWidthNavRow = styled(EuiPage)` + /* no left padding so link is against column left edge */ + padding-left: 0; +`; + +const Text = styled.span` + margin-right: ${props => props.theme.eui.euiSizeM}; +`; + +const StyledVersion = styled(Version)` + font-size: ${props => props.theme.eui.euiFontSizeS}; + color: ${props => props.theme.eui.euiColorDarkShade}; +`; + +type HeaderProps = PackageInfo & { iconType?: IconType }; + +export function Header(props: HeaderProps) { + const { iconType, name, title, version } = props; + const hasWriteCapabilites = useCapabilities().write; + const { toListView } = useLinks(); + // useBreadcrumbs([{ text: PLUGIN.TITLE, href: toListView() }, { text: title }]); + + const ADD_DATASOURCE_URI = useLink(`${EPM_PATH}/${name}-${version}/add-datasource`); + + return ( + + + + + + {iconType ? ( + + + + ) : null} + + +

+ {title} + +

+
+
+ + + + + + + + + +
+
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx new file mode 100644 index 0000000000000..4bc90c6a0f8fd --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiPage, EuiPageBody, EuiPageProps, ICON_TYPES } from '@elastic/eui'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import { DetailViewPanelName, InstallStatus } from '../../../../types'; +import { PackageInfo } from '../../../../types'; +import { useSetPackageInstallStatus } from '../../hooks'; +import { Content } from './content'; +import { Header } from './header'; +import { sendGetPackageInfoByKey } from '../../../../hooks'; + +export const DEFAULT_PANEL: DetailViewPanelName = 'overview'; + +export interface DetailParams { + pkgkey: string; + panel?: DetailViewPanelName; +} + +export function Detail() { + // TODO: fix forced cast if possible + const { pkgkey, panel = DEFAULT_PANEL } = useParams() as DetailParams; + + const [info, setInfo] = useState(null); + const setPackageInstallStatus = useSetPackageInstallStatus(); + useEffect(() => { + sendGetPackageInfoByKey(pkgkey).then(response => { + const packageInfo = response.data?.response; + const title = packageInfo?.title; + const name = packageInfo?.name; + const status: InstallStatus = packageInfo?.status as any; + + // track install status state + if (name) { + setPackageInstallStatus({ name, status }); + } + if (packageInfo) { + setInfo({ ...packageInfo, title: title || '' }); + } + }); + }, [pkgkey, setPackageInstallStatus]); + + if (!info) return null; + + return ; +} + +const FullWidthHeader = styled(EuiPage)` + border-bottom: ${props => props.theme.eui.euiBorderThin}; + padding-bottom: ${props => props.theme.eui.paddingSizes.xl}; +`; + +const FullWidthContent = styled(EuiPage)` + background-color: ${props => props.theme.eui.euiColorEmptyShade}; + padding-top: ${props => parseInt(props.theme.eui.paddingSizes.xl, 10) * 1.25}px; + flex-grow: 1; +`; + +type LayoutProps = PackageInfo & Pick & Pick; +export function DetailLayout(props: LayoutProps) { + const { name, restrictWidth } = props; + const iconType = ICON_TYPES.find(key => key.toLowerCase() === `logo${name}`); + + return ( + + + +
+ + + + + + + + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx new file mode 100644 index 0000000000000..8a8afed5570ed --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/installation_button.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiButton } from '@elastic/eui'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { PackageInfo, InstallStatus } from '../../../../types'; +import { useCapabilities } from '../../../../hooks'; +import { useDeletePackage, useGetPackageInstallStatus, useInstallPackage } from '../../hooks'; +import { ConfirmPackageDelete } from './confirm_package_delete'; +import { ConfirmPackageInstall } from './confirm_package_install'; + +interface InstallationButtonProps { + package: PackageInfo; +} + +export function InstallationButton(props: InstallationButtonProps) { + const { assets, name, title, version } = props.package; + const hasWriteCapabilites = useCapabilities().write; + const installPackage = useInstallPackage(); + const deletePackage = useDeletePackage(); + const getPackageInstallStatus = useGetPackageInstallStatus(); + const installationStatus = getPackageInstallStatus(name); + + const isInstalling = installationStatus === InstallStatus.installing; + const isRemoving = installationStatus === InstallStatus.uninstalling; + const isInstalled = installationStatus === InstallStatus.installed; + const [isModalVisible, setModalVisible] = useState(false); + const toggleModal = useCallback(() => { + setModalVisible(!isModalVisible); + }, [isModalVisible]); + + const handleClickInstall = useCallback(() => { + installPackage({ name, version, title }); + toggleModal(); + }, [installPackage, name, title, toggleModal, version]); + + const handleClickDelete = useCallback(() => { + deletePackage({ name, version, title }); + toggleModal(); + }, [deletePackage, name, title, toggleModal, version]); + + const numOfAssets = useMemo( + () => + Object.entries(assets).reduce( + (acc, [serviceName, serviceNameValue]) => + acc + + Object.entries(serviceNameValue).reduce( + (acc2, [assetName, assetNameValue]) => acc2 + assetNameValue.length, + 0 + ), + 0 + ), + [assets] + ); + + const installButton = ( + + {isInstalling ? 'Installing' : 'Install package'} + + ); + + const installedButton = ( + + {isInstalling ? 'Deleting' : 'Delete package'} + + ); + + const deletionModal = ( + + ); + + const installationModal = ( + + ); + + return hasWriteCapabilites ? ( + + {isInstalled ? installedButton : installButton} + {isModalVisible && (isInstalled ? deletionModal : installationModal)} + + ) : null; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx new file mode 100644 index 0000000000000..a802e35add7db --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/layout.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem } from '@elastic/eui'; +import React, { FunctionComponent, ReactNode } from 'react'; + +interface ColumnProps { + children?: ReactNode; + className?: string; +} + +export const LeftColumn: FunctionComponent = ({ children, ...rest }) => { + return ( + + {children} + + ); +}; + +export const CenterColumn: FunctionComponent = ({ children, ...rest }) => { + return ( + + {children} + + ); +}; + +export const RightColumn: FunctionComponent = ({ children, ...rest }) => { + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/markdown_renderers.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/markdown_renderers.tsx new file mode 100644 index 0000000000000..2e321e8bfc36f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/markdown_renderers.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiCodeBlock, + EuiLink, + EuiTableHeaderCell, + EuiTableRow, + EuiTableRowCell, + EuiText, +} from '@elastic/eui'; +import React from 'react'; + +/** prevents links to the new pages from accessing `window.opener` */ +const REL_NOOPENER = 'noopener'; + +/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */ +const REL_NOFOLLOW = 'nofollow'; + +/** prevents the browser from sending the current address as referrer via the Referer HTTP header */ +const REL_NOREFERRER = 'noreferrer'; + +export const markdownRenderers = { + root: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + table: ({ children }: { children: React.ReactNode[] }) => ( + + {children} +
+ ), + tableRow: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableCell: ({ isHeader, children }: { isHeader: boolean; children: React.ReactNode[] }) => { + return isHeader ? ( + {children} + ) : ( + {children} + ); + }, + // the headings used in markdown don't match our page so mapping them to the appropriate one + heading: ({ level, children }: { level: number; children: React.ReactNode[] }) => { + switch (level) { + case 1: + return

{children}

; + case 2: + return

{children}

; + case 3: + return
{children}
; + default: + return
{children}
; + } + }, + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + + {children} + + ), + code: ({ language, value }: { language: string; value: string }) => { + return ( + + {value} + + ); + }, +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/overview_panel.tsx new file mode 100644 index 0000000000000..ca6aceabe7f36 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/overview_panel.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiSpacer } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { PackageInfo } from '../../../../types'; +import { Readme } from './readme'; +import { Screenshots } from './screenshots'; + +export function OverviewPanel(props: PackageInfo) { + const { screenshots, readme, name, version } = props; + return ( + + {readme && } + + {screenshots && } + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/readme.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/readme.tsx new file mode 100644 index 0000000000000..72e2d779c39be --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/readme.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiLoadingContent, EuiText } from '@elastic/eui'; +import React, { Fragment, useEffect, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { useLinks } from '../../hooks'; +import { ContentCollapse } from './content_collapse'; +import { markdownRenderers } from './markdown_renderers'; +import { sendGetFileByPath } from '../../../../hooks'; + +export function Readme({ + readmePath, + packageName, + version, +}: { + readmePath: string; + packageName: string; + version: string; +}) { + const [markdown, setMarkdown] = useState(undefined); + const { toRelativeImage } = useLinks(); + const handleImageUri = React.useCallback( + (uri: string) => { + const isRelative = + uri.indexOf('http://') === 0 || uri.indexOf('https://') === 0 ? false : true; + const fullUri = isRelative ? toRelativeImage({ packageName, version, path: uri }) : uri; + return fullUri; + }, + [toRelativeImage, packageName, version] + ); + + useEffect(() => { + sendGetFileByPath(readmePath).then(res => { + setMarkdown(res.data || ''); + }); + }, [readmePath]); + + return ( + + {markdown !== undefined ? ( + + + + ) : ( + + {/* simulates a long page of text loading */} +

+ +

+

+ +

+

+ +

+
+ )} +
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx new file mode 100644 index 0000000000000..10cf9c97723c0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/screenshots.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { ScreenshotItem } from '../../../../types'; +import { useLinks } from '../../hooks'; + +interface ScreenshotProps { + images: ScreenshotItem[]; +} + +export function Screenshots(props: ScreenshotProps) { + const { toImage } = useLinks(); + const { images } = props; + + // for now, just get first image + const image = images[0]; + const hasCaption = image.title ? true : false; + + const getHorizontalPadding = (styledProps: any): number => + parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 2; + const getVerticalPadding = (styledProps: any): number => + parseInt(styledProps.theme.eui.paddingSizes.xl, 10) * 1.75; + const getPadding = (styledProps: any) => + hasCaption + ? `${styledProps.theme.eui.paddingSizes.xl} ${getHorizontalPadding( + styledProps + )}px ${getVerticalPadding(styledProps)}px` + : `${getHorizontalPadding(styledProps)}px ${getVerticalPadding(styledProps)}px`; + + const ScreenshotsContainer = styled(EuiFlexGroup)` + background: linear-gradient(360deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%), + ${styledProps => styledProps.theme.eui.euiColorPrimary}; + padding: ${styledProps => getPadding(styledProps)}; + flex: 0 0 auto; + border-radius: ${styledProps => styledProps.theme.eui.euiBorderRadius}; + `; + + // fixes ie11 problems with nested flex items + const NestedEuiFlexItem = styled(EuiFlexItem)` + flex: 0 0 auto !important; + `; + return ( + + +

Screenshots

+
+ + + {hasCaption && ( + + + {image.title} + + + + )} + + {/* By default EuiImage sets width to 100% and Figure to 22.5rem for size=l images, + set image to same width. Will need to update if size changes. + */} + + + +
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx new file mode 100644 index 0000000000000..39a6fca2e4318 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/detail/side_nav_links.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiButtonEmptyProps } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import styled from 'styled-components'; +import { PackageInfo, entries, DetailViewPanelName, InstallStatus } from '../../../../types'; +import { useLinks, useGetPackageInstallStatus } from '../../hooks'; + +export type NavLinkProps = Pick & { + active: DetailViewPanelName; +}; + +const PanelDisplayNames: Record = { + overview: 'Overview', + 'data-sources': 'Data Sources', +}; + +export function SideNavLinks({ name, version, active }: NavLinkProps) { + const { toDetailView } = useLinks(); + const getPackageInstallStatus = useGetPackageInstallStatus(); + const packageInstallStatus = getPackageInstallStatus(name); + + return ( + + {entries(PanelDisplayNames).map(([panel, display]) => { + const Link = styled(EuiButtonEmpty).attrs({ + href: toDetailView({ name, version, panel }), + })` + font-weight: ${p => + active === panel + ? p.theme.eui.euiFontWeightSemiBold + : p.theme.eui.euiFontWeightRegular}; + `; + // don't display Data Sources tab if the package is not installed + if (packageInstallStatus !== InstallStatus.installed && panel === 'data-sources') + return null; + + return ( +
+ {display} +
+ ); + })} +
+ ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/category_facets.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/category_facets.tsx new file mode 100644 index 0000000000000..e138f9f531a39 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/category_facets.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiFacetButton, EuiFacetGroup } from '@elastic/eui'; +import React from 'react'; +import { CategorySummaryItem, CategorySummaryList } from '../../../../types'; + +export function CategoryFacets({ + categories, + selectedCategory, + onCategoryChange, +}: { + categories: CategorySummaryList; + selectedCategory: string; + onCategoryChange: (category: CategorySummaryItem) => unknown; +}) { + const controls = ( + + {categories.map(category => ( + onCategoryChange(category)} + > + {category.title} + + ))} + + ); + + return controls; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx new file mode 100644 index 0000000000000..2cb5aca39c807 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import styled from 'styled-components'; +import { useLinks } from '../../hooks'; + +export function HeroCopy() { + return ( + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ); +} + +export function HeroImage() { + const { toAssets } = useLinks(); + const ImageWrapper = styled.div` + margin-bottom: -38px; // revert to -62px when tabs are restored + `; + + return ( + + + + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/hooks.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/hooks.tsx new file mode 100644 index 0000000000000..c3e29f723dcba --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/hooks.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { PackageList } from '../../../../types'; +import { fieldsToSearch, LocalSearch, searchIdField } from './search_packages'; + +export function useAllPackages(selectedCategory: string, categoryPackages: PackageList = []) { + const [allPackages, setAllPackages] = useState([]); + + useEffect(() => { + if (!selectedCategory) setAllPackages(categoryPackages); + }, [selectedCategory, categoryPackages]); + + return [allPackages, setAllPackages] as [typeof allPackages, typeof setAllPackages]; +} + +export function useLocalSearch(allPackages: PackageList) { + const localSearchRef = useRef(null); + + useEffect(() => { + if (!allPackages.length) return; + + const localSearch = new LocalSearch(searchIdField); + fieldsToSearch.forEach(field => localSearch.addIndex(field)); + localSearch.addDocuments(allPackages); + localSearchRef.current = localSearch; + }, [allPackages]); + + return localSearchRef; +} + +export function useInstalledPackages(allPackages: PackageList) { + const [installedPackages, setInstalledPackages] = useState([]); + + useEffect(() => { + setInstalledPackages(allPackages.filter(({ status }) => status === 'installed')); + }, [allPackages]); + + return [installedPackages, setInstalledPackages] as [ + typeof installedPackages, + typeof setInstalledPackages + ]; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx new file mode 100644 index 0000000000000..640e4a30a40ca --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/index.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiHorizontalRule, + // @ts-ignore + EuiSearchBar, + EuiSpacer, +} from '@elastic/eui'; +import React, { Fragment, useState } from 'react'; +import { useGetCategories, useGetPackages } from '../../../../hooks'; +import { WithHeaderLayout } from '../../../../layouts'; +import { CategorySummaryItem, PackageList } from '../../../../types'; +import { PackageListGrid } from '../../components/package_list_grid'; +// import { useBreadcrumbs, useLinks } from '../../hooks'; +import { CategoryFacets } from './category_facets'; +import { HeroCopy, HeroImage } from './header'; +import { useAllPackages, useInstalledPackages, useLocalSearch } from './hooks'; +import { SearchPackages } from './search_packages'; + +export function Home() { + // useBreadcrumbs([{ text: PLUGIN.TITLE, href: toListView() }]); + + const state = useHomeState(); + const searchBar = ( + { + state.setSearchTerm(userInput); + }} + /> + ); + const body = state.searchTerm ? ( + + ) : ( + + {state.installedPackages.length ? ( + + + + + ) : null} + + + ); + + return ( + } + rightColumn={} + // tabs={[ + // { + // id: 'all_packages', + // name: 'All packages', + // isSelected: true, + // }, + // { + // id: 'installed_packages', + // name: 'Installed packages', + // }, + // ]} + > + {searchBar} + + {body} + + ); +} + +type HomeState = ReturnType; + +export function useHomeState() { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(''); + const { data: categoriesRes } = useGetCategories(); + const categories = categoriesRes?.response; + const { data: categoryPackagesRes } = useGetPackages({ category: selectedCategory }); + const categoryPackages = categoryPackagesRes?.response; + const [allPackages, setAllPackages] = useAllPackages(selectedCategory, categoryPackages); + const localSearchRef = useLocalSearch(allPackages); + const [installedPackages, setInstalledPackages] = useInstalledPackages(allPackages); + + return { + searchTerm, + setSearchTerm, + selectedCategory, + setSelectedCategory, + categories, + allPackages, + setAllPackages, + installedPackages, + localSearchRef, + setInstalledPackages, + categoryPackages, + }; +} + +function InstalledPackages({ list }: { list: PackageList }) { + const title = 'Your Packages'; + + return ; +} + +function AvailablePackages({ + allPackages, + categories, + categoryPackages, + selectedCategory, + setSelectedCategory, +}: HomeState) { + const title = 'Available Packages'; + const noFilter = { + id: '', + title: 'All', + count: allPackages.length, + }; + + const controls = categories ? ( + setSelectedCategory(id)} + /> + ) : null; + + return ; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_packages.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_packages.tsx new file mode 100644 index 0000000000000..adffdefd30a4f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_packages.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Search as LocalSearch } from 'js-search'; +import React from 'react'; +import { PackageList, PackageListItem } from '../../../../types'; +import { SearchResults } from './search_results'; + +export { LocalSearch }; +export type SearchField = keyof PackageListItem; +export const searchIdField: SearchField = 'name'; +export const fieldsToSearch: SearchField[] = ['description', 'name', 'title']; + +interface SearchPackagesProps { + searchTerm: string; + localSearchRef: React.MutableRefObject; + allPackages: PackageList; +} + +export function SearchPackages({ searchTerm, localSearchRef, allPackages }: SearchPackagesProps) { + // this means the search index hasn't been built yet. + // i.e. the intial fetch of all packages hasn't finished + if (!localSearchRef.current) return
Still fetching matches. Try again in a moment.
; + + const matches = localSearchRef.current.search(searchTerm) as PackageList; + const matchingIds = matches.map(match => match[searchIdField]); + const filtered = allPackages.filter(item => matchingIds.includes(item[searchIdField])); + + return ; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx new file mode 100644 index 0000000000000..fbdcaac01931b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/search_results.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiText, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { PackageList } from '../../../../types'; +import { PackageListGrid } from '../../components/package_list_grid'; + +interface SearchResultsProps { + term: string; + results: PackageList; +} + +export function SearchResults({ term, results }: SearchResultsProps) { + const title = 'Search results'; + return ( + + + {results.length} results for "{term}" + + + } + /> + ); +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx new file mode 100644 index 0000000000000..c0f55a5a275fd --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { + EuiBasicTable, + // @ts-ignore + EuiSuggest, + EuiFlexGroup, + EuiButton, + EuiSpacer, + EuiFlexItem, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, FormattedTime } from '@kbn/i18n/react'; +import { Agent, AgentEvent } from '../../../../types'; +import { usePagination, useGetOneAgentEvents } from '../../../../hooks'; +import { SearchBar } from '../../../../components/search_bar'; + +function useSearch() { + const [state, setState] = useState<{ search: string }>({ + search: '', + }); + + const setSearch = (s: string) => + setState({ + search: s, + }); + + return { + ...state, + setSearch, + }; +} + +export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { + const { pageSizeOptions, pagination, setPagination } = usePagination(); + const { search, setSearch } = useSearch(); + + const { isLoading, data, sendRequest } = useGetOneAgentEvents(agent.id, { + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: search && search.trim() !== '' ? search.trim() : undefined, + }); + + const refresh = () => sendRequest(); + + const total = data ? data.total : 0; + const list = data ? data.list : []; + const paginationOptions = { + pageIndex: pagination.currentPage - 1, + pageSize: pagination.pageSize, + totalItemCount: total, + pageSizeOptions, + }; + + const columns = [ + { + field: 'timestamp', + name: i18n.translate('xpack.ingestManager.agentEventsList.timestampColumnTitle', { + defaultMessage: 'Timestamp', + }), + render: (timestamp: string) => ( + + ), + sortable: true, + }, + { + field: 'type', + name: i18n.translate('xpack.ingestManager.agentEventsList.typeColumnTitle', { + defaultMessage: 'Type', + }), + width: '90px', + }, + { + field: 'subtype', + name: i18n.translate('xpack.ingestManager.agentEventsList.subtypeColumnTitle', { + defaultMessage: 'Subtype', + }), + width: '90px', + }, + { + field: 'message', + name: i18n.translate('xpack.ingestManager.agentEventsList.messageColumnTitle', { + defaultMessage: 'Message', + }), + }, + { + field: 'payload', + name: i18n.translate('xpack.ingestManager.agentEventsList.paylodColumnTitle', { + defaultMessage: 'Payload', + }), + truncateText: true, + render: (payload: any) => ( + + {payload && JSON.stringify(payload, null, 2)} + + ), + }, + ]; + + const onClickRefresh = () => { + refresh(); + }; + + const onChange = ({ page }: { page: { index: number; size: number } }) => { + const newPagination = { + ...pagination, + currentPage: page.index + 1, + pageSize: page.size, + }; + + setPagination(newPagination); + }; + + return ( + <> + +

+ +

+
+ + + + + + + + + + + + + + onChange={onChange} + items={list} + columns={columns} + pagination={paginationOptions} + loading={isLoading} + /> + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx new file mode 100644 index 0000000000000..0844368dc214b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/details_section.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiDescriptionList, + EuiButton, + EuiDescriptionListTitle, + EuiDescriptionListDescription, + EuiButtonEmpty, + EuiIconTip, + EuiTextColor, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useAgentRefresh } from '../hooks'; +import { AgentMetadataFlyout } from './metadata_flyout'; +import { Agent } from '../../../../types'; +import { AgentHealth } from '../../components/agent_health'; +import { useCapabilities, useGetOneAgentConfig } from '../../../../hooks'; +import { Loading } from '../../../../components'; +import { ConnectedLink } from '../../components'; +import { AgentUnenrollProvider } from '../../components/agent_unenroll_provider'; + +const Item: React.FunctionComponent<{ label: string }> = ({ label, children }) => { + return ( + + + {label} + {children} + + + ); +}; + +function useFlyout() { + const [isVisible, setVisible] = useState(false); + return { + isVisible, + show: () => setVisible(true), + hide: () => setVisible(false), + }; +} + +interface Props { + agent: Agent; +} +export const AgentDetailSection: React.FunctionComponent = ({ agent }) => { + const hasWriteCapabilites = useCapabilities().write; + const metadataFlyout = useFlyout(); + const refreshAgent = useAgentRefresh(); + + // Fetch AgentConfig information + const { isLoading: isAgentConfigLoading, data: agentConfigData } = useGetOneAgentConfig( + agent.config_id as string + ); + + const items = [ + { + title: i18n.translate('xpack.ingestManager.agentDetails.statusLabel', { + defaultMessage: 'Status', + }), + description: , + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.idLabel', { + defaultMessage: 'ID', + }), + description: agent.id, + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.typeLabel', { + defaultMessage: 'Type', + }), + description: agent.type, + }, + { + title: i18n.translate('xpack.ingestManager.agentDetails.agentConfigLabel', { + defaultMessage: 'AgentConfig', + }), + description: isAgentConfigLoading ? ( + + ) : agentConfigData && agentConfigData.item ? ( + + {agentConfigData.item.name} + + ) : ( + + + } + />{' '} + {agent.config_id} + + ), + }, + ]; + + return ( + <> + + + +

+ +

+
+
+ + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt([agent.id], 1, refreshAgent); + }} + > + + + )} + + +
+ + + {items.map((item, idx) => ( + + {item.description} + + ))} + + metadataFlyout.show()}>View metadata + + + {metadataFlyout.isVisible && } + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts new file mode 100644 index 0000000000000..9dffa54aeaf7f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { AgentEventsTable } from './agent_events_table'; +export { AgentDetailSection } from './details_section'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx new file mode 100644 index 0000000000000..ee43385e601c2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_flyout.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiSpacer, + EuiDescriptionList, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiHorizontalRule, +} from '@elastic/eui'; +import { MetadataForm } from './metadata_form'; +import { Agent } from '../../../../types'; + +interface Props { + agent: Agent; + flyout: { hide: () => void }; +} +export const AgentMetadataFlyout: React.FunctionComponent = ({ agent, flyout }) => { + const mapMetadata = (obj: { [key: string]: string } | undefined) => { + return Object.keys(obj || {}).map(key => ({ + title: key, + description: obj ? obj[key] : '', + })); + }; + + const localItems = mapMetadata(agent.local_metadata); + const userProvidedItems = mapMetadata(agent.user_provided_metadata); + + return ( + flyout.hide()} size="s" aria-labelledby="flyoutTitle"> + + +

+ +

+
+
+ + +

+ +

+
+ + + + +

+ +

+
+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx new file mode 100644 index 0000000000000..ce28bbdc590b0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/metadata_form.tsx @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiPopover, + EuiFormRow, + EuiButton, + EuiFlexItem, + EuiFieldText, + EuiFlexGroup, + EuiForm, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AxiosError } from 'axios'; +import { useAgentRefresh } from '../hooks'; +import { useInput, sendRequest } from '../../../../hooks'; +import { Agent } from '../../../../types'; +import { agentRouteService } from '../../../../services'; + +function useAddMetadataForm(agent: Agent, done: () => void) { + const refreshAgent = useAgentRefresh(); + const keyInput = useInput(); + const valueInput = useInput(); + const [state, setState] = useState<{ + isLoading: boolean; + error: null | string; + }>({ + isLoading: false, + error: null, + }); + + function clearInputs() { + keyInput.clear(); + valueInput.clear(); + } + + function setError(error: AxiosError) { + setState({ + isLoading: false, + error: error.response && error.response.data ? error.response.data.message : error.message, + }); + } + + async function success() { + await refreshAgent(); + setState({ + isLoading: false, + error: null, + }); + clearInputs(); + done(); + } + + return { + state, + onSubmit: async (e: React.FormEvent | React.MouseEvent) => { + e.preventDefault(); + setState({ + ...state, + isLoading: true, + }); + + try { + const { error } = await sendRequest({ + path: agentRouteService.getUpdatePath(agent.id), + method: 'put', + body: JSON.stringify({ + user_provided_metadata: { + ...agent.user_provided_metadata, + [keyInput.value]: valueInput.value, + }, + }), + }); + + if (error) { + throw error; + } + await success(); + } catch (error) { + setError(error); + } + }, + inputs: { + keyInput, + valueInput, + }, + }; +} + +export const MetadataForm: React.FunctionComponent<{ agent: Agent }> = ({ agent }) => { + const [isOpen, setOpen] = useState(false); + + const form = useAddMetadataForm(agent, () => { + setOpen(false); + }); + const { keyInput, valueInput } = form.inputs; + + const button = ( + setOpen(true)} color={'text'}> + + + ); + return ( + <> + setOpen(false)} + initialFocus="[id=fleet-details-metadata-form]" + > +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/index.ts new file mode 100644 index 0000000000000..c4e8ea13a89e0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { useAgentRefresh, AgentRefreshContext } from './use_agent'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/use_agent.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/use_agent.tsx new file mode 100644 index 0000000000000..78b04260f384f --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/hooks/use_agent.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +export const AgentRefreshContext = React.createContext({ refresh: () => {} }); + +export function useAgentRefresh() { + return React.useContext(AgentRefreshContext).refresh; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx new file mode 100644 index 0000000000000..f8ba829135f3c --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { useRouteMatch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { AgentEventsTable, AgentDetailSection } from './components'; +import { AgentRefreshContext } from './hooks'; +import { Loading } from '../../../components'; +import { useGetOneAgent } from '../../../hooks'; +import { WithHeaderLayout } from '../../../layouts'; + +export const AgentDetailsPage: React.FunctionComponent = () => { + const { + params: { agentId }, + } = useRouteMatch(); + const agentRequest = useGetOneAgent(agentId, { + pollIntervalMs: 5000, + }); + + if (agentRequest.isLoading && agentRequest.isInitialRequest) { + return ; + } + + if (agentRequest.error) { + return ( + + +

+ {agentRequest.error.message} +

+
+
+ ); + } + + if (!agentRequest.data) { + return ( + + + + ); + } + + const agent = agentRequest.data.item; + + return ( + agentRequest.sendRequest() }}> + }> + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx new file mode 100644 index 0000000000000..9c14a2e9dfed1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiSpacer, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiFlyoutFooter, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../../types'; +import { APIKeySelection } from './key_selection'; +import { EnrollmentInstructions } from './instructions'; + +interface Props { + onClose: () => void; + agentConfigs: AgentConfig[]; +} + +export const AgentEnrollmentFlyout: React.FunctionComponent = ({ + onClose, + agentConfigs = [], +}) => { + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(null); + + return ( + + + +

+ +

+
+
+ + setSelectedAPIKeyId(keyId)} + /> + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx new file mode 100644 index 0000000000000..97434d2178852 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/instructions.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiText, EuiButtonGroup, EuiSteps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEnrollmentApiKey } from '../enrollment_api_keys'; +import { ShellEnrollmentInstructions, ManualInstructions } from '../enrollment_instructions'; +import { useCore, useGetAgents } from '../../../../../hooks'; +import { Loading } from '../../../components'; + +interface Props { + selectedAPIKeyId: string | null; +} +function useNewEnrolledAgents() { + // New enrolled agents + const [timestamp] = useState(new Date().toISOString()); + const agentsRequest = useGetAgents( + { + perPage: 100, + page: 1, + showInactive: false, + }, + { + pollIntervalMs: 3000, + } + ); + return React.useMemo(() => { + if (!agentsRequest.data) { + return []; + } + + return agentsRequest.data.list.filter(agent => agent.enrolled_at >= timestamp); + }, [agentsRequest.data, timestamp]); +} + +export const EnrollmentInstructions: React.FunctionComponent = ({ selectedAPIKeyId }) => { + const core = useCore(); + const [installType, setInstallType] = useState<'quickInstall' | 'manual'>('quickInstall'); + + const apiKey = useEnrollmentApiKey(selectedAPIKeyId); + + const newAgents = useNewEnrolledAgents(); + if (!apiKey.data) { + return null; + } + + return ( + <> + { + setInstallType(installType === 'manual' ? 'quickInstall' : 'manual'); + }} + buttonSize="m" + isFullWidth + /> + + {installType === 'manual' ? ( + + ) : ( + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepTestAgents', { + defaultMessage: 'Test Agents', + }), + children: ( + + {!newAgents.length ? ( + <> + + + + ) : ( + <> + + + )} + + ), + }, + ]} + /> + )} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx new file mode 100644 index 0000000000000..89801bc6bee1e --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/agent_enrollment_flyout/key_selection.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiText, + EuiLink, + EuiFieldText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useEnrollmentApiKeys } from '../enrollment_api_keys'; +import { AgentConfig } from '../../../../../types'; +import { useInput, useCore, sendRequest } from '../../../../../hooks'; +import { enrollmentAPIKeyRouteService } from '../../../../../services'; + +interface Props { + onKeyChange: (keyId: string | null) => void; + agentConfigs: AgentConfig[]; +} + +function useCreateApiKeyForm(configId: string | null, onSuccess: (keyId: string) => void) { + const { notifications } = useCore(); + const [isLoading, setIsLoading] = useState(false); + const apiKeyNameInput = useInput(''); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); + try { + const res = await sendRequest({ + method: 'post', + path: enrollmentAPIKeyRouteService.getCreatePath(), + body: JSON.stringify({ + name: apiKeyNameInput.value, + config_id: configId, + }), + }); + apiKeyNameInput.clear(); + setIsLoading(false); + onSuccess(res.data.item.id); + } catch (err) { + notifications.toasts.addError(err as Error, { + title: 'Error', + }); + setIsLoading(false); + } + }; + + return { + isLoading, + onSubmit, + apiKeyNameInput, + }; +} + +export const APIKeySelection: React.FunctionComponent = ({ onKeyChange, agentConfigs }) => { + const enrollmentAPIKeysRequest = useEnrollmentApiKeys({ + currentPage: 1, + pageSize: 1000, + }); + + const [selectedState, setSelectedState] = useState<{ + agentConfigId: string | null; + enrollmentAPIKeyId: string | null; + }>({ + agentConfigId: agentConfigs.length ? agentConfigs[0].id : null, + enrollmentAPIKeyId: null, + }); + const filteredEnrollmentAPIKeys = React.useMemo(() => { + if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { + return []; + } + + return enrollmentAPIKeysRequest.data.list.filter( + key => key.config_id === selectedState.agentConfigId + ); + }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); + + // Select first API key when config change + React.useEffect(() => { + if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { + const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; + setSelectedState({ + agentConfigId: selectedState.agentConfigId, + enrollmentAPIKeyId, + }); + onKeyChange(enrollmentAPIKeyId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); + + const [showAPIKeyForm, setShowAPIKeyForm] = useState(false); + const apiKeyForm = useCreateApiKeyForm(selectedState.agentConfigId, async (keyId: string) => { + const res = await enrollmentAPIKeysRequest.refresh(); + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: res.data?.list.find(key => key.id === keyId)?.id ?? null, + }); + setShowAPIKeyForm(false); + }); + + return ( + <> + + + + + + + + } + > + ({ + value: agentConfig.id, + text: agentConfig.name, + }))} + value={selectedState.agentConfigId || undefined} + onChange={e => + setSelectedState({ + agentConfigId: e.target.value, + enrollmentAPIKeyId: null, + }) + } + /> + + + + + } + labelAppend={ + + setShowAPIKeyForm(!showAPIKeyForm)} color="primary"> + {showAPIKeyForm ? ( + + ) : ( + + )} + + + } + > + {showAPIKeyForm ? ( +
+ + + ) : ( + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + onChange={e => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + onKeyChange(selectedState.enrollmentAPIKeyId); + }} + /> + )} +
+
+
+ + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/donut_chart.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/donut_chart.tsx new file mode 100644 index 0000000000000..8cd363576aa85 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/donut_chart.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef } from 'react'; +import d3 from 'd3'; +import { EuiFlexItem } from '@elastic/eui'; + +interface DonutChartProps { + data: { + [key: string]: number; + }; + height: number; + width: number; +} + +export const DonutChart = ({ height, width, data }: DonutChartProps) => { + const chartElement = useRef(null); + + useEffect(() => { + if (chartElement.current !== null) { + // we must remove any existing paths before painting + d3.selectAll('g').remove(); + const svgElement = d3 + .select(chartElement.current) + .append('g') + .attr('transform', `translate(${width / 2}, ${height / 2})`); + const color = d3.scale + .ordinal() + // @ts-ignore + .domain(data) + .range(['#017D73', '#98A2B3', '#BD271E']); + const pieGenerator = d3.layout + .pie() + .value(({ value }: any) => value) + // these start/end angles will reverse the direction of the pie, + // which matches our design + .startAngle(2 * Math.PI) + .endAngle(0); + + svgElement + .selectAll('g') + // @ts-ignore + .data(pieGenerator(d3.entries(data))) + .enter() + .append('path') + .attr( + 'd', + // @ts-ignore attr does not expect a param of type Arc but it behaves as desired + d3.svg + .arc() + .innerRadius(width * 0.36) + .outerRadius(Math.min(width, height) / 2) + ) + .attr('fill', (d: any) => color(d.data.key)); + } + }, [data, height, width]); + return ( + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/confirm_delete_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/confirm_delete_modal.tsx new file mode 100644 index 0000000000000..8ce20a85e14b8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/confirm_delete_modal.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +export const ConfirmDeleteModal: React.FunctionComponent<{ + onConfirm: () => void; + onCancel: () => void; + apiKeyId: string; +}> = ({ onConfirm, onCancel, apiKeyId }) => { + return ( + + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + /> + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/create_api_key_form.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/create_api_key_form.tsx new file mode 100644 index 0000000000000..009080a4da186 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/create_api_key_form.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiButton, + EuiSelect, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useInput, sendRequest } from '../../../../../hooks'; +import { useConfigs } from './hooks'; +import { enrollmentAPIKeyRouteService } from '../../../../../services'; + +export const CreateApiKeyForm: React.FunctionComponent<{ onChange: () => void }> = ({ + onChange, +}) => { + const { data: configs } = useConfigs(); + const { inputs, onSubmit, submitted } = useCreateApiKey(() => onChange()); + + return ( + + + + + + + + + ({ + value: config.id, + text: config.name, + }))} + /> + + + + + onSubmit()}> + + + + + + ); +}; + +function useCreateApiKey(onSuccess: () => void) { + const [submitted, setSubmitted] = React.useState(false); + const inputs = { + nameInput: useInput(), + configIdInput: useInput('default'), + }; + + const onSubmit = async () => { + setSubmitted(true); + await sendRequest({ + method: 'post', + path: enrollmentAPIKeyRouteService.getCreatePath(), + body: JSON.stringify({ + name: inputs.nameInput.value, + config_id: inputs.configIdInput.value, + }), + }); + setSubmitted(false); + onSuccess(); + }; + + return { + inputs, + onSubmit, + submitted, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/hooks.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/hooks.tsx new file mode 100644 index 0000000000000..957e1201fd43b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/hooks.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Pagination, + useGetAgentConfigs, + useGetEnrollmentAPIKeys, + useGetOneEnrollmentAPIKey, +} from '../../../../../hooks'; + +export function useEnrollmentApiKeys(pagination: Pagination) { + const request = useGetEnrollmentAPIKeys(); + + return { + data: request.data, + isLoading: request.isLoading, + refresh: () => request.sendRequest(), + }; +} + +export function useConfigs() { + const request = useGetAgentConfigs(); + + return { + data: request.data ? request.data.items : [], + isLoading: request.isLoading, + }; +} + +export function useEnrollmentApiKey(apiKeyId: string | null) { + const request = useGetOneEnrollmentAPIKey(apiKeyId as string); + + return { + data: request.data, + isLoading: request.isLoading, + }; +} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/index.tsx new file mode 100644 index 0000000000000..19957e7827680 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_api_keys/index.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { EuiBasicTable, EuiButtonEmpty, EuiSpacer, EuiPopover, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { usePagination, sendRequest } from '../../../../../hooks'; +import { useEnrollmentApiKeys, useEnrollmentApiKey } from './hooks'; +import { ConfirmDeleteModal } from './confirm_delete_modal'; +import { CreateApiKeyForm } from './create_api_key_form'; +import { EnrollmentAPIKey } from '../../../../../types'; +import { useCapabilities } from '../../../../../hooks'; +import { enrollmentAPIKeyRouteService } from '../../../../../services'; +export { useEnrollmentApiKeys, useEnrollmentApiKey } from './hooks'; + +export const EnrollmentApiKeysTable: React.FunctionComponent<{ + onChange: () => void; +}> = ({ onChange }) => { + const [confirmDeleteApiKeyId, setConfirmDeleteApiKeyId] = useState(null); + const { pagination } = usePagination(); + const { data, isLoading, refresh } = useEnrollmentApiKeys(pagination); + + const columns: any[] = [ + { + field: 'name', + name: i18n.translate('xpack.ingestManager.apiKeysList.nameColumnTitle', { + defaultMessage: 'Name', + }), + width: '300px', + }, + { + field: 'config_id', + name: i18n.translate('xpack.ingestManager.apiKeysList.configColumnTitle', { + defaultMessage: 'Config', + }), + width: '100px', + }, + { + field: null, + name: i18n.translate('xpack.ingestManager.apiKeysList.apiKeyColumnTitle', { + defaultMessage: 'API Key', + }), + render: (key: EnrollmentAPIKey) => , + }, + { + field: null, + width: '50px', + render: (key: EnrollmentAPIKey) => { + return ( + setConfirmDeleteApiKeyId(key.id)} iconType={'trash'} /> + ); + }, + }, + ]; + + return ( + <> + {confirmDeleteApiKeyId && ( + setConfirmDeleteApiKeyId(null)} + onConfirm={async () => { + await sendRequest({ + method: 'delete', + path: enrollmentAPIKeyRouteService.getDeletePath(confirmDeleteApiKeyId), + }); + setConfirmDeleteApiKeyId(null); + refresh(); + }} + /> + )} + + } + items={data ? data.list : []} + itemId="id" + columns={columns} + /> + + { + refresh(); + onChange(); + }} + /> + + ); +}; + +export const CreateApiKeyButton: React.FunctionComponent<{ onChange: () => void }> = ({ + onChange, +}) => { + const hasWriteCapabilites = useCapabilities().write; + const [isOpen, setIsOpen] = React.useState(false); + + return ( + setIsOpen(true)} color="primary"> + + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + { + setIsOpen(false); + onChange(); + }} + /> + + ); + return <>; +}; + +const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => { + const [visible, setVisible] = useState(false); + const { data } = useEnrollmentApiKey(apiKeyId); + + return ( + <> + {visible && data ? data.item.api_key : '••••••••••••••••••••••••••••'} + setVisible(!visible)}> + {visible ? ( + + ) : ( + + )} + {' '} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx new file mode 100644 index 0000000000000..34233a00e630a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ShellEnrollmentInstructions } from './shell'; +export { ManualInstructions } from './manual'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx new file mode 100644 index 0000000000000..b1da4583b74cc --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiText, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +export const ManualInstructions: React.FunctionComponent = () => { + return ( + <> + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam vestibulum ullamcorper + turpis vitae interdum. Maecenas orci magna, auctor volutpat pellentesque eu, consectetur id + est. Nunc orci lacus, condimentum vel congue ac, fringilla eget tortor. Aliquam blandit, + nisi et congue euismod, leo lectus blandit risus, eu blandit erat metus sit amet leo. Nam + dictum lobortis condimentum. + + + + Vivamus sem sapien, dictum eu tellus vel, rutrum aliquam purus. Cras quis cursus nibh. + Aliquam fermentum ipsum nec turpis luctus lobortis. Nulla facilisi. Etiam nec fringilla + urna, sed vehicula ipsum. Quisque vel pellentesque lorem, at egestas enim. Nunc semper elit + lectus, in sollicitudin erat fermentum in. Pellentesque tempus massa eget purus pharetra + blandit. + + + + Mauris congue enim nulla, nec semper est posuere non. Donec et eros eu nisi gravida + malesuada eget in velit. Morbi placerat semper euismod. Suspendisse potenti. Morbi quis + porta erat, quis cursus nulla. Aenean mauris lorem, mollis in mattis et, lobortis a lectus. + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx new file mode 100644 index 0000000000000..04e84902bc9d4 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiCopy, + EuiFieldText, + EuiPopover, +} from '@elastic/eui'; +import { EnrollmentAPIKey } from '../../../../../../types'; + +// No need for i18n as these are platform names +const PLATFORMS = { + macos: 'macOS', + windows: 'Windows', + linux: 'Linux', +}; + +interface Props { + kibanaUrl: string; + kibanaCASha256?: string; + apiKey: EnrollmentAPIKey; +} + +export const ShellEnrollmentInstructions: React.FunctionComponent = ({ + kibanaUrl, + kibanaCASha256, + apiKey, +}) => { + // Platform state + const [currentPlatform, setCurrentPlatform] = useState('macos'); + const [isPlatformOptionsOpen, setIsPlatformOptionsOpen] = useState(false); + + // Build quick installation command + const quickInstallInstructions = `${ + kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' + }API_KEY=${ + apiKey.api_key + } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; + + return ( + <> + setIsPlatformOptionsOpen(true)} + > + {PLATFORMS[currentPlatform]} + + } + isOpen={isPlatformOptionsOpen} + closePopover={() => setIsPlatformOptionsOpen(false)} + > + ( + { + setCurrentPlatform(platform as typeof currentPlatform); + setIsPlatformOptionsOpen(false); + }} + > + {name} + + ))} + /> + + } + append={ + + {copy => } + + } + /> + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts new file mode 100644 index 0000000000000..c82c82db6f713 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { AgentEnrollmentFlyout } from './agent_enrollment_flyout'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss new file mode 100644 index 0000000000000..10e809c5f5566 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.scss @@ -0,0 +1,6 @@ +.fleet__agentList__table .euiTableFooterCell { + .euiTableCellContent, + .euiTableCellContent__text { + overflow: visible; + } +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx new file mode 100644 index 0000000000000..acf09dedc25f7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -0,0 +1,729 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useCallback } from 'react'; +import styled, { CSSProperties } from 'styled-components'; +import { + EuiBasicTable, + EuiButton, + EuiEmptyPrompt, + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPopover, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTitle, + EuiStat, + EuiI18nNumber, + EuiHealth, + EuiButtonIcon, + EuiContextMenuPanel, + EuiContextMenuItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { AgentEnrollmentFlyout } from './components'; +import { WithHeaderLayout } from '../../../layouts'; +import { Agent } from '../../../types'; +import { + usePagination, + useCapabilities, + useGetAgentConfigs, + useGetAgents, + useUrlParams, + useLink, +} from '../../../hooks'; +import { ConnectedLink } from '../components'; +import { SearchBar } from '../../../components/search_bar'; +import { AgentHealth } from '../components/agent_health'; +import { AgentUnenrollProvider } from '../components/agent_unenroll_provider'; +import { DonutChart } from './components/donut_chart'; +import { useGetAgentStatus } from '../../agent_config/details_page/hooks'; +import { AgentStatusKueryHelper } from '../../../services'; +import { FLEET_AGENT_DETAIL_PATH, AGENT_CONFIG_DETAILS_PATH } from '../../../constants'; + +const Divider = styled.div` + width: 0; + height: 100%; + border-left: ${props => props.theme.eui.euiBorderThin}; + height: 45px; +`; +const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}); +const REFRESH_INTERVAL_MS = 5000; + +const statusFilters = [ + { + status: 'online', + label: i18n.translate('xpack.ingestManager.agentList.statusOnlineFilterText', { + defaultMessage: 'Online', + }), + }, + { + status: 'offline', + label: i18n.translate('xpack.ingestManager.agentList.statusOfflineFilterText', { + defaultMessage: 'Offline', + }), + }, + , + { + status: 'error', + label: i18n.translate('xpack.ingestManager.agentList.statusErrorFilterText', { + defaultMessage: 'Error', + }), + }, +] as Array<{ label: string; status: string }>; + +const RowActions = React.memo<{ agent: Agent; refresh: () => void }>(({ agent, refresh }) => { + const hasWriteCapabilites = useCapabilities().write; + const DETAILS_URI = useLink(FLEET_AGENT_DETAIL_PATH); + const [isOpen, setIsOpen] = useState(false); + const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); + const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); + + return ( + + } + isOpen={isOpen} + closePopover={handleCloseMenu} + > + + + , + + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt([agent.id], 1, () => { + refresh(); + }); + }} + > + + + )} + , + ]} + /> + + ); +}); + +export const AgentListPage: React.FunctionComponent<{}> = () => { + const defaultKuery: string = (useUrlParams().urlParams.kuery as string) || ''; + const hasWriteCapabilites = useCapabilities().write; + // Agent data states + const [showInactive, setShowInactive] = useState(false); + + // Table and search states + const [search, setSearch] = useState(defaultKuery); + const { pagination, pageSizeOptions, setPagination } = usePagination(); + const [selectedAgents, setSelectedAgents] = useState([]); + const [areAllAgentsSelected, setAreAllAgentsSelected] = useState(false); + + // Configs state (for filtering) + const [isConfigsFilterOpen, setIsConfigsFilterOpen] = useState(false); + const [selectedConfigs, setSelectedConfigs] = useState([]); + // Status for filtering + const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); + const [selectedStatus, setSelectedStatus] = useState([]); + + // Add a config id to current search + const addConfigFilter = (configId: string) => { + setSelectedConfigs([...selectedConfigs, configId]); + }; + + // Remove a config id from current search + const removeConfigFilter = (configId: string) => { + setSelectedConfigs(selectedConfigs.filter(config => config !== configId)); + }; + + // Agent enrollment flyout state + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + + let kuery = search.trim(); + if (selectedConfigs.length) { + if (kuery) { + kuery = `(${kuery}) and`; + } + kuery = `${kuery} agents.config_id : (${selectedConfigs + .map(config => `"${config}"`) + .join(' or ')})`; + } + + if (selectedStatus.length) { + if (kuery) { + kuery = `(${kuery}) and`; + } + + kuery = selectedStatus + .map(status => { + switch (status) { + case 'online': + return AgentStatusKueryHelper.buildKueryForOnlineAgents(); + case 'offline': + return AgentStatusKueryHelper.buildKueryForOfflineAgents(); + case 'error': + return AgentStatusKueryHelper.buildKueryForErrorAgents(); + } + + return ''; + }) + .join(' or '); + } + + const agentStatusRequest = useGetAgentStatus(undefined, { + pollIntervalMs: REFRESH_INTERVAL_MS, + }); + const agentStatus = agentStatusRequest.data?.results; + + const agentsRequest = useGetAgents( + { + page: pagination.currentPage, + perPage: pagination.pageSize, + kuery: kuery && kuery !== '' ? kuery : undefined, + showInactive, + }, + { + pollIntervalMs: REFRESH_INTERVAL_MS, + } + ); + + const agents = agentsRequest.data ? agentsRequest.data.list : []; + const totalAgents = agentsRequest.data ? agentsRequest.data.total : 0; + const { isLoading } = agentsRequest; + + const agentConfigsRequest = useGetAgentConfigs({ + page: 1, + perPage: 1000, + }); + + const agentConfigs = agentConfigsRequest.data ? agentConfigsRequest.data.items : []; + const { isLoading: isAgentConfigsLoading } = agentConfigsRequest; + + const CONFIG_DETAILS_URI = useLink(AGENT_CONFIG_DETAILS_PATH); + + const columns = [ + { + field: 'local_metadata.host', + name: i18n.translate('xpack.ingestManager.agentList.hostColumnTitle', { + defaultMessage: 'Host', + }), + render: (host: string, agent: Agent) => ( + + {host} + + ), + footer: () => { + if (selectedAgents.length === agents.length && totalAgents > selectedAgents.length) { + return areAllAgentsSelected ? ( + setAreAllAgentsSelected(false)}> + + + ), + }} + /> + ) : ( + setAreAllAgentsSelected(true)}> + + + ), + }} + /> + ); + } + return null; + }, + }, + { + field: 'active', + name: i18n.translate('xpack.ingestManager.agentList.statusColumnTitle', { + defaultMessage: 'Status', + }), + render: (active: boolean, agent: any) => , + }, + { + field: 'config_id', + name: i18n.translate('xpack.ingestManager.agentList.configColumnTitle', { + defaultMessage: 'Configuration', + }), + render: (configId: string) => { + const configName = agentConfigs.find(p => p.id === configId)?.name; + return ( + + + + {configName || configId} + + + + + + + + + ); + }, + }, + { + field: 'local_metadata.agent_version', + name: i18n.translate('xpack.ingestManager.agentList.versionTitle', { + defaultMessage: 'Version', + }), + }, + { + field: 'last_checkin', + name: i18n.translate('xpack.ingestManager.agentList.lastCheckinTitle', { + defaultMessage: 'Last activity', + }), + render: (lastCheckin: string, agent: any) => + lastCheckin ? : null, + }, + { + name: i18n.translate('xpack.ingestManager.agentList.actionsColumnTitle', { + defaultMessage: 'Actions', + }), + actions: [ + { + render: (agent: Agent) => { + return agentsRequest.sendRequest()} />; + }, + }, + ], + width: '100px', + }, + ]; + + const emptyPrompt = ( + + + + } + actions={ + hasWriteCapabilites ? ( + setIsEnrollmentFlyoutOpen(true)}> + + + ) : null + } + /> + ); + const headerRightColumn = ( + + + } + description={i18n.translate('xpack.ingestManager.agentListStatus.totalLabel', { + defaultMessage: 'Agents', + })} + /> + + + + + + + {' '} + + + } + description={i18n.translate('xpack.ingestManager.agentListStatus.onlineLabel', { + defaultMessage: 'Online', + })} + /> + + + } + description={i18n.translate('xpack.ingestManager.agentListStatus.offlineLabel', { + defaultMessage: 'Offline', + })} + /> + + + } + description={i18n.translate('xpack.ingestManager.agentListStatus.errorLabel', { + defaultMessage: 'Error', + })} + /> + + {hasWriteCapabilites && ( + <> + + + + + setIsEnrollmentFlyoutOpen(true)}> + + + + + )} + + ); + const headerLeftColumn = ( + + + +

+ +

+
+
+ + +

+ +

+
+
+
+ ); + + return ( + + {isEnrollmentFlyoutOpen ? ( + setIsEnrollmentFlyoutOpen(false)} + /> + ) : null} + +

+ +

+
+ + + + + + + + + + + setShowInactive(!showInactive)} + /> + + + + + + {selectedAgents.length ? ( + + + {unenrollAgentsPrompt => ( + { + unenrollAgentsPrompt( + areAllAgentsSelected ? search : selectedAgents.map(agent => agent.id), + areAllAgentsSelected ? totalAgents : selectedAgents.length, + () => { + // Reload agents if on first page and no search query, otherwise + // reset to first page and reset search, which will trigger a reload + if (pagination.currentPage === 1 && !search) { + agentsRequest.sendRequest(); + } else { + setPagination({ + ...pagination, + currentPage: 1, + }); + setSearch(''); + } + + setAreAllAgentsSelected(false); + setSelectedAgents([]); + } + ); + }} + > + + + )} + + + ) : null} + + + + { + setPagination({ + ...pagination, + currentPage: 1, + }); + setSearch(newSearch); + }} + fieldPrefix="agents" + /> + + + + setIsStatutsFilterOpen(!isStatusFilterOpen)} + isSelected={isStatusFilterOpen} + hasActiveFilters={selectedStatus.length > 0} + numActiveFilters={selectedStatus.length} + disabled={isAgentConfigsLoading} + > + + + } + isOpen={isStatusFilterOpen} + closePopover={() => setIsStatutsFilterOpen(false)} + panelPaddingSize="none" + > +
+ {statusFilters.map(({ label, status }, idx) => ( + { + if (selectedStatus.includes(status)) { + setSelectedStatus([...selectedStatus.filter(s => s !== status)]); + } else { + setSelectedStatus([...selectedStatus, status]); + } + }} + > + {label} + + ))} +
+
+ setIsConfigsFilterOpen(!isConfigsFilterOpen)} + isSelected={isConfigsFilterOpen} + hasActiveFilters={selectedConfigs.length > 0} + numActiveFilters={selectedConfigs.length} + numFilters={agentConfigs.length} + disabled={isAgentConfigsLoading} + > + + + } + isOpen={isConfigsFilterOpen} + closePopover={() => setIsConfigsFilterOpen(false)} + panelPaddingSize="none" + > +
+ {agentConfigs.map((config, index) => ( + { + if (selectedConfigs.includes(config.id)) { + removeConfigFilter(config.id); + } else { + addConfigFilter(config.id); + } + }} + > + {config.name} + + ))} +
+
+
+
+
+
+
+ + + + className="fleet__agentList__table" + loading={isLoading && agentsRequest.isInitialRequest} + hasActions={true} + noItemsMessage={ + isLoading ? ( + + ) : !search.trim() && selectedConfigs.length === 0 && totalAgents === 0 ? ( + emptyPrompt + ) : ( + setSearch('')}> + + + ), + }} + /> + ) + } + items={totalAgents ? agents : []} + itemId="id" + columns={columns} + isSelectable={true} + selection={{ + selectable: (agent: Agent) => agent.active, + onSelectionChange: (newSelectedAgents: Agent[]) => { + setSelectedAgents(newSelectedAgents); + setAreAllAgentsSelected(false); + }, + }} + pagination={{ + pageIndex: pagination.currentPage - 1, + pageSize: pagination.pageSize, + totalItemCount: totalAgents, + pageSizeOptions, + }} + onChange={({ page }: { page: { index: number; size: number } }) => { + const newPagination = { + ...pagination, + currentPage: page.index + 1, + pageSize: page.size, + }; + setPagination(newPagination); + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx new file mode 100644 index 0000000000000..181ebe3504222 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_health.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; +import { EuiHealth, EuiToolTip } from '@elastic/eui'; +import { Agent } from '../../../types'; + +interface Props { + agent: Agent; +} + +const Status = { + Online: ( + + + + ), + Offline: ( + + + + ), + Inactive: ( + + + + ), + Warning: ( + + + + ), + Error: ( + + + + ), +}; + +function getStatusComponent(agent: Agent): React.ReactElement { + switch (agent.status) { + case 'error': + return Status.Error; + case 'inactive': + return Status.Inactive; + case 'offline': + return Status.Offline; + case 'warning': + return Status.Warning; + default: + return Status.Online; + } +} + +export const AgentHealth: React.FunctionComponent = ({ agent }) => { + const { last_checkin: lastCheckIn } = agent; + const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); + + return ( + + , + }} + /> + {agent.current_error_events.map((event, idx) => ( +

{event.message}

+ ))} + + ) : ( + + ) + } + > + {getStatusComponent(agent)} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx new file mode 100644 index 0000000000000..25499495a7897 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useRef, useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useCore, sendRequest } from '../../../hooks'; +import { PostAgentUnenrollResponse } from '../../../types'; +import { agentRouteService } from '../../../services'; + +interface Props { + children: (unenrollAgents: UnenrollAgents) => React.ReactElement; +} + +export type UnenrollAgents = ( + agents: string[] | string, + agentsCount: number, + onSuccess?: OnSuccessCallback +) => void; + +type OnSuccessCallback = (agentsUnenrolled: string[]) => void; + +export const AgentUnenrollProvider: React.FunctionComponent = ({ children }) => { + const core = useCore(); + const [agents, setAgents] = useState([]); + const [agentsCount, setAgentsCount] = useState(0); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const onSuccessCallback = useRef(null); + + const unenrollAgentsPrompt: UnenrollAgents = ( + agentsToUnenroll, + agentsToUnenrollCount, + onSuccess = () => undefined + ) => { + if ( + agentsToUnenroll === undefined || + (Array.isArray(agentsToUnenroll) && agentsToUnenroll.length === 0) + ) { + throw new Error('No agents specified for unenrollment'); + } + setIsModalOpen(true); + setAgents(agentsToUnenroll); + setAgentsCount(agentsToUnenrollCount); + onSuccessCallback.current = onSuccess; + }; + + const closeModal = () => { + setAgents([]); + setAgentsCount(0); + setIsLoading(false); + setIsModalOpen(false); + }; + + const unenrollAgents = async () => { + setIsLoading(true); + + try { + const unenrollByKuery = typeof agents === 'string'; + const { data, error } = await sendRequest({ + path: agentRouteService.getUnenrollPath(), + method: 'post', + body: JSON.stringify({ + kuery: unenrollByKuery ? agents : undefined, + ids: !unenrollByKuery ? agents : undefined, + }), + }); + + if (error) { + throw new Error(error.message); + } + + const results = data ? data.results : []; + + const successfulResults = results.filter(result => result.success); + const failedResults = results.filter(result => !result.success); + + if (successfulResults.length) { + const hasMultipleSuccesses = successfulResults.length > 1; + const successMessage = hasMultipleSuccesses + ? i18n.translate('xpack.ingestManager.unenrollAgents.successMultipleNotificationTitle', { + defaultMessage: 'Unenrolled {count} agents', + values: { count: successfulResults.length }, + }) + : i18n.translate('xpack.ingestManager.unenrollAgents.successSingleNotificationTitle', { + defaultMessage: "Unenrolled agent '{id}'", + values: { id: successfulResults[0].id }, + }); + core.notifications.toasts.addSuccess(successMessage); + } + + if (failedResults.length) { + const hasMultipleFailures = failedResults.length > 1; + const failureMessage = hasMultipleFailures + ? i18n.translate('xpack.ingestManager.unenrollAgents.failureMultipleNotificationTitle', { + defaultMessage: 'Error unenrolling {count} agents', + values: { count: failedResults.length }, + }) + : i18n.translate('xpack.ingestManager.unenrollAgents.failureSingleNotificationTitle', { + defaultMessage: "Error unenrolling agent '{id}'", + values: { id: failedResults[0].id }, + }); + core.notifications.toasts.addDanger(failureMessage); + } + + if (onSuccessCallback.current) { + onSuccessCallback.current(successfulResults.map(result => result.id)); + } + } catch (e) { + core.notifications.toasts.addDanger( + i18n.translate('xpack.ingestManager.unenrollAgents.fatalErrorNotificationTitle', { + defaultMessage: 'Error unenrolling agents', + }) + ); + } + + closeModal(); + }; + + const renderModal = () => { + if (!isModalOpen) { + return null; + } + + const unenrollByKuery = typeof agents === 'string'; + const isSingle = agentsCount === 1; + + return ( + + + ) : ( + + ) + } + onCancel={closeModal} + onConfirm={unenrollAgents} + cancelButtonText={ + + } + confirmButtonText={ + isLoading ? ( + + ) : ( + + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading} + /> + + ); + }; + + return ( + + {children(unenrollAgentsPrompt)} + {renderModal()} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx new file mode 100644 index 0000000000000..19378fe2fb952 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './loading'; +export * from './navigation/child_routes'; +export * from './navigation/connected_link'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/loading.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/loading.tsx new file mode 100644 index 0000000000000..5dcd2ff4d2477 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/loading.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; + +export const Loading: React.FunctionComponent<{}> = () => ( + + + + + +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/child_routes.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/child_routes.tsx new file mode 100644 index 0000000000000..8af0e0a5cbc25 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/child_routes.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +interface RouteConfig { + path: string; + component: React.ComponentType; + routes?: RouteConfig[]; +} + +export const ChildRoutes: React.FunctionComponent<{ + routes?: RouteConfig[]; + useSwitch?: boolean; + [other: string]: any; +}> = ({ routes, useSwitch = true, ...rest }) => { + if (!routes) { + return null; + } + const Parent = useSwitch ? Switch : React.Fragment; + return ( + + {routes.map(route => ( + { + const Component = route.component; + return ; + }} + /> + ))} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/connected_link.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/connected_link.tsx new file mode 100644 index 0000000000000..489ee85ffe28a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/navigation/connected_link.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiLink } from '@elastic/eui'; +import { Link, withRouter } from 'react-router-dom'; + +export function ConnectedLinkComponent({ + location, + path, + query, + disabled, + children, + ...props +}: { + location: any; + path: string; + disabled: boolean; + query: any; + [key: string]: any; +}) { + if (disabled) { + return ; + } + + // Shorthand for pathname + const pathname = path || _.get(props.to, 'pathname') || location.pathname; + + return ( + + ); +} + +export const ConnectedLink = withRouter(ConnectedLinkComponent); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/components/no_data_layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/components/no_data_layout.tsx new file mode 100644 index 0000000000000..02134513edd64 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/components/no_data_layout.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiPageContent } from '@elastic/eui'; +import React from 'react'; +import { withRouter } from 'react-router-dom'; + +interface LayoutProps { + title: string | React.ReactNode; + actionSection?: React.ReactNode; + modalClosePath?: string; +} + +export const NoDataLayout: React.FunctionComponent = withRouter< + any, + React.FunctionComponent +>(({ actionSection, title, modalClosePath, children }) => { + return ( + + + + {title}} + body={children} + actions={actionSection} + /> + + + + ); +}) as any; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/enforce_security.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/enforce_security.tsx new file mode 100644 index 0000000000000..e131da159d6cb --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/enforce_security.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + +import { NoDataLayout } from './components/no_data_layout'; + +export const EnforceSecurityPage = injectI18n(({ intl }) => ( + +

+ +

+
+)); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/invalid_license.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/invalid_license.tsx new file mode 100644 index 0000000000000..883e41fea95b8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/invalid_license.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + +import { NoDataLayout } from './components/no_data_layout'; + +export const InvalidLicensePage = injectI18n(({ intl }) => ( + +

+ +

+
+)); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/no_access.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/no_access.tsx new file mode 100644 index 0000000000000..5a3afd6216824 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/error_pages/no_access.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; + +import { NoDataLayout } from './components/no_data_layout'; + +export const NoAccessPage = injectI18n(({ intl }) => ( + +

+ +

+
+)); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx index c4e8c576a1d7d..edc2b5b7eb87f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/index.tsx @@ -4,53 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { WithHeaderLayout } from '../../layouts'; -import { useConfig } from '../../hooks'; +import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; +import { Loading } from '../../components'; +import { useConfig, useCore, useRequest } from '../../hooks'; +import { AgentListPage } from './agent_list_page'; +import { SetupPage } from './setup_page'; +import { AgentDetailsPage } from './agent_details_page'; +import { NoAccessPage } from './error_pages/no_access'; +import { fleetSetupRouteService } from '../../services'; export const FleetApp: React.FunctionComponent = () => { + const core = useCore(); const { fleet } = useConfig(); - if (!fleet.enabled) { - return null; + + const setupRequest = useRequest({ + method: 'get', + path: fleetSetupRouteService.getFleetSetupPath(), + }); + + if (!fleet.enabled) return null; + if (setupRequest.isLoading) { + return ; + } + + if (setupRequest.data.isInitialized === false) { + return ( + { + await setupRequest.sendRequest(); + }} + /> + ); + } + if (!core.application.capabilities.ingestManager.read) { + return ; } return ( - - - -

- -

-
-
- - -

- -

-
-
- - } - tabs={[ - { - id: 'agents', - name: 'Agents', - isSelected: true, - }, - { - id: 'enrollment_keys', - name: 'Enrollment keys', - }, - ]} - > - hello world - fleet app -
+ + + } /> + + + + + + + + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx new file mode 100644 index 0000000000000..31e5e99ad284b --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/setup_page/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import { + EuiPageBody, + EuiPageContent, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiFieldPassword, + EuiText, + EuiButton, + EuiCallOut, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; +import { sendRequest, useInput, useCore } from '../../../hooks'; +import { fleetSetupRouteService } from '../../../services'; + +export const SetupPage: React.FunctionComponent<{ + refresh: () => Promise; +}> = ({ refresh }) => { + const [isFormLoading, setIsFormLoading] = useState(false); + const core = useCore(); + const usernameInput = useInput(); + const passwordInput = useInput(); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsFormLoading(true); + try { + await sendRequest({ + method: 'post', + path: fleetSetupRouteService.postFleetSetupPath(), + body: JSON.stringify({ + admin_username: usernameInput.value, + admin_password: passwordInput.value, + }), + }); + await refresh(); + } catch (error) { + core.notifications.toasts.addDanger(error.message); + setIsFormLoading(false); + } + }; + + return ( + + + +

Setup

+
+ + + + To setup fleet and ingest you need to a enable a user that can create API Keys and write + to logs-* and metrics-* + + + + +
+ + + + + + + + Submit + +
+
+
+
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index 6502b0fff7123..0aa08602e4d4d 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -4,4 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export { agentConfigRouteService } from '../../../../common'; +export { + agentConfigRouteService, + datasourceRouteService, + fleetSetupRouteService, + agentRouteService, + enrollmentAPIKeyRouteService, + epmRouteService, + setupRouteService, + packageToConfigDatasourceInputs, + storedDatasourceToAgentDatasource, + AgentStatusKueryHelper, +} from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 8597d6fd59323..a59fb06145a3a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -4,16 +4,69 @@ * you may not use this file except in compliance with the Elastic License. */ export { + // utility function + entries, // Object types + Agent, AgentConfig, NewAgentConfig, - // API schemas + AgentEvent, + EnrollmentAPIKey, + Datasource, + NewDatasource, + DatasourceInput, + DatasourceInputStream, + // API schemas - Agent Config GetAgentConfigsResponse, + GetAgentConfigsResponseItem, GetOneAgentConfigResponse, - CreateAgentConfigRequestSchema, + CreateAgentConfigRequest, CreateAgentConfigResponse, - UpdateAgentConfigRequestSchema, + UpdateAgentConfigRequest, UpdateAgentConfigResponse, - DeleteAgentConfigsRequestSchema, + DeleteAgentConfigsRequest, DeleteAgentConfigsResponse, + // API schemas - Datasource + CreateDatasourceRequest, + CreateDatasourceResponse, + // API schemas - Agents + GetAgentsResponse, + GetAgentsRequest, + GetOneAgentResponse, + PostAgentUnenrollResponse, + GetOneAgentEventsRequest, + GetOneAgentEventsResponse, + GetAgentStatusRequest, + GetAgentStatusResponse, + // API schemas - Enrollment API Keys + GetEnrollmentAPIKeysResponse, + GetOneEnrollmentAPIKeyResponse, + // EPM types + AssetReference, + AssetsGroupedByServiceByType, + AssetType, + AssetTypeToParts, + CategoryId, + CategorySummaryItem, + CategorySummaryList, + ElasticsearchAssetType, + KibanaAssetType, + PackageInfo, + RegistryVarsEntry, + RegistryInput, + RegistryStream, + PackageList, + PackageListItem, + PackagesGroupedByStatus, + RequirementsByServiceName, + RequirementVersion, + ScreenshotItem, + ServiceName, + GetCategoriesResponse, + GetPackagesResponse, + GetInfoResponse, + InstallPackageResponse, + DeletePackageResponse, + DetailViewPanelName, + InstallStatus, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/public/plugin.ts b/x-pack/plugins/ingest_manager/public/plugin.ts index ae244e7ebec3d..a1dc2c057e9e5 100644 --- a/x-pack/plugins/ingest_manager/public/plugin.ts +++ b/x-pack/plugins/ingest_manager/public/plugin.ts @@ -6,15 +6,16 @@ import { AppMountParameters, CoreSetup, - CoreStart, Plugin, PluginInitializerContext, + CoreStart, } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/utils'; -import { DataPublicPluginSetup } from '../../../../src/plugins/data/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { PLUGIN_ID } from '../common/constants'; + import { IngestManagerConfigType } from '../common/types'; export { IngestManagerConfigType } from '../common/types'; @@ -27,6 +28,10 @@ export interface IngestManagerSetupDeps { data: DataPublicPluginSetup; } +export interface IngestManagerStartDeps { + data: DataPublicPluginStart; +} + export class IngestManagerPlugin implements Plugin { private config: IngestManagerConfigType; @@ -36,7 +41,6 @@ export class IngestManagerPlugin implements Plugin { public setup(core: CoreSetup, deps: IngestManagerSetupDeps) { const config = this.config; - // Register main Ingest Manager app core.application.register({ id: PLUGIN_ID, @@ -44,9 +48,12 @@ export class IngestManagerPlugin implements Plugin { title: i18n.translate('xpack.ingestManager.appTitle', { defaultMessage: 'Ingest Manager' }), euiIconType: 'savedObjectsApp', async mount(params: AppMountParameters) { - const [coreStart] = await core.getStartServices(); + const [coreStart, startDeps] = (await core.getStartServices()) as [ + CoreStart, + IngestManagerStartDeps + ]; const { renderApp } = await import('./applications/ingest_manager'); - return renderApp(coreStart, params, deps, config); + return renderApp(coreStart, params, deps, startDeps, config); }, }); } diff --git a/x-pack/plugins/ingest_manager/scripts/dev_agent/index.js b/x-pack/plugins/ingest_manager/scripts/dev_agent/index.js new file mode 100644 index 0000000000000..75d9a27e9f241 --- /dev/null +++ b/x-pack/plugins/ingest_manager/scripts/dev_agent/index.js @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +require('../../../../../src/setup_node_env'); +require('./script'); diff --git a/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts new file mode 100644 index 0000000000000..c7b8edd0c332e --- /dev/null +++ b/x-pack/plugins/ingest_manager/scripts/dev_agent/script.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createFlagError, run, ToolingLog } from '@kbn/dev-utils'; +import fetch from 'node-fetch'; +import os from 'os'; +import { + Agent as _Agent, + PostAgentCheckinRequest, + PostAgentCheckinResponse, + PostAgentEnrollRequest, + PostAgentEnrollResponse, +} from '../../common/types'; + +const CHECKIN_INTERVAL = 3000; // 3 seconds + +type Agent = Pick<_Agent, 'id' | 'access_api_key'>; + +let closing = false; + +process.once('SIGINT', () => { + closing = true; +}); + +run( + async ({ flags, log }) => { + if (!flags.kibanaUrl || typeof flags.kibanaUrl !== 'string') { + throw createFlagError('please provide a single --path flag'); + } + + if (!flags.enrollmentApiKey || typeof flags.enrollmentApiKey !== 'string') { + throw createFlagError('please provide a single --enrollmentApiKey flag'); + } + const kibanaUrl = flags.kibanaUrl || 'http://localhost:5601'; + const agent = await enroll(kibanaUrl, flags.enrollmentApiKey, log); + + log.info('Enrolled with sucess', agent); + + while (!closing) { + await checkin(kibanaUrl, agent, log); + await new Promise((resolve, reject) => setTimeout(() => resolve(), CHECKIN_INTERVAL)); + } + }, + { + description: ` + Run a fleet development agent. + `, + flags: { + string: ['kibanaUrl', 'enrollmentApiKey'], + help: ` + --kibanaUrl kibanaURL to run the fleet agent + --enrollmentApiKey enrollment api key + `, + }, + } +); + +async function checkin(kibanaURL: string, agent: Agent, log: ToolingLog) { + const body: PostAgentCheckinRequest['body'] = { + events: [ + { + type: 'STATE', + subtype: 'RUNNING', + message: 'state changed from STOPPED to RUNNING', + timestamp: new Date().toISOString(), + payload: { + random: 'data', + state: 'RUNNING', + previous_state: 'STOPPED', + }, + agent_id: agent.id, + }, + ], + }; + const res = await fetch(`${kibanaURL}/api/ingest_manager/fleet/agents/${agent.id}/checkin`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'kbn-xsrf': 'xxx', + Authorization: `ApiKey ${agent.access_api_key}`, + 'Content-Type': 'application/json', + }, + }); + + if (res.status === 403) { + closing = true; + log.info('Unenrolling agent'); + return; + } + + const obj: PostAgentCheckinResponse = await res.json(); + log.info('checkin', obj); +} + +async function enroll(kibanaURL: string, apiKey: string, log: ToolingLog): Promise { + const body: PostAgentEnrollRequest['body'] = { + type: 'PERMANENT', + metadata: { + local: { + host: 'localhost', + ip: '127.0.0.1', + system: `${os.type()} ${os.release()}`, + memory: os.totalmem(), + }, + user_provided: { + dev_agent_version: '0.0.1', + region: 'us-east', + }, + }, + }; + const res = await fetch(`${kibanaURL}/api/ingest_manager/fleet/agents/enroll`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'kbn-xsrf': 'xxx', + Authorization: `ApiKey ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + const obj: PostAgentEnrollResponse = await res.json(); + + if (!obj.success) { + log.error(JSON.stringify(obj, null, 2)); + throw new Error('unable to enroll'); + } + + return { + id: obj.item.id, + access_api_key: obj.item.access_api_key, + }; +} diff --git a/x-pack/plugins/ingest_manager/scripts/readme.md b/x-pack/plugins/ingest_manager/scripts/readme.md new file mode 100644 index 0000000000000..efec40b0aba1e --- /dev/null +++ b/x-pack/plugins/ingest_manager/scripts/readme.md @@ -0,0 +1,8 @@ +### Dev agents + +You can run a development fleet agent that is going to enroll and checkin every 3 seconds. +For this you can run the following command in the fleet pluging directory. + +``` +node scripts/dev_agent --enrollmentApiKey= --kibanaUrl=http://localhost:5603/qed +``` diff --git a/x-pack/plugins/ingest_manager/server/constants/index.ts b/x-pack/plugins/ingest_manager/server/constants/index.ts index 6b54afa1d81cb..f6ee475614c5e 100644 --- a/x-pack/plugins/ingest_manager/server/constants/index.ts +++ b/x-pack/plugins/ingest_manager/server/constants/index.ts @@ -4,19 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ export { + AGENT_TYPE_PERMANENT, + AGENT_TYPE_EPHEMERAL, + AGENT_TYPE_TEMPORARY, + AGENT_POLLING_THRESHOLD_MS, + AGENT_POLLING_INTERVAL, // Routes PLUGIN_ID, EPM_API_ROUTES, DATASOURCE_API_ROUTES, + AGENT_API_ROUTES, AGENT_CONFIG_API_ROUTES, FLEET_SETUP_API_ROUTES, + ENROLLMENT_API_KEY_ROUTES, + INSTALL_SCRIPT_API_ROUTES, + SETUP_API_ROUTE, // Saved object types + AGENT_EVENT_SAVED_OBJECT_TYPE, + AGENT_SAVED_OBJECT_TYPE, AGENT_CONFIG_SAVED_OBJECT_TYPE, DATASOURCE_SAVED_OBJECT_TYPE, OUTPUT_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, + INDEX_PATTERN_SAVED_OBJECT_TYPE, + ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, // Defaults - DEFAULT_AGENT_CONFIG_ID, DEFAULT_AGENT_CONFIG, - DEFAULT_OUTPUT_ID, DEFAULT_OUTPUT, } from '../../common'; diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 5228f1e0e3469..b732cb8005efb 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { PluginInitializerContext } from 'kibana/server'; import { IngestManagerPlugin } from './plugin'; @@ -21,11 +20,20 @@ export const config = { }), fleet: schema.object({ enabled: schema.boolean({ defaultValue: false }), - defaultOutputHost: schema.string({ defaultValue: 'http://localhost:9200' }), + kibana: schema.object({ + host: schema.maybe(schema.string()), + ca_sha256: schema.maybe(schema.string()), + }), + elasticsearch: schema.object({ + host: schema.string({ defaultValue: 'http://localhost:9200' }), + ca_sha256: schema.maybe(schema.string()), + }), }), }), }; +export type IngestManagerConfigType = TypeOf; + export const plugin = (initializerContext: PluginInitializerContext) => { return new IngestManagerPlugin(initializerContext); }; @@ -37,7 +45,5 @@ export { OUTPUT_SAVED_OBJECT_TYPE, AGENT_CONFIG_SAVED_OBJECT_TYPE, DATASOURCE_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, } from './constants'; - -// TODO: Temporary exports for Fleet dependencies, remove once Fleet moved into this plugin -export { agentConfigService, outputService } from './services'; diff --git a/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts b/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts index c2546454e2131..bfd8428222643 100644 --- a/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts +++ b/x-pack/plugins/ingest_manager/server/integration_tests/router.test.ts @@ -51,11 +51,11 @@ describe('ingestManager', () => { }); it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/epm').expect(404); + await kbnTestServer.request.get(root, '/api/ingest_manager/epm/packages').expect(404); }); it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/fleet').expect(404); + await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404); }); }); @@ -84,7 +84,7 @@ describe('ingestManager', () => { }); it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404); + await kbnTestServer.request.get(root, '/api/ingest_manager/epm/packages').expect(404); }); it('does not have Fleet api', async () => { @@ -122,8 +122,8 @@ describe('ingestManager', () => { await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200); }); - it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404); + it('does have EPM api', async () => { + await kbnTestServer.request.get(root, '/api/ingest_manager/epm/packages').expect(500); }); it('does not have Fleet api', async () => { @@ -137,7 +137,6 @@ describe('ingestManager', () => { beforeAll(async () => { const ingestManagerConfig = { enabled: true, - epm: { enabled: true }, fleet: { enabled: true }, }; root = createXPackRoot({ @@ -158,11 +157,11 @@ describe('ingestManager', () => { }); it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404); + await kbnTestServer.request.get(root, '/api/ingest_manager/epm/packages').expect(404); }); - it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404); + it('does have Fleet api', async () => { + await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(200); }); }); @@ -192,12 +191,12 @@ describe('ingestManager', () => { await kbnTestServer.request.get(root, '/api/ingest_manager/datasources').expect(200); }); - it('does not have EPM api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/epm/list').expect(404); + it('does have EPM api', async () => { + await kbnTestServer.request.get(root, '/api/ingest_manager/epm/packages').expect(500); }); - it('does not have Fleet api', async () => { - await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(404); + it('does have Fleet api', async () => { + await kbnTestServer.request.get(root, '/api/ingest_manager/fleet/setup').expect(200); }); }); }); diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 4f30a171ab0c0..c162ea5fadabe 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -4,15 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ import { Observable } from 'rxjs'; -import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'kibana/server'; +import { first } from 'rxjs/operators'; +import { + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, + SavedObjectsServiceStart, +} from 'kibana/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { EncryptedSavedObjectsPluginStart } from '../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../security/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { PLUGIN_ID } from './constants'; -import { appContextService } from './services'; -import { registerDatasourceRoutes, registerAgentConfigRoutes } from './routes'; +import { + PLUGIN_ID, + OUTPUT_SAVED_OBJECT_TYPE, + AGENT_CONFIG_SAVED_OBJECT_TYPE, + DATASOURCE_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, + ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, +} from './constants'; + +import { + registerEPMRoutes, + registerDatasourceRoutes, + registerAgentConfigRoutes, + registerSetupRoutes, + registerAgentRoutes, + registerEnrollmentApiKeyRoutes, + registerInstallScriptRoutes, +} from './routes'; + import { IngestManagerConfigType } from '../common'; +import { appContextService } from './services'; export interface IngestManagerSetupDeps { licensing: LicensingPluginSetup; @@ -24,8 +50,19 @@ export interface IngestManagerAppContext { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; security?: SecurityPluginSetup; config$?: Observable; + savedObjects: SavedObjectsServiceStart; } +const allSavedObjectTypes = [ + OUTPUT_SAVED_OBJECT_TYPE, + AGENT_CONFIG_SAVED_OBJECT_TYPE, + DATASOURCE_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, + ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, +]; + export class IngestManagerPlugin implements Plugin { private config$: Observable; private security: SecurityPluginSetup | undefined; @@ -50,37 +87,47 @@ export class IngestManagerPlugin implements Plugin { app: [PLUGIN_ID, 'kibana'], privileges: { all: { - api: [PLUGIN_ID], + api: [`${PLUGIN_ID}-read`, `${PLUGIN_ID}-all`], savedObject: { - all: [], + all: allSavedObjectTypes, read: [], }, - ui: ['show'], + ui: ['show', 'read', 'write'], }, read: { - api: [PLUGIN_ID], + api: [`${PLUGIN_ID}-read`], savedObject: { all: [], - read: [], + read: allSavedObjectTypes, }, - ui: ['show'], + ui: ['show', 'read'], }, }, }); } - // Create router const router = core.http.createRouter(); + const config = await this.config$.pipe(first()).toPromise(); // Register routes registerAgentConfigRoutes(router); registerDatasourceRoutes(router); - // Optional route registration depending on Kibana config - // restore when EPM & Fleet features are added - // const config = await this.config$.pipe(first()).toPromise(); - // if (config.epm.enabled) registerEPMRoutes(router); - // if (config.fleet.enabled) registerFleetSetupRoutes(router); + // Conditional routes + if (config.epm.enabled) { + registerEPMRoutes(router); + } + + if (config.fleet.enabled) { + registerSetupRoutes(router); + registerAgentRoutes(router); + registerEnrollmentApiKeyRoutes(router); + registerInstallScriptRoutes({ + router, + serverInfo: core.http.getServerInfo(), + basePath: core.http.basePath, + }); + } } public async start( @@ -93,6 +140,7 @@ export class IngestManagerPlugin implements Plugin { encryptedSavedObjects: plugins.encryptedSavedObjects, security: this.security, config$: this.config$, + savedObjects: core.savedObjects, }); } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts new file mode 100644 index 0000000000000..cb4e4d557d74f --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -0,0 +1,411 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler, KibanaRequest } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + GetAgentsResponse, + GetOneAgentResponse, + GetOneAgentEventsResponse, + PostAgentCheckinResponse, + PostAgentEnrollResponse, + PostAgentUnenrollResponse, + GetAgentStatusResponse, +} from '../../../common/types'; +import { + GetAgentsRequestSchema, + GetOneAgentRequestSchema, + UpdateAgentRequestSchema, + DeleteAgentRequestSchema, + GetOneAgentEventsRequestSchema, + PostAgentCheckinRequestSchema, + PostAgentEnrollRequestSchema, + PostAgentAcksRequestSchema, + PostAgentUnenrollRequestSchema, + GetAgentStatusRequestSchema, +} from '../../types'; +import * as AgentService from '../../services/agents'; +import * as APIKeyService from '../../services/api_keys'; +import { appContextService } from '../../services/app_context'; + +function getInternalUserSOClient(request: KibanaRequest) { + // soClient as kibana internal users, be carefull on how you use it, security is not enabled + return appContextService.getSavedObjects().getScopedClient(request, { + excludedWrappers: ['security'], + }); +} + +export const getAgentHandler: RequestHandler> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const agent = await AgentService.getAgent(soClient, request.params.agentId); + + const body: GetOneAgentResponse = { + item: { + ...agent, + status: AgentService.getAgentStatus(agent), + }, + success: true, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `Agent ${request.params.agentId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getAgentEventsHandler: RequestHandler< + TypeOf, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const { page, perPage, kuery } = request.query; + const { items, total } = await AgentService.getAgentEvents(soClient, request.params.agentId, { + page, + perPage, + kuery, + }); + + const body: GetOneAgentEventsResponse = { + list: items, + total, + success: true, + page, + perPage, + }; + + return response.ok({ + body, + }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `Agent ${request.params.agentId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const deleteAgentHandler: RequestHandler> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + await AgentService.deleteAgent(soClient, request.params.agentId); + + const body = { + success: true, + action: 'deleted', + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: `Agent ${request.params.agentId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const updateAgentHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + await AgentService.updateAgent(soClient, request.params.agentId, { + userProvidedMetatada: request.body.user_provided_metadata, + }); + const agent = await AgentService.getAgent(soClient, request.params.agentId); + + const body = { + item: { + ...agent, + status: AgentService.getAgentStatus(agent), + }, + success: true, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `Agent ${request.params.agentId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const postAgentCheckinHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + try { + const soClient = getInternalUserSOClient(request); + const res = APIKeyService.parseApiKey(request.headers); + const agent = await AgentService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId); + const { actions } = await AgentService.agentCheckin( + soClient, + agent, + request.body.events || [], + request.body.local_metadata + ); + const body: PostAgentCheckinResponse = { + action: 'checkin', + success: true, + actions: actions.map(a => ({ + type: a.type, + data: a.data ? JSON.parse(a.data) : a.data, + id: a.id, + created_at: a.created_at, + })), + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `Agent ${request.params.agentId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const postAgentAcksHandler: RequestHandler< + TypeOf, + undefined, + TypeOf +> = async (context, request, response) => { + try { + const soClient = getInternalUserSOClient(request); + const res = APIKeyService.parseApiKey(request.headers); + const agent = await AgentService.getAgentByAccessAPIKeyId(soClient, res.apiKeyId as string); + + await AgentService.acknowledgeAgentActions(soClient, agent, request.body.action_ids); + + const body = { + action: 'acks', + success: true, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const postAgentEnrollHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + try { + const soClient = getInternalUserSOClient(request); + const { apiKeyId } = APIKeyService.parseApiKey(request.headers); + const enrollmentAPIKey = await APIKeyService.getEnrollmentAPIKeyById(soClient, apiKeyId); + + if (!enrollmentAPIKey || !enrollmentAPIKey.active) { + return response.unauthorized({ + body: { message: 'Invalid Enrollment API Key' }, + }); + } + + const agent = await AgentService.enroll( + soClient, + request.body.type, + enrollmentAPIKey.config_id as string, + { + userProvided: request.body.metadata.user_provided, + local: request.body.metadata.local, + }, + request.body.shared_id + ); + const body: PostAgentEnrollResponse = { + action: 'created', + success: true, + item: { + ...agent, + status: AgentService.getAgentStatus(agent), + }, + }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom) { + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.message }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getAgentsHandler: RequestHandler< + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const { agents, total, page, perPage } = await AgentService.listAgents(soClient, { + page: request.query.page, + perPage: request.query.perPage, + showInactive: request.query.showInactive, + kuery: request.query.kuery, + }); + + const body: GetAgentsResponse = { + list: agents.map(agent => ({ + ...agent, + status: AgentService.getAgentStatus(agent), + })), + success: true, + total, + page, + perPage, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const postAgentsUnenrollHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const kuery = (request.body as { kuery: string }).kuery; + let toUnenrollIds: string[] = (request.body as { ids: string[] }).ids || []; + + if (kuery) { + let hasMore = true; + let page = 1; + while (hasMore) { + const { agents } = await AgentService.listAgents(soClient, { + page: page++, + perPage: 100, + kuery, + showInactive: true, + }); + if (agents.length === 0) { + hasMore = false; + } + const agentIds = agents.filter(a => a.active).map(a => a.id); + toUnenrollIds = toUnenrollIds.concat(agentIds); + } + } + const results = (await AgentService.unenrollAgents(soClient, toUnenrollIds)).map( + ({ + success, + id, + error, + }): { + success: boolean; + id: string; + action: 'unenrolled'; + error?: { + message: string; + }; + } => { + return { + success, + id, + action: 'unenrolled', + error: error && { + message: error.message, + }, + }; + } + ); + + const body: PostAgentUnenrollResponse = { + results, + success: results.every(result => result.success), + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getAgentStatusForConfigHandler: RequestHandler< + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + // TODO change path + const results = await AgentService.getAgentStatusForConfig(soClient, request.query.configId); + + const body: GetAgentStatusResponse = { results, success: true }; + + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts new file mode 100644 index 0000000000000..8a65fa9c50e8b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { PLUGIN_ID, AGENT_API_ROUTES } from '../../constants'; +import { + GetAgentsRequestSchema, + GetOneAgentRequestSchema, + GetOneAgentEventsRequestSchema, + UpdateAgentRequestSchema, + DeleteAgentRequestSchema, + PostAgentCheckinRequestSchema, + PostAgentEnrollRequestSchema, + PostAgentAcksRequestSchema, + PostAgentUnenrollRequestSchema, + GetAgentStatusRequestSchema, +} from '../../types'; +import { + getAgentsHandler, + getAgentHandler, + updateAgentHandler, + deleteAgentHandler, + getAgentEventsHandler, + postAgentCheckinHandler, + postAgentEnrollHandler, + postAgentAcksHandler, + postAgentsUnenrollHandler, + getAgentStatusForConfigHandler, +} from './handlers'; + +export const registerRoutes = (router: IRouter) => { + // Get one + router.get( + { + path: AGENT_API_ROUTES.INFO_PATTERN, + validate: GetOneAgentRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getAgentHandler + ); + // Update + router.put( + { + path: AGENT_API_ROUTES.UPDATE_PATTERN, + validate: UpdateAgentRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + updateAgentHandler + ); + // Delete + router.delete( + { + path: AGENT_API_ROUTES.DELETE_PATTERN, + validate: DeleteAgentRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + deleteAgentHandler + ); + // List + router.get( + { + path: AGENT_API_ROUTES.LIST_PATTERN, + validate: GetAgentsRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getAgentsHandler + ); + + // Agent checkin + router.post( + { + path: AGENT_API_ROUTES.CHECKIN_PATTERN, + validate: PostAgentCheckinRequestSchema, + options: { tags: [] }, + }, + postAgentCheckinHandler + ); + + // Agent enrollment + router.post( + { + path: AGENT_API_ROUTES.ENROLL_PATTERN, + validate: PostAgentEnrollRequestSchema, + options: { tags: [] }, + }, + postAgentEnrollHandler + ); + + // Agent acks + router.post( + { + path: AGENT_API_ROUTES.ACKS_PATTERN, + validate: PostAgentAcksRequestSchema, + options: { tags: [] }, + }, + postAgentAcksHandler + ); + + router.post( + { + path: AGENT_API_ROUTES.UNENROLL_PATTERN, + validate: PostAgentUnenrollRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postAgentsUnenrollHandler + ); + + // Get agent events + router.get( + { + path: AGENT_API_ROUTES.EVENTS_PATTERN, + validate: GetOneAgentEventsRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getAgentEventsHandler + ); + + // Get agent status for config + router.get( + { + path: AGENT_API_ROUTES.STATUS_PATTERN, + validate: GetAgentStatusRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getAgentStatusForConfigHandler + ); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 67da6a4cf2f1d..8c3ca82f327b0 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -5,19 +5,25 @@ */ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler } from 'kibana/server'; +import bluebird from 'bluebird'; import { appContextService, agentConfigService } from '../../services'; +import { listAgents } from '../../services/agents'; import { GetAgentConfigsRequestSchema, - GetAgentConfigsResponse, GetOneAgentConfigRequestSchema, - GetOneAgentConfigResponse, CreateAgentConfigRequestSchema, - CreateAgentConfigResponse, UpdateAgentConfigRequestSchema, - UpdateAgentConfigResponse, DeleteAgentConfigsRequestSchema, - DeleteAgentConfigsResponse, + GetFullAgentConfigRequestSchema, } from '../../types'; +import { + GetAgentConfigsResponse, + GetOneAgentConfigResponse, + CreateAgentConfigResponse, + UpdateAgentConfigResponse, + DeleteAgentConfigsResponse, + GetFullAgentConfigResponse, +} from '../../../common'; export const getAgentConfigsHandler: RequestHandler< undefined, @@ -33,6 +39,19 @@ export const getAgentConfigsHandler: RequestHandler< perPage, success: true, }; + + await bluebird.map( + items, + agentConfig => + listAgents(soClient, { + showInactive: true, + perPage: 0, + page: 1, + kuery: `agents.config_id:${agentConfig.id}`, + }).then(({ total: agentTotal }) => (agentConfig.agents = agentTotal)), + { concurrency: 10 } + ); + return response.ok({ body }); } catch (e) { return response.customError({ @@ -142,3 +161,35 @@ export const deleteAgentConfigsHandler: RequestHandler< }); } }; + +export const getFullAgentConfig: RequestHandler> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + + try { + const fullAgentConfig = await agentConfigService.getFullConfig( + soClient, + request.params.agentConfigId + ); + if (fullAgentConfig) { + const body: GetFullAgentConfigResponse = { + item: fullAgentConfig, + success: true, + }; + return response.ok({ + body, + }); + } else { + return response.customError({ + statusCode: 404, + body: { message: 'Agent config not found' }, + }); + } + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts index 67ad915b71e45..c3b3c00a9574c 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/index.ts @@ -11,6 +11,7 @@ import { CreateAgentConfigRequestSchema, UpdateAgentConfigRequestSchema, DeleteAgentConfigsRequestSchema, + GetFullAgentConfigRequestSchema, } from '../../types'; import { getAgentConfigsHandler, @@ -18,6 +19,7 @@ import { createAgentConfigHandler, updateAgentConfigHandler, deleteAgentConfigsHandler, + getFullAgentConfig, } from './handlers'; export const registerRoutes = (router: IRouter) => { @@ -26,7 +28,7 @@ export const registerRoutes = (router: IRouter) => { { path: AGENT_CONFIG_API_ROUTES.LIST_PATTERN, validate: GetAgentConfigsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getAgentConfigsHandler ); @@ -36,7 +38,7 @@ export const registerRoutes = (router: IRouter) => { { path: AGENT_CONFIG_API_ROUTES.INFO_PATTERN, validate: GetOneAgentConfigRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getOneAgentConfigHandler ); @@ -46,7 +48,7 @@ export const registerRoutes = (router: IRouter) => { { path: AGENT_CONFIG_API_ROUTES.CREATE_PATTERN, validate: CreateAgentConfigRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, createAgentConfigHandler ); @@ -56,7 +58,7 @@ export const registerRoutes = (router: IRouter) => { { path: AGENT_CONFIG_API_ROUTES.UPDATE_PATTERN, validate: UpdateAgentConfigRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, updateAgentConfigHandler ); @@ -66,8 +68,18 @@ export const registerRoutes = (router: IRouter) => { { path: AGENT_CONFIG_API_ROUTES.DELETE_PATTERN, validate: DeleteAgentConfigsRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, deleteAgentConfigsHandler ); + + // Get one full agent config + router.get( + { + path: AGENT_CONFIG_API_ROUTES.FULL_INFO_PATTERN, + validate: GetFullAgentConfigRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getFullAgentConfig + ); }; diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts index 78cad2e21c5fa..349e88d8fb59d 100644 --- a/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/datasource/handlers.ts @@ -5,15 +5,16 @@ */ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler } from 'kibana/server'; -import { datasourceService } from '../../services'; +import { appContextService, datasourceService } from '../../services'; +import { ensureInstalledPackage } from '../../services/epm/packages'; import { GetDatasourcesRequestSchema, GetOneDatasourceRequestSchema, CreateDatasourceRequestSchema, UpdateDatasourceRequestSchema, DeleteDatasourcesRequestSchema, - DeleteDatasourcesResponse, } from '../../types'; +import { CreateDatasourceResponse, DeleteDatasourcesResponse } from '../../../common'; export const getDatasourcesHandler: RequestHandler< undefined, @@ -72,10 +73,23 @@ export const createDatasourceHandler: RequestHandler< TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; + const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; try { - const datasource = await datasourceService.create(soClient, request.body); + // Make sure the datasource package is installed + if (request.body.package?.name) { + await ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: request.body.package.name, + callCluster, + }); + } + + // Create datasource + const datasource = await datasourceService.create(soClient, request.body, { user }); + const body: CreateDatasourceResponse = { item: datasource, success: true }; return response.ok({ - body: { item: datasource, success: true }, + body, }); } catch (e) { return response.customError({ @@ -91,11 +105,13 @@ export const updateDatasourceHandler: RequestHandler< TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; try { const datasource = await datasourceService.update( soClient, request.params.datasourceId, - request.body + request.body, + { user } ); return response.ok({ body: { item: datasource, success: true }, @@ -108,16 +124,18 @@ export const updateDatasourceHandler: RequestHandler< } }; -export const deleteDatasourcesHandler: RequestHandler< +export const deleteDatasourceHandler: RequestHandler< unknown, unknown, TypeOf > = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const user = (await appContextService.getSecurity()?.authc.getCurrentUser(request)) || undefined; try { const body: DeleteDatasourcesResponse = await datasourceService.delete( soClient, - request.body.datasourceIds + request.body.datasourceIds, + { user } ); return response.ok({ body, diff --git a/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts b/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts index d9e3ba9de8838..e5891cc7377e9 100644 --- a/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/datasource/index.ts @@ -17,7 +17,7 @@ import { getOneDatasourceHandler, createDatasourceHandler, updateDatasourceHandler, - deleteDatasourcesHandler, + deleteDatasourceHandler, } from './handlers'; export const registerRoutes = (router: IRouter) => { @@ -26,7 +26,7 @@ export const registerRoutes = (router: IRouter) => { { path: DATASOURCE_API_ROUTES.LIST_PATTERN, validate: GetDatasourcesRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getDatasourcesHandler ); @@ -36,7 +36,7 @@ export const registerRoutes = (router: IRouter) => { { path: DATASOURCE_API_ROUTES.INFO_PATTERN, validate: GetOneDatasourceRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getOneDatasourceHandler ); @@ -46,7 +46,7 @@ export const registerRoutes = (router: IRouter) => { { path: DATASOURCE_API_ROUTES.CREATE_PATTERN, validate: CreateDatasourceRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, createDatasourceHandler ); @@ -56,7 +56,7 @@ export const registerRoutes = (router: IRouter) => { { path: DATASOURCE_API_ROUTES.UPDATE_PATTERN, validate: UpdateDatasourceRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, updateDatasourceHandler ); @@ -68,6 +68,6 @@ export const registerRoutes = (router: IRouter) => { validate: DeleteDatasourcesRequestSchema, options: { tags: [`access:${PLUGIN_ID}`] }, }, - deleteDatasourcesHandler + deleteDatasourceHandler ); }; diff --git a/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/handler.ts b/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/handler.ts new file mode 100644 index 0000000000000..478078a934186 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/handler.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { + GetEnrollmentAPIKeysRequestSchema, + PostEnrollmentAPIKeyRequestSchema, + DeleteEnrollmentAPIKeyRequestSchema, + GetOneEnrollmentAPIKeyRequestSchema, +} from '../../types'; +import { + GetEnrollmentAPIKeysResponse, + GetOneEnrollmentAPIKeyResponse, + DeleteEnrollmentAPIKeyResponse, + PostEnrollmentAPIKeyResponse, +} from '../../../common'; +import * as APIKeyService from '../../services/api_keys'; + +export const getEnrollmentApiKeysHandler: RequestHandler< + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const { items, total, page, perPage } = await APIKeyService.listEnrollmentApiKeys(soClient, { + page: request.query.page, + perPage: request.query.perPage, + kuery: request.query.kuery, + }); + const body: GetEnrollmentAPIKeysResponse = { list: items, success: true, total, page, perPage }; + + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; +export const postEnrollmentApiKeyHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const apiKey = await APIKeyService.generateEnrollmentAPIKey(soClient, { + name: request.body.name, + expiration: request.body.expiration, + configId: request.body.config_id, + }); + + const body: PostEnrollmentAPIKeyResponse = { item: apiKey, success: true, action: 'created' }; + + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const deleteEnrollmentApiKeyHandler: RequestHandler> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + await APIKeyService.deleteEnrollmentApiKey(soClient, request.params.keyId); + + const body: DeleteEnrollmentAPIKeyResponse = { action: 'deleted', success: true }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `EnrollmentAPIKey ${request.params.keyId} not found` }, + }); + } + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getOneEnrollmentApiKeyHandler: RequestHandler> = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + try { + const apiKey = await APIKeyService.getEnrollmentAPIKey(soClient, request.params.keyId); + const body: GetOneEnrollmentAPIKeyResponse = { item: apiKey, success: true }; + + return response.ok({ body }); + } catch (e) { + if (e.isBoom && e.output.statusCode === 404) { + return response.notFound({ + body: { message: `EnrollmentAPIKey ${request.params.keyId} not found` }, + }); + } + + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/index.ts b/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/index.ts new file mode 100644 index 0000000000000..6df5299d30bd4 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/enrollment_api_key/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'kibana/server'; +import { PLUGIN_ID, ENROLLMENT_API_KEY_ROUTES } from '../../constants'; +import { + GetEnrollmentAPIKeysRequestSchema, + GetOneEnrollmentAPIKeyRequestSchema, + DeleteEnrollmentAPIKeyRequestSchema, + PostEnrollmentAPIKeyRequestSchema, +} from '../../types'; +import { + getEnrollmentApiKeysHandler, + getOneEnrollmentApiKeyHandler, + deleteEnrollmentApiKeyHandler, + postEnrollmentApiKeyHandler, +} from './handler'; + +export const registerRoutes = (router: IRouter) => { + router.get( + { + path: ENROLLMENT_API_KEY_ROUTES.INFO_PATTERN, + validate: GetOneEnrollmentAPIKeyRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getOneEnrollmentApiKeyHandler + ); + + router.delete( + { + path: ENROLLMENT_API_KEY_ROUTES.DELETE_PATTERN, + validate: DeleteEnrollmentAPIKeyRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + deleteEnrollmentApiKeyHandler + ); + + router.get( + { + path: ENROLLMENT_API_KEY_ROUTES.LIST_PATTERN, + validate: GetEnrollmentAPIKeysRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + getEnrollmentApiKeysHandler + ); + + router.post( + { + path: ENROLLMENT_API_KEY_ROUTES.CREATE_PATTERN, + validate: PostEnrollmentAPIKeyRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + postEnrollmentApiKeyHandler + ); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts new file mode 100644 index 0000000000000..6b1dde92ec0e1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TypeOf } from '@kbn/config-schema'; +import { RequestHandler, CustomHttpResponseOptions } from 'kibana/server'; +import { + GetPackagesRequestSchema, + GetFileRequestSchema, + GetInfoRequestSchema, + InstallPackageRequestSchema, + DeletePackageRequestSchema, +} from '../../types'; +import { + GetInfoResponse, + InstallPackageResponse, + DeletePackageResponse, + GetCategoriesResponse, + GetPackagesResponse, +} from '../../../common'; +import { + getCategories, + getPackages, + getFile, + getPackageInfo, + installPackage, + removeInstallation, +} from '../../services/epm/packages'; + +export const getCategoriesHandler: RequestHandler = async (context, request, response) => { + try { + const res = await getCategories(); + const body: GetCategoriesResponse = { + response: res, + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getListHandler: RequestHandler< + undefined, + TypeOf +> = async (context, request, response) => { + try { + const savedObjectsClient = context.core.savedObjects.client; + const res = await getPackages({ + savedObjectsClient, + category: request.query.category, + }); + const body: GetPackagesResponse = { + response: res, + success: true, + }; + return response.ok({ + body, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getFileHandler: RequestHandler> = async ( + context, + request, + response +) => { + try { + const { pkgkey, filePath } = request.params; + const registryResponse = await getFile(`/package/${pkgkey}/${filePath}`); + const contentType = registryResponse.headers.get('Content-Type'); + const customResponseObj: CustomHttpResponseOptions = { + body: registryResponse.body, + statusCode: registryResponse.status, + }; + if (contentType !== null) { + customResponseObj.headers = { 'Content-Type': contentType }; + } + return response.custom(customResponseObj); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const getInfoHandler: RequestHandler> = async ( + context, + request, + response +) => { + try { + const { pkgkey } = request.params; + const savedObjectsClient = context.core.savedObjects.client; + const res = await getPackageInfo({ savedObjectsClient, pkgkey }); + const body: GetInfoResponse = { + response: res, + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const installPackageHandler: RequestHandler> = async (context, request, response) => { + try { + const { pkgkey } = request.params; + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; + const res = await installPackage({ + savedObjectsClient, + pkgkey, + callCluster, + }); + const body: InstallPackageResponse = { + response: res, + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const deletePackageHandler: RequestHandler> = async (context, request, response) => { + try { + const { pkgkey } = request.params; + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; + const res = await removeInstallation({ savedObjectsClient, pkgkey, callCluster }); + const body: DeletePackageResponse = { + response: res, + success: true, + }; + return response.ok({ body }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts index 7bdcafe633843..cb9ec5cc532c4 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts @@ -5,71 +5,74 @@ */ import { IRouter } from 'kibana/server'; import { PLUGIN_ID, EPM_API_ROUTES } from '../../constants'; +import { + getCategoriesHandler, + getListHandler, + getFileHandler, + getInfoHandler, + installPackageHandler, + deletePackageHandler, +} from './handlers'; +import { + GetPackagesRequestSchema, + GetFileRequestSchema, + GetInfoRequestSchema, + InstallPackageRequestSchema, + DeletePackageRequestSchema, +} from '../../types'; export const registerRoutes = (router: IRouter) => { router.get( { path: EPM_API_ROUTES.CATEGORIES_PATTERN, validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + getCategoriesHandler ); router.get( { path: EPM_API_ROUTES.LIST_PATTERN, - validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + validate: GetPackagesRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + getListHandler ); router.get( { - path: `${EPM_API_ROUTES.INFO_PATTERN}/{filePath*}`, - validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + path: EPM_API_ROUTES.FILEPATH_PATTERN, + validate: GetFileRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + getFileHandler ); router.get( { path: EPM_API_ROUTES.INFO_PATTERN, - validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + validate: GetInfoRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + getInfoHandler ); - router.get( + router.post( { path: EPM_API_ROUTES.INSTALL_PATTERN, - validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + validate: InstallPackageRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + installPackageHandler ); - router.get( + router.delete( { path: EPM_API_ROUTES.DELETE_PATTERN, - validate: false, - options: { tags: [`access:${PLUGIN_ID}`] }, + validate: DeletePackageRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, - async (context, req, res) => { - return res.ok({ body: { hello: 'world' } }); - } + deletePackageHandler ); }; diff --git a/x-pack/plugins/ingest_manager/server/routes/index.ts b/x-pack/plugins/ingest_manager/server/routes/index.ts index b458ef31dee45..33d75f3ab82cd 100644 --- a/x-pack/plugins/ingest_manager/server/routes/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/index.ts @@ -6,4 +6,7 @@ export { registerRoutes as registerAgentConfigRoutes } from './agent_config'; export { registerRoutes as registerDatasourceRoutes } from './datasource'; export { registerRoutes as registerEPMRoutes } from './epm'; -export { registerRoutes as registerFleetSetupRoutes } from './fleet_setup'; +export { registerRoutes as registerSetupRoutes } from './setup'; +export { registerRoutes as registerAgentRoutes } from './agent'; +export { registerRoutes as registerEnrollmentApiKeyRoutes } from './enrollment_api_key'; +export { registerRoutes as registerInstallScriptRoutes } from './install_script'; diff --git a/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts new file mode 100644 index 0000000000000..5470df31adbdd --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/routes/install_script/index.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import url from 'url'; +import { IRouter, BasePath, HttpServerInfo, KibanaRequest } from 'kibana/server'; +import { INSTALL_SCRIPT_API_ROUTES } from '../../constants'; +import { getScript } from '../../services/install_script'; +import { InstallScriptRequestSchema } from '../../types'; + +export const registerRoutes = ({ + router, + basePath, + serverInfo, +}: { + router: IRouter; + basePath: Pick; + serverInfo: HttpServerInfo; +}) => { + const kibanaUrl = url.format({ + protocol: serverInfo.protocol, + hostname: serverInfo.host, + port: serverInfo.port, + pathname: basePath.serverBasePath, + }); + + router.get( + { + path: INSTALL_SCRIPT_API_ROUTES, + validate: InstallScriptRequestSchema, + options: { tags: [], authRequired: false }, + }, + async function getInstallScriptHandler( + context, + request: KibanaRequest<{ osType: 'macos' }>, + response + ) { + const script = getScript(request.params.osType, kibanaUrl); + + return response.ok({ body: script }); + } + ); +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts similarity index 58% rename from x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts rename to x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 72fe34eb23c5f..30e725bb5ad4a 100644 --- a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -5,17 +5,18 @@ */ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler } from 'kibana/server'; -import { DEFAULT_OUTPUT_ID } from '../../constants'; import { outputService, agentConfigService } from '../../services'; import { CreateFleetSetupRequestSchema, CreateFleetSetupResponse } from '../../types'; +import { setup } from '../../services/setup'; +import { generateEnrollmentAPIKey } from '../../services/api_keys'; export const getFleetSetupHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; const successBody: CreateFleetSetupResponse = { isInitialized: true }; const failureBody: CreateFleetSetupResponse = { isInitialized: false }; try { - const output = await outputService.get(soClient, DEFAULT_OUTPUT_ID); - if (output) { + const adminUser = await outputService.getAdminUser(soClient); + if (adminUser) { return response.ok({ body: successBody, }); @@ -38,11 +39,31 @@ export const createFleetSetupHandler: RequestHandler< > = async (context, request, response) => { const soClient = context.core.savedObjects.client; try { - await outputService.createDefaultOutput(soClient, { - username: request.body.admin_username, - password: request.body.admin_password, + await outputService.updateOutput(soClient, await outputService.getDefaultOutputId(soClient), { + admin_username: request.body.admin_username, + admin_password: request.body.admin_password, }); - await agentConfigService.ensureDefaultAgentConfig(soClient); + await generateEnrollmentAPIKey(soClient, { + name: 'Default', + configId: await agentConfigService.getDefaultAgentConfigId(soClient), + }); + + return response.ok({ + body: { isInitialized: true }, + }); + } catch (e) { + return response.customError({ + statusCode: 500, + body: { message: e.message }, + }); + } +}; + +export const ingestManagerSetupHandler: RequestHandler = async (context, request, response) => { + const soClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; + try { + await setup(soClient, callCluster); return response.ok({ body: { isInitialized: true }, }); diff --git a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts similarity index 51% rename from x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts rename to x-pack/plugins/ingest_manager/server/routes/setup/index.ts index c23164db6b6eb..7e09d8dbef1f6 100644 --- a/x-pack/plugins/ingest_manager/server/routes/fleet_setup/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/index.ts @@ -4,27 +4,42 @@ * you may not use this file except in compliance with the Elastic License. */ import { IRouter } from 'kibana/server'; -import { PLUGIN_ID, FLEET_SETUP_API_ROUTES } from '../../constants'; +import { PLUGIN_ID, FLEET_SETUP_API_ROUTES, SETUP_API_ROUTE } from '../../constants'; import { GetFleetSetupRequestSchema, CreateFleetSetupRequestSchema } from '../../types'; -import { getFleetSetupHandler, createFleetSetupHandler } from './handlers'; +import { + getFleetSetupHandler, + createFleetSetupHandler, + ingestManagerSetupHandler, +} from './handlers'; export const registerRoutes = (router: IRouter) => { - // Get + // Ingest manager setup + router.post( + { + path: SETUP_API_ROUTE, + validate: false, + // if this route is set to `-all`, a read-only user get a 404 for this route + // and will see `Unable to initialize Ingest Manager` in the UI + options: { tags: [`access:${PLUGIN_ID}-read`] }, + }, + ingestManagerSetupHandler + ); + // Get Fleet setup router.get( { path: FLEET_SETUP_API_ROUTES.INFO_PATTERN, validate: GetFleetSetupRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-read`] }, }, getFleetSetupHandler ); - // Create + // Create Fleet setup router.post( { path: FLEET_SETUP_API_ROUTES.CREATE_PATTERN, validate: CreateFleetSetupRequestSchema, - options: { tags: [`access:${PLUGIN_ID}`] }, + options: { tags: [`access:${PLUGIN_ID}-all`] }, }, createFleetSetupHandler ); diff --git a/x-pack/plugins/ingest_manager/server/saved_objects.ts b/x-pack/plugins/ingest_manager/server/saved_objects.ts index 976556f388acf..860b95b58c7f7 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects.ts @@ -7,6 +7,10 @@ import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_CONFIG_SAVED_OBJECT_TYPE, DATASOURCE_SAVED_OBJECT_TYPE, + PACKAGES_SAVED_OBJECT_TYPE, + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, + ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, } from './constants'; /* @@ -15,28 +19,87 @@ import { * Please update typings in `/common/types` if mappings are updated. */ export const savedObjectMappings = { + [AGENT_SAVED_OBJECT_TYPE]: { + properties: { + shared_id: { type: 'keyword' }, + type: { type: 'keyword' }, + active: { type: 'boolean' }, + enrolled_at: { type: 'date' }, + access_api_key_id: { type: 'keyword' }, + version: { type: 'keyword' }, + user_provided_metadata: { type: 'text' }, + local_metadata: { type: 'text' }, + config_id: { type: 'keyword' }, + last_updated: { type: 'date' }, + last_checkin: { type: 'date' }, + config_updated_at: { type: 'date' }, + // FIXME_INGEST https://github.com/elastic/kibana/issues/56554 + default_api_key: { type: 'keyword' }, + updated_at: { type: 'date' }, + current_error_events: { type: 'text' }, + // FIXME_INGEST https://github.com/elastic/kibana/issues/56554 + actions: { + type: 'nested', + properties: { + id: { type: 'keyword' }, + type: { type: 'keyword' }, + data: { type: 'text' }, + sent_at: { type: 'date' }, + created_at: { type: 'date' }, + }, + }, + }, + }, + [AGENT_EVENT_SAVED_OBJECT_TYPE]: { + properties: { + type: { type: 'keyword' }, + subtype: { type: 'keyword' }, + agent_id: { type: 'keyword' }, + action_id: { type: 'keyword' }, + config_id: { type: 'keyword' }, + stream_id: { type: 'keyword' }, + timestamp: { type: 'date' }, + message: { type: 'text' }, + payload: { type: 'text' }, + data: { type: 'text' }, + }, + }, [AGENT_CONFIG_SAVED_OBJECT_TYPE]: { properties: { id: { type: 'keyword' }, name: { type: 'text' }, + is_default: { type: 'boolean' }, namespace: { type: 'keyword' }, description: { type: 'text' }, status: { type: 'keyword' }, datasources: { type: 'keyword' }, updated_on: { type: 'keyword' }, updated_by: { type: 'keyword' }, + revision: { type: 'integer' }, + }, + }, + [ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE]: { + properties: { + name: { type: 'keyword' }, + type: { type: 'keyword' }, + // FIXME_INGEST https://github.com/elastic/kibana/issues/56554 + api_key: { type: 'binary' }, + api_key_id: { type: 'keyword' }, + config_id: { type: 'keyword' }, + created_at: { type: 'date' }, + updated_at: { type: 'date' }, + expire_at: { type: 'date' }, + active: { type: 'boolean' }, }, }, [OUTPUT_SAVED_OBJECT_TYPE]: { properties: { - id: { type: 'keyword' }, name: { type: 'keyword' }, type: { type: 'keyword' }, - username: { type: 'keyword' }, - password: { type: 'keyword' }, - index_name: { type: 'keyword' }, - ingest_pipeline: { type: 'keyword' }, + is_default: { type: 'boolean' }, hosts: { type: 'keyword' }, + ca_sha256: { type: 'keyword' }, + // FIXME_INGEST https://github.com/elastic/kibana/issues/56554 api_key: { type: 'keyword' }, admin_username: { type: 'binary' }, admin_password: { type: 'binary' }, @@ -45,32 +108,49 @@ export const savedObjectMappings = { }, [DATASOURCE_SAVED_OBJECT_TYPE]: { properties: { - id: { type: 'keyword' }, name: { type: 'keyword' }, + description: { type: 'text' }, namespace: { type: 'keyword' }, - read_alias: { type: 'keyword' }, - agent_config_id: { type: 'keyword' }, + config_id: { type: 'keyword' }, + enabled: { type: 'boolean' }, package: { properties: { - assets: { + name: { type: 'keyword' }, + title: { type: 'keyword' }, + version: { type: 'keyword' }, + }, + }, + output_id: { type: 'keyword' }, + inputs: { + type: 'nested', + properties: { + type: { type: 'keyword' }, + enabled: { type: 'boolean' }, + processors: { type: 'keyword' }, + streams: { + type: 'nested', properties: { id: { type: 'keyword' }, - type: { type: 'keyword' }, + enabled: { type: 'boolean' }, + dataset: { type: 'keyword' }, + processors: { type: 'keyword' }, + config: { type: 'flattened' }, }, }, - description: { type: 'keyword' }, - name: { type: 'keyword' }, - title: { type: 'keyword' }, - version: { type: 'keyword' }, }, }, - streams: { + revision: { type: 'integer' }, + }, + }, + [PACKAGES_SAVED_OBJECT_TYPE]: { + properties: { + name: { type: 'keyword' }, + version: { type: 'keyword' }, + installed: { + type: 'nested', properties: { - config: { type: 'flattened' }, id: { type: 'keyword' }, - input: { type: 'flattened' }, - output_id: { type: 'keyword' }, - processors: { type: 'keyword' }, + type: { type: 'keyword' }, }, }, }, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 0690e115ca862..c0eb614102b29 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -5,37 +5,29 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; import { AuthenticatedUser } from '../../../security/server'; +import { DEFAULT_AGENT_CONFIG, AGENT_CONFIG_SAVED_OBJECT_TYPE } from '../constants'; import { - DEFAULT_AGENT_CONFIG_ID, - DEFAULT_AGENT_CONFIG, - AGENT_CONFIG_SAVED_OBJECT_TYPE, -} from '../constants'; -import { + Datasource, NewAgentConfig, AgentConfig, + FullAgentConfig, AgentConfigStatus, - AgentConfigUpdateHandler, ListWithKuery, - DeleteAgentConfigsResponse, } from '../types'; +import { DeleteAgentConfigsResponse, storedDatasourceToAgentDatasource } from '../../common'; import { datasourceService } from './datasource'; +import { outputService } from './output'; +import { agentConfigUpdateEventHandler } from './agent_config_update'; const SAVED_OBJECT_TYPE = AGENT_CONFIG_SAVED_OBJECT_TYPE; class AgentConfigService { - private eventsHandler: AgentConfigUpdateHandler[] = []; - - public registerAgentConfigUpdateHandler(handler: AgentConfigUpdateHandler) { - this.eventsHandler.push(handler); - } - - public triggerAgentConfigUpdatedEvent: AgentConfigUpdateHandler = async ( - action, - agentConfigId + private triggerAgentConfigUpdatedEvent = async ( + soClient: SavedObjectsClientContract, + action: string, + agentConfigId: string ) => { - for (const handler of this.eventsHandler) { - await handler(action, agentConfigId); - } + return agentConfigUpdateEventHandler(soClient, action, agentConfigId); }; private async _update( @@ -44,37 +36,51 @@ class AgentConfigService { agentConfig: Partial, user?: AuthenticatedUser ): Promise { + const oldAgentConfig = await this.get(soClient, id, false); + + if (!oldAgentConfig) { + throw new Error('Agent config not found'); + } + + if ( + oldAgentConfig.status === AgentConfigStatus.Inactive && + agentConfig.status !== AgentConfigStatus.Active + ) { + throw new Error( + `Agent config ${id} cannot be updated because it is ${oldAgentConfig.status}` + ); + } + await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentConfig, + revision: oldAgentConfig.revision + 1, updated_on: new Date().toString(), updated_by: user ? user.username : 'system', }); - await this.triggerAgentConfigUpdatedEvent('updated', id); + await this.triggerAgentConfigUpdatedEvent(soClient, 'updated', id); return (await this.get(soClient, id)) as AgentConfig; } public async ensureDefaultAgentConfig(soClient: SavedObjectsClientContract) { - let defaultAgentConfig; - - try { - defaultAgentConfig = await this.get(soClient, DEFAULT_AGENT_CONFIG_ID); - } catch (err) { - if (!err.isBoom || err.output.statusCode !== 404) { - throw err; - } - } + const configs = await soClient.find({ + type: AGENT_CONFIG_SAVED_OBJECT_TYPE, + filter: 'agent_configs.attributes.is_default:true', + }); - if (!defaultAgentConfig) { + if (configs.total === 0) { const newDefaultAgentConfig: NewAgentConfig = { ...DEFAULT_AGENT_CONFIG, }; - await this.create(soClient, newDefaultAgentConfig, { - id: DEFAULT_AGENT_CONFIG_ID, - }); + return this.create(soClient, newDefaultAgentConfig); } + + return { + id: configs.saved_objects[0].id, + ...configs.saved_objects[0].attributes, + }; } public async create( @@ -86,13 +92,16 @@ class AgentConfigService { SAVED_OBJECT_TYPE, { ...agentConfig, + revision: 1, updated_on: new Date().toISOString(), updated_by: options?.user?.username || 'system', } as AgentConfig, options ); - await this.triggerAgentConfigUpdatedEvent('created', newSo.id); + if (!agentConfig.is_default) { + await this.triggerAgentConfigUpdatedEvent(soClient, 'created', newSo.id); + } return { id: newSo.id, @@ -100,7 +109,11 @@ class AgentConfigService { }; } - public async get(soClient: SavedObjectsClientContract, id: string): Promise { + public async get( + soClient: SavedObjectsClientContract, + id: string, + withDatasources: boolean = true + ): Promise { const agentConfigSO = await soClient.get(SAVED_OBJECT_TYPE, id); if (!agentConfigSO) { return null; @@ -110,15 +123,20 @@ class AgentConfigService { throw new Error(agentConfigSO.error.message); } - return { + const agentConfig: AgentConfig = { id: agentConfigSO.id, ...agentConfigSO.attributes, - datasources: + }; + + if (withDatasources) { + agentConfig.datasources = (await datasourceService.getByIDs( soClient, (agentConfigSO.attributes.datasources as string[]) || [] - )) || [], - }; + )) || []; + } + + return agentConfig; } public async list( @@ -159,31 +177,24 @@ class AgentConfigService { agentConfig: Partial, options?: { user?: AuthenticatedUser } ): Promise { - const oldAgentConfig = await this.get(soClient, id); - - if (!oldAgentConfig) { - throw new Error('Agent config not found'); - } - - if ( - oldAgentConfig.status === AgentConfigStatus.Inactive && - agentConfig.status !== AgentConfigStatus.Active - ) { - throw new Error( - `Agent config ${id} cannot be updated because it is ${oldAgentConfig.status}` - ); - } - return this._update(soClient, id, agentConfig, options?.user); } + public async bumpRevision( + soClient: SavedObjectsClientContract, + id: string, + options?: { user?: AuthenticatedUser } + ): Promise { + return this._update(soClient, id, {}, options?.user); + } + public async assignDatasources( soClient: SavedObjectsClientContract, id: string, datasourceIds: string[], options?: { user?: AuthenticatedUser } ): Promise { - const oldAgentConfig = await this.get(soClient, id); + const oldAgentConfig = await this.get(soClient, id, false); if (!oldAgentConfig) { throw new Error('Agent config not found'); @@ -206,7 +217,7 @@ class AgentConfigService { datasourceIds: string[], options?: { user?: AuthenticatedUser } ): Promise { - const oldAgentConfig = await this.get(soClient, id); + const oldAgentConfig = await this.get(soClient, id, false); if (!oldAgentConfig) { throw new Error('Agent config not found'); @@ -225,20 +236,34 @@ class AgentConfigService { ); } + public async getDefaultAgentConfigId(soClient: SavedObjectsClientContract) { + const configs = await soClient.find({ + type: AGENT_CONFIG_SAVED_OBJECT_TYPE, + filter: 'agent_configs.attributes.is_default:true', + }); + + if (configs.saved_objects.length === 0) { + throw new Error('No default agent config'); + } + + return configs.saved_objects[0].id; + } + public async delete( soClient: SavedObjectsClientContract, ids: string[] ): Promise { const result: DeleteAgentConfigsResponse = []; + const defaultConfigId = await this.getDefaultAgentConfigId(soClient); - if (ids.includes(DEFAULT_AGENT_CONFIG_ID)) { + if (ids.includes(defaultConfigId)) { throw new Error('The default agent configuration cannot be deleted'); } for (const id of ids) { try { await soClient.delete(SAVED_OBJECT_TYPE, id); - await this.triggerAgentConfigUpdatedEvent('deleted', id); + await this.triggerAgentConfigUpdatedEvent(soClient, 'deleted', id); result.push({ id, success: true, @@ -253,6 +278,50 @@ class AgentConfigService { return result; } + + public async getFullConfig( + soClient: SavedObjectsClientContract, + id: string + ): Promise { + let config; + + try { + config = await this.get(soClient, id); + } catch (err) { + if (!err.isBoom || err.output.statusCode !== 404) { + throw err; + } + } + + if (!config) { + return null; + } + + const agentConfig: FullAgentConfig = { + id: config.id, + outputs: { + // TEMPORARY as we only support a default output + ...[ + await outputService.get(soClient, await outputService.getDefaultOutputId(soClient)), + ].reduce((outputs, { config: outputConfig, name, type, hosts, ca_sha256, api_key }) => { + outputs[name] = { + type, + hosts, + ca_sha256, + api_key, + ...outputConfig, + }; + return outputs; + }, {} as FullAgentConfig['outputs']), + }, + datasources: (config.datasources as Datasource[]).map(ds => + storedDatasourceToAgentDatasource(ds) + ), + revision: config.revision, + }; + + return agentConfig; + } } export const agentConfigService = new AgentConfigService(); diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts new file mode 100644 index 0000000000000..38894ff321a0b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agent_config_update.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { generateEnrollmentAPIKey, deleteEnrollmentApiKeyForConfigId } from './api_keys'; +import { updateAgentsForConfigId, unenrollForConfigId } from './agents'; + +export async function agentConfigUpdateEventHandler( + soClient: SavedObjectsClientContract, + action: string, + configId: string +) { + if (action === 'created') { + await generateEnrollmentAPIKey(soClient, { + configId, + }); + } + + if (action === 'updated') { + await updateAgentsForConfigId(soClient, configId); + } + + if (action === 'deleted') { + await unenrollForConfigId(soClient, configId); + await deleteEnrollmentApiKeyForConfigId(soClient, configId); + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts new file mode 100644 index 0000000000000..1732ff9cf5b5c --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { Agent, AgentSOAttributes } from '../../types'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; + +export async function acknowledgeAgentActions( + soClient: SavedObjectsClientContract, + agent: Agent, + actionIds: string[] +) { + const now = new Date().toISOString(); + + const updatedActions = agent.actions.map(action => { + if (action.sent_at) { + return action; + } + return { ...action, sent_at: actionIds.indexOf(action.id) >= 0 ? now : undefined }; + }); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + actions: updatedActions, + }); +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts new file mode 100644 index 0000000000000..76dfc0867fb4e --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObjectsBulkCreateObject } from 'kibana/server'; +import uuid from 'uuid'; +import { + Agent, + AgentEvent, + AgentAction, + AgentSOAttributes, + AgentEventSOAttributes, +} from '../../types'; + +import { agentConfigService } from '../agent_config'; +import * as APIKeysService from '../api_keys'; +import { AGENT_SAVED_OBJECT_TYPE, AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; + +export async function agentCheckin( + soClient: SavedObjectsClientContract, + agent: Agent, + events: AgentEvent[], + localMetadata?: any +) { + const updateData: { + last_checkin: string; + default_api_key?: string; + actions?: AgentAction[]; + local_metadata?: string; + current_error_events?: string; + } = { + last_checkin: new Date().toISOString(), + }; + + const actions = filterActionsForCheckin(agent); + + // Generate new agent config if config is updated + if (isNewAgentConfig(agent) && agent.config_id) { + const config = await agentConfigService.getFullConfig(soClient, agent.config_id); + if (config) { + // Assign output API keys + // We currently only support default ouput + if (!agent.default_api_key) { + updateData.default_api_key = await APIKeysService.generateOutputApiKey( + soClient, + 'default', + agent.id + ); + } + // Mutate the config to set the api token for this agent + config.outputs.default.api_key = agent.default_api_key || updateData.default_api_key; + + const configChangeAction: AgentAction = { + id: uuid.v4(), + type: 'CONFIG_CHANGE', + created_at: new Date().toISOString(), + data: JSON.stringify({ + config, + }), + sent_at: undefined, + }; + actions.push(configChangeAction); + // persist new action + updateData.actions = actions; + } + } + if (localMetadata) { + updateData.local_metadata = JSON.stringify(localMetadata); + } + + const { updatedErrorEvents } = await processEventsForCheckin(soClient, agent, events); + + // Persist changes + if (updatedErrorEvents) { + updateData.current_error_events = JSON.stringify(updatedErrorEvents); + } + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, updateData); + + return { actions }; +} + +async function processEventsForCheckin( + soClient: SavedObjectsClientContract, + agent: Agent, + events: AgentEvent[] +) { + const acknowledgedActionIds: string[] = []; + const updatedErrorEvents = [...agent.current_error_events]; + for (const event of events) { + // @ts-ignore + event.config_id = agent.config_id; + + if (isActionEvent(event)) { + acknowledgedActionIds.push(event.action_id as string); + } + + if (isErrorOrState(event)) { + // Remove any global or specific to a stream event + const existingEventIndex = updatedErrorEvents.findIndex(e => e.stream_id === event.stream_id); + if (existingEventIndex >= 0) { + updatedErrorEvents.splice(existingEventIndex, 1); + } + if (event.type === 'ERROR') { + updatedErrorEvents.push(event); + } + } + } + + if (events.length > 0) { + await createEventsForAgent(soClient, agent.id, events); + } + + return { + acknowledgedActionIds, + updatedErrorEvents, + }; +} + +async function createEventsForAgent( + soClient: SavedObjectsClientContract, + agentId: string, + events: AgentEvent[] +) { + const objects: Array> = events.map( + eventData => { + return { + attributes: { + ...eventData, + payload: eventData.payload ? JSON.stringify(eventData.payload) : undefined, + }, + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + }; + } + ); + + return soClient.bulkCreate(objects); +} + +function isErrorOrState(event: AgentEvent) { + return event.type === 'STATE' || event.type === 'ERROR'; +} + +function isActionEvent(event: AgentEvent) { + return ( + event.type === 'ACTION' && (event.subtype === 'ACKNOWLEDGED' || event.subtype === 'UNKNOWN') + ); +} + +function isNewAgentConfig(agent: Agent) { + const isFirstCheckin = !agent.last_checkin; + const isConfigUpdatedSinceLastCheckin = + agent.last_checkin && agent.config_updated_at && agent.last_checkin <= agent.config_updated_at; + + return isFirstCheckin || isConfigUpdatedSinceLastCheckin; +} + +function filterActionsForCheckin(agent: Agent): AgentAction[] { + return agent.actions.filter((a: AgentAction) => !a.sent_at); +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/crud.ts b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts new file mode 100644 index 0000000000000..bcd825fee8725 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/crud.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, + AGENT_TYPE_EPHEMERAL, + AGENT_POLLING_THRESHOLD_MS, +} from '../../constants'; +import { AgentSOAttributes, Agent, AgentEventSOAttributes } from '../../types'; +import { savedObjectToAgent } from './saved_objects'; + +export async function listAgents( + soClient: SavedObjectsClientContract, + options: { + page: number; + perPage: number; + kuery?: string; + showInactive: boolean; + } +) { + const { page, perPage, kuery, showInactive = false } = options; + + const filters = []; + + if (kuery && kuery !== '') { + // To ensure users dont need to know about SO data structure... + filters.push(kuery.replace(/agents\./g, 'agents.attributes.')); + } + + if (showInactive === false) { + const agentActiveCondition = `agents.attributes.active:true AND not agents.attributes.type:${AGENT_TYPE_EPHEMERAL}`; + const recentlySeenEphemeralAgent = `agents.attributes.active:true AND agents.attributes.type:${AGENT_TYPE_EPHEMERAL} AND agents.attributes.last_checkin > ${Date.now() - + 3 * AGENT_POLLING_THRESHOLD_MS}`; + filters.push(`(${agentActiveCondition}) OR (${recentlySeenEphemeralAgent})`); + } + + const { saved_objects, total } = await soClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + page, + perPage, + filter: _joinFilters(filters), + ..._getSortFields(), + }); + + const agents: Agent[] = saved_objects.map(savedObjectToAgent); + + return { + agents, + total, + page, + perPage, + }; +} + +export async function getAgent(soClient: SavedObjectsClientContract, agentId: string) { + const agent = savedObjectToAgent( + await soClient.get(AGENT_SAVED_OBJECT_TYPE, agentId) + ); + return agent; +} + +export async function getAgentByAccessAPIKeyId( + soClient: SavedObjectsClientContract, + accessAPIKeyId: string +) { + const response = await soClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + searchFields: ['access_api_key_id'], + search: accessAPIKeyId, + }); + + const [agent] = response.saved_objects.map(savedObjectToAgent); + + if (!agent) { + throw Boom.notFound('Agent not found'); + } + if (!agent.active) { + throw Boom.forbidden('Agent inactive'); + } + + return agent; +} + +export async function updateAgent( + soClient: SavedObjectsClientContract, + agentId: string, + data: { + userProvidedMetatada: any; + } +) { + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + user_provided_metadata: JSON.stringify(data.userProvidedMetatada), + }); +} + +export async function deleteAgent(soClient: SavedObjectsClientContract, agentId: string) { + const agent = await getAgent(soClient, agentId); + if (agent.type === 'EPHEMERAL') { + // Delete events + let more = true; + while (more === true) { + const { saved_objects: events } = await soClient.find({ + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + fields: ['id'], + search: agentId, + searchFields: ['agent_id'], + perPage: 1000, + }); + if (events.length === 0) { + more = false; + } + for (const event of events) { + await soClient.delete(AGENT_EVENT_SAVED_OBJECT_TYPE, event.id); + } + } + await soClient.delete(AGENT_SAVED_OBJECT_TYPE, agentId); + return; + } + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agentId, { + active: false, + }); +} + +function _getSortFields(sortOption?: string) { + switch (sortOption) { + case 'ASC': + return { + sortField: 'enrolled_at', + sortOrder: 'ASC', + }; + + case 'DESC': + default: + return { + sortField: 'enrolled_at', + sortOrder: 'DESC', + }; + } +} + +function _joinFilters(filters: string[], operator = 'AND') { + return filters.reduce((acc: string | undefined, filter) => { + if (acc) { + return `${acc} ${operator} (${filter})`; + } + + return `(${filter})`; + }, undefined); +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts new file mode 100644 index 0000000000000..b48d311da4440 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/enroll.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { AgentType, Agent, AgentSOAttributes } from '../../types'; +import { savedObjectToAgent } from './saved_objects'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; +import * as APIKeyService from '../api_keys'; + +export async function enroll( + soClient: SavedObjectsClientContract, + type: AgentType, + configId: string, + metadata?: { local: any; userProvided: any }, + sharedId?: string +): Promise { + const existingAgent = sharedId ? await getAgentBySharedId(soClient, sharedId) : null; + + if (existingAgent && existingAgent.active === true) { + throw Boom.badRequest('Impossible to enroll an already active agent'); + } + + const enrolledAt = new Date().toISOString(); + + const agentData: AgentSOAttributes = { + shared_id: sharedId, + active: true, + config_id: configId, + type, + enrolled_at: enrolledAt, + user_provided_metadata: JSON.stringify(metadata?.userProvided ?? {}), + local_metadata: JSON.stringify(metadata?.local ?? {}), + current_error_events: undefined, + actions: [], + access_api_key_id: undefined, + config_updated_at: undefined, + last_checkin: undefined, + default_api_key: undefined, + }; + + let agent; + if (existingAgent) { + await soClient.update(AGENT_SAVED_OBJECT_TYPE, existingAgent.id, agentData); + agent = { + ...existingAgent, + ...agentData, + user_provided_metadata: metadata?.userProvided ?? {}, + local_metadata: metadata?.local ?? {}, + current_error_events: [], + } as Agent; + } else { + agent = savedObjectToAgent( + await soClient.create(AGENT_SAVED_OBJECT_TYPE, agentData) + ); + } + + const accessAPIKey = await APIKeyService.generateAccessApiKey(soClient, agent.id, configId); + + await soClient.update(AGENT_SAVED_OBJECT_TYPE, agent.id, { + access_api_key_id: accessAPIKey.id, + }); + + return { ...agent, access_api_key: accessAPIKey.key }; +} + +async function getAgentBySharedId(soClient: SavedObjectsClientContract, sharedId: string) { + const response = await soClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + searchFields: ['shared_id'], + search: sharedId, + }); + + const agents = response.saved_objects.map(savedObjectToAgent); + + if (agents.length > 0) { + return agents[0]; + } + + return null; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/events.ts b/x-pack/plugins/ingest_manager/server/services/agents/events.ts new file mode 100644 index 0000000000000..908d289fbc4bb --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/events.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; +import { AgentEventSOAttributes, AgentEvent } from '../../types'; + +export async function getAgentEvents( + soClient: SavedObjectsClientContract, + agentId: string, + options: { + kuery?: string; + page: number; + perPage: number; + } +) { + const { page, perPage, kuery } = options; + + const { total, saved_objects } = await soClient.find({ + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + filter: + kuery && kuery !== '' + ? kuery.replace(/agent_events\./g, 'agent_events.attributes.') + : undefined, + perPage, + page, + sortField: 'timestamp', + sortOrder: 'DESC', + defaultSearchOperator: 'AND', + search: agentId, + searchFields: ['agent_id'], + }); + + const items: AgentEvent[] = saved_objects.map(so => { + return { + ...so.attributes, + payload: so.attributes.payload ? JSON.parse(so.attributes.payload) : undefined, + }; + }); + + return { items, total }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/index.ts b/x-pack/plugins/ingest_manager/server/services/agents/index.ts new file mode 100644 index 0000000000000..477f081d1900b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './acks'; +export * from './events'; +export * from './checkin'; +export * from './enroll'; +export * from './unenroll'; +export * from './status'; +export * from './crud'; +export * from './update'; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts new file mode 100644 index 0000000000000..adb096a444903 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/saved_objects.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject } from 'kibana/server'; +import { Agent, AgentSOAttributes } from '../../types'; + +export function savedObjectToAgent(so: SavedObject): Agent { + if (so.error) { + throw new Error(so.error.message); + } + + return { + id: so.id, + ...so.attributes, + current_error_events: so.attributes.current_error_events + ? JSON.parse(so.attributes.current_error_events) + : [], + local_metadata: JSON.parse(so.attributes.local_metadata), + user_provided_metadata: JSON.parse(so.attributes.user_provided_metadata), + access_api_key: undefined, + status: undefined, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/status.ts b/x-pack/plugins/ingest_manager/server/services/agents/status.ts new file mode 100644 index 0000000000000..f6477bf1c7334 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/status.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { listAgents } from './crud'; +import { AGENT_EVENT_SAVED_OBJECT_TYPE } from '../../constants'; +import { AgentStatus, Agent } from '../../types'; + +import { + AGENT_POLLING_THRESHOLD_MS, + AGENT_TYPE_PERMANENT, + AGENT_TYPE_TEMPORARY, + AGENT_TYPE_EPHEMERAL, +} from '../../constants'; +import { AgentStatusKueryHelper } from '../../../common/services'; + +export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentStatus { + const { type, last_checkin: lastCheckIn } = agent; + const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); + const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn; + const intervalsSinceLastCheckIn = Math.floor(msSinceLastCheckIn / AGENT_POLLING_THRESHOLD_MS); + if (!agent.active) { + return 'inactive'; + } + if (agent.current_error_events.length > 0) { + return 'error'; + } + switch (type) { + case AGENT_TYPE_PERMANENT: + if (intervalsSinceLastCheckIn >= 4) { + return 'error'; + } + if (intervalsSinceLastCheckIn >= 2) { + return 'warning'; + } + case AGENT_TYPE_TEMPORARY: + if (intervalsSinceLastCheckIn >= 3) { + return 'offline'; + } + case AGENT_TYPE_EPHEMERAL: + if (intervalsSinceLastCheckIn >= 3) { + return 'inactive'; + } + } + return 'online'; +} + +export async function getAgentStatusForConfig( + soClient: SavedObjectsClientContract, + configId?: string +) { + const [all, error, offline] = await Promise.all( + [ + undefined, + AgentStatusKueryHelper.buildKueryForErrorAgents(), + AgentStatusKueryHelper.buildKueryForOfflineAgents(), + ].map(kuery => + listAgents(soClient, { + showInactive: true, + perPage: 0, + page: 1, + kuery: configId + ? kuery + ? `(${kuery}) and (agents.config_id:"${configId}")` + : `agents.config_id:"${configId}"` + : kuery, + }) + ) + ); + + return { + events: await getEventsCount(soClient, configId), + total: all.total, + online: all.total - error.total - offline.total, + error: error.total, + offline: offline.total, + }; +} + +async function getEventsCount(soClient: SavedObjectsClientContract, configId?: string) { + const { total } = await soClient.find({ + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + filter: configId ? `agent_events.attributes.config_id:"${configId}"` : undefined, + perPage: 0, + page: 1, + sortField: 'timestamp', + sortOrder: 'DESC', + defaultSearchOperator: 'AND', + }); + + return total; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.ts new file mode 100644 index 0000000000000..e45620c3cf588 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/unenroll.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { AgentSOAttributes } from '../../types'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; + +export async function unenrollAgents( + soClient: SavedObjectsClientContract, + toUnenrollIds: string[] +) { + const response = []; + for (const id of toUnenrollIds) { + try { + await soClient.update(AGENT_SAVED_OBJECT_TYPE, id, { + active: false, + }); + response.push({ + id, + success: true, + }); + } catch (error) { + response.push({ + id, + error, + success: false, + }); + } + } + + return response; +} diff --git a/x-pack/plugins/ingest_manager/server/services/agents/update.ts b/x-pack/plugins/ingest_manager/server/services/agents/update.ts new file mode 100644 index 0000000000000..8452c05d53a1f --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/update.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { listAgents } from './crud'; +import { AGENT_SAVED_OBJECT_TYPE } from '../../constants'; +import { unenrollAgents } from './unenroll'; + +export async function updateAgentsForConfigId( + soClient: SavedObjectsClientContract, + configId: string +) { + let hasMore = true; + let page = 1; + const now = new Date().toISOString(); + while (hasMore) { + const { agents } = await listAgents(soClient, { + kuery: `agents.config_id:"${configId}"`, + page: page++, + perPage: 1000, + showInactive: true, + }); + if (agents.length === 0) { + hasMore = false; + break; + } + const agentUpdate = agents.map(agent => ({ + id: agent.id, + type: AGENT_SAVED_OBJECT_TYPE, + attributes: { config_updated_at: now }, + })); + + await soClient.bulkUpdate(agentUpdate); + } +} + +export async function unenrollForConfigId(soClient: SavedObjectsClientContract, configId: string) { + let hasMore = true; + let page = 1; + while (hasMore) { + const { agents } = await listAgents(soClient, { + kuery: `agents.config_id:"${configId}"`, + page: page++, + perPage: 1000, + showInactive: true, + }); + + if (agents.length === 0) { + hasMore = false; + } + await unenrollAgents( + soClient, + agents.map(a => a.id) + ); + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts new file mode 100644 index 0000000000000..9a1a91f9ed8a9 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/enrollment_api_key.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { SavedObjectsClientContract, SavedObject } from 'kibana/server'; +import { EnrollmentAPIKey, EnrollmentAPIKeySOAttributes } from '../../types'; +import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../constants'; +import { createAPIKey, invalidateAPIKey } from './security'; +import { agentConfigService } from '../agent_config'; + +export async function listEnrollmentApiKeys( + soClient: SavedObjectsClientContract, + options: { + page?: number; + perPage?: number; + kuery?: string; + showInactive?: boolean; + } +): Promise<{ items: EnrollmentAPIKey[]; total: any; page: any; perPage: any }> { + const { page = 1, perPage = 20, kuery } = options; + + const { saved_objects, total } = await soClient.find({ + type: ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + page, + perPage, + filter: + kuery && kuery !== '' + ? kuery.replace(/enrollment_api_keys\./g, 'enrollment_api_keys.attributes.') + : undefined, + }); + + const items = saved_objects.map(savedObjectToEnrollmentApiKey); + + return { + items, + total, + page, + perPage, + }; +} + +export async function getEnrollmentAPIKey(soClient: SavedObjectsClientContract, id: string) { + return savedObjectToEnrollmentApiKey( + await soClient.get(ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, id) + ); +} + +export async function deleteEnrollmentApiKey(soClient: SavedObjectsClientContract, id: string) { + const enrollmentApiKey = await getEnrollmentAPIKey(soClient, id); + + await invalidateAPIKey(soClient, enrollmentApiKey.api_key_id); + + await soClient.delete(ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, id); +} + +export async function deleteEnrollmentApiKeyForConfigId( + soClient: SavedObjectsClientContract, + configId: string +) { + let hasMore = true; + let page = 1; + while (hasMore) { + const { items } = await listEnrollmentApiKeys(soClient, { + page: page++, + perPage: 100, + kuery: `enrollment_api_keys.config_id:${configId}`, + }); + + if (items.length === 0) { + hasMore = false; + } + + for (const apiKey of items) { + await deleteEnrollmentApiKey(soClient, apiKey.id); + } + } +} + +export async function generateEnrollmentAPIKey( + soClient: SavedObjectsClientContract, + data: { + name?: string; + expiration?: string; + configId?: string; + } +) { + const id = uuid.v4(); + const { name: providedKeyName } = data; + const configId = data.configId ?? (await agentConfigService.getDefaultAgentConfigId(soClient)); + + const name = providedKeyName ? `${providedKeyName} (${id})` : id; + + const key = await createAPIKey(soClient, name, {}); + + if (!key) { + throw new Error('Unable to create an enrollment api key'); + } + + const apiKey = Buffer.from(`${key.id}:${key.api_key}`).toString('base64'); + + return savedObjectToEnrollmentApiKey( + await soClient.create(ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, { + active: true, + api_key_id: key.id, + api_key: apiKey, + name, + config_id: configId, + }) + ); +} + +function savedObjectToEnrollmentApiKey({ + error, + attributes, + id, +}: SavedObject): EnrollmentAPIKey { + if (error) { + throw new Error(error.message); + } + + return { + id, + ...attributes, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts new file mode 100644 index 0000000000000..6482c2a045a17 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/index.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract, SavedObject, KibanaRequest } from 'kibana/server'; +import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../constants'; +import { EnrollmentAPIKeySOAttributes, EnrollmentAPIKey } from '../../types'; +import { createAPIKey } from './security'; + +export * from './enrollment_api_key'; + +export async function generateOutputApiKey( + soClient: SavedObjectsClientContract, + outputId: string, + agentId: string +): Promise { + const name = `${agentId}:${outputId}`; + const key = await createAPIKey(soClient, name, { + 'fleet-output': { + cluster: ['monitor'], + index: [ + { + names: ['logs-*', 'metrics-*'], + privileges: ['write'], + }, + ], + }, + }); + + if (!key) { + throw new Error('Unable to create an output api key'); + } + + return `${key.id}:${key.api_key}`; +} + +export async function generateAccessApiKey( + soClient: SavedObjectsClientContract, + agentId: string, + configId: string +) { + const key = await createAPIKey(soClient, agentId, { + 'fleet-agent': {}, + }); + + if (!key) { + throw new Error('Unable to create an access api key'); + } + + return { id: key.id, key: Buffer.from(`${key.id}:${key.api_key}`).toString('base64') }; +} + +export async function getEnrollmentAPIKeyById( + soClient: SavedObjectsClientContract, + apiKeyId: string +) { + const [enrollmentAPIKey] = ( + await soClient.find({ + type: ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + searchFields: ['api_key_id'], + search: apiKeyId, + }) + ).saved_objects.map(_savedObjectToEnrollmentApiKey); + + return enrollmentAPIKey; +} + +export function parseApiKey(headers: KibanaRequest['headers']) { + const authorizationHeader = headers.authorization; + + if (!authorizationHeader) { + throw new Error('Authorization header must be set'); + } + + if (Array.isArray(authorizationHeader)) { + throw new Error('Authorization header must be `string` not `string[]`'); + } + + if (!authorizationHeader.startsWith('ApiKey ')) { + throw new Error('Authorization header is malformed'); + } + + const apiKey = authorizationHeader.split(' ')[1]; + if (!apiKey) { + throw new Error('Authorization header is malformed'); + } + const apiKeyId = Buffer.from(apiKey, 'base64') + .toString('utf8') + .split(':')[0]; + + return { + apiKey, + apiKeyId, + }; +} + +function _savedObjectToEnrollmentApiKey({ + error, + attributes, + id, +}: SavedObject): EnrollmentAPIKey { + if (error) { + throw new Error(error.message); + } + + return { + id, + ...attributes, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/api_keys/security.ts b/x-pack/plugins/ingest_manager/server/services/api_keys/security.ts new file mode 100644 index 0000000000000..ffc269bca94eb --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/api_keys/security.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, FakeRequest, SavedObjectsClientContract } from 'kibana/server'; +import { CallESAsCurrentUser } from '../../types'; +import { appContextService } from '../app_context'; +import { outputService } from '../output'; + +export async function createAPIKey( + soClient: SavedObjectsClientContract, + name: string, + roleDescriptors: any +) { + const adminUser = await outputService.getAdminUser(soClient); + if (!adminUser) { + throw new Error('No admin user configured'); + } + const request: FakeRequest = { + headers: { + authorization: `Basic ${Buffer.from(`${adminUser.username}:${adminUser.password}`).toString( + 'base64' + )}`, + }, + }; + const security = appContextService.getSecurity(); + if (!security) { + throw new Error('Missing security plugin'); + } + + return security.authc.createAPIKey(request as KibanaRequest, { + name, + role_descriptors: roleDescriptors, + }); +} +export async function authenticate(callCluster: CallESAsCurrentUser) { + try { + await callCluster('transport.request', { + path: '/_security/_authenticate', + method: 'GET', + }); + } catch (e) { + throw new Error('ApiKey is not valid: impossible to authenticate user'); + } +} + +export async function invalidateAPIKey(soClient: SavedObjectsClientContract, id: string) { + const adminUser = await outputService.getAdminUser(soClient); + if (!adminUser) { + throw new Error('No admin user configured'); + } + const request: FakeRequest = { + headers: { + authorization: `Basic ${Buffer.from(`${adminUser.username}:${adminUser.password}`).toString( + 'base64' + )}`, + }, + }; + + const security = appContextService.getSecurity(); + if (!security) { + throw new Error('Missing security plugin'); + } + + return security.authc.invalidateAPIKey(request as KibanaRequest, { + id, + }); +} diff --git a/x-pack/plugins/ingest_manager/server/services/app_context.ts b/x-pack/plugins/ingest_manager/server/services/app_context.ts index 69a014fca37fb..c06b282389fc7 100644 --- a/x-pack/plugins/ingest_manager/server/services/app_context.ts +++ b/x-pack/plugins/ingest_manager/server/services/app_context.ts @@ -5,6 +5,7 @@ */ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; +import { SavedObjectsServiceStart } from 'kibana/server'; import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server'; import { SecurityPluginSetup } from '../../../security/server'; import { IngestManagerConfigType } from '../../common'; @@ -15,10 +16,12 @@ class AppContextService { private security: SecurityPluginSetup | undefined; private config$?: Observable; private configSubject$?: BehaviorSubject; + private savedObjects: SavedObjectsServiceStart | undefined; public async start(appContext: IngestManagerAppContext) { this.encryptedSavedObjects = appContext.encryptedSavedObjects; this.security = appContext.security; + this.savedObjects = appContext.savedObjects; if (appContext.config$) { this.config$ = appContext.config$; @@ -45,6 +48,13 @@ class AppContextService { public getConfig$() { return this.config$; } + + public getSavedObjects() { + if (!this.savedObjects) { + throw new Error('Saved objects start service not set.'); + } + return this.savedObjects; + } } export const appContextService = new AppContextService(); diff --git a/x-pack/plugins/ingest_manager/server/services/config.ts b/x-pack/plugins/ingest_manager/server/services/config.ts new file mode 100644 index 0000000000000..9043b1cc634a0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/config.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable, Subscription } from 'rxjs'; +import { IngestManagerConfigType } from '../'; + +/** + * Kibana config observable service, *NOT* agent config + */ +class ConfigService { + private observable: Observable | null = null; + private subscription: Subscription | null = null; + private config: IngestManagerConfigType | null = null; + + private updateInformation(config: IngestManagerConfigType) { + this.config = config; + } + + public start(config$: Observable) { + this.observable = config$; + this.subscription = this.observable.subscribe(this.updateInformation.bind(this)); + } + + public stop() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + public getConfig() { + return this.config; + } +} + +export const configService = new ConfigService(); diff --git a/x-pack/plugins/ingest_manager/server/services/datasource.ts b/x-pack/plugins/ingest_manager/server/services/datasource.ts index b305ccaab777b..615b29087ba1e 100644 --- a/x-pack/plugins/ingest_manager/server/services/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/services/datasource.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { SavedObjectsClientContract } from 'kibana/server'; +import { AuthenticatedUser } from '../../../security/server'; +import { DeleteDatasourcesResponse } from '../../common'; import { DATASOURCE_SAVED_OBJECT_TYPE } from '../constants'; -import { NewDatasource, Datasource, DeleteDatasourcesResponse, ListWithKuery } from '../types'; +import { NewDatasource, Datasource, ListWithKuery } from '../types'; +import { agentConfigService } from './agent_config'; const SAVED_OBJECT_TYPE = DATASOURCE_SAVED_OBJECT_TYPE; @@ -13,14 +16,22 @@ class DatasourceService { public async create( soClient: SavedObjectsClientContract, datasource: NewDatasource, - options?: { id?: string } + options?: { id?: string; user?: AuthenticatedUser } ): Promise { - const newSo = await soClient.create( + const newSo = await soClient.create>( SAVED_OBJECT_TYPE, - datasource as Datasource, + { + ...datasource, + revision: 1, + }, options ); + // Assign it to the given agent config + await agentConfigService.assignDatasources(soClient, datasource.config_id, [newSo.id], { + user: options?.user, + }); + return { id: newSo.id, ...newSo.attributes, @@ -98,20 +109,47 @@ class DatasourceService { public async update( soClient: SavedObjectsClientContract, id: string, - datasource: NewDatasource + datasource: NewDatasource, + options?: { user?: AuthenticatedUser } ): Promise { - await soClient.update(SAVED_OBJECT_TYPE, id, datasource); + const oldDatasource = await this.get(soClient, id); + + if (!oldDatasource) { + throw new Error('Datasource not found'); + } + + await soClient.update(SAVED_OBJECT_TYPE, id, { + ...datasource, + revision: oldDatasource.revision + 1, + }); + + // Bump revision of associated agent config + await agentConfigService.bumpRevision(soClient, datasource.config_id, { user: options?.user }); + return (await this.get(soClient, id)) as Datasource; } public async delete( soClient: SavedObjectsClientContract, - ids: string[] + ids: string[], + options?: { user?: AuthenticatedUser } ): Promise { const result: DeleteDatasourcesResponse = []; for (const id of ids) { try { + const oldDatasource = await this.get(soClient, id); + if (!oldDatasource) { + throw new Error('Datasource not found'); + } + await agentConfigService.unassignDatasources( + soClient, + oldDatasource.config_id, + [oldDatasource.id], + { + user: options?.user, + } + ); await soClient.delete(SAVED_OBJECT_TYPE, id); result.push({ id, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts new file mode 100644 index 0000000000000..4f75ba0332418 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fs from 'fs'; +import * as yaml from 'js-yaml'; +import path from 'path'; +import { createInput } from './agent'; + +test('test converting input and manifest into template', () => { + const manifest = yaml.safeLoad( + fs.readFileSync(path.join(__dirname, 'tests/manifest.yml'), 'utf8') + ); + + const inputTemplate = fs.readFileSync(path.join(__dirname, 'tests/input.yml'), 'utf8'); + const output = createInput(manifest.vars, inputTemplate); + + // Golden file path + const generatedFile = path.join(__dirname, './tests/input.generated.yaml'); + + // Regenerate the file if `-generate` flag is used + if (process.argv.includes('-generate')) { + fs.writeFileSync(generatedFile, output); + } + + const outputData = fs.readFileSync(generatedFile, 'utf-8'); + + // Check that content file and generated file are equal + expect(outputData).toBe(output); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts new file mode 100644 index 0000000000000..c7dd3dab38bc1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Handlebars from 'handlebars'; +import { RegistryVarsEntry } from '../../../types'; + +/** + * This takes a dataset object as input and merges it with the input template. + * It returns the resolved template as a string. + */ +export function createInput(vars: RegistryVarsEntry[], inputTemplate: string): string { + const view: Record = {}; + + for (const v of vars) { + view[v.name] = v.default; + } + + const template = Handlebars.compile(inputTemplate); + return template(view); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml new file mode 100644 index 0000000000000..451ed554ce259 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.generated.yaml @@ -0,0 +1,5 @@ +type: log +paths: + - "/var/log/nginx/access.log*" + +tags: nginx diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml new file mode 100644 index 0000000000000..65a23fc2fa9ad --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/input.yml @@ -0,0 +1,7 @@ +type: log +paths: +{{#each paths}} + - "{{this}}" +{{/each}} + +tags: {{tags}} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml new file mode 100644 index 0000000000000..46a38179fe132 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/tests/manifest.yml @@ -0,0 +1,20 @@ +title: Nginx Acess Logs +release: beta +type: logs +ingest_pipeline: default + +vars: + - name: paths + # Should we define this as array? How will the UI best make sense of it? + type: textarea + default: + - /var/log/nginx/access.log* + # I suggest to use ECS fields for this config options here: https://github.com/elastic/ecs/blob/master/schemas/os.yml + # This would need to be based on a predefined definition on what can be filtered on + os.darwin: + - /usr/local/var/log/nginx/access.log* + os.windows: + - c:/programdata/nginx/logs/*access.log* + - name: tags + default: [nginx] + type: text diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ilm/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ilm/install.ts new file mode 100644 index 0000000000000..c56322239f27b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ilm/install.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CallESAsCurrentUser, ElasticsearchAssetType } from '../../../../types'; +import * as Registry from '../../registry'; + +export async function installILMPolicy(pkgkey: string, callCluster: CallESAsCurrentUser) { + const ilmPaths = await Registry.getArchiveInfo(pkgkey, (entry: Registry.ArchiveEntry) => + isILMPolicy(entry) + ); + if (!ilmPaths.length) return; + await Promise.all( + ilmPaths.map(async path => { + const body = Registry.getAsset(path).toString('utf-8'); + const { file } = Registry.pathParts(path); + const name = file.substr(0, file.lastIndexOf('.')); + try { + if (await policyExists(name, callCluster)) return; + await callCluster('transport.request', { + method: 'PUT', + path: '/_ilm/policy/' + name, + body, + }); + } catch (err) { + throw new Error(err.message); + } + }) + ); +} +const isILMPolicy = ({ path }: Registry.ArchiveEntry) => { + const pathParts = Registry.pathParts(path); + return pathParts.type === ElasticsearchAssetType.ilmPolicy; +}; +export async function policyExists( + name: string, + callCluster: CallESAsCurrentUser +): Promise { + const response = await callCluster('transport.request', { + method: 'GET', + path: '/_ilm/policy/?filter_path=' + name, + }); + + // If the response contains a key, it means the policy exists + return Object.keys(response).length > 0; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.test.ts new file mode 100644 index 0000000000000..851a3bc2dd720 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dataset } from '../../../types'; +import { getDatasetAssetBaseName } from './index'; + +test('getBaseName', () => { + const dataset: Dataset = { + id: 'nginx.access', + title: 'Nginx Acess Logs', + release: 'beta', + type: 'logs', + ingest_pipeline: 'default', + package: 'nginx', + path: 'access', + }; + const name = getDatasetAssetBaseName(dataset); + expect(name).toStrictEqual('logs-nginx.access'); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.ts new file mode 100644 index 0000000000000..e00b9db71db10 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dataset } from '../../../types'; + +/** + * Creates the base name for Elasticsearch assets in the form of + * {type}-{id} + */ +export function getDatasetAssetBaseName(dataset: Dataset): string { + return `${dataset.type}-${dataset.id}`; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/ingest_pipelines.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/ingest_pipelines.test.ts new file mode 100644 index 0000000000000..c3b135993105e --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/ingest_pipelines.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readFileSync } from 'fs'; +import path from 'path'; +import { rewriteIngestPipeline, getPipelineNameForInstallation } from './install'; +import { Dataset } from '../../../../types'; + +test('a json-format pipeline with pipeline references is correctly rewritten', () => { + const inputStandard = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_input_standard.json'), + 'utf-8' + ); + const inputBeats = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_input_beats.json'), + 'utf-8' + ); + const output = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_output.json') + ).toString('utf-8'); + + const substitutions = [ + { + source: 'pipeline-json', + target: 'new-pipeline-json', + templateFunction: 'IngestPipeline', + }, + { + source: 'pipeline-plaintext', + target: 'new-pipeline-plaintext', + templateFunction: 'IngestPipeline', + }, + ]; + expect(rewriteIngestPipeline(inputStandard, substitutions)).toBe(output); + expect(rewriteIngestPipeline(inputBeats, substitutions)).toBe(output); +}); + +test('a yml-format pipeline with pipeline references is correctly rewritten', () => { + const inputStandard = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_input_standard.yml') + ).toString('utf-8'); + const inputBeats = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_input_beats.yml') + ).toString('utf-8'); + const output = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/real_output.yml') + ).toString('utf-8'); + + const substitutions = [ + { + source: 'pipeline-json', + target: 'new-pipeline-json', + templateFunction: 'IngestPipeline', + }, + { + source: 'pipeline-plaintext', + target: 'new-pipeline-plaintext', + templateFunction: 'IngestPipeline', + }, + ]; + expect(rewriteIngestPipeline(inputStandard, substitutions)).toBe(output); + expect(rewriteIngestPipeline(inputBeats, substitutions)).toBe(output); +}); + +test('a json-format pipeline with no pipeline references stays unchanged', () => { + const input = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/no_replacement.json') + ).toString('utf-8'); + + const substitutions = [ + { + source: 'pipeline-json', + target: 'new-pipeline-json', + templateFunction: 'IngestPipeline', + }, + { + source: 'pipeline-plaintext', + target: 'new-pipeline-plaintext', + templateFunction: 'IngestPipeline', + }, + ]; + expect(rewriteIngestPipeline(input, substitutions)).toBe(input); +}); + +test('a yml-format pipeline with no pipeline references stays unchanged', () => { + const input = readFileSync( + path.join(__dirname, '/tests/ingest_pipelines/no_replacement.yml') + ).toString('utf-8'); + + const substitutions = [ + { + source: 'pipeline-json', + target: 'new-pipeline-json', + templateFunction: 'IngestPipeline', + }, + { + source: 'pipeline-plaintext', + target: 'new-pipeline-plaintext', + templateFunction: 'IngestPipeline', + }, + ]; + expect(rewriteIngestPipeline(input, substitutions)).toBe(input); +}); + +test('getPipelineNameForInstallation gets correct name', () => { + const dataset: Dataset = { + id: 'coredns.log', + title: 'CoreDNS logs', + release: 'ga', + type: 'logs', + ingest_pipeline: 'pipeline-entry', + package: 'coredns', + path: 'log', + }; + const packageVersion = '1.0.1'; + const pipelineRefName = 'pipeline-json'; + const pipelineEntryNameForInstallation = getPipelineNameForInstallation({ + pipelineName: dataset.ingest_pipeline, + dataset, + packageVersion, + }); + const pipelineRefNameForInstallation = getPipelineNameForInstallation({ + pipelineName: pipelineRefName, + dataset, + packageVersion, + }); + expect(pipelineEntryNameForInstallation).toBe(`${dataset.type}-${dataset.id}-${packageVersion}`); + expect(pipelineRefNameForInstallation).toBe( + `${dataset.type}-${dataset.id}-${packageVersion}-${pipelineRefName}` + ); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts new file mode 100644 index 0000000000000..4b65e5554567e --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AssetReference, + Dataset, + ElasticsearchAssetType, + IngestAssetType, + RegistryPackage, +} from '../../../../types'; +import * as Registry from '../../registry'; +import { CallESAsCurrentUser } from '../../../../types'; + +interface RewriteSubstitution { + source: string; + target: string; + templateFunction: string; +} + +export const installPipelines = async ( + registryPackage: RegistryPackage, + callCluster: CallESAsCurrentUser +) => { + const datasets = registryPackage.datasets; + if (datasets) { + const pipelines = datasets.reduce>>((acc, dataset) => { + if (dataset.ingest_pipeline) { + acc.push( + installPipelinesForDataset({ + pkgkey: Registry.pkgToPkgKey(registryPackage), + dataset, + callCluster, + packageName: registryPackage.name, + packageVersion: registryPackage.version, + }) + ); + } + return acc; + }, []); + return Promise.all(pipelines).then(results => results.flat()); + } + return []; +}; + +export function rewriteIngestPipeline( + pipeline: string, + substitutions: RewriteSubstitution[] +): string { + substitutions.forEach(sub => { + const { source, target, templateFunction } = sub; + // This fakes the use of the golang text/template expression {{SomeTemplateFunction 'some-param'}} + // cf. https://github.com/elastic/beats/blob/master/filebeat/fileset/fileset.go#L294 + + // "Standard style" uses '{{' and '}}' as delimiters + const matchStandardStyle = `{{\\s?${templateFunction}\\s+['"]${source}['"]\\s?}}`; + // "Beats style" uses '{<' and '>}' as delimiters because this is current practice in the beats project + const matchBeatsStyle = `{<\\s?${templateFunction}\\s+['"]${source}['"]\\s?>}`; + + const regexStandardStyle = new RegExp(matchStandardStyle); + const regexBeatsStyle = new RegExp(matchBeatsStyle); + pipeline = pipeline.replace(regexStandardStyle, target).replace(regexBeatsStyle, target); + }); + return pipeline; +} + +export async function installPipelinesForDataset({ + callCluster, + pkgkey, + dataset, + packageName, + packageVersion, +}: { + callCluster: CallESAsCurrentUser; + pkgkey: string; + dataset: Dataset; + packageName: string; + packageVersion: string; +}): Promise { + const pipelinePaths = await Registry.getArchiveInfo(pkgkey, (entry: Registry.ArchiveEntry) => + isDatasetPipeline(entry, dataset.path) + ); + let pipelines: any[] = []; + const substitutions: RewriteSubstitution[] = []; + + pipelinePaths.forEach(path => { + const { name, extension } = getNameAndExtension(path); + const nameForInstallation = getPipelineNameForInstallation({ + pipelineName: name, + dataset, + packageVersion, + }); + const content = Registry.getAsset(path).toString('utf-8'); + pipelines.push({ + name, + nameForInstallation, + content, + extension, + }); + substitutions.push({ + source: name, + target: nameForInstallation, + templateFunction: 'IngestPipeline', + }); + }); + + pipelines = pipelines.map(pipeline => { + return { + ...pipeline, + contentForInstallation: rewriteIngestPipeline(pipeline.content, substitutions), + }; + }); + + const installationPromises = pipelines.map(async pipeline => { + return installPipeline({ callCluster, pipeline }); + }); + + return Promise.all(installationPromises); +} + +async function installPipeline({ + callCluster, + pipeline, +}: { + callCluster: CallESAsCurrentUser; + pipeline: any; +}): Promise { + const callClusterParams: { + method: string; + path: string; + ignore: number[]; + body: any; + headers?: any; + } = { + method: 'PUT', + path: `/_ingest/pipeline/${pipeline.nameForInstallation}`, + ignore: [404], + body: pipeline.contentForInstallation, + }; + if (pipeline.extension === 'yml') { + callClusterParams.headers = { ['Content-Type']: 'application/yaml' }; + } + + // This uses the catch-all endpoint 'transport.request' because we have to explicitly + // set the Content-Type header above for sending yml data. Setting the headers is not + // exposed in the convenience endpoint 'ingest.putPipeline' of elasticsearch-js-legacy + // which we could otherwise use. + // See src/core/server/elasticsearch/api_types.ts for available endpoints. + await callCluster('transport.request', callClusterParams); + return { id: pipeline.nameForInstallation, type: IngestAssetType.IngestPipeline }; +} + +const isDirectory = ({ path }: Registry.ArchiveEntry) => path.endsWith('/'); +const isDatasetPipeline = ({ path }: Registry.ArchiveEntry, datasetName: string) => { + // TODO: better way to get particular assets + const pathParts = Registry.pathParts(path); + return ( + !isDirectory({ path }) && + pathParts.type === ElasticsearchAssetType.ingestPipeline && + pathParts.dataset !== undefined && + datasetName === pathParts.dataset + ); +}; + +// XXX: assumes path/to/file.ext -- 0..n '/' and exactly one '.' +const getNameAndExtension = ( + path: string +): { + name: string; + extension: string; +} => { + const splitPath = path.split('/'); + const filename = splitPath[splitPath.length - 1]; + return { + name: filename.split('.')[0], + extension: filename.split('.')[1], + }; +}; + +export const getPipelineNameForInstallation = ({ + pipelineName, + dataset, + packageVersion, +}: { + pipelineName: string; + dataset: Dataset; + packageVersion: string; +}): string => { + const isPipelineEntry = pipelineName === dataset.ingest_pipeline; + const suffix = isPipelineEntry ? '' : `-${pipelineName}`; + // if this is the pipeline entry, don't add a suffix + return `${dataset.type}-${dataset.id}-${packageVersion}${suffix}`; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipeline_template.json b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipeline_template.json new file mode 100644 index 0000000000000..87fbb0c36ee84 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipeline_template.json @@ -0,0 +1,101 @@ +{ + "description": "Pipeline for normalizing Kubernetes coredns logs", + "processors": [ + { + "pipeline": { + "if": "ctx.message.charAt(0) == (char)(\"{\")", + "name": "{{IngestPipeline 'pipeline-json' }}" + } + }, + { + "pipeline": { + "if": "ctx.message.charAt(0) != (char)(\"{\")", + "name": "{{IngestPipeline 'pipeline-plaintext' }}" + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');", + "ignore_failure" : true + } + }, + { + "script": { + "lang": "painless", + "source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');", + "if": "ctx.temp?.source != null" + } + }, + { + "set": { + "field": "source.ip", + "value": "{{source.address}}", + "if": "ctx.source?.address != null" + } + }, + { + "convert" : { + "field" : "source.port", + "type": "integer" + } + }, + { + "convert" : { + "field" : "coredns.duration", + "type": "double" + } + }, + { + "convert" : { + "field" : "coredns.query.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.response.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.dnssec_ok", + "type": "boolean" + } + }, + { + "uppercase": { + "field": "coredns.response.flags" + } + }, + { + "split": { + "field": "coredns.response.flags", + "separator": "," + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)", + "params": { + "scale": 1000000000 + }, + "if": "ctx.coredns?.duration != null" + } + }, + { + "remove": { + "field": "coredns.duration", + "ignore_missing": true + } + } + ], + "on_failure" : [{ + "set" : { + "field" : "error.message", + "value" : "{{ _ingest.on_failure_message }}" + } + }] +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.json b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.json new file mode 100644 index 0000000000000..14d4c0f990742 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.json @@ -0,0 +1,49 @@ +{ + "description": "Pipeline for dissecting message field in JSON logs", + "processors": [ + { + "json" : { + "field" : "message", + "target_field" : "json" + } + }, + { + "dissect": { + "field": "json.message", + "pattern": "%{timestamp} [%{log.level}] %{temp.source} - %{coredns.id} \"%{coredns.query.type} %{coredns.query.class} %{coredns.query.name} %{network.transport} %{coredns.query.size} %{coredns.dnssec_ok} %{bufsize}\" %{coredns.response.code} %{coredns.response.flags} %{coredns.response.size} %{coredns.duration}s" + } + }, + { + "remove": { + "field": ["message"], + "ignore_failure" : true + } + }, + { + "rename": { + "field": "json.message", + "target_field": "message", + "ignore_failure" : true + } + }, + { + "rename": { + "field": "json.kubernetes", + "target_field": "kubernetes", + "ignore_failure" : true + } + }, + { + "remove": { + "field": ["json", "bufsize"], + "ignore_failure" : true + } + } + ], + "on_failure" : [{ + "set" : { + "field" : "error.message", + "value" : "{{ _ingest.on_failure_message }}" + } + }] +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.yml b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.yml new file mode 100644 index 0000000000000..df3094fbfad5b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/no_replacement.yml @@ -0,0 +1,51 @@ +description: Pipeline for Cisco IOS logs. + +processors: + # IP Geolocation Lookup + - geoip: + field: source.ip + target_field: source.geo + ignore_missing: true + - geoip: + field: destination.ip + target_field: destination.geo + ignore_missing: true + + # IP Autonomous System (AS) Lookup + - geoip: + database_file: GeoLite2-ASN.mmdb + field: source.ip + target_field: source.as + properties: + - asn + - organization_name + ignore_missing: true + - geoip: + database_file: GeoLite2-ASN.mmdb + field: destination.ip + target_field: destination.as + properties: + - asn + - organization_name + ignore_missing: true + - rename: + field: source.as.asn + target_field: source.as.number + ignore_missing: true + - rename: + field: source.as.organization_name + target_field: source.as.organization.name + ignore_missing: true + - rename: + field: destination.as.asn + target_field: destination.as.number + ignore_missing: true + - rename: + field: destination.as.organization_name + target_field: destination.as.organization.name + ignore_missing: true + +on_failure: + - set: + field: error.message + value: '{{ _ingest.on_failure_message }}' \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.json b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.json new file mode 100644 index 0000000000000..a1188ea08c762 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.json @@ -0,0 +1,101 @@ +{ + "description": "Pipeline for normalizing Kubernetes coredns logs", + "processors": [ + { + "pipeline": { + "if": "ctx.message.charAt(0) == (char)(\"{\")", + "name": "{}" + } + }, + { + "pipeline": { + "if": "ctx.message.charAt(0) != (char)(\"{\")", + "name": "{}" + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');", + "ignore_failure" : true + } + }, + { + "script": { + "lang": "painless", + "source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');", + "if": "ctx.temp?.source != null" + } + }, + { + "set": { + "field": "source.ip", + "value": "{{source.address}}", + "if": "ctx.source?.address != null" + } + }, + { + "convert" : { + "field" : "source.port", + "type": "integer" + } + }, + { + "convert" : { + "field" : "coredns.duration", + "type": "double" + } + }, + { + "convert" : { + "field" : "coredns.query.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.response.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.dnssec_ok", + "type": "boolean" + } + }, + { + "uppercase": { + "field": "coredns.response.flags" + } + }, + { + "split": { + "field": "coredns.response.flags", + "separator": "," + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)", + "params": { + "scale": 1000000000 + }, + "if": "ctx.coredns?.duration != null" + } + }, + { + "remove": { + "field": "coredns.duration", + "ignore_missing": true + } + } + ], + "on_failure" : [{ + "set" : { + "field" : "error.message", + "value" : "{{ _ingest.on_failure_message }}" + } + }] +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.yml b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.yml new file mode 100644 index 0000000000000..4aabb8c0cf1ef --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_beats.yml @@ -0,0 +1,113 @@ +--- +description: Pipeline for normalizing Kubernetes CoreDNS logs. +processors: + - pipeline: + if: ctx.message.charAt(0) == (char)("{") + name: '{}' + - pipeline: + if: ctx.message.charAt(0) != (char)("{") + name: '{}' + - script: + lang: painless + source: > + ctx.event.created = ctx['@timestamp']; + ctx['@timestamp'] = ctx['timestamp']; + ctx.remove('timestamp'); + ignore_failure: true + - script: + lang: painless + if: ctx.temp?.source != null + source: > + ctx['source'] = new HashMap(); + if (ctx.temp.source.charAt(0) == (char)("[")) { + def p = ctx.temp.source.indexOf (']'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(1, p); + ctx.source.port = ctx.temp.source.substring(p+2, l); + } else { + def p = ctx.temp.source.indexOf(':'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(0, p); + ctx.source.port = ctx.temp.source.substring(p+1, l); + } + ctx.remove('temp'); + - set: + field: source.ip + value: "{{source.address}}" + if: ctx.source?.address != null + - convert: + field: source.port + type: integer + - convert: + field: coredns.duration + type: double + - convert: + field: coredns.query.size + type: long + - convert: + field: coredns.response.size + type: long + - convert: + field: coredns.dnssec_ok + type: boolean + - uppercase: + field: dns.header_flags + - split: + field: dns.header_flags + separator: "," + - append: + if: ctx.coredns?.dnssec_ok + field: dns.header_flags + value: DO + - script: + lang: painless + source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale); + params: + scale: 1000000000 + if: ctx.coredns?.duration != null + - remove: + field: + - coredns.duration + ignore_missing: true + # The following copies values from dns namespace (ECS) to the coredns + # namespace to avoid introducing breaking change. This should be removed + # for 8.0.0. Additionally coredns.dnssec_ok can be removed. + - set: + if: ctx.dns?.id != null + field: coredns.id + value: '{{dns.id}}' + - set: + if: ctx.dns?.question?.class != null + field: coredns.query.class + value: '{{dns.question.class}}' + - set: + if: ctx.dns?.question?.name != null + field: coredns.query.name + value: '{{dns.question.name}}' + - set: + if: ctx.dns?.question?.type != null + field: coredns.query.type + value: '{{dns.question.type}}' + - set: + if: ctx.dns?.response_code != null + field: coredns.response.code + value: '{{dns.response_code}}' + - script: + if: ctx.dns?.header_flags != null + lang: painless + source: > + ctx.coredns.response.flags = ctx.dns.header_flags; + # Right trim the trailing dot from domain names. + - script: + if: ctx.dns?.question?.name != null + lang: painless + source: > + def q = ctx.dns.question.name; + def end = q.length() - 1; + if (q.charAt(end) == (char) '.') { + ctx.dns.question.name = q.substring(0, end); + } +on_failure: + - set: + field: error.message + value: "{{ _ingest.on_failure_message }}" \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.json b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.json new file mode 100644 index 0000000000000..87fbb0c36ee84 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.json @@ -0,0 +1,101 @@ +{ + "description": "Pipeline for normalizing Kubernetes coredns logs", + "processors": [ + { + "pipeline": { + "if": "ctx.message.charAt(0) == (char)(\"{\")", + "name": "{{IngestPipeline 'pipeline-json' }}" + } + }, + { + "pipeline": { + "if": "ctx.message.charAt(0) != (char)(\"{\")", + "name": "{{IngestPipeline 'pipeline-plaintext' }}" + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');", + "ignore_failure" : true + } + }, + { + "script": { + "lang": "painless", + "source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');", + "if": "ctx.temp?.source != null" + } + }, + { + "set": { + "field": "source.ip", + "value": "{{source.address}}", + "if": "ctx.source?.address != null" + } + }, + { + "convert" : { + "field" : "source.port", + "type": "integer" + } + }, + { + "convert" : { + "field" : "coredns.duration", + "type": "double" + } + }, + { + "convert" : { + "field" : "coredns.query.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.response.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.dnssec_ok", + "type": "boolean" + } + }, + { + "uppercase": { + "field": "coredns.response.flags" + } + }, + { + "split": { + "field": "coredns.response.flags", + "separator": "," + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)", + "params": { + "scale": 1000000000 + }, + "if": "ctx.coredns?.duration != null" + } + }, + { + "remove": { + "field": "coredns.duration", + "ignore_missing": true + } + } + ], + "on_failure" : [{ + "set" : { + "field" : "error.message", + "value" : "{{ _ingest.on_failure_message }}" + } + }] +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.yml b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.yml new file mode 100644 index 0000000000000..f5e3491fedbcd --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_input_standard.yml @@ -0,0 +1,113 @@ +--- +description: Pipeline for normalizing Kubernetes CoreDNS logs. +processors: + - pipeline: + if: ctx.message.charAt(0) == (char)("{") + name: '{{IngestPipeline "pipeline-json" }}' + - pipeline: + if: ctx.message.charAt(0) != (char)("{") + name: '{{IngestPipeline "pipeline-plaintext" }}' + - script: + lang: painless + source: > + ctx.event.created = ctx['@timestamp']; + ctx['@timestamp'] = ctx['timestamp']; + ctx.remove('timestamp'); + ignore_failure: true + - script: + lang: painless + if: ctx.temp?.source != null + source: > + ctx['source'] = new HashMap(); + if (ctx.temp.source.charAt(0) == (char)("[")) { + def p = ctx.temp.source.indexOf (']'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(1, p); + ctx.source.port = ctx.temp.source.substring(p+2, l); + } else { + def p = ctx.temp.source.indexOf(':'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(0, p); + ctx.source.port = ctx.temp.source.substring(p+1, l); + } + ctx.remove('temp'); + - set: + field: source.ip + value: "{{source.address}}" + if: ctx.source?.address != null + - convert: + field: source.port + type: integer + - convert: + field: coredns.duration + type: double + - convert: + field: coredns.query.size + type: long + - convert: + field: coredns.response.size + type: long + - convert: + field: coredns.dnssec_ok + type: boolean + - uppercase: + field: dns.header_flags + - split: + field: dns.header_flags + separator: "," + - append: + if: ctx.coredns?.dnssec_ok + field: dns.header_flags + value: DO + - script: + lang: painless + source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale); + params: + scale: 1000000000 + if: ctx.coredns?.duration != null + - remove: + field: + - coredns.duration + ignore_missing: true + # The following copies values from dns namespace (ECS) to the coredns + # namespace to avoid introducing breaking change. This should be removed + # for 8.0.0. Additionally coredns.dnssec_ok can be removed. + - set: + if: ctx.dns?.id != null + field: coredns.id + value: '{{dns.id}}' + - set: + if: ctx.dns?.question?.class != null + field: coredns.query.class + value: '{{dns.question.class}}' + - set: + if: ctx.dns?.question?.name != null + field: coredns.query.name + value: '{{dns.question.name}}' + - set: + if: ctx.dns?.question?.type != null + field: coredns.query.type + value: '{{dns.question.type}}' + - set: + if: ctx.dns?.response_code != null + field: coredns.response.code + value: '{{dns.response_code}}' + - script: + if: ctx.dns?.header_flags != null + lang: painless + source: > + ctx.coredns.response.flags = ctx.dns.header_flags; + # Right trim the trailing dot from domain names. + - script: + if: ctx.dns?.question?.name != null + lang: painless + source: > + def q = ctx.dns.question.name; + def end = q.length() - 1; + if (q.charAt(end) == (char) '.') { + ctx.dns.question.name = q.substring(0, end); + } +on_failure: + - set: + field: error.message + value: "{{ _ingest.on_failure_message }}" \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.json b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.json new file mode 100644 index 0000000000000..91b54fdf664a9 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.json @@ -0,0 +1,101 @@ +{ + "description": "Pipeline for normalizing Kubernetes coredns logs", + "processors": [ + { + "pipeline": { + "if": "ctx.message.charAt(0) == (char)(\"{\")", + "name": "new-pipeline-json" + } + }, + { + "pipeline": { + "if": "ctx.message.charAt(0) != (char)(\"{\")", + "name": "new-pipeline-plaintext" + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');", + "ignore_failure" : true + } + }, + { + "script": { + "lang": "painless", + "source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');", + "if": "ctx.temp?.source != null" + } + }, + { + "set": { + "field": "source.ip", + "value": "{{source.address}}", + "if": "ctx.source?.address != null" + } + }, + { + "convert" : { + "field" : "source.port", + "type": "integer" + } + }, + { + "convert" : { + "field" : "coredns.duration", + "type": "double" + } + }, + { + "convert" : { + "field" : "coredns.query.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.response.size", + "type": "long" + } + }, + { + "convert" : { + "field" : "coredns.dnssec_ok", + "type": "boolean" + } + }, + { + "uppercase": { + "field": "coredns.response.flags" + } + }, + { + "split": { + "field": "coredns.response.flags", + "separator": "," + } + }, + { + "script": { + "lang": "painless", + "source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)", + "params": { + "scale": 1000000000 + }, + "if": "ctx.coredns?.duration != null" + } + }, + { + "remove": { + "field": "coredns.duration", + "ignore_missing": true + } + } + ], + "on_failure" : [{ + "set" : { + "field" : "error.message", + "value" : "{{ _ingest.on_failure_message }}" + } + }] +} \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.yml b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.yml new file mode 100644 index 0000000000000..0e5b588f03b0d --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/ingest_pipeline/tests/ingest_pipelines/real_output.yml @@ -0,0 +1,113 @@ +--- +description: Pipeline for normalizing Kubernetes CoreDNS logs. +processors: + - pipeline: + if: ctx.message.charAt(0) == (char)("{") + name: 'new-pipeline-json' + - pipeline: + if: ctx.message.charAt(0) != (char)("{") + name: 'new-pipeline-plaintext' + - script: + lang: painless + source: > + ctx.event.created = ctx['@timestamp']; + ctx['@timestamp'] = ctx['timestamp']; + ctx.remove('timestamp'); + ignore_failure: true + - script: + lang: painless + if: ctx.temp?.source != null + source: > + ctx['source'] = new HashMap(); + if (ctx.temp.source.charAt(0) == (char)("[")) { + def p = ctx.temp.source.indexOf (']'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(1, p); + ctx.source.port = ctx.temp.source.substring(p+2, l); + } else { + def p = ctx.temp.source.indexOf(':'); + def l = ctx.temp.source.length(); + ctx.source.address = ctx.temp.source.substring(0, p); + ctx.source.port = ctx.temp.source.substring(p+1, l); + } + ctx.remove('temp'); + - set: + field: source.ip + value: "{{source.address}}" + if: ctx.source?.address != null + - convert: + field: source.port + type: integer + - convert: + field: coredns.duration + type: double + - convert: + field: coredns.query.size + type: long + - convert: + field: coredns.response.size + type: long + - convert: + field: coredns.dnssec_ok + type: boolean + - uppercase: + field: dns.header_flags + - split: + field: dns.header_flags + separator: "," + - append: + if: ctx.coredns?.dnssec_ok + field: dns.header_flags + value: DO + - script: + lang: painless + source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale); + params: + scale: 1000000000 + if: ctx.coredns?.duration != null + - remove: + field: + - coredns.duration + ignore_missing: true + # The following copies values from dns namespace (ECS) to the coredns + # namespace to avoid introducing breaking change. This should be removed + # for 8.0.0. Additionally coredns.dnssec_ok can be removed. + - set: + if: ctx.dns?.id != null + field: coredns.id + value: '{{dns.id}}' + - set: + if: ctx.dns?.question?.class != null + field: coredns.query.class + value: '{{dns.question.class}}' + - set: + if: ctx.dns?.question?.name != null + field: coredns.query.name + value: '{{dns.question.name}}' + - set: + if: ctx.dns?.question?.type != null + field: coredns.query.type + value: '{{dns.question.type}}' + - set: + if: ctx.dns?.response_code != null + field: coredns.response.code + value: '{{dns.response_code}}' + - script: + if: ctx.dns?.header_flags != null + lang: painless + source: > + ctx.coredns.response.flags = ctx.dns.header_flags; + # Right trim the trailing dot from domain names. + - script: + if: ctx.dns?.question?.name != null + lang: painless + source: > + def q = ctx.dns.question.name; + def end = q.length() - 1; + if (q.charAt(end) == (char) '.') { + ctx.dns.question.name = q.substring(0, end); + } +on_failure: + - set: + field: error.message + value: "{{ _ingest.on_failure_message }}" \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap new file mode 100644 index 0000000000000..ad4d636164d71 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tests loading fields.yml: base.yml 1`] = ` +{ + "order": 1, + "index_patterns": [ + "foo-*" + ], + "settings": { + "index": { + "lifecycle": { + "name": "logs-default" + }, + "codec": "best_compression", + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "refresh_interval": "5s", + "number_of_shards": "1", + "query": { + "default_field": [ + "message" + ] + }, + "number_of_routing_shards": "30" + } + }, + "mappings": { + "_meta": { + "package": "foo" + }, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "date_detection": false, + "properties": { + "user": { + "properties": { + "auid": { + "type": "keyword" + }, + "euid": { + "type": "keyword" + } + } + }, + "long": { + "properties": { + "nested": { + "properties": { + "foo": { + "type": "keyword" + } + } + } + } + }, + "nested": { + "properties": { + "bar": { + "type": "keyword" + } + } + } + } + }, + "aliases": {} +} +`; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts new file mode 100644 index 0000000000000..005bb78e458e3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AssetReference, + Dataset, + RegistryPackage, + IngestAssetType, + ElasticsearchAssetType, +} from '../../../../types'; +import { CallESAsCurrentUser } from '../../../../types'; +import { Field, loadFieldsFromYaml } from '../../fields/field'; +import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; +import { generateMappings, generateTemplateName, getTemplate } from './template'; +import * as Registry from '../../registry'; + +export const installTemplates = async ( + registryPackage: RegistryPackage, + callCluster: CallESAsCurrentUser, + pkgkey: string +) => { + // install any pre-built index template assets, + // atm, this is only the base package's global template + installPreBuiltTemplates(pkgkey, callCluster); + + // build templates per dataset from yml files + const datasets = registryPackage.datasets; + if (datasets) { + const templates = datasets.reduce>>((acc, dataset) => { + acc.push( + installTemplateForDataset({ + pkg: registryPackage, + callCluster, + dataset, + }) + ); + return acc; + }, []); + return Promise.all(templates).then(results => results.flat()); + } + return []; +}; + +// this is temporary until we update the registry to use index templates v2 structure +const installPreBuiltTemplates = async (pkgkey: string, callCluster: CallESAsCurrentUser) => { + const templatePaths = await Registry.getArchiveInfo(pkgkey, (entry: Registry.ArchiveEntry) => + isTemplate(entry) + ); + templatePaths.forEach(async path => { + const { file } = Registry.pathParts(path); + const templateName = file.substr(0, file.lastIndexOf('.')); + const content = JSON.parse(Registry.getAsset(path).toString('utf8')); + await callCluster('indices.putTemplate', { + name: templateName, + body: content, + }); + }); +}; +const isTemplate = ({ path }: Registry.ArchiveEntry) => { + const pathParts = Registry.pathParts(path); + return pathParts.type === ElasticsearchAssetType.indexTemplate; +}; +/** + * installTemplatesForDataset installs one template for each dataset + * + * The template is currently loaded with the pkgey-package-dataset + */ + +export async function installTemplateForDataset({ + pkg, + callCluster, + dataset, +}: { + pkg: RegistryPackage; + callCluster: CallESAsCurrentUser; + dataset: Dataset; +}): Promise { + const fields = await loadFieldsFromYaml(pkg, dataset.path); + return installTemplate({ + callCluster, + fields, + dataset, + packageVersion: pkg.version, + }); +} + +export async function installTemplate({ + callCluster, + fields, + dataset, + packageVersion, +}: { + callCluster: CallESAsCurrentUser; + fields: Field[]; + dataset: Dataset; + packageVersion: string; +}): Promise { + const mappings = generateMappings(fields); + const templateName = generateTemplateName(dataset); + let pipelineName; + if (dataset.ingest_pipeline) { + pipelineName = getPipelineNameForInstallation({ + pipelineName: dataset.ingest_pipeline, + dataset, + packageVersion, + }); + } + const template = getTemplate(dataset.type, templateName, mappings, pipelineName); + // TODO: Check return values for errors + await callCluster('indices.putTemplate', { + name: templateName, + body: template, + }); + + // The id of a template is its name + return { id: templateName, type: IngestAssetType.IndexTemplate }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts new file mode 100644 index 0000000000000..aa5be59b6a5cd --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readFileSync } from 'fs'; +import { safeLoad } from 'js-yaml'; +import path from 'path'; +import { Field, processFields } from '../../fields/field'; +import { generateMappings, getTemplate } from './template'; + +// Add our own serialiser to just do JSON.stringify +expect.addSnapshotSerializer({ + print(val) { + return JSON.stringify(val, null, 2); + }, + + test(val) { + return val; + }, +}); + +test('get template', () => { + const templateName = 'logs-nginx-access-abcd'; + + const template = getTemplate('logs', templateName, { properties: {} }); + expect(template.index_patterns).toStrictEqual([`${templateName}-*`]); +}); + +test('tests loading fields.yml', () => { + // Load fields.yml file + const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); + const fieldsYML = readFileSync(ymlPath, 'utf-8'); + const fields: Field[] = safeLoad(fieldsYML); + + processFields(fields); + const mappings = generateMappings(fields); + const template = getTemplate('logs', 'foo', mappings); + + expect(template).toMatchSnapshot(path.basename(ymlPath)); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts new file mode 100644 index 0000000000000..f075771e9808a --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Field } from '../../fields/field'; +import { Dataset, IndexTemplate } from '../../../../types'; +import { getDatasetAssetBaseName } from '../index'; + +interface Properties { + [key: string]: any; +} +interface Mappings { + properties: any; +} +/** + * getTemplate retrieves the default template but overwrites the index pattern with the given value. + * + * @param indexPattern String with the index pattern + */ +export function getTemplate( + type: string, + templateName: string, + mappings: Mappings, + pipelineName?: string | undefined +): IndexTemplate { + const template = getBaseTemplate(type, templateName, mappings); + if (pipelineName) { + template.settings.index.default_pipeline = pipelineName; + } + return template; +} + +/** + * Generate mapping takes the given fields array and creates the Elasticsearch + * mapping properties out of it. + * + * @param fields + */ +export function generateMappings(fields: Field[]): Mappings { + const props: Properties = {}; + fields.forEach(field => { + // Are there more fields inside this field? Build them recursively + if (field.fields && field.fields.length > 0) { + props[field.name] = generateMappings(field.fields); + return; + } + + // If not type is defined, take keyword + const type = field.type || 'keyword'; + // Only add keyword fields for now + // TODO: add support for other field types + if (type === 'keyword') { + props[field.name] = { type }; + } + }); + return { properties: props }; +} + +/** + * Generates the template name out of the given information + */ +export function generateTemplateName(dataset: Dataset): string { + return getDatasetAssetBaseName(dataset); +} + +function getBaseTemplate(type: string, templateName: string, mappings: Mappings): IndexTemplate { + return { + // We need to decide which order we use for the templates + order: 1, + // To be completed with the correct index patterns + index_patterns: [`${templateName}-*`], + settings: { + index: { + // ILM Policy must be added here, for now point to the default global ILM policy name + lifecycle: { + name: `${type}-default`, + }, + // What should be our default for the compression? + codec: 'best_compression', + // W + mapping: { + total_fields: { + limit: '10000', + }, + }, + // This is the default from Beats? So far seems to be a good value + refresh_interval: '5s', + // Default in the stack now, still good to have it in + number_of_shards: '1', + // All the default fields which should be queried have to be added here. + // So far we add all keyword and text fields here. + query: { + default_field: ['message'], + }, + // We are setting 30 because it can be devided by several numbers. Useful when shrinking. + number_of_routing_shards: '30', + }, + }, + mappings: { + // To be filled with interesting information about this specific index + _meta: { + package: 'foo', + }, + // All the dynamic field mappings + dynamic_templates: [ + // This makes sure all mappings are keywords by default + { + strings_as_keyword: { + mapping: { + ignore_above: 1024, + type: 'keyword', + }, + match_mapping_type: 'string', + }, + }, + ], + // As we define fields ahead, we don't need any automatic field detection + // This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts + date_detection: false, + // All the properties we know from the fields.yml file + properties: mappings.properties, + }, + // To be filled with the aliases that we need + aliases: {}, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap new file mode 100644 index 0000000000000..76991bde77008 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/__snapshots__/field.test.ts.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`tests loading fields.yml: base.yml 1`] = ` +[ + { + "name": "user", + "type": "group", + "fields": [ + { + "name": "auid" + }, + { + "name": "euid" + } + ] + }, + { + "name": "long", + "type": "group", + "fields": [ + { + "name": "nested", + "type": "group", + "fields": [ + { + "name": "foo" + } + ] + } + ] + }, + { + "name": "nested", + "type": "group", + "fields": [ + { + "name": "bar" + } + ] + } +] +`; + +exports[`tests loading fields.yml: coredns.logs.yml 1`] = ` +[ + { + "name": "coredns", + "type": "group", + "description": "coredns fields after normalization\\n", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "id of the DNS transaction\\n" + }, + { + "name": "query.size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS query\\n" + }, + { + "name": "query.class", + "type": "keyword", + "description": "DNS query class\\n" + }, + { + "name": "query.name", + "type": "keyword", + "description": "DNS query name\\n" + }, + { + "name": "query.type", + "type": "keyword", + "description": "DNS query type\\n" + }, + { + "name": "response.code", + "type": "keyword", + "description": "DNS response code\\n" + }, + { + "name": "response.flags", + "type": "keyword", + "description": "DNS response flags\\n" + }, + { + "name": "response.size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS response\\n" + }, + { + "name": "dnssec_ok", + "type": "boolean", + "description": "dnssec flag\\n" + } + ] + } +] +`; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts new file mode 100644 index 0000000000000..3cdf011d9d0e3 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { readFileSync } from 'fs'; +import glob from 'glob'; +import { safeLoad } from 'js-yaml'; +import path from 'path'; +import { Field, processFields } from './field'; + +// Add our own serialiser to just do JSON.stringify +expect.addSnapshotSerializer({ + print(val) { + return JSON.stringify(val, null, 2); + }, + + test(val) { + return val; + }, +}); + +test('tests loading fields.yml', () => { + // Find all .yml files to run tests on + const files = glob.sync(path.join(__dirname, '/tests/*.yml')); + for (const file of files) { + const fieldsYML = readFileSync(file, 'utf-8'); + const fields: Field[] = safeLoad(fieldsYML); + processFields(fields); + + // Check that content file and generated file are equal + expect(fields).toMatchSnapshot(path.basename(file)); + } +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts new file mode 100644 index 0000000000000..eb515f5652f36 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { safeLoad } from 'js-yaml'; +import { RegistryPackage } from '../../../types'; +import { getAssetsData } from '../packages/assets'; + +// This should become a copy of https://github.com/elastic/beats/blob/d9a4c9c240a9820fab15002592e5bb6db318543b/libbeat/mapping/field.go#L39 +export interface Field { + name: string; + type?: string; + description?: string; + format?: string; + fields?: Fields; + enabled?: boolean; + path?: string; + index?: boolean; + required?: boolean; + multi_fields?: Fields; + doc_values?: boolean; + + // Kibana specific + analyzed?: boolean; + count?: number; + searchable?: boolean; + aggregatable?: boolean; + script?: string; + readFromDocValues?: boolean; + + // Kibana field format params + pattern?: string; + input_format?: string; + output_format?: string; + output_precision?: number; + label_template?: string; + url_template?: string; + open_link_in_current_tab?: boolean; +} + +export type Fields = Field[]; + +/** + * ProcessFields takes the given fields read from yaml and expands it. + * There are dotted fields in the field.yml like `foo.bar`. These should + * be stored as an object inside an object and is the main purpose of this + * preprocessing. + * + * Note: This function modifies the passed field param. + */ +export function processFields(fields: Fields) { + fields.forEach((field, key) => { + const fieldName = field.name; + + // If the field name contains a dot, it means we need to create sub objects + if (fieldName.includes('.')) { + // Split up the name by dots to extract first and other parts + const nameParts = fieldName.split('.'); + + // Getting first part of the name for the new field + const newNameTop = nameParts[0]; + delete nameParts[0]; + + // Put back together the parts again for the new field name + const newName = nameParts.length === 1 ? nameParts[0] : nameParts.slice(1).join('.'); + + field.name = newName; + + // Create the new field with the old field inside + const newField: Field = { + name: newNameTop, + type: 'group', + fields: [field], + }; + // Replace the old field in the array + fields[key] = newField; + if (newField.fields) { + processFields(newField.fields); + } + } + }); +} + +const isFields = (path: string) => { + return path.includes('/fields/'); +}; + +/** + * loadFieldsFromYaml + * + * Gets all field files, optionally filtered by dataset, extracts .yml files, merges them together + */ + +export const loadFieldsFromYaml = async ( + pkg: RegistryPackage, + datasetName?: string +): Promise => { + // Fetch all field definition files + const fieldDefinitionFiles = await getAssetsData(pkg, isFields, datasetName); + return fieldDefinitionFiles.reduce((acc, file) => { + // Make sure it is defined as it is optional. Should never happen. + if (file.buffer) { + const tmpFields = safeLoad(file.buffer.toString()); + // safeLoad() returns undefined for empty files, we don't want that + if (tmpFields) { + acc = acc.concat(tmpFields); + } + } + return acc; + }, []); +}; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml new file mode 100644 index 0000000000000..86b61245aa3b8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/base.yml @@ -0,0 +1,7 @@ +- name: user + type: group + fields: + - name: auid + - name: euid +- name: long.nested.foo +- name: nested.bar diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/coredns.logs.yml b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/coredns.logs.yml new file mode 100644 index 0000000000000..439849742a851 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/tests/coredns.logs.yml @@ -0,0 +1,51 @@ +- name: coredns + type: group + description: > + coredns fields after normalization + fields: + - name: id + type: keyword + description: > + id of the DNS transaction + + - name: query.size + type: integer + format: bytes + description: > + size of the DNS query + + - name: query.class + type: keyword + description: > + DNS query class + + - name: query.name + type: keyword + description: > + DNS query name + + - name: query.type + type: keyword + description: > + DNS query type + + - name: response.code + type: keyword + description: > + DNS response code + + - name: response.flags + type: keyword + description: > + DNS response flags + + - name: response.size + type: integer + format: bytes + description: > + size of the DNS response + + - name: dnssec_ok + type: boolean + description: > + dnssec flag \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap new file mode 100644 index 0000000000000..79f778f9bba8f --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/__snapshots__/install.test.ts.snap @@ -0,0 +1,1326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`creating index patterns from yaml fields createFieldFormatMap creates correct map based on inputs all variations and all the params get passed through: createFieldFormatMap 1`] = ` +{ + "fieldPattern": { + "params": { + "pattern": "patternVal" + } + }, + "fieldFormat": { + "id": "formatVal" + }, + "fieldFormatWithParam": { + "id": "formatVal", + "params": { + "outputPrecision": 2 + } + }, + "fieldFormatAndPattern": { + "id": "formatVal", + "params": { + "pattern": "patternVal" + } + }, + "fieldFormatAndAllParams": { + "id": "formatVal", + "params": { + "pattern": "pattenVal", + "inputFormat": "inputFormatVal", + "outputFormat": "outputFormalVal", + "outputPrecision": 3, + "labelTemplate": "labelTemplateVal", + "urlTemplate": "urlTemplateVal" + } + } +} +`; + +exports[`creating index patterns from yaml fields createIndexPattern function creates Kibana index pattern: createIndexPattern 1`] = ` +{ + "title": "logs-*", + "timeFieldName": "@timestamp", + "fields": "[{\\"name\\":\\"coredns.id\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.allParams\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"number\\"},{\\"name\\":\\"coredns.query.length\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"number\\"},{\\"name\\":\\"coredns.query.size\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"number\\"},{\\"name\\":\\"coredns.query.class\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.query.name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.query.type\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.response.code\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.response.flags\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"coredns.response.size\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"number\\"},{\\"name\\":\\"coredns.dnssec_ok\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"boolean\\"},{\\"name\\":\\"@timestamp\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"date\\"},{\\"name\\":\\"labels\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"message\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":false,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"tags\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"agent.ephemeral_id\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"agent.id\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"agent.name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"agent.type\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"agent.version\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"as.number\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"number\\"},{\\"name\\":\\"as.organization.name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"nginx.access.remote_ip_list\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.body_sent.bytes\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.method\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.url\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.http_version\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.response_code\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.referrer\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.agent\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_agent.device\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_agent.name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_agent.os\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_agent.os_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.user_agent.original\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.geoip.continent_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":false,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"nginx.access.geoip.country_iso_code\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.geoip.location\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.geoip.region_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.geoip.city_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"nginx.access.geoip.region_iso_code\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true},{\\"name\\":\\"source.geo.continent_name\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":false,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"country\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"country.keyword\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":true,\\"doc_values\\":true,\\"type\\":\\"string\\"},{\\"name\\":\\"country.text\\",\\"count\\":0,\\"scripted\\":false,\\"indexed\\":true,\\"analyzed\\":false,\\"searchable\\":true,\\"aggregatable\\":false,\\"doc_values\\":true,\\"type\\":\\"string\\"}]", + "fieldFormatMap": "{\\"coredns.allParams\\":{\\"id\\":\\"bytes\\",\\"params\\":{\\"pattern\\":\\"patternValQueryWeight\\",\\"inputFormat\\":\\"inputFormatVal,\\",\\"outputFormat\\":\\"outputFormalVal,\\",\\"outputPrecision\\":\\"3,\\",\\"labelTemplate\\":\\"labelTemplateVal,\\",\\"urlTemplate\\":\\"urlTemplateVal,\\"}},\\"coredns.query.length\\":{\\"params\\":{\\"pattern\\":\\"patternValQueryLength\\"}},\\"coredns.query.size\\":{\\"id\\":\\"bytes\\",\\"params\\":{\\"pattern\\":\\"patternValQuerySize\\"}},\\"coredns.response.size\\":{\\"id\\":\\"bytes\\"}}" +} +`; + +exports[`creating index patterns from yaml fields createIndexPatternFields function creates Kibana index pattern fields and fieldFormatMap: createIndexPatternFields 1`] = ` +{ + "indexPatternFields": [ + { + "name": "coredns.id", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.allParams", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "number" + }, + { + "name": "coredns.query.length", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "number" + }, + { + "name": "coredns.query.size", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "number" + }, + { + "name": "coredns.query.class", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.query.name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.query.type", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.response.code", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.response.flags", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "coredns.response.size", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "number" + }, + { + "name": "coredns.dnssec_ok", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "boolean" + }, + { + "name": "@timestamp", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "date" + }, + { + "name": "labels", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "message", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": false, + "doc_values": true, + "type": "string" + }, + { + "name": "tags", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "agent.ephemeral_id", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "agent.id", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "agent.name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "agent.type", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "agent.version", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "as.number", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "number" + }, + { + "name": "as.organization.name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "nginx.access.remote_ip_list", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.body_sent.bytes", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.method", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.url", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.http_version", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.response_code", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.referrer", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.agent", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_agent.device", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_agent.name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_agent.os", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_agent.os_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.user_agent.original", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.geoip.continent_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": false, + "doc_values": true, + "type": "string" + }, + { + "name": "nginx.access.geoip.country_iso_code", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.geoip.location", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.geoip.region_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.geoip.city_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "nginx.access.geoip.region_iso_code", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true + }, + { + "name": "source.geo.continent_name", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": false, + "doc_values": true, + "type": "string" + }, + { + "name": "country", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "country.keyword", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": true, + "doc_values": true, + "type": "string" + }, + { + "name": "country.text", + "count": 0, + "scripted": false, + "indexed": true, + "analyzed": false, + "searchable": true, + "aggregatable": false, + "doc_values": true, + "type": "string" + } + ], + "fieldFormatMap": { + "coredns.allParams": { + "id": "bytes", + "params": { + "pattern": "patternValQueryWeight", + "inputFormat": "inputFormatVal,", + "outputFormat": "outputFormalVal,", + "outputPrecision": "3,", + "labelTemplate": "labelTemplateVal,", + "urlTemplate": "urlTemplateVal," + } + }, + "coredns.query.length": { + "params": { + "pattern": "patternValQueryLength" + } + }, + "coredns.query.size": { + "id": "bytes", + "params": { + "pattern": "patternValQuerySize" + } + }, + "coredns.response.size": { + "id": "bytes" + } + } +} +`; + +exports[`creating index patterns from yaml fields dedupFields function remove duplicated fields when parsing multiple files: dedupeFields 1`] = ` +[ + { + "name": "coredns", + "type": "group", + "description": "coredns fields after normalization\\n", + "fields": [ + { + "name": "id", + "type": "keyword", + "description": "id of the DNS transaction\\n" + }, + { + "name": "allParams", + "type": "integer", + "format": "bytes", + "pattern": "patternValQueryWeight", + "input_format": "inputFormatVal,", + "output_format": "outputFormalVal,", + "output_precision": "3,", + "label_template": "labelTemplateVal,", + "url_template": "urlTemplateVal,", + "openLinkInCurrentTab": "true,", + "description": "weight of the DNS query\\n" + }, + { + "name": "query.length", + "type": "integer", + "pattern": "patternValQueryLength", + "description": "length of the DNS query\\n" + }, + { + "name": "query.size", + "type": "integer", + "format": "bytes", + "pattern": "patternValQuerySize", + "description": "size of the DNS query\\n" + }, + { + "name": "query.class", + "type": "keyword", + "description": "DNS query class\\n" + }, + { + "name": "query.name", + "type": "keyword", + "description": "DNS query name\\n" + }, + { + "name": "query.type", + "type": "keyword", + "description": "DNS query type\\n" + }, + { + "name": "response.code", + "type": "keyword", + "description": "DNS response code\\n" + }, + { + "name": "response.flags", + "type": "keyword", + "description": "DNS response flags\\n" + }, + { + "name": "response.size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS response\\n" + }, + { + "name": "dnssec_ok", + "type": "boolean", + "description": "dnssec flag\\n" + } + ] + }, + { + "name": "@timestamp", + "level": "core", + "required": true, + "type": "date", + "description": "Date/time when the event originated. This is the date/time extracted from the event, typically representing when the event was generated by the source. If the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline. Required field for all events.", + "example": "2016-05-23T08:05:34.853Z" + }, + { + "name": "labels", + "level": "core", + "type": "object", + "object_type": "keyword", + "description": "Custom key/value pairs. Can be used to add meta information to events. Should not contain nested objects. All values are stored as keyword. Example: \`docker\` and \`k8s\` labels.", + "example": { + "application": "foo-bar", + "env": "production" + } + }, + { + "name": "message", + "level": "core", + "type": "text", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World" + }, + { + "name": "tags", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "List of keywords used to tag each event.", + "example": "[\\"production\\", \\"env2\\"]" + }, + { + "name": "agent", + "title": "Agent", + "group": 2, + "description": "The agent fields contain the data about the software entity, if any, that collects, detects, or observes events on a host, or takes measurements on a host. Examples include Beats. Agents may also run on observers. ECS agent.* fields shall be populated with details of the agent running on the host or observer where the event happened or the measurement was taken.", + "footnote": "Examples: In the case of Beats for logs, the agent.name is filebeat. For APM, it is the agent running in the app/service. The agent information does not change if data is sent through queuing systems like Kafka, Redis, or processing systems such as Logstash or APM Server.", + "type": "group", + "fields": [ + { + "name": "ephemeral_id", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but \`agent.id\` does not.", + "example": "8a4f500f" + }, + { + "name": "id", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.", + "example": "8a4f500d" + }, + { + "name": "name", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Custom name of the agent. This is a name that can be given to an agent. This can be helpful if for example two Filebeat instances are running on the same host but a human readable separation is needed on which Filebeat instance data is coming from. If no name is given, the name is often left empty.", + "example": "foo" + }, + { + "name": "type", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Type of the agent. The agent type stays always the same and should be given by the agent used. In case of Filebeat the agent would always be Filebeat also if two Filebeat instances are run on the same machine.", + "example": "filebeat" + }, + { + "name": "version", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Version of the agent.", + "example": "6.0.0-rc2" + } + ] + }, + { + "name": "as", + "title": "Autonomous System", + "group": 2, + "description": "An autonomous system (AS) is a collection of connected Internet Protocol (IP) routing prefixes under the control of one or more network operators on behalf of a single administrative entity or domain that presents a common, clearly defined routing policy to the internet.", + "type": "group", + "fields": [ + { + "name": "number", + "level": "extended", + "type": "long", + "description": "Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet.", + "example": 15169 + }, + { + "name": "organization.name", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Organization name.", + "example": "Google LLC" + } + ] + }, + { + "name": "nginx.access", + "type": "group", + "description": "Contains fields for the Nginx access logs.\\n", + "fields": [ + { + "name": "group_disabled", + "type": "group", + "enabled": false, + "fields": [ + { + "name": "message", + "type": "text" + } + ] + }, + { + "name": "remote_ip_list", + "type": "array", + "description": "An array of remote IP addresses. It is a list because it is common to include, besides the client IP address, IP addresses from headers like \`X-Forwarded-For\`. Real source IP is restored to \`source.ip\`.\\n" + }, + { + "name": "body_sent.bytes", + "type": "alias", + "path": "http.response.body.bytes", + "migration": true + }, + { + "name": "user_name", + "type": "alias", + "path": "user.name", + "migration": true + }, + { + "name": "method", + "type": "alias", + "path": "http.request.method", + "migration": true + }, + { + "name": "url", + "type": "alias", + "path": "url.original", + "migration": true + }, + { + "name": "http_version", + "type": "alias", + "path": "http.version", + "migration": true + }, + { + "name": "response_code", + "type": "alias", + "path": "http.response.status_code", + "migration": true + }, + { + "name": "referrer", + "type": "alias", + "path": "http.request.referrer", + "migration": true + }, + { + "name": "agent", + "type": "alias", + "path": "user_agent.original", + "migration": true + }, + { + "name": "user_agent", + "type": "group", + "fields": [ + { + "name": "device", + "type": "alias", + "path": "user_agent.device.name", + "migration": true + }, + { + "name": "name", + "type": "alias", + "path": "user_agent.name", + "migration": true + }, + { + "name": "os", + "type": "alias", + "path": "user_agent.os.full_name", + "migration": true + }, + { + "name": "os_name", + "type": "alias", + "path": "user_agent.os.name", + "migration": true + }, + { + "name": "original", + "type": "alias", + "path": "user_agent.original", + "migration": true + } + ] + }, + { + "name": "geoip", + "type": "group", + "fields": [ + { + "name": "continent_name", + "type": "alias", + "path": "source.geo.continent_name", + "migration": true + }, + { + "name": "country_iso_code", + "type": "alias", + "path": "source.geo.country_iso_code", + "migration": true + }, + { + "name": "location", + "type": "alias", + "path": "source.geo.location", + "migration": true + }, + { + "name": "region_name", + "type": "alias", + "path": "source.geo.region_name", + "migration": true + }, + { + "name": "city_name", + "type": "alias", + "path": "source.geo.city_name", + "migration": true + }, + { + "name": "region_iso_code", + "type": "alias", + "path": "source.geo.region_iso_code", + "migration": true + } + ] + } + ] + }, + { + "name": "source", + "type": "group", + "fields": [ + { + "name": "geo", + "type": "group", + "fields": [ + { + "name": "continent_name", + "type": "text" + } + ] + } + ] + }, + { + "name": "country", + "type": "", + "multi_fields": [ + { + "name": "keyword", + "type": "keyword" + }, + { + "name": "text", + "type": "text" + } + ] + } +] +`; + +exports[`creating index patterns from yaml fields flattenFields function flattens recursively and handles copying alias fields: flattenFields 1`] = ` +[ + { + "name": "coredns.id", + "type": "keyword", + "description": "id of the DNS transaction\\n" + }, + { + "name": "coredns.allParams", + "type": "integer", + "format": "bytes", + "pattern": "patternValQueryWeight", + "input_format": "inputFormatVal,", + "output_format": "outputFormalVal,", + "output_precision": "3,", + "label_template": "labelTemplateVal,", + "url_template": "urlTemplateVal,", + "openLinkInCurrentTab": "true,", + "description": "weight of the DNS query\\n" + }, + { + "name": "coredns.query.length", + "type": "integer", + "pattern": "patternValQueryLength", + "description": "length of the DNS query\\n" + }, + { + "name": "coredns.query.size", + "type": "integer", + "format": "bytes", + "pattern": "patternValQuerySize", + "description": "size of the DNS query\\n" + }, + { + "name": "coredns.query.class", + "type": "keyword", + "description": "DNS query class\\n" + }, + { + "name": "coredns.query.name", + "type": "keyword", + "description": "DNS query name\\n" + }, + { + "name": "coredns.query.type", + "type": "keyword", + "description": "DNS query type\\n" + }, + { + "name": "coredns.response.code", + "type": "keyword", + "description": "DNS response code\\n" + }, + { + "name": "coredns.response.flags", + "type": "keyword", + "description": "DNS response flags\\n" + }, + { + "name": "coredns.response.size", + "type": "integer", + "format": "bytes", + "description": "size of the DNS response\\n" + }, + { + "name": "coredns.dnssec_ok", + "type": "boolean", + "description": "dnssec flag\\n" + }, + { + "name": "@timestamp", + "level": "core", + "required": true, + "type": "date", + "description": "Date/time when the event originated. This is the date/time extracted from the event, typically representing when the event was generated by the source. If the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline. Required field for all events.", + "example": "2016-05-23T08:05:34.853Z" + }, + { + "name": "labels", + "level": "core", + "type": "object", + "object_type": "keyword", + "description": "Custom key/value pairs. Can be used to add meta information to events. Should not contain nested objects. All values are stored as keyword. Example: \`docker\` and \`k8s\` labels.", + "example": { + "application": "foo-bar", + "env": "production" + } + }, + { + "name": "message", + "level": "core", + "type": "text", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World" + }, + { + "name": "tags", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "List of keywords used to tag each event.", + "example": "[\\"production\\", \\"env2\\"]" + }, + { + "name": "agent.ephemeral_id", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but \`agent.id\` does not.", + "example": "8a4f500f" + }, + { + "name": "agent.id", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.", + "example": "8a4f500d" + }, + { + "name": "agent.name", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Custom name of the agent. This is a name that can be given to an agent. This can be helpful if for example two Filebeat instances are running on the same host but a human readable separation is needed on which Filebeat instance data is coming from. If no name is given, the name is often left empty.", + "example": "foo" + }, + { + "name": "agent.type", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Type of the agent. The agent type stays always the same and should be given by the agent used. In case of Filebeat the agent would always be Filebeat also if two Filebeat instances are run on the same machine.", + "example": "filebeat" + }, + { + "name": "agent.version", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Version of the agent.", + "example": "6.0.0-rc2" + }, + { + "name": "as.number", + "level": "extended", + "type": "long", + "description": "Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet.", + "example": 15169 + }, + { + "name": "as.organization.name", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Organization name.", + "example": "Google LLC" + }, + { + "name": "@timestamp", + "level": "core", + "required": true, + "type": "date", + "description": "Date/time when the event originated. This is the date/time extracted from the event, typically representing when the event was generated by the source. If the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline. Required field for all events.", + "example": "2016-05-23T08:05:34.853Z" + }, + { + "name": "labels", + "level": "core", + "type": "object", + "object_type": "keyword", + "description": "Custom key/value pairs. Can be used to add meta information to events. Should not contain nested objects. All values are stored as keyword. Example: \`docker\` and \`k8s\` labels.", + "example": { + "application": "foo-bar", + "env": "production" + } + }, + { + "name": "message", + "level": "core", + "type": "text", + "description": "For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message.", + "example": "Hello World" + }, + { + "name": "tags", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "List of keywords used to tag each event.", + "example": "[\\"production\\", \\"env2\\"]" + }, + { + "name": "agent.ephemeral_id", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but \`agent.id\` does not.", + "example": "8a4f500f" + }, + { + "name": "agent.id", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.", + "example": "8a4f500d" + }, + { + "name": "agent.name", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Custom name of the agent. This is a name that can be given to an agent. This can be helpful if for example two Filebeat instances are running on the same host but a human readable separation is needed on which Filebeat instance data is coming from. If no name is given, the name is often left empty.", + "example": "foo" + }, + { + "name": "agent.type", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Type of the agent. The agent type stays always the same and should be given by the agent used. In case of Filebeat the agent would always be Filebeat also if two Filebeat instances are run on the same machine.", + "example": "filebeat" + }, + { + "name": "agent.version", + "level": "core", + "type": "keyword", + "ignore_above": 1024, + "description": "Version of the agent.", + "example": "6.0.0-rc2" + }, + { + "name": "as.number", + "level": "extended", + "type": "long", + "description": "Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet.", + "example": 15169 + }, + { + "name": "as.organization.name", + "level": "extended", + "type": "keyword", + "ignore_above": 1024, + "description": "Organization name.", + "example": "Google LLC" + }, + { + "name": "nginx.access.remote_ip_list", + "type": "array", + "description": "An array of remote IP addresses. It is a list because it is common to include, besides the client IP address, IP addresses from headers like \`X-Forwarded-For\`. Real source IP is restored to \`source.ip\`.\\n" + }, + { + "name": "nginx.access.body_sent.bytes", + "type": "alias", + "path": "http.response.body.bytes", + "migration": true + }, + { + "name": "nginx.access.user_name", + "type": "alias", + "path": "user.name", + "migration": true + }, + { + "name": "nginx.access.method", + "type": "alias", + "path": "http.request.method", + "migration": true + }, + { + "name": "nginx.access.url", + "type": "alias", + "path": "url.original", + "migration": true + }, + { + "name": "nginx.access.http_version", + "type": "alias", + "path": "http.version", + "migration": true + }, + { + "name": "nginx.access.response_code", + "type": "alias", + "path": "http.response.status_code", + "migration": true + }, + { + "name": "nginx.access.referrer", + "type": "alias", + "path": "http.request.referrer", + "migration": true + }, + { + "name": "nginx.access.agent", + "type": "alias", + "path": "user_agent.original", + "migration": true + }, + { + "name": "nginx.access.user_agent.device", + "type": "alias", + "path": "user_agent.device.name", + "migration": true + }, + { + "name": "nginx.access.user_agent.name", + "type": "alias", + "path": "user_agent.name", + "migration": true + }, + { + "name": "nginx.access.user_agent.os", + "type": "alias", + "path": "user_agent.os.full_name", + "migration": true + }, + { + "name": "nginx.access.user_agent.os_name", + "type": "alias", + "path": "user_agent.os.name", + "migration": true + }, + { + "name": "nginx.access.user_agent.original", + "type": "alias", + "path": "user_agent.original", + "migration": true + }, + { + "name": "nginx.access.geoip.continent_name", + "type": "text", + "path": "source.geo.continent_name" + }, + { + "name": "nginx.access.geoip.country_iso_code", + "type": "alias", + "path": "source.geo.country_iso_code", + "migration": true + }, + { + "name": "nginx.access.geoip.location", + "type": "alias", + "path": "source.geo.location", + "migration": true + }, + { + "name": "nginx.access.geoip.region_name", + "type": "alias", + "path": "source.geo.region_name", + "migration": true + }, + { + "name": "nginx.access.geoip.city_name", + "type": "alias", + "path": "source.geo.city_name", + "migration": true + }, + { + "name": "nginx.access.geoip.region_iso_code", + "type": "alias", + "path": "source.geo.region_iso_code", + "migration": true + }, + { + "name": "source.geo.continent_name", + "type": "text" + }, + { + "name": "country", + "type": "", + "multi_fields": [ + { + "name": "keyword", + "type": "keyword" + }, + { + "name": "text", + "type": "text" + } + ] + }, + { + "name": "country.keyword", + "type": "keyword" + }, + { + "name": "country.text", + "type": "text" + } +] +`; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts new file mode 100644 index 0000000000000..5e883772957d2 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.test.ts @@ -0,0 +1,311 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { readFileSync } from 'fs'; +import glob from 'glob'; +import { safeLoad } from 'js-yaml'; +import { + flattenFields, + dedupeFields, + transformField, + findFieldByPath, + IndexPatternField, + createFieldFormatMap, + createIndexPatternFields, + createIndexPattern, +} from './install'; +import { Fields, Field } from '../../fields/field'; + +// Add our own serialiser to just do JSON.stringify +expect.addSnapshotSerializer({ + print(val) { + return JSON.stringify(val, null, 2); + }, + + test(val) { + return val; + }, +}); +const files = glob.sync(path.join(__dirname, '/tests/*.yml')); +let fields: Fields = []; +for (const file of files) { + const fieldsYML = readFileSync(file, 'utf-8'); + fields = fields.concat(safeLoad(fieldsYML)); +} + +describe('creating index patterns from yaml fields', () => { + interface Test { + fields: Field[]; + expect: string | number | boolean | undefined; + } + + const name = 'testField'; + + test('createIndexPatternFields function creates Kibana index pattern fields and fieldFormatMap', () => { + const indexPatternFields = createIndexPatternFields(fields); + expect(indexPatternFields).toMatchSnapshot('createIndexPatternFields'); + }); + + test('createIndexPattern function creates Kibana index pattern', () => { + const indexPattern = createIndexPattern('logs', fields); + expect(indexPattern).toMatchSnapshot('createIndexPattern'); + }); + + test('flattenFields function flattens recursively and handles copying alias fields', () => { + const flattened = flattenFields(fields); + expect(flattened).toMatchSnapshot('flattenFields'); + }); + + test('dedupFields function remove duplicated fields when parsing multiple files', () => { + const deduped = dedupeFields(fields); + expect(deduped).toMatchSnapshot('dedupeFields'); + }); + + describe('getFieldByPath searches recursively for field in fields given dot separated path', () => { + const searchFields: Fields = [ + { + name: '1', + fields: [ + { + name: '1-1', + }, + { + name: '1-2', + }, + ], + }, + { + name: '2', + fields: [ + { + name: '2-1', + }, + { + name: '2-2', + fields: [ + { + name: '2-2-1', + }, + { + name: '2-2-2', + }, + ], + }, + ], + }, + ]; + test('returns undefined when the field does not exist', () => { + expect(findFieldByPath(searchFields, '0')).toBe(undefined); + }); + test('returns undefined if the field is not a leaf node', () => { + expect(findFieldByPath(searchFields, '1')?.name).toBe(undefined); + }); + test('returns undefined searching for a nested field that does not exist', () => { + expect(findFieldByPath(searchFields, '1.1-3')?.name).toBe(undefined); + }); + test('returns nested field that is a leaf node', () => { + expect(findFieldByPath(searchFields, '2.2-2.2-2-1')?.name).toBe('2-2-1'); + }); + }); + + test('transformField maps field types to kibana index pattern data types', () => { + const tests: Test[] = [ + { fields: [{ name: 'testField' }], expect: 'string' }, + { fields: [{ name: 'testField', type: 'half_float' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'scaled_float' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'float' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'integer' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'long' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'short' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'byte' }], expect: 'number' }, + { fields: [{ name: 'testField', type: 'keyword' }], expect: 'string' }, + { fields: [{ name: 'testField', type: 'invalidType' }], expect: undefined }, + { fields: [{ name: 'testField', type: 'text' }], expect: 'string' }, + { fields: [{ name: 'testField', type: 'date' }], expect: 'date' }, + { fields: [{ name: 'testField', type: 'geo_point' }], expect: 'geo_point' }, + ]; + + tests.forEach(test => { + const res = test.fields.map(transformField); + expect(res[0].type).toBe(test.expect); + }); + }); + + test('transformField changes values based on other values', () => { + interface TestWithAttr extends Test { + attr: keyof IndexPatternField; + } + + const tests: TestWithAttr[] = [ + // count + { fields: [{ name }], expect: 0, attr: 'count' }, + { fields: [{ name, count: 4 }], expect: 4, attr: 'count' }, + + // searchable + { fields: [{ name }], expect: true, attr: 'searchable' }, + { fields: [{ name, searchable: true }], expect: true, attr: 'searchable' }, + { fields: [{ name, searchable: false }], expect: false, attr: 'searchable' }, + { fields: [{ name, type: 'binary' }], expect: false, attr: 'searchable' }, + { fields: [{ name, searchable: true, type: 'binary' }], expect: false, attr: 'searchable' }, + { + fields: [{ name, searchable: true, type: 'object', enabled: false }], + expect: false, + attr: 'searchable', + }, + + // aggregatable + { fields: [{ name }], expect: true, attr: 'aggregatable' }, + { fields: [{ name, aggregatable: true }], expect: true, attr: 'aggregatable' }, + { fields: [{ name, aggregatable: false }], expect: false, attr: 'aggregatable' }, + { fields: [{ name, type: 'binary' }], expect: false, attr: 'aggregatable' }, + { + fields: [{ name, aggregatable: true, type: 'binary' }], + expect: false, + attr: 'aggregatable', + }, + { fields: [{ name, type: 'keyword' }], expect: true, attr: 'aggregatable' }, + { fields: [{ name, type: 'text', aggregatable: true }], expect: false, attr: 'aggregatable' }, + { fields: [{ name, type: 'text' }], expect: false, attr: 'aggregatable' }, + { + fields: [{ name, aggregatable: true, type: 'object', enabled: false }], + expect: false, + attr: 'aggregatable', + }, + + // analyzed + { fields: [{ name }], expect: false, attr: 'analyzed' }, + { fields: [{ name, analyzed: true }], expect: true, attr: 'analyzed' }, + { fields: [{ name, analyzed: false }], expect: false, attr: 'analyzed' }, + { fields: [{ name, type: 'binary' }], expect: false, attr: 'analyzed' }, + { fields: [{ name, analyzed: true, type: 'binary' }], expect: false, attr: 'analyzed' }, + { + fields: [{ name, analyzed: true, type: 'object', enabled: false }], + expect: false, + attr: 'analyzed', + }, + + // doc_values always set to true except for meta fields + { fields: [{ name }], expect: true, attr: 'doc_values' }, + { fields: [{ name, doc_values: true }], expect: true, attr: 'doc_values' }, + { fields: [{ name, doc_values: false }], expect: false, attr: 'doc_values' }, + { fields: [{ name, script: 'doc[]' }], expect: false, attr: 'doc_values' }, + { fields: [{ name, doc_values: true, script: 'doc[]' }], expect: false, attr: 'doc_values' }, + { fields: [{ name, type: 'binary' }], expect: false, attr: 'doc_values' }, + { fields: [{ name, doc_values: true, type: 'binary' }], expect: true, attr: 'doc_values' }, + { + fields: [{ name, doc_values: true, type: 'object', enabled: false }], + expect: false, + attr: 'doc_values', + }, + + // enabled - only applies to objects (and only if set) + { fields: [{ name, type: 'binary', enabled: false }], expect: undefined, attr: 'enabled' }, + { fields: [{ name, type: 'binary', enabled: true }], expect: undefined, attr: 'enabled' }, + { fields: [{ name, type: 'object', enabled: true }], expect: true, attr: 'enabled' }, + { fields: [{ name, type: 'object', enabled: false }], expect: false, attr: 'enabled' }, + { + fields: [{ name, type: 'object', enabled: false }], + expect: false, + attr: 'doc_values', + }, + + // indexed + { fields: [{ name, type: 'binary' }], expect: false, attr: 'indexed' }, + { + fields: [{ name, index: true, type: 'binary' }], + expect: false, + attr: 'indexed', + }, + { + fields: [{ name, index: true, type: 'object', enabled: false }], + expect: false, + attr: 'indexed', + }, + + // script, scripted + { fields: [{ name }], expect: false, attr: 'scripted' }, + { fields: [{ name }], expect: undefined, attr: 'script' }, + { fields: [{ name, script: 'doc[]' }], expect: true, attr: 'scripted' }, + { fields: [{ name, script: 'doc[]' }], expect: 'doc[]', attr: 'script' }, + + // lang + { fields: [{ name }], expect: undefined, attr: 'lang' }, + { fields: [{ name, script: 'doc[]' }], expect: 'painless', attr: 'lang' }, + ]; + tests.forEach(test => { + const res = test.fields.map(transformField); + expect(res[0][test.attr]).toBe(test.expect); + }); + }); + + describe('createFieldFormatMap creates correct map based on inputs', () => { + test('field with no format or pattern have empty fieldFormatMap', () => { + const fieldsToFormat = [{ name: 'fieldName', input_format: 'inputFormatVal' }]; + const fieldFormatMap = createFieldFormatMap(fieldsToFormat); + expect(fieldFormatMap).toEqual({}); + }); + test('field with pattern and no format creates fieldFormatMap with no id', () => { + const fieldsToFormat = [ + { name: 'fieldName', pattern: 'patternVal', input_format: 'inputFormatVal' }, + ]; + const fieldFormatMap = createFieldFormatMap(fieldsToFormat); + const expectedFieldFormatMap = { + fieldName: { + params: { + pattern: 'patternVal', + inputFormat: 'inputFormatVal', + }, + }, + }; + expect(fieldFormatMap).toEqual(expectedFieldFormatMap); + }); + + test('field with format and params creates fieldFormatMap with id', () => { + const fieldsToFormat = [ + { + name: 'fieldName', + format: 'formatVal', + pattern: 'patternVal', + input_format: 'inputFormatVal', + }, + ]; + const fieldFormatMap = createFieldFormatMap(fieldsToFormat); + const expectedFieldFormatMap = { + fieldName: { + id: 'formatVal', + params: { + pattern: 'patternVal', + inputFormat: 'inputFormatVal', + }, + }, + }; + expect(fieldFormatMap).toEqual(expectedFieldFormatMap); + }); + + test('all variations and all the params get passed through', () => { + const fieldsToFormat = [ + { name: 'fieldPattern', pattern: 'patternVal' }, + { name: 'fieldFormat', format: 'formatVal' }, + { name: 'fieldFormatWithParam', format: 'formatVal', output_precision: 2 }, + { name: 'fieldFormatAndPattern', format: 'formatVal', pattern: 'patternVal' }, + { + name: 'fieldFormatAndAllParams', + format: 'formatVal', + pattern: 'pattenVal', + input_format: 'inputFormatVal', + output_format: 'outputFormalVal', + output_precision: 3, + label_template: 'labelTemplateVal', + url_template: 'urlTemplateVal', + openLinkInCurrentTab: true, + }, + ]; + const fieldFormatMap = createFieldFormatMap(fieldsToFormat); + expect(fieldFormatMap).toMatchSnapshot('createFieldFormatMap'); + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts new file mode 100644 index 0000000000000..264000f9892ba --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/install.ts @@ -0,0 +1,338 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { INDEX_PATTERN_SAVED_OBJECT_TYPE } from '../../../../constants'; +import * as Registry from '../../registry'; +import { loadFieldsFromYaml, Fields, Field } from '../../fields/field'; +import { getPackageKeysByStatus } from '../../packages/get'; +import { InstallationStatus, RegistryPackage } from '../../../../types'; + +interface FieldFormatMap { + [key: string]: FieldFormatMapItem; +} +interface FieldFormatMapItem { + id?: string; + params?: FieldFormatParams; +} +interface FieldFormatParams { + pattern?: string; + inputFormat?: string; + outputFormat?: string; + outputPrecision?: number; + labelTemplate?: string; + urlTemplate?: string; + openLinkInCurrentTab?: boolean; +} +/* this should match https://github.com/elastic/beats/blob/d9a4c9c240a9820fab15002592e5bb6db318543b/libbeat/kibana/fields_transformer.go */ +interface TypeMap { + [key: string]: string; +} +const typeMap: TypeMap = { + binary: 'binary', + half_float: 'number', + scaled_float: 'number', + float: 'number', + integer: 'number', + long: 'number', + short: 'number', + byte: 'number', + text: 'string', + keyword: 'string', + '': 'string', + geo_point: 'geo_point', + date: 'date', + ip: 'ip', + boolean: 'boolean', +}; + +export interface IndexPatternField { + name: string; + type?: string; + count: number; + scripted: boolean; + indexed: boolean; + analyzed: boolean; + searchable: boolean; + aggregatable: boolean; + doc_values: boolean; + enabled?: boolean; + script?: string; + lang?: string; +} +export enum IndexPatternType { + logs = 'logs', + metrics = 'metrics', + events = 'events', +} + +export async function installIndexPatterns( + savedObjectsClient: SavedObjectsClientContract, + pkgkey?: string +) { + // get all user installed packages + const installedPackages = await getPackageKeysByStatus( + savedObjectsClient, + InstallationStatus.installed + ); + // add this package + if (pkgkey) installedPackages.push(pkgkey); + + // get each package's registry info + const installedPackagesFetchInfoPromise = installedPackages.map(pkg => Registry.fetchInfo(pkg)); + const installedPackagesInfo = await Promise.all(installedPackagesFetchInfoPromise); + + // for each index pattern type, create an index pattern + const indexPatternTypes = [ + IndexPatternType.logs, + IndexPatternType.metrics, + IndexPatternType.events, + ]; + indexPatternTypes.forEach(async indexPatternType => { + // if this is an update because a package is being unisntalled (no pkgkey argument passed) and no other packages are installed, remove the index pattern + if (!pkgkey && installedPackages.length === 0) { + try { + await savedObjectsClient.delete( + INDEX_PATTERN_SAVED_OBJECT_TYPE, + `epm-ip-${indexPatternType}` + ); + } catch (err) { + // index pattern was probably deleted by the user already + } + return; + } + + // get all dataset fields from all installed packages + const fields = await getAllDatasetFieldsByType(installedPackagesInfo, indexPatternType); + + const kibanaIndexPattern = createIndexPattern(indexPatternType, fields); + // create or overwrite the index pattern + await savedObjectsClient.create(INDEX_PATTERN_SAVED_OBJECT_TYPE, kibanaIndexPattern, { + id: `epm-ip-${indexPatternType}`, + overwrite: true, + }); + }); +} + +// loops through all given packages and returns an array +// of all fields from all datasets matching datasetType +export const getAllDatasetFieldsByType = async ( + packages: RegistryPackage[], + datasetType: IndexPatternType +): Promise => { + const datasetsPromises = packages.reduce>>((acc, pkg) => { + if (pkg.datasets) { + // filter out datasets by datasetType + const matchingDatasets = pkg.datasets.filter(dataset => dataset.type === datasetType); + matchingDatasets.forEach(dataset => acc.push(loadFieldsFromYaml(pkg, dataset.path))); + } + return acc; + }, []); + + // get all the datasets for each installed package into one array + const allDatasetFields: Fields[] = await Promise.all(datasetsPromises); + return allDatasetFields.flat(); +}; + +// creates or updates index pattern +export const createIndexPattern = (indexPatternType: string, fields: Fields) => { + const { indexPatternFields, fieldFormatMap } = createIndexPatternFields(fields); + + return { + title: `${indexPatternType}-*`, + timeFieldName: '@timestamp', + fields: JSON.stringify(indexPatternFields), + fieldFormatMap: JSON.stringify(fieldFormatMap), + }; +}; + +// takes fields from yaml files and transforms into Kibana Index Pattern fields +// and also returns the fieldFormatMap +export const createIndexPatternFields = ( + fields: Fields +): { indexPatternFields: IndexPatternField[]; fieldFormatMap: FieldFormatMap } => { + const dedupedFields = dedupeFields(fields); + const flattenedFields = flattenFields(dedupedFields); + const fieldFormatMap = createFieldFormatMap(flattenedFields); + const transformedFields = flattenedFields.map(transformField); + return { indexPatternFields: transformedFields, fieldFormatMap }; +}; + +export const dedupeFields = (fields: Fields) => { + const uniqueObj = fields.reduce<{ [name: string]: Field }>((acc, field) => { + if (!acc[field.name]) { + acc[field.name] = field; + } + return acc; + }, {}); + + return Object.values(uniqueObj); +}; + +/** + * search through fields with field's path property + * returns undefined if field not found or field is not a leaf node + * @param allFields fields to search + * @param path dot separated path from field.path + */ +export const findFieldByPath = (allFields: Fields, path: string): Field | undefined => { + const pathParts = path.split('.'); + return getField(allFields, pathParts); +}; + +const getField = (fields: Fields, pathNames: string[]): Field | undefined => { + if (!pathNames.length) return undefined; + // get the first rest of path names + const [name, ...restPathNames] = pathNames; + for (const field of fields) { + if (field.name === name) { + // check field's fields, passing in the remaining path names + if (field.fields && field.fields.length > 0) { + return getField(field.fields, restPathNames); + } + // no nested fields to search, but still more names - not found + if (restPathNames.length) { + return undefined; + } + return field; + } + } + return undefined; +}; + +export const transformField = (field: Field, i: number, fields: Fields): IndexPatternField => { + const newField: IndexPatternField = { + name: field.name, + count: field.count ?? 0, + scripted: false, + indexed: field.index ?? true, + analyzed: field.analyzed ?? false, + searchable: field.searchable ?? true, + aggregatable: field.aggregatable ?? true, + doc_values: field.doc_values ?? true, + }; + + // if type exists, check if it exists in the map + if (field.type) { + // if no type match type is not set (undefined) + if (typeMap[field.type]) { + newField.type = typeMap[field.type]; + } + // if type isn't set, default to string + } else { + newField.type = 'string'; + } + + if (newField.type === 'binary') { + newField.aggregatable = false; + newField.analyzed = false; + newField.doc_values = field.doc_values ?? false; + newField.indexed = false; + newField.searchable = false; + } + + if (field.type === 'object' && field.hasOwnProperty('enabled')) { + const enabled = field.enabled ?? true; + newField.enabled = enabled; + if (!enabled) { + newField.aggregatable = false; + newField.analyzed = false; + newField.doc_values = false; + newField.indexed = false; + newField.searchable = false; + } + } + + if (field.type === 'text') { + newField.aggregatable = false; + } + + if (field.hasOwnProperty('script')) { + newField.scripted = true; + newField.script = field.script; + newField.lang = 'painless'; + newField.doc_values = false; + } + + return newField; +}; + +/** + * flattenFields + * + * flattens fields and renames them with a path of the parent names + */ + +export const flattenFields = (allFields: Fields): Fields => { + const flatten = (fields: Fields): Fields => + fields.reduce((acc, field) => { + // recurse through nested fields + if (field.type === 'group' && field.fields?.length) { + // skip if field.enabled is not explicitly set to false + if (!field.hasOwnProperty('enabled') || field.enabled === true) { + acc = renameAndFlatten(field, field.fields, [...acc]); + } + } else { + // handle alias type fields + if (field.type === 'alias' && field.path) { + const foundField = findFieldByPath(allFields, field.path); + // if aliased leaf field is found copy its props over except path and name + if (foundField) { + const { path, name } = field; + field = { ...foundField, path, name }; + } + } + // add field before going through multi_fields because we still want to add the parent field + acc.push(field); + + // for each field in multi_field add new field + if (field.multi_fields?.length) { + acc = renameAndFlatten(field, field.multi_fields, [...acc]); + } + } + return acc; + }, []); + + // helper function to call flatten() and rename the fields + const renameAndFlatten = (field: Field, fields: Fields, acc: Fields): Fields => { + const flattenedFields = flatten(fields); + flattenedFields.forEach(nestedField => { + acc.push({ + ...nestedField, + name: `${field.name}.${nestedField.name}`, + }); + }); + return acc; + }; + + return flatten(allFields); +}; + +export const createFieldFormatMap = (fields: Fields): FieldFormatMap => + fields.reduce((acc, field) => { + if (field.format || field.pattern) { + const fieldFormatMapItem: FieldFormatMapItem = {}; + if (field.format) { + fieldFormatMapItem.id = field.format; + } + const params = getFieldFormatParams(field); + if (Object.keys(params).length) fieldFormatMapItem.params = params; + acc[field.name] = fieldFormatMapItem; + } + return acc; + }, {}); + +const getFieldFormatParams = (field: Field): FieldFormatParams => { + const params: FieldFormatParams = {}; + if (field.pattern) params.pattern = field.pattern; + if (field.input_format) params.inputFormat = field.input_format; + if (field.output_format) params.outputFormat = field.output_format; + if (field.output_precision) params.outputPrecision = field.output_precision; + if (field.label_template) params.labelTemplate = field.label_template; + if (field.url_template) params.urlTemplate = field.url_template; + if (field.open_link_in_current_tab) params.openLinkInCurrentTab = field.open_link_in_current_tab; + return params; +}; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/coredns.logs.yml b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/coredns.logs.yml new file mode 100644 index 0000000000000..d66a4cf62bc41 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/coredns.logs.yml @@ -0,0 +1,71 @@ +- name: coredns + type: group + description: > + coredns fields after normalization + fields: + - name: id + type: keyword + description: > + id of the DNS transaction + + - name: allParams + type: integer + format: bytes + pattern: patternValQueryWeight + input_format: inputFormatVal, + output_format: outputFormalVal, + output_precision: 3, + label_template: labelTemplateVal, + url_template: urlTemplateVal, + openLinkInCurrentTab: true, + description: > + weight of the DNS query + + - name: query.length + type: integer + pattern: patternValQueryLength + description: > + length of the DNS query + + - name: query.size + type: integer + format: bytes + pattern: patternValQuerySize + description: > + size of the DNS query + + - name: query.class + type: keyword + description: > + DNS query class + + - name: query.name + type: keyword + description: > + DNS query name + + - name: query.type + type: keyword + description: > + DNS query type + + - name: response.code + type: keyword + description: > + DNS response code + + - name: response.flags + type: keyword + description: > + DNS response flags + + - name: response.size + type: integer + format: bytes + description: > + size of the DNS response + + - name: dnssec_ok + type: boolean + description: > + dnssec flag diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.access.ecs.yml b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.access.ecs.yml new file mode 100644 index 0000000000000..51090a0fe7cf0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.access.ecs.yml @@ -0,0 +1,112 @@ +- name: '@timestamp' + level: core + required: true + type: date + description: 'Date/time when the event originated. + This is the date/time extracted from the event, typically representing when + the event was generated by the source. + If the event source has no original timestamp, this value is typically populated + by the first time the event was received by the pipeline. + Required field for all events.' + example: '2016-05-23T08:05:34.853Z' +- name: labels + level: core + type: object + object_type: keyword + description: 'Custom key/value pairs. + Can be used to add meta information to events. Should not contain nested objects. + All values are stored as keyword. + Example: `docker` and `k8s` labels.' + example: + application: foo-bar + env: production +- name: message + level: core + type: text + description: 'For log events the message field contains the log message, optimized + for viewing in a log viewer. + For structured logs without an original message field, other fields can be concatenated + to form a human-readable summary of the event. + If multiple messages exist, they can be combined into one message.' + example: Hello World +- name: tags + level: core + type: keyword + ignore_above: 1024 + description: List of keywords used to tag each event. + example: '["production", "env2"]' +- name: agent + title: Agent + group: 2 + description: 'The agent fields contain the data about the software entity, if + any, that collects, detects, or observes events on a host, or takes measurements + on a host. + Examples include Beats. Agents may also run on observers. ECS agent.* fields + shall be populated with details of the agent running on the host or observer + where the event happened or the measurement was taken.' + footnote: 'Examples: In the case of Beats for logs, the agent.name is filebeat. + For APM, it is the agent running in the app/service. The agent information does + not change if data is sent through queuing systems like Kafka, Redis, or processing + systems such as Logstash or APM Server.' + type: group + fields: + - name: ephemeral_id + level: extended + type: keyword + ignore_above: 1024 + description: 'Ephemeral identifier of this agent (if one exists). + This id normally changes across restarts, but `agent.id` does not.' + example: 8a4f500f + - name: id + level: core + type: keyword + ignore_above: 1024 + description: 'Unique identifier of this agent (if one exists). + Example: For Beats this would be beat.id.' + example: 8a4f500d + - name: name + level: core + type: keyword + ignore_above: 1024 + description: 'Custom name of the agent. + This is a name that can be given to an agent. This can be helpful if for example + two Filebeat instances are running on the same host but a human readable separation + is needed on which Filebeat instance data is coming from. + If no name is given, the name is often left empty.' + example: foo + - name: type + level: core + type: keyword + ignore_above: 1024 + description: 'Type of the agent. + The agent type stays always the same and should be given by the agent used. + In case of Filebeat the agent would always be Filebeat also if two Filebeat + instances are run on the same machine.' + example: filebeat + - name: version + level: core + type: keyword + ignore_above: 1024 + description: Version of the agent. + example: 6.0.0-rc2 +- name: as + title: Autonomous System + group: 2 + description: An autonomous system (AS) is a collection of connected Internet Protocol + (IP) routing prefixes under the control of one or more network operators on + behalf of a single administrative entity or domain that presents a common, clearly + defined routing policy to the internet. + type: group + fields: + - name: number + level: extended + type: long + description: Unique number allocated to the autonomous system. The autonomous + system number (ASN) uniquely identifies each network on the Internet. + example: 15169 + - name: organization.name + level: extended + type: keyword + ignore_above: 1024 + description: Organization name. + example: Google LLC \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.error.ecs.yml b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.error.ecs.yml new file mode 100644 index 0000000000000..51090a0fe7cf0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.error.ecs.yml @@ -0,0 +1,112 @@ +- name: '@timestamp' + level: core + required: true + type: date + description: 'Date/time when the event originated. + This is the date/time extracted from the event, typically representing when + the event was generated by the source. + If the event source has no original timestamp, this value is typically populated + by the first time the event was received by the pipeline. + Required field for all events.' + example: '2016-05-23T08:05:34.853Z' +- name: labels + level: core + type: object + object_type: keyword + description: 'Custom key/value pairs. + Can be used to add meta information to events. Should not contain nested objects. + All values are stored as keyword. + Example: `docker` and `k8s` labels.' + example: + application: foo-bar + env: production +- name: message + level: core + type: text + description: 'For log events the message field contains the log message, optimized + for viewing in a log viewer. + For structured logs without an original message field, other fields can be concatenated + to form a human-readable summary of the event. + If multiple messages exist, they can be combined into one message.' + example: Hello World +- name: tags + level: core + type: keyword + ignore_above: 1024 + description: List of keywords used to tag each event. + example: '["production", "env2"]' +- name: agent + title: Agent + group: 2 + description: 'The agent fields contain the data about the software entity, if + any, that collects, detects, or observes events on a host, or takes measurements + on a host. + Examples include Beats. Agents may also run on observers. ECS agent.* fields + shall be populated with details of the agent running on the host or observer + where the event happened or the measurement was taken.' + footnote: 'Examples: In the case of Beats for logs, the agent.name is filebeat. + For APM, it is the agent running in the app/service. The agent information does + not change if data is sent through queuing systems like Kafka, Redis, or processing + systems such as Logstash or APM Server.' + type: group + fields: + - name: ephemeral_id + level: extended + type: keyword + ignore_above: 1024 + description: 'Ephemeral identifier of this agent (if one exists). + This id normally changes across restarts, but `agent.id` does not.' + example: 8a4f500f + - name: id + level: core + type: keyword + ignore_above: 1024 + description: 'Unique identifier of this agent (if one exists). + Example: For Beats this would be beat.id.' + example: 8a4f500d + - name: name + level: core + type: keyword + ignore_above: 1024 + description: 'Custom name of the agent. + This is a name that can be given to an agent. This can be helpful if for example + two Filebeat instances are running on the same host but a human readable separation + is needed on which Filebeat instance data is coming from. + If no name is given, the name is often left empty.' + example: foo + - name: type + level: core + type: keyword + ignore_above: 1024 + description: 'Type of the agent. + The agent type stays always the same and should be given by the agent used. + In case of Filebeat the agent would always be Filebeat also if two Filebeat + instances are run on the same machine.' + example: filebeat + - name: version + level: core + type: keyword + ignore_above: 1024 + description: Version of the agent. + example: 6.0.0-rc2 +- name: as + title: Autonomous System + group: 2 + description: An autonomous system (AS) is a collection of connected Internet Protocol + (IP) routing prefixes under the control of one or more network operators on + behalf of a single administrative entity or domain that presents a common, clearly + defined routing policy to the internet. + type: group + fields: + - name: number + level: extended + type: long + description: Unique number allocated to the autonomous system. The autonomous + system number (ASN) uniquely identifies each network on the Internet. + example: 15169 + - name: organization.name + level: extended + type: keyword + ignore_above: 1024 + description: Organization name. + example: Google LLC \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml new file mode 100644 index 0000000000000..220225a2c246b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/kibana/index_pattern/tests/nginx.fields.yml @@ -0,0 +1,118 @@ +- name: nginx.access + type: group + description: > + Contains fields for the Nginx access logs. + fields: + - name: group_disabled + type: group + enabled: false + fields: + - name: message + type: text + - name: remote_ip_list + type: array + description: > + An array of remote IP addresses. It is a list because it is common to include, besides the client + IP address, IP addresses from headers like `X-Forwarded-For`. + Real source IP is restored to `source.ip`. + + - name: body_sent.bytes + type: alias + path: http.response.body.bytes + migration: true + - name: user_name + type: alias + path: user.name + migration: true + - name: method + type: alias + path: http.request.method + migration: true + - name: url + type: alias + path: url.original + migration: true + - name: http_version + type: alias + path: http.version + migration: true + - name: response_code + type: alias + path: http.response.status_code + migration: true + - name: referrer + type: alias + path: http.request.referrer + migration: true + - name: agent + type: alias + path: user_agent.original + migration: true + + - name: user_agent + type: group + fields: + - name: device + type: alias + path: user_agent.device.name + migration: true + - name: name + type: alias + path: user_agent.name + migration: true + - name: os + type: alias + path: user_agent.os.full_name + migration: true + - name: os_name + type: alias + path: user_agent.os.name + migration: true + - name: original + type: alias + path: user_agent.original + migration: true + + - name: geoip + type: group + fields: + - name: continent_name + type: alias + path: source.geo.continent_name + migration: true + - name: country_iso_code + type: alias + path: source.geo.country_iso_code + migration: true + - name: location + type: alias + path: source.geo.location + migration: true + - name: region_name + type: alias + path: source.geo.region_name + migration: true + - name: city_name + type: alias + path: source.geo.city_name + migration: true + - name: region_iso_code + type: alias + path: source.geo.region_iso_code + migration: true + +- name: source + type: group + fields: + - name: geo + type: group + fields: + - name: continent_name + type: text +- name: country + type: "" + multi_fields: + - name: keyword + type: keyword + - name: text + type: text diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts new file mode 100644 index 0000000000000..5153f9205dde7 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RegistryPackage } from '../../../types'; +import { getAssets } from './assets'; + +const tests = [ + { + package: { + assets: [ + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-json.json', + ], + name: 'coredns', + version: '1.0.1', + }, + dataset: 'log', + filter: (path: string) => { + return true; + }, + expected: [ + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-json.json', + ], + }, + { + package: { + assets: [ + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-json.json', + ], + name: 'coredns', + version: '1.0.1', + }, + // Non existant dataset + dataset: 'foo', + filter: (path: string) => { + return true; + }, + expected: [], + }, + { + package: { + assets: [ + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-plaintext.json', + '/package/coredns-1.0.1/dataset/log/elasticsearch/ingest-pipeline/pipeline-json.json', + ], + }, + // Filter which does not exist + filter: (path: string) => { + return path.includes('foo'); + }, + expected: [], + }, +]; + +test('testGetAssets', () => { + for (const value of tests) { + // as needed to pretent it is a RegistryPackage + const assets = getAssets(value.package as RegistryPackage, value.filter, value.dataset); + expect(assets).toStrictEqual(value.expected); + } +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts new file mode 100644 index 0000000000000..ecc882d9c2e70 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/assets.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RegistryPackage } from '../../../types'; +import * as Registry from '../registry'; +import { cacheHas } from '../registry/cache'; + +// paths from RegistryPackage are routes to the assets on EPR +// e.g. `/package/nginx-1.2.0/dataset/access/fields/fields.yml` +// paths for ArchiveEntry are routes to the assets in the archive +// e.g. `nginx-1.2.0/dataset/access/fields/fields.yml` +// RegistryPackage paths have a `/package/` prefix compared to ArchiveEntry paths +const EPR_PATH_PREFIX = '/package'; +function registryPathToArchivePath(registryPath: RegistryPackage['path']): string { + const archivePath = registryPath.replace(`${EPR_PATH_PREFIX}/`, ''); + return archivePath; +} + +export function getAssets( + packageInfo: RegistryPackage, + filter = (path: string): boolean => true, + datasetName?: string +): string[] { + const assets: string[] = []; + if (!packageInfo?.assets) return assets; + + // Skip directories + for (const path of packageInfo.assets) { + if (path.endsWith('/')) { + continue; + } + + // if dataset, filter for them + if (datasetName) { + // TODO: Filter for dataset path + const comparePath = `${EPR_PATH_PREFIX}/${packageInfo.name}-${packageInfo.version}/dataset/${datasetName}`; + if (!path.includes(comparePath)) { + continue; + } + } + if (!filter(path)) { + continue; + } + + assets.push(path); + } + return assets; +} + +export async function getAssetsData( + packageInfo: RegistryPackage, + filter = (path: string): boolean => true, + datasetName?: string +): Promise { + // TODO: Needs to be called to fill the cache but should not be required + const pkgkey = packageInfo.name + '-' + packageInfo.version; + if (!cacheHas(pkgkey)) await Registry.getArchiveInfo(pkgkey); + + // Gather all asset data + const assets = getAssets(packageInfo, filter, datasetName); + const entries: Registry.ArchiveEntry[] = assets.map(registryPath => { + const archivePath = registryPathToArchivePath(registryPath); + const buffer = Registry.getAsset(archivePath); + + return { path: registryPath, buffer }; + }); + + return entries; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts new file mode 100644 index 0000000000000..58416b7f66d2d --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'src/core/server/'; +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import { Installation, InstallationStatus, PackageInfo } from '../../../types'; +import * as Registry from '../registry'; +import { createInstallableFrom } from './index'; + +export { fetchFile as getFile, SearchParams } from '../registry'; + +function nameAsTitle(name: string) { + return name.charAt(0).toUpperCase() + name.substr(1).toLowerCase(); +} + +export async function getCategories() { + return Registry.fetchCategories(); +} + +export async function getPackages( + options: { + savedObjectsClient: SavedObjectsClientContract; + } & Registry.SearchParams +) { + const { savedObjectsClient } = options; + const registryItems = await Registry.fetchList({ category: options.category }).then(items => { + return items.map(item => + Object.assign({}, item, { title: item.title || nameAsTitle(item.name) }) + ); + }); + const searchObjects = registryItems.map(({ name, version }) => ({ + type: PACKAGES_SAVED_OBJECT_TYPE, + id: `${name}-${version}`, + })); + const results = await savedObjectsClient.bulkGet(searchObjects); + const savedObjects = results.saved_objects.filter(o => !o.error); // ignore errors for now + const packageList = registryItems + .map(item => + createInstallableFrom( + item, + savedObjects.find(({ id }) => id === `${item.name}-${item.version}`) + ) + ) + .sort(sortByName); + return packageList; +} + +export async function getPackageKeysByStatus( + savedObjectsClient: SavedObjectsClientContract, + status: InstallationStatus +) { + const allPackages = await getPackages({ savedObjectsClient }); + return allPackages.reduce((acc, pkg) => { + if (pkg.status === status) { + acc.push(`${pkg.name}-${pkg.version}`); + } + return acc; + }, []); +} + +export async function getPackageInfo(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; +}): Promise { + const { savedObjectsClient, pkgkey } = options; + const [item, savedObject] = await Promise.all([ + Registry.fetchInfo(pkgkey), + getInstallationObject({ savedObjectsClient, pkgkey }), + Registry.getArchiveInfo(pkgkey), + ] as const); + // adding `as const` due to regression in TS 3.7.2 + // see https://github.com/microsoft/TypeScript/issues/34925#issuecomment-550021453 + // and https://github.com/microsoft/TypeScript/pull/33707#issuecomment-550718523 + + // add properties that aren't (or aren't yet) on Registry response + const updated = { + ...item, + title: item.title || nameAsTitle(item.name), + assets: Registry.groupPathsByService(item?.assets || []), + }; + return createInstallableFrom(updated, savedObject); +} + +export async function getInstallationObject(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; +}) { + const { savedObjectsClient, pkgkey } = options; + return savedObjectsClient + .get(PACKAGES_SAVED_OBJECT_TYPE, pkgkey) + .catch(e => undefined); +} + +export async function getInstallation(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; +}) { + const savedObject = await getInstallationObject(options); + return savedObject?.attributes; +} + +export async function findInstalledPackageByName(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgName: string; +}): Promise { + const { savedObjectsClient, pkgName } = options; + + const res = await savedObjectsClient.find({ + type: PACKAGES_SAVED_OBJECT_TYPE, + search: pkgName, + searchFields: ['name'], + }); + if (res.saved_objects.length) return res.saved_objects[0].attributes; + return undefined; +} + +function sortByName(a: { name: string }, b: { name: string }) { + if (a.name > b.name) { + return 1; + } else if (a.name < b.name) { + return -1; + } else { + return 0; + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts new file mode 100644 index 0000000000000..e0424aa8a36f5 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/get_objects.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsBulkCreateObject } from 'src/core/server/'; +import { AssetType } from '../../../types'; +import * as Registry from '../registry'; + +type ArchiveAsset = Pick; +type SavedObjectToBe = Required & { type: AssetType }; + +export async function getObjects( + pkgkey: string, + filter = (entry: Registry.ArchiveEntry): boolean => true +): Promise { + // Create a Map b/c some values, especially index-patterns, are referenced multiple times + const objects: Map = new Map(); + + // Get paths which match the given filter + const paths = await Registry.getArchiveInfo(pkgkey, filter); + + // Get all objects which matched filter. Add them to the Map + const rootObjects = await Promise.all(paths.map(getObject)); + rootObjects.forEach(obj => objects.set(obj.id, obj)); + + // Each of those objects might have `references` property like [{id, type, name}] + for (const object of rootObjects) { + // For each of those objects, if they have references + for (const reference of object.references) { + // Get the referenced objects. Call same function with a new filter + const referencedObjects = await getObjects(pkgkey, (entry: Registry.ArchiveEntry) => { + // Skip anything we've already stored + if (objects.has(reference.id)) return false; + + // Is the archive entry the reference we want? + const { type, file } = Registry.pathParts(entry.path); + const isType = type === reference.type; + const isJson = file === `${reference.id}.json`; + + return isType && isJson; + }); + + // Add referenced objects to the Map + referencedObjects.forEach(ro => objects.set(ro.id, ro)); + } + } + + // return the array of unique objects + return Array.from(objects.values()); +} + +export async function getObject(key: string) { + const buffer = Registry.getAsset(key); + + // cache values are buffers. convert to string / JSON + const json = buffer.toString('utf8'); + // convert that to an object + const asset: ArchiveAsset = JSON.parse(json); + + const { type, file } = Registry.pathParts(key); + const savedObject: SavedObjectToBe = { + type, + id: file.replace('.json', ''), + attributes: asset.attributes, + references: asset.references || [], + migrationVersion: asset.migrationVersion || {}, + }; + + return savedObject; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts new file mode 100644 index 0000000000000..2f84ea5b6f8db --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/index.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject } from '../../../../../../../src/core/server'; +import { + AssetType, + Installable, + Installation, + InstallationStatus, + KibanaAssetType, +} from '../../../../common/types/models/epm'; + +export { + getCategories, + getFile, + getInstallationObject, + getInstallation, + getPackageInfo, + getPackages, + SearchParams, + findInstalledPackageByName, +} from './get'; + +export { installKibanaAssets, installPackage, ensureInstalledPackage } from './install'; +export { removeInstallation } from './remove'; + +export class PackageNotInstalledError extends Error { + constructor(pkgkey: string) { + super(`${pkgkey} is not installed`); + } +} + +// only Kibana Assets use Saved Objects at this point +export const savedObjectTypes: AssetType[] = Object.values(KibanaAssetType); + +export function createInstallableFrom( + from: T, + savedObject?: SavedObject +): Installable { + return savedObject + ? { + ...from, + status: InstallationStatus.installed, + savedObject, + } + : { + ...from, + status: InstallationStatus.notInstalled, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts new file mode 100644 index 0000000000000..acf77998fdb3c --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsClientContract } from 'src/core/server/'; +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import { + AssetReference, + Installation, + KibanaAssetType, + CallESAsCurrentUser, + DefaultPackages, +} from '../../../types'; +import { installIndexPatterns } from '../kibana/index_pattern/install'; +import * as Registry from '../registry'; +import { getObject } from './get_objects'; +import { getInstallation, findInstalledPackageByName } from './index'; +import { installTemplates } from '../elasticsearch/template/install'; +import { installPipelines } from '../elasticsearch/ingest_pipeline/install'; +import { installILMPolicy } from '../elasticsearch/ilm/install'; + +export async function installLatestPackage(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgName: string; + callCluster: CallESAsCurrentUser; +}): Promise { + const { savedObjectsClient, pkgName, callCluster } = options; + try { + const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + const pkgkey = Registry.pkgToPkgKey({ + name: latestPackage.name, + version: latestPackage.version, + }); + return installPackage({ savedObjectsClient, pkgkey, callCluster }); + } catch (err) { + throw err; + } +} + +export async function ensureInstalledDefaultPackages( + savedObjectsClient: SavedObjectsClientContract, + callCluster: CallESAsCurrentUser +): Promise { + const installations = []; + for (const pkgName in DefaultPackages) { + if (!DefaultPackages.hasOwnProperty(pkgName)) continue; + const installation = await ensureInstalledPackage({ + savedObjectsClient, + pkgName, + callCluster, + }); + if (installation) installations.push(installation); + } + + return installations; +} + +export async function ensureInstalledPackage(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgName: string; + callCluster: CallESAsCurrentUser; +}): Promise { + const { savedObjectsClient, pkgName, callCluster } = options; + const installedPackage = await findInstalledPackageByName({ savedObjectsClient, pkgName }); + if (installedPackage) { + return installedPackage; + } + // if the requested packaged was not found to be installed, try installing + try { + await installLatestPackage({ + savedObjectsClient, + pkgName, + callCluster, + }); + return await findInstalledPackageByName({ savedObjectsClient, pkgName }); + } catch (err) { + throw new Error(err.message); + } +} + +export async function installPackage(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; + callCluster: CallESAsCurrentUser; +}): Promise { + const { savedObjectsClient, pkgkey, callCluster } = options; + const registryPackageInfo = await Registry.fetchInfo(pkgkey); + const { name: pkgName, version: pkgVersion } = registryPackageInfo; + + const installKibanaAssetsPromise = installKibanaAssets({ + savedObjectsClient, + pkgkey, + }); + const installPipelinePromises = installPipelines(registryPackageInfo, callCluster); + const installTemplatePromises = installTemplates(registryPackageInfo, callCluster, pkgkey); + + // index patterns and ilm policies are not currently associated with a particular package + // so we do not save them in the package saved object state. at some point ILM policies can be installed/modified + // per dataset and we should then save them + await installIndexPatterns(savedObjectsClient, pkgkey); + // currenly only the base package has an ILM policy + await installILMPolicy(pkgkey, callCluster); + + const res = await Promise.all([ + installKibanaAssetsPromise, + installPipelinePromises, + installTemplatePromises, + ]); + + const toSave = res.flat(); + // Save those references in the package manager's state saved object + await saveInstallationReferences({ + savedObjectsClient, + pkgkey, + pkgName, + pkgVersion, + toSave, + }); + return toSave; +} + +// TODO: make it an exhaustive list +// e.g. switch statement with cases for each enum key returning `never` for default case +export async function installKibanaAssets(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; +}) { + const { savedObjectsClient, pkgkey } = options; + + // Only install Kibana assets during package installation. + const kibanaAssetTypes = Object.values(KibanaAssetType); + const installationPromises = kibanaAssetTypes.map(async assetType => + installKibanaSavedObjects({ savedObjectsClient, pkgkey, assetType }) + ); + + // installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][] + // call .flat to flatten into one dimensional array + return Promise.all(installationPromises).then(results => results.flat()); +} + +export async function saveInstallationReferences(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; + pkgName: string; + pkgVersion: string; + toSave: AssetReference[]; +}) { + const { savedObjectsClient, pkgkey, pkgName, pkgVersion, toSave } = options; + const installation = await getInstallation({ savedObjectsClient, pkgkey }); + const savedRefs = installation?.installed || []; + const mergeRefsReducer = (current: AssetReference[], pending: AssetReference) => { + const hasRef = current.find(c => c.id === pending.id && c.type === pending.type); + if (!hasRef) current.push(pending); + return current; + }; + + const toInstall = toSave.reduce(mergeRefsReducer, savedRefs); + await savedObjectsClient.create( + PACKAGES_SAVED_OBJECT_TYPE, + { installed: toInstall, name: pkgName, version: pkgVersion }, + { id: pkgkey, overwrite: true } + ); + + return toInstall; +} + +async function installKibanaSavedObjects({ + savedObjectsClient, + pkgkey, + assetType, +}: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; + assetType: KibanaAssetType; +}) { + const isSameType = ({ path }: Registry.ArchiveEntry) => + assetType === Registry.pathParts(path).type; + const paths = await Registry.getArchiveInfo(pkgkey, isSameType); + const toBeSavedObjects = await Promise.all(paths.map(getObject)); + + if (toBeSavedObjects.length === 0) { + return []; + } else { + const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, { + overwrite: true, + }); + const createdObjects = createResults.saved_objects; + const installed = createdObjects.map(toAssetReference); + return installed; + } +} + +function toAssetReference({ id, type }: SavedObject) { + const reference: AssetReference = { id, type: type as KibanaAssetType }; + + return reference; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts new file mode 100644 index 0000000000000..e57729a7ab2ba --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/remove.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'src/core/server/'; +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; +import { AssetReference, AssetType, ElasticsearchAssetType } from '../../../types'; +import { CallESAsCurrentUser } from '../../../types'; +import { getInstallation, savedObjectTypes } from './index'; +import { installIndexPatterns } from '../kibana/index_pattern/install'; + +export async function removeInstallation(options: { + savedObjectsClient: SavedObjectsClientContract; + pkgkey: string; + callCluster: CallESAsCurrentUser; +}): Promise { + const { savedObjectsClient, pkgkey, callCluster } = options; + const installation = await getInstallation({ savedObjectsClient, pkgkey }); + const installedObjects = installation?.installed || []; + + // Delete the manager saved object with references to the asset objects + // could also update with [] or some other state + await savedObjectsClient.delete(PACKAGES_SAVED_OBJECT_TYPE, pkgkey); + + // recreate or delete index patterns when a package is uninstalled + await installIndexPatterns(savedObjectsClient); + + // Delete the installed assets + const deletePromises = installedObjects.map(async ({ id, type }) => { + const assetType = type as AssetType; + if (savedObjectTypes.includes(assetType)) { + savedObjectsClient.delete(assetType, id); + } else if (assetType === ElasticsearchAssetType.ingestPipeline) { + deletePipeline(callCluster, id); + } else if (assetType === ElasticsearchAssetType.indexTemplate) { + deleteTemplate(callCluster, id); + } + }); + await Promise.all([...deletePromises]); + + // successful delete's in SO client return {}. return something more useful + return installedObjects; +} + +async function deletePipeline(callCluster: CallESAsCurrentUser, id: string): Promise { + // '*' shouldn't ever appear here, but it still would delete all ingest pipelines + if (id && id !== '*') { + await callCluster('ingest.deletePipeline', { id }); + } +} + +async function deleteTemplate(callCluster: CallESAsCurrentUser, name: string): Promise { + // '*' shouldn't ever appear here, but it still would delete all templates + if (name && name !== '*') { + await callCluster('indices.deleteTemplate', { name }); + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts new file mode 100644 index 0000000000000..17d52bc745a55 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/cache.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const cache: Map = new Map(); +export const cacheGet = (key: string) => cache.get(key); +export const cacheSet = (key: string, value: Buffer) => cache.set(key, value); +export const cacheHas = (key: string) => cache.has(key); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts new file mode 100644 index 0000000000000..feed2236f06eb --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/extract.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import tar from 'tar'; +import { bufferToStream, streamToBuffer } from './streams'; + +export interface ArchiveEntry { + path: string; + buffer?: Buffer; +} + +export async function untarBuffer( + buffer: Buffer, + filter = (entry: ArchiveEntry): boolean => true, + onEntry = (entry: ArchiveEntry): void => {} +): Promise { + const deflatedStream = bufferToStream(buffer); + // use tar.list vs .extract to avoid writing to disk + const inflateStream = tar.list().on('entry', (entry: tar.FileStat) => { + const path = entry.header.path || ''; + if (!filter({ path })) return; + streamToBuffer(entry).then(entryBuffer => onEntry({ buffer: entryBuffer, path })); + }); + + return new Promise((resolve, reject) => { + inflateStream.on('end', resolve).on('error', reject); + deflatedStream.pipe(inflateStream); + }); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts new file mode 100644 index 0000000000000..eae84275a49b9 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AssetParts } from '../../../types'; +import { pathParts } from './index'; + +const testPaths = [ + { + path: 'foo-1.1.0/service/type/file.yml', + assetParts: { + dataset: undefined, + file: 'file.yml', + path: 'foo-1.1.0/service/type/file.yml', + pkgkey: 'foo-1.1.0', + service: 'service', + type: 'type', + }, + }, + { + path: 'iptables-1.0.4/kibana/visualization/683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json', + assetParts: { + dataset: undefined, + file: '683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json', + path: 'iptables-1.0.4/kibana/visualization/683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json', + pkgkey: 'iptables-1.0.4', + service: 'kibana', + type: 'visualization', + }, + }, + { + path: 'coredns-1.0.1/dataset/stats/fields/coredns.stats.yml', + assetParts: { + dataset: 'stats', + file: 'coredns.stats.yml', + path: 'coredns-1.0.1/dataset/stats/fields/coredns.stats.yml', + pkgkey: 'coredns-1.0.1', + service: '', + type: 'fields', + }, + }, +]; + +test('testPathParts', () => { + for (const value of testPaths) { + expect(pathParts(value.path)).toStrictEqual(value.assetParts as AssetParts); + } +}); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts new file mode 100644 index 0000000000000..ba4b3135aac1d --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -0,0 +1,180 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Response } from 'node-fetch'; +import { URL } from 'url'; +import { + AssetParts, + AssetsGroupedByServiceByType, + CategoryId, + CategorySummaryList, + KibanaAssetType, + RegistryPackage, + RegistrySearchResults, + RegistrySearchResult, +} from '../../../types'; +import { appContextService } from '../../'; +import { cacheGet, cacheSet } from './cache'; +import { ArchiveEntry, untarBuffer } from './extract'; +import { fetchUrl, getResponse, getResponseStream } from './requests'; +import { streamToBuffer } from './streams'; + +export { ArchiveEntry } from './extract'; + +export interface SearchParams { + category?: CategoryId; +} + +export const pkgToPkgKey = ({ name, version }: { name: string; version: string }) => + `${name}-${version}`; + +export async function fetchList(params?: SearchParams): Promise { + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + const url = new URL(`${registryUrl}/search`); + if (params && params.category) { + url.searchParams.set('category', params.category); + } + + return fetchUrl(url.toString()).then(JSON.parse); +} + +export async function fetchFindLatestPackage( + packageName: string, + internal: boolean = true +): Promise { + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + const url = new URL(`${registryUrl}/search?package=${packageName}&internal=${internal}`); + const res = await fetchUrl(url.toString()); + const searchResults = JSON.parse(res); + if (searchResults.length) { + return searchResults[0]; + } else { + throw new Error('package not found'); + } +} + +export async function fetchInfo(pkgkey: string): Promise { + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + return fetchUrl(`${registryUrl}/package/${pkgkey}`).then(JSON.parse); +} + +export async function fetchFile(filePath: string): Promise { + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + return getResponse(`${registryUrl}${filePath}`); +} + +export async function fetchCategories(): Promise { + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + return fetchUrl(`${registryUrl}/categories`).then(JSON.parse); +} + +export async function getArchiveInfo( + pkgkey: string, + filter = (entry: ArchiveEntry): boolean => true +): Promise { + const paths: string[] = []; + const onEntry = (entry: ArchiveEntry) => { + const { path, buffer } = entry; + const { file } = pathParts(path); + if (!file) return; + if (buffer) { + cacheSet(path, buffer); + paths.push(path); + } + }; + + await extract(pkgkey, filter, onEntry); + + return paths; +} + +export function pathParts(path: string): AssetParts { + let dataset; + + let [pkgkey, service, type, file] = path.split('/'); + + // if it's a dataset + if (service === 'dataset') { + // save the dataset name + dataset = type; + // drop the `dataset/dataset-name` portion & re-parse + [pkgkey, service, type, file] = path.replace(`dataset/${dataset}/`, '').split('/'); + } + + // This is to cover for the fields.yml files inside the "fields" directory + if (file === undefined) { + file = type; + type = 'fields'; + service = ''; + } + + return { + pkgkey, + service, + type, + file, + dataset, + path, + } as AssetParts; +} + +async function extract( + pkgkey: string, + filter = (entry: ArchiveEntry): boolean => true, + onEntry: (entry: ArchiveEntry) => void +) { + const archiveBuffer = await getOrFetchArchiveBuffer(pkgkey); + + return untarBuffer(archiveBuffer, filter, onEntry); +} + +async function getOrFetchArchiveBuffer(pkgkey: string): Promise { + // assume .tar.gz for now. add support for .zip if/when we need it + const key = `${pkgkey}.tar.gz`; + let buffer = cacheGet(key); + if (!buffer) { + buffer = await fetchArchiveBuffer(pkgkey); + cacheSet(key, buffer); + } + + if (buffer) { + return buffer; + } else { + throw new Error(`no archive buffer for ${key}`); + } +} + +async function fetchArchiveBuffer(key: string): Promise { + const { download: archivePath } = await fetchInfo(key); + const registryUrl = appContextService.getConfig()?.epm.registryUrl; + return getResponseStream(`${registryUrl}${archivePath}`).then(streamToBuffer); +} + +export function getAsset(key: string) { + const buffer = cacheGet(key); + if (buffer === undefined) throw new Error(`Cannot find asset ${key}`); + + return buffer; +} + +export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType { + // ASK: best way, if any, to avoid `any`? + const assets = paths.reduce((map: any, path) => { + const parts = pathParts(path.replace(/^\/package\//, '')); + if (parts.type in KibanaAssetType) { + if (!map[parts.service]) map[parts.service] = {}; + if (!map[parts.service][parts.type]) map[parts.service][parts.type] = []; + map[parts.service][parts.type].push(parts); + } + + return map; + }, {}); + + return { + kibana: assets.kibana, + // elasticsearch: assets.elasticsearch, + }; +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts new file mode 100644 index 0000000000000..654aa8aae1355 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import fetch, { Response } from 'node-fetch'; +import { streamToString } from './streams'; + +export async function getResponse(url: string): Promise { + try { + const response = await fetch(url); + if (response.ok) { + return response; + } else { + throw new Boom(response.statusText, { statusCode: response.status }); + } + } catch (e) { + throw Boom.boomify(e); + } +} + +export async function getResponseStream(url: string): Promise { + const res = await getResponse(url); + return res.body; +} + +export async function fetchUrl(url: string): Promise { + return getResponseStream(url).then(streamToString); +} diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/streams.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/streams.ts new file mode 100644 index 0000000000000..e174c5f2e4d72 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/streams.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PassThrough } from 'stream'; + +export function bufferToStream(buffer: Buffer): PassThrough { + const stream = new PassThrough(); + stream.end(buffer); + return stream; +} + +export function streamToString(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const body: string[] = []; + stream.on('data', (chunk: string) => body.push(chunk)); + stream.on('end', () => resolve(body.join(''))); + stream.on('error', reject); + }); +} + +export function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', chunk => chunks.push(Buffer.from(chunk))); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); +} diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/index.ts b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts new file mode 100644 index 0000000000000..7e7f8d2a3734b --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/install_script/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { macosInstallTemplate } from './install_templates/macos'; + +export function getScript(osType: 'macos', kibanaUrl: string): string { + const variables = { kibanaUrl }; + + switch (osType) { + case 'macos': + return macosInstallTemplate(variables); + default: + throw new Error(`${osType} is not supported.`); + } +} diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts new file mode 100644 index 0000000000000..e59dc6174b40f --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/macos.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { InstallTemplateFunction } from './types'; + +const PROJECT_ROOT = resolve(__dirname, '../../../../'); +export const macosInstallTemplate: InstallTemplateFunction = variables => `#!/bin/sh + +eval "node ${PROJECT_ROOT}/scripts/dev_agent --enrollmentApiKey=$API_KEY --kibanaUrl=${variables.kibanaUrl}" + +`; diff --git a/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts new file mode 100644 index 0000000000000..a478beaa96cfc --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/install_script/install_templates/types.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type InstallTemplateFunction = (variables: { kibanaUrl: string }) => string; diff --git a/x-pack/plugins/ingest_manager/server/services/license.ts b/x-pack/plugins/ingest_manager/server/services/license.ts new file mode 100644 index 0000000000000..bd96dbc7e3aff --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/license.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Observable, Subscription } from 'rxjs'; +import { ILicense } from '../../../licensing/server'; + +class LicenseService { + private observable: Observable | null = null; + private subscription: Subscription | null = null; + private licenseInformation: ILicense | null = null; + + private updateInformation(licenseInformation: ILicense) { + this.licenseInformation = licenseInformation; + } + + public start(license$: Observable) { + this.observable = license$; + this.subscription = this.observable.subscribe(this.updateInformation.bind(this)); + } + + public stop() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + public getLicenseInformation() { + return this.licenseInformation; + } + + public getLicenseInformation$() { + return this.observable; + } +} + +export const licenseService = new LicenseService(); diff --git a/x-pack/plugins/ingest_manager/server/services/output.ts b/x-pack/plugins/ingest_manager/server/services/output.ts index 8f60ed295205d..066f8e8a316a5 100644 --- a/x-pack/plugins/ingest_manager/server/services/output.ts +++ b/x-pack/plugins/ingest_manager/server/services/output.ts @@ -3,47 +3,66 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, KibanaRequest } from 'kibana/server'; +import { SavedObjectsClientContract } from 'kibana/server'; import { NewOutput, Output } from '../types'; -import { DEFAULT_OUTPUT, DEFAULT_OUTPUT_ID, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; +import { DEFAULT_OUTPUT, OUTPUT_SAVED_OBJECT_TYPE } from '../constants'; import { appContextService } from './app_context'; const SAVED_OBJECT_TYPE = OUTPUT_SAVED_OBJECT_TYPE; class OutputService { - public async createDefaultOutput( - soClient: SavedObjectsClientContract, - adminUser: { username: string; password: string } - ) { - let defaultOutput; - - try { - defaultOutput = await this.get(soClient, DEFAULT_OUTPUT_ID); - } catch (err) { - if (!err.isBoom || err.output.statusCode !== 404) { - throw err; - } - } + public async ensureDefaultOutput(soClient: SavedObjectsClientContract) { + const outputs = await soClient.find({ + type: OUTPUT_SAVED_OBJECT_TYPE, + filter: 'outputs.attributes.is_default:true', + }); - if (!defaultOutput) { + if (!outputs.saved_objects.length) { const newDefaultOutput = { ...DEFAULT_OUTPUT, - hosts: [appContextService.getConfig()!.fleet.defaultOutputHost], - api_key: await this.createDefaultOutputApiKey(adminUser.username, adminUser.password), - admin_username: adminUser.username, - admin_password: adminUser.password, + hosts: [appContextService.getConfig()!.fleet.elasticsearch.host], + ca_sha256: appContextService.getConfig()!.fleet.elasticsearch.ca_sha256, } as NewOutput; - await this.create(soClient, newDefaultOutput, { - id: DEFAULT_OUTPUT_ID, - }); + return await this.create(soClient, newDefaultOutput); } + + return { + id: outputs.saved_objects[0].id, + ...outputs.saved_objects[0].attributes, + }; } - public async getAdminUser() { + public async updateOutput( + soClient: SavedObjectsClientContract, + id: string, + data: Partial + ) { + await soClient.update(SAVED_OBJECT_TYPE, id, data); + } + + public async getDefaultOutputId(soClient: SavedObjectsClientContract) { + const outputs = await soClient.find({ + type: OUTPUT_SAVED_OBJECT_TYPE, + filter: 'outputs.attributes.is_default:true', + }); + + if (!outputs.saved_objects.length) { + throw new Error('No default output'); + } + + return outputs.saved_objects[0].id; + } + + public async getAdminUser(soClient: SavedObjectsClientContract) { + const defaultOutputId = await this.getDefaultOutputId(soClient); const so = await appContextService .getEncryptedSavedObjects() - ?.getDecryptedAsInternalUser(OUTPUT_SAVED_OBJECT_TYPE, DEFAULT_OUTPUT_ID); + ?.getDecryptedAsInternalUser(OUTPUT_SAVED_OBJECT_TYPE, defaultOutputId); + + if (!so || !so.attributes.admin_username || !so.attributes.admin_password) { + return null; + } return { username: so!.attributes.admin_username, @@ -51,35 +70,6 @@ class OutputService { }; } - // TODO: TEMPORARY this is going to be per agent - private async createDefaultOutputApiKey(username: string, password: string): Promise { - const key = await appContextService.getSecurity()?.authc.createAPIKey( - { - headers: { - authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, - }, - } as KibanaRequest, - { - name: 'fleet-default-output', - role_descriptors: { - 'fleet-output': { - cluster: ['monitor'], - index: [ - { - names: ['logs-*', 'metrics-*'], - privileges: ['write'], - }, - ], - }, - }, - } - ); - if (!key) { - throw new Error('An error occured while creating default API Key'); - } - return `${key.id}:${key.api_key}`; - } - public async create( soClient: SavedObjectsClientContract, output: NewOutput, @@ -93,11 +83,8 @@ class OutputService { }; } - public async get(soClient: SavedObjectsClientContract, id: string): Promise { + public async get(soClient: SavedObjectsClientContract, id: string): Promise { const outputSO = await soClient.get(SAVED_OBJECT_TYPE, id); - if (!outputSO) { - return null; - } if (outputSO.error) { throw new Error(outputSO.error.message); diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts new file mode 100644 index 0000000000000..f770e9d17ffb1 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; +import { CallESAsCurrentUser } from '../types'; +import { agentConfigService } from './agent_config'; +import { outputService } from './output'; +import { ensureInstalledDefaultPackages } from './epm/packages/install'; +import { + packageToConfigDatasourceInputs, + Datasource, + AgentConfig, + Installation, + Output, + DEFAULT_AGENT_CONFIGS_PACKAGES, +} from '../../common'; +import { getPackageInfo } from './epm/packages'; +import { datasourceService } from './datasource'; + +export async function setup( + soClient: SavedObjectsClientContract, + callCluster: CallESAsCurrentUser +) { + const [installedPackages, defaultOutput, config] = await Promise.all([ + // packages installed by default + ensureInstalledDefaultPackages(soClient, callCluster), + outputService.ensureDefaultOutput(soClient), + agentConfigService.ensureDefaultAgentConfig(soClient), + ]); + + // ensure default packages are added to the default conifg + const configWithDatasource = await agentConfigService.get(soClient, config.id, true); + if (!configWithDatasource) { + throw new Error('Config not found'); + } + if ( + configWithDatasource.datasources.length && + typeof configWithDatasource.datasources[0] === 'string' + ) { + throw new Error('Config not found'); + } + for (const installedPackage of installedPackages) { + const packageShouldBeInstalled = DEFAULT_AGENT_CONFIGS_PACKAGES.some( + packageName => installedPackage.name === packageName + ); + if (!packageShouldBeInstalled) { + continue; + } + + const isInstalled = configWithDatasource.datasources.some((d: Datasource | string) => { + return typeof d !== 'string' && d.package?.name === installedPackage.name; + }); + + if (!isInstalled) { + await addPackageToConfig(soClient, installedPackage, configWithDatasource, defaultOutput); + } + } +} + +async function addPackageToConfig( + soClient: SavedObjectsClientContract, + packageToInstall: Installation, + config: AgentConfig, + defaultOutput: Output +) { + const packageInfo = await getPackageInfo({ + savedObjectsClient: soClient, + pkgkey: `${packageToInstall.name}-${packageToInstall.version}`, + }); + const datasource = await datasourceService.create(soClient, { + name: `${packageInfo.name}-1`, + enabled: true, + package: { + name: packageInfo.name, + title: packageInfo.title, + version: packageInfo.version, + }, + inputs: packageToConfigDatasourceInputs(packageInfo), + config_id: config.id, + output_id: defaultOutput.id, + }); + // Assign it to the given agent config + await agentConfigService.assignDatasources(soClient, datasource.config_id, [datasource.id]); +} diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index f44d03923d424..c9a4bf79f3516 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -3,10 +3,57 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export * from './models'; -export * from './rest_spec'; +import { ScopedClusterClient } from 'src/core/server/'; + +export { + // Object types + Agent, + AgentSOAttributes, + AgentStatus, + AgentType, + AgentEvent, + AgentEventSOAttributes, + AgentAction, + Datasource, + NewDatasource, + FullAgentConfigDatasource, + FullAgentConfig, + AgentConfig, + NewAgentConfig, + AgentConfigStatus, + Output, + NewOutput, + OutputType, + EnrollmentAPIKey, + EnrollmentAPIKeySOAttributes, + Installation, + InstallationStatus, + PackageInfo, + RegistryVarsEntry, + Dataset, + AssetReference, + ElasticsearchAssetType, + IngestAssetType, + RegistryPackage, + AssetType, + Installable, + KibanaAssetType, + AssetParts, + AssetsGroupedByServiceByType, + CategoryId, + CategorySummaryList, + IndexTemplate, + RegistrySearchResults, + RegistrySearchResult, + DefaultPackages, +} from '../../common'; + +export type CallESAsCurrentUser = ScopedClusterClient['callAsCurrentUser']; export type AgentConfigUpdateHandler = ( action: 'created' | 'updated' | 'deleted', agentConfigId: string ) => Promise; + +export * from './models'; +export * from './rest_spec'; diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent.ts b/x-pack/plugins/ingest_manager/server/types/models/agent.ts new file mode 100644 index 0000000000000..276dddf9e3d1c --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/models/agent.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { AGENT_TYPE_EPHEMERAL, AGENT_TYPE_PERMANENT, AGENT_TYPE_TEMPORARY } from '../../../common'; + +export const AgentTypeSchema = schema.oneOf([ + schema.literal(AGENT_TYPE_EPHEMERAL), + schema.literal(AGENT_TYPE_PERMANENT), + schema.literal(AGENT_TYPE_TEMPORARY), +]); + +const AgentEventBase = { + type: schema.oneOf([ + schema.literal('STATE'), + schema.literal('ERROR'), + schema.literal('ACTION_RESULT'), + schema.literal('ACTION'), + ]), + subtype: schema.oneOf([ + // State + schema.literal('RUNNING'), + schema.literal('STARTING'), + schema.literal('IN_PROGRESS'), + schema.literal('CONFIG'), + schema.literal('FAILED'), + schema.literal('STOPPING'), + schema.literal('STOPPED'), + // Action results + schema.literal('DATA_DUMP'), + // Actions + schema.literal('ACKNOWLEDGED'), + schema.literal('UNKNOWN'), + ]), + timestamp: schema.string(), + message: schema.string(), + payload: schema.maybe(schema.any()), + agent_id: schema.string(), + action_id: schema.maybe(schema.string()), + config_id: schema.maybe(schema.string()), + stream_id: schema.maybe(schema.string()), +}; + +export const AgentEventSchema = schema.object({ + ...AgentEventBase, +}); diff --git a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts index d3acb0c167837..040b2eb16289a 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/agent_config.ts @@ -3,17 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; import { DatasourceSchema } from './datasource'; - -export enum AgentConfigStatus { - Active = 'active', - Inactive = 'inactive', -} +import { AgentConfigStatus } from '../../../common'; const AgentConfigBaseSchema = { name: schema.string(), - namespace: schema.string(), + namespace: schema.maybe(schema.string()), description: schema.maybe(schema.string()), }; @@ -32,7 +28,3 @@ export const AgentConfigSchema = schema.object({ updated_on: schema.string(), updated_by: schema.string(), }); - -export type NewAgentConfig = TypeOf; - -export type AgentConfig = TypeOf; diff --git a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts index 4179d4c3a511a..94d0a1cc1aabf 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/datasource.ts @@ -3,40 +3,37 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; +export { Datasource, NewDatasource } from '../../../common'; const DatasourceBaseSchema = { name: schema.string(), + description: schema.maybe(schema.string()), namespace: schema.maybe(schema.string()), - read_alias: schema.maybe(schema.string()), - agent_config_id: schema.string(), + config_id: schema.string(), + enabled: schema.boolean(), package: schema.maybe( schema.object({ - assets: schema.arrayOf( - schema.object({ - id: schema.string(), - type: schema.string(), - }) - ), - description: schema.string(), name: schema.string(), title: schema.string(), version: schema.string(), }) ), - streams: schema.arrayOf( + output_id: schema.string(), + inputs: schema.arrayOf( schema.object({ - config: schema.recordOf(schema.string(), schema.any()), - input: schema.object({ - type: schema.string(), - config: schema.recordOf(schema.string(), schema.any()), - fields: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), - ilm_policy: schema.maybe(schema.string()), - index_template: schema.maybe(schema.string()), - ingest_pipelines: schema.maybe(schema.arrayOf(schema.string())), - }), - output_id: schema.string(), + type: schema.string(), + enabled: schema.boolean(), processors: schema.maybe(schema.arrayOf(schema.string())), + streams: schema.arrayOf( + schema.object({ + id: schema.string(), + enabled: schema.boolean(), + dataset: schema.string(), + processors: schema.maybe(schema.arrayOf(schema.string())), + config: schema.recordOf(schema.string(), schema.any()), + }) + ), }) ), }; @@ -49,7 +46,3 @@ export const DatasourceSchema = schema.object({ ...DatasourceBaseSchema, id: schema.string(), }); - -export type NewDatasource = TypeOf; - -export type Datasource = TypeOf; diff --git a/x-pack/plugins/ingest_manager/server/types/models/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/server/types/models/enrollment_api_key.ts new file mode 100644 index 0000000000000..e563b39e53f50 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/models/enrollment_api_key.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +export const EnrollmentAPIKeySchema = schema.object({ + id: schema.string(), + api_key_id: schema.string(), + api_key: schema.string(), + name: schema.maybe(schema.string()), + active: schema.boolean(), + config_id: schema.maybe(schema.string()), +}); + +export const EnrollmentAPIKeySOAttributesSchema = schema.object({ + api_key_id: schema.string(), + api_key: schema.string(), + name: schema.maybe(schema.string()), + active: schema.boolean(), + config_id: schema.maybe(schema.string()), + // ASK: Is this allowUnknown? How do we type this with config-schema? + // [k: string]: schema.any(), // allow to use it as saved object attributes type +}); diff --git a/x-pack/plugins/ingest_manager/server/types/models/index.ts b/x-pack/plugins/ingest_manager/server/types/models/index.ts index 959dfe1d937b9..7da36c8a18ad2 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/index.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/index.ts @@ -4,5 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ export * from './agent_config'; +export * from './agent'; export * from './datasource'; export * from './output'; +export * from './enrollment_api_key'; diff --git a/x-pack/plugins/ingest_manager/server/types/models/output.ts b/x-pack/plugins/ingest_manager/server/types/models/output.ts index 610fa6796cc2d..8c8f4c76af7fe 100644 --- a/x-pack/plugins/ingest_manager/server/types/models/output.ts +++ b/x-pack/plugins/ingest_manager/server/types/models/output.ts @@ -3,7 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { schema } from '@kbn/config-schema'; +export { Output, NewOutput } from '../../../common'; export enum OutputType { Elasticsearch = 'elasticsearch', @@ -12,10 +13,6 @@ export enum OutputType { const OutputBaseSchema = { name: schema.string(), type: schema.oneOf([schema.literal(OutputType.Elasticsearch)]), - username: schema.maybe(schema.string()), - password: schema.maybe(schema.string()), - index_name: schema.maybe(schema.string()), - ingest_pipeline: schema.maybe(schema.string()), hosts: schema.maybe(schema.arrayOf(schema.string())), api_key: schema.maybe(schema.string()), admin_username: schema.maybe(schema.string()), @@ -31,7 +28,3 @@ export const OutputSchema = schema.object({ ...OutputBaseSchema, id: schema.string(), }); - -export type NewOutput = TypeOf; - -export type Output = TypeOf; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts new file mode 100644 index 0000000000000..92422274d5cf4 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { AgentEventSchema, AgentTypeSchema } from '../models'; + +export const GetAgentsRequestSchema = { + query: schema.object({ + page: schema.number({ defaultValue: 1 }), + perPage: schema.number({ defaultValue: 20 }), + kuery: schema.maybe(schema.string()), + showInactive: schema.boolean({ defaultValue: false }), + }), +}; + +export const GetOneAgentRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), +}; + +export const PostAgentCheckinRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), + body: schema.object({ + local_metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), + events: schema.maybe(schema.arrayOf(AgentEventSchema)), + }), +}; + +export const PostAgentEnrollRequestSchema = { + body: schema.object({ + type: AgentTypeSchema, + shared_id: schema.maybe(schema.string()), + metadata: schema.object({ + local: schema.recordOf(schema.string(), schema.any()), + user_provided: schema.recordOf(schema.string(), schema.any()), + }), + }), +}; + +export const PostAgentAcksRequestSchema = { + body: schema.object({ + action_ids: schema.arrayOf(schema.string()), + }), + params: schema.object({ + agentId: schema.string(), + }), +}; + +export const PostAgentUnenrollRequestSchema = { + body: schema.oneOf([ + schema.object({ + kuery: schema.string(), + }), + schema.object({ + ids: schema.arrayOf(schema.string()), + }), + ]), +}; + +export const GetOneAgentEventsRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), + query: schema.object({ + page: schema.number({ defaultValue: 1 }), + perPage: schema.number({ defaultValue: 20 }), + kuery: schema.maybe(schema.string()), + }), +}; + +export const DeleteAgentRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), +}; + +export const UpdateAgentRequestSchema = { + params: schema.object({ + agentId: schema.string(), + }), + body: schema.object({ + user_provided_metadata: schema.recordOf(schema.string(), schema.any()), + }), +}; + +export const GetAgentStatusRequestSchema = { + query: schema.object({ + configId: schema.maybe(schema.string()), + }), +}; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts index cb4680a4eed04..7c40cc1b70009 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts @@ -4,58 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ import { schema } from '@kbn/config-schema'; -import { AgentConfig, NewAgentConfigSchema } from '../models'; -import { ListWithKuerySchema } from './common'; +import { NewAgentConfigSchema } from '../models'; +import { ListWithKuerySchema } from './index'; export const GetAgentConfigsRequestSchema = { query: ListWithKuerySchema, }; -export interface GetAgentConfigsResponse { - items: AgentConfig[]; - total: number; - page: number; - perPage: number; - success: boolean; -} - export const GetOneAgentConfigRequestSchema = { params: schema.object({ agentConfigId: schema.string(), }), }; -export interface GetOneAgentConfigResponse { - item: AgentConfig; - success: boolean; -} - export const CreateAgentConfigRequestSchema = { body: NewAgentConfigSchema, }; -export interface CreateAgentConfigResponse { - item: AgentConfig; - success: boolean; -} - export const UpdateAgentConfigRequestSchema = { ...GetOneAgentConfigRequestSchema, body: NewAgentConfigSchema, }; -export interface UpdateAgentConfigResponse { - item: AgentConfig; - success: boolean; -} - export const DeleteAgentConfigsRequestSchema = { body: schema.object({ agentConfigIds: schema.arrayOf(schema.string()), }), }; -export type DeleteAgentConfigsResponse = Array<{ - id: string; - success: boolean; -}>; +export const GetFullAgentConfigRequestSchema = { + params: schema.object({ + agentConfigId: schema.string(), + }), +}; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/datasource.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/datasource.ts index 5c165517e9c9d..fce2c94b282bd 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/datasource.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/datasource.ts @@ -5,7 +5,7 @@ */ import { schema } from '@kbn/config-schema'; import { NewDatasourceSchema } from '../models'; -import { ListWithKuerySchema } from './common'; +import { ListWithKuerySchema } from './index'; export const GetDatasourcesRequestSchema = { query: ListWithKuerySchema, @@ -31,8 +31,3 @@ export const DeleteDatasourcesRequestSchema = { datasourceIds: schema.arrayOf(schema.string()), }), }; - -export type DeleteDatasourcesResponse = Array<{ - id: string; - success: boolean; -}>; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/enrollment_api_key.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/enrollment_api_key.ts new file mode 100644 index 0000000000000..ff342bd165770 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/enrollment_api_key.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const GetEnrollmentAPIKeysRequestSchema = { + query: schema.object({ + page: schema.number({ defaultValue: 1 }), + perPage: schema.number({ defaultValue: 20 }), + kuery: schema.maybe(schema.string()), + }), +}; + +export const GetOneEnrollmentAPIKeyRequestSchema = { + params: schema.object({ + keyId: schema.string(), + }), +}; + +export const DeleteEnrollmentAPIKeyRequestSchema = { + params: schema.object({ + keyId: schema.string(), + }), +}; + +export const PostEnrollmentAPIKeyRequestSchema = { + body: schema.object({ + name: schema.maybe(schema.string()), + config_id: schema.string(), + expiration: schema.maybe(schema.string()), + }), +}; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts new file mode 100644 index 0000000000000..2ca83276b0228 --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +export const GetPackagesRequestSchema = { + query: schema.object({ + category: schema.maybe(schema.string()), + }), +}; + +export const GetFileRequestSchema = { + params: schema.object({ + pkgkey: schema.string(), + filePath: schema.string(), + }), +}; + +export const GetInfoRequestSchema = { + params: schema.object({ + pkgkey: schema.string(), + }), +}; + +export const InstallPackageRequestSchema = { + params: schema.object({ + pkgkey: schema.string(), + }), +}; + +export const DeletePackageRequestSchema = { + params: schema.object({ + pkgkey: schema.string(), + }), +}; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/index.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/index.ts index 7d0d7e67f2db0..c143cd3b35f91 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/index.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/index.ts @@ -4,6 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ export * from './common'; -export * from './datasource'; export * from './agent_config'; +export * from './agent'; +export * from './datasource'; +export * from './epm'; +export * from './enrollment_api_key'; export * from './fleet_setup'; +export * from './install_script'; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts new file mode 100644 index 0000000000000..cf676129cce7a --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/install_script.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +export const InstallScriptRequestSchema = { + params: schema.object({ + osType: schema.oneOf([schema.literal('macos')]), + }), +}; diff --git a/x-pack/plugins/ingest_manager/yarn.lock b/x-pack/plugins/ingest_manager/yarn.lock new file mode 120000 index 0000000000000..6e09764ec763b --- /dev/null +++ b/x-pack/plugins/ingest_manager/yarn.lock @@ -0,0 +1 @@ +../../../yarn.lock \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index 34b2c5e324187..7a0196adbfffd 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -115,6 +115,7 @@ export default function({ getService }: FtrProviderContext) { 'uptime', 'siem', 'endpoint', + 'ingestManager', ].sort() ); }); diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/api_integration/apis/fleet/agents/acks.ts new file mode 100644 index 0000000000000..1ab54554d62f0 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/acks.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import uuid from 'uuid'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getSupertestWithoutAuth } from './services'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const esClient = getService('es'); + + const supertest = getSupertestWithoutAuth(providerContext); + let apiKey: { id: string; api_key: string }; + + describe('fleet_agents_acks', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + + const { body: apiKeyBody } = await esClient.security.createApiKey({ + body: { + name: `test access api key: ${uuid.v4()}`, + }, + }); + apiKey = apiKeyBody; + const { + body: { _source: agentDoc }, + } = await esClient.get({ + index: '.kibana', + id: 'agents:agent1', + }); + agentDoc.agents.access_api_key_id = apiKey.id; + await esClient.update({ + index: '.kibana', + id: 'agents:agent1', + refresh: 'true', + body: { + doc: agentDoc, + }, + }); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 401 if this a not a valid acks access', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/acks`) + .set('kbn-xsrf', 'xx') + .set('Authorization', 'ApiKey NOT_A_VALID_TOKEN') + .send({ + action_ids: [], + }) + .expect(401); + }); + + it('should return a 200 if this a valid acks access', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/acks`) + .set('kbn-xsrf', 'xx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + action_ids: ['action1'], + }) + .expect(200); + + expect(apiResponse.action).to.be('acks'); + expect(apiResponse.success).to.be(true); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts new file mode 100644 index 0000000000000..ca51676126e73 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/checkin.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import uuid from 'uuid'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getSupertestWithoutAuth } from './services'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const esArchiver = getService('esArchiver'); + const esClient = getService('es'); + + const supertest = getSupertestWithoutAuth(providerContext); + let apiKey: { id: string; api_key: string }; + + describe('fleet_agents_checkin', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + + const { body: apiKeyBody } = await esClient.security.createApiKey({ + body: { + name: `test access api key: ${uuid.v4()}`, + }, + }); + apiKey = apiKeyBody; + const { + body: { _source: agentDoc }, + } = await esClient.get({ + index: '.kibana', + id: 'agents:agent1', + }); + agentDoc.agents.access_api_key_id = apiKey.id; + await esClient.update({ + index: '.kibana', + id: 'agents:agent1', + refresh: 'true', + body: { + doc: agentDoc, + }, + }); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 401 if this a not a valid checkin access', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/checkin`) + .set('kbn-xsrf', 'xx') + .set('Authorization', 'ApiKey NOT_A_VALID_TOKEN') + .send({ + events: [], + }) + .expect(401); + }); + + it('should return a 400 if for a malformed request payload', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/checkin`) + .set('kbn-xsrf', 'xx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + events: ['i-am-not-valid-event'], + metadata: {}, + }) + .expect(400); + }); + + it('should return a 200 if this a valid checkin access', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/agent1/checkin`) + .set('kbn-xsrf', 'xx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + events: [ + { + type: 'STATE', + timestamp: '2019-01-04T14:32:03.36764-05:00', + subtype: 'STARTING', + message: 'State change: STARTING', + agent_id: 'agent1', + }, + ], + local_metadata: { + cpu: 12, + }, + }) + .expect(200); + + expect(apiResponse.action).to.be('checkin'); + expect(apiResponse.success).to.be(true); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts new file mode 100644 index 0000000000000..666d97452ad3d --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/enroll.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import uuid from 'uuid'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { getSupertestWithoutAuth, setupIngest } from './services'; + +export default function(providerContext: FtrProviderContext) { + const { getService } = providerContext; + + const esArchiver = getService('esArchiver'); + const esClient = getService('es'); + + const supertest = getSupertestWithoutAuth(providerContext); + let apiKey: { id: string; api_key: string }; + + describe('fleet_agents_enroll', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + + const { body: apiKeyBody } = await esClient.security.createApiKey({ + body: { + name: `test access api key: ${uuid.v4()}`, + }, + }); + apiKey = apiKeyBody; + const { + body: { _source: enrollmentApiKeyDoc }, + } = await esClient.get({ + index: '.kibana', + id: 'enrollment_api_keys:ed22ca17-e178-4cfe-8b02-54ea29fbd6d0', + }); + // @ts-ignore + enrollmentApiKeyDoc.enrollment_api_keys.api_key_id = apiKey.id; + await esClient.update({ + index: '.kibana', + id: 'enrollment_api_keys:ed22ca17-e178-4cfe-8b02-54ea29fbd6d0', + refresh: 'true', + body: { + doc: enrollmentApiKeyDoc, + }, + }); + }); + setupIngest(providerContext); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should not allow to enroll an agent with a invalid enrollment', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/enroll`) + .set('kbn-xsrf', 'xxx') + .set('Authorization', 'ApiKey NOTAVALIDKEY') + .send({ + type: 'PERMANENT', + metadata: { + local: {}, + user_provided: {}, + }, + }) + .expect(401); + }); + + it('should not allow to enroll an agent with a shared id if it already exists ', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/enroll`) + .set('kbn-xsrf', 'xxx') + .set( + 'authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + shared_id: 'agent2_filebeat', + type: 'PERMANENT', + metadata: { + local: {}, + user_provided: {}, + }, + }) + .expect(400); + expect(apiResponse.message).to.match(/Impossible to enroll an already active agent/); + }); + + it('should allow to enroll an agent with a valid enrollment token', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/agents/enroll`) + .set('kbn-xsrf', 'xxx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + type: 'PERMANENT', + metadata: { + local: {}, + user_provided: {}, + }, + }) + .expect(200); + expect(apiResponse.success).to.eql(true); + expect(apiResponse.item).to.have.keys('id', 'active', 'access_api_key', 'type', 'config_id'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/agents/events.ts b/x-pack/test/api_integration/apis/fleet/agents/events.ts new file mode 100644 index 0000000000000..ac5eb9d1779d8 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/events.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_agents_events', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 200 and the events for a given agent', async () => { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/agent1/events`) + .expect(200); + expect(apiResponse).to.have.keys(['list', 'total', 'page']); + expect(apiResponse.total).to.be(2); + expect(apiResponse.page).to.be(1); + + const event = apiResponse.list[0]; + expect(event).to.have.keys('type', 'subtype', 'message', 'payload'); + expect(event.payload).to.have.keys('previous_state'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/agents/services.ts b/x-pack/test/api_integration/apis/fleet/agents/services.ts new file mode 100644 index 0000000000000..5c111b8ea9a84 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/agents/services.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import supertestAsPromised from 'supertest-as-promised'; +import url from 'url'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export function getSupertestWithoutAuth({ getService }: FtrProviderContext) { + const config = getService('config'); + const kibanaUrl = config.get('servers.kibana'); + kibanaUrl.auth = null; + kibanaUrl.password = null; + + return supertestAsPromised(url.format(kibanaUrl)); +} + +export function setupIngest({ getService }: FtrProviderContext) { + before(async () => { + await getService('supertest') + .post(`/api/ingest_manager/setup`) + .set('kbn-xsrf', 'xxx') + .send(); + await getService('supertest') + .post(`/api/ingest_manager/fleet/setup`) + .set('kbn-xsrf', 'xxx') + .send({ + admin_username: 'elastic', + admin_password: 'changeme', + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/delete_agent.ts b/x-pack/test/api_integration/apis/fleet/delete_agent.ts new file mode 100644 index 0000000000000..bddfcfbf6715e --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/delete_agent.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const security = getService('security'); + const users: { [rollName: string]: { username: string; password: string; permissions?: any } } = { + fleet_user: { + permissions: { + feature: { + ingestManager: ['read'], + }, + spaces: ['*'], + }, + username: 'fleet_user', + password: 'changeme', + }, + fleet_admin: { + permissions: { + feature: { + ingestManager: ['all'], + }, + spaces: ['*'], + }, + username: 'fleet_admin', + password: 'changeme', + }, + }; + describe('fleet_delete_agent', () => { + before(async () => { + for (const roleName in users) { + if (users.hasOwnProperty(roleName)) { + const user = users[roleName]; + + if (user.permissions) { + await security.role.create(roleName, { + kibana: [user.permissions], + }); + } + + // Import a repository first + await security.user.create(user.username, { + password: user.password, + roles: [roleName], + full_name: user.username, + }); + } + } + + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return a 404 if user lacks fleet-write permissions', async () => { + const { body: apiResponse } = await supertest + .delete(`/api/ingest_manager/fleet/agents/agent1`) + .auth(users.fleet_user.username, users.fleet_user.password) + .set('kbn-xsrf', 'xx') + .expect(404); + + expect(apiResponse).not.to.eql({ + success: true, + action: 'deleted', + }); + }); + + it('should return a 404 if there is no agent to delete', async () => { + await supertest + .delete(`/api/ingest_manager/fleet/agents/i-do-not-exist`) + .auth(users.fleet_admin.username, users.fleet_admin.password) + .set('kbn-xsrf', 'xx') + .expect(404); + }); + + it('should return a 200 after deleting an agent', async () => { + const { body: apiResponse } = await supertest + .delete(`/api/ingest_manager/fleet/agents/agent1`) + .auth(users.fleet_admin.username, users.fleet_admin.password) + .set('kbn-xsrf', 'xx') + .expect(200); + expect(apiResponse).to.eql({ + success: true, + action: 'deleted', + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts b/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts new file mode 100644 index 0000000000000..800e0147528e5 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { setupIngest } from '../agents/services'; + +const ENROLLMENT_KEY_ID = 'ed22ca17-e178-4cfe-8b02-54ea29fbd6d0'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_enrollment_api_keys_crud', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + setupIngest({ getService } as FtrProviderContext); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + describe('GET /fleet/enrollment-api-keys', async () => { + it('should list existing api keys', async () => { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/enrollment-api-keys`) + .expect(200); + + expect(apiResponse.total).to.be(2); + expect(apiResponse.list[0]).to.have.keys('id', 'api_key_id', 'name'); + }); + }); + + describe('GET /fleet/enrollment-api-keys/{id}', async () => { + it('should allow to retrieve existing api keys', async () => { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/enrollment-api-keys/${ENROLLMENT_KEY_ID}`) + .expect(200); + + expect(apiResponse.item).to.have.keys('id', 'api_key_id', 'name'); + }); + }); + + describe('GET /fleet/enrollment-api-keys/{id}', async () => { + it('should allow to retrieve existing api keys', async () => { + const { body: apiResponse } = await supertest + .delete(`/api/ingest_manager/fleet/enrollment-api-keys/${ENROLLMENT_KEY_ID}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + expect(apiResponse.success).to.eql(true); + }); + }); + + describe('POST /fleet/enrollment-api-keys', () => { + it('should not accept bad parameters', async () => { + await supertest + .post(`/api/ingest_manager/fleet/enrollment-api-keys`) + .set('kbn-xsrf', 'xxx') + .send({ + raoul: 'raoul', + }) + .expect(400); + }); + + it('should allow to create an enrollment api key with a policy', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest_manager/fleet/enrollment-api-keys`) + .set('kbn-xsrf', 'xxx') + .send({ + config_id: 'policy1', + }) + .expect(200); + + expect(apiResponse.success).to.eql(true); + expect(apiResponse.item).to.have.keys('id', 'api_key', 'api_key_id', 'name', 'config_id'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js new file mode 100644 index 0000000000000..69d30291f030b --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function loadTests({ loadTestFile }) { + describe('Fleet Endpoints', () => { + loadTestFile(require.resolve('./delete_agent')); + loadTestFile(require.resolve('./list_agent')); + loadTestFile(require.resolve('./unenroll_agent')); + loadTestFile(require.resolve('./agents/enroll')); + loadTestFile(require.resolve('./agents/checkin')); + loadTestFile(require.resolve('./agents/events')); + loadTestFile(require.resolve('./agents/acks')); + loadTestFile(require.resolve('./enrollment_api_keys/crud')); + loadTestFile(require.resolve('./install')); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/install.ts b/x-pack/test/api_integration/apis/fleet/install.ts new file mode 100644 index 0000000000000..f5d8a06227151 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/install.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('fleet_install', () => { + it('should return a 400 if we try download an install script for a not supported OS', async () => { + await supertest.get(`/api/ingest_manager/fleet/install/gameboy`).expect(400); + }); + + it('should return an install script for a supported OS', async () => { + const { text: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/install/macos`) + .expect(200); + expect(apiResponse).match(/^#!\/bin\/sh/); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/list_agent.ts b/x-pack/test/api_integration/apis/fleet/list_agent.ts new file mode 100644 index 0000000000000..e9798c71e030e --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/list_agent.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const security = getService('security'); + const users: { [rollName: string]: { username: string; password: string; permissions?: any } } = { + kibana_basic_user: { + permissions: { + feature: { + dashboards: ['read'], + }, + spaces: ['*'], + }, + username: 'kibana_basic_user', + password: 'changeme', + }, + fleet_user: { + permissions: { + feature: { + ingestManager: ['read'], + }, + spaces: ['*'], + }, + username: 'fleet_user', + password: 'changeme', + }, + fleet_admin: { + permissions: { + feature: { + ingestManager: ['all'], + }, + spaces: ['*'], + }, + username: 'fleet_admin', + password: 'changeme', + }, + }; + + describe('fleet_list_agent', () => { + before(async () => { + for (const roleName in users) { + if (users.hasOwnProperty(roleName)) { + const user = users[roleName]; + + if (user.permissions) { + await security.role.create(roleName, { + kibana: [user.permissions], + }); + } + + // Import a repository first + await security.user.create(user.username, { + password: user.password, + roles: [roleName], + full_name: user.username, + }); + } + } + + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should return the list of agents when requesting as a user with fleet write permissions', async () => { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents`) + .auth(users.fleet_admin.username, users.fleet_admin.password) + .expect(200); + + expect(apiResponse).to.have.keys('success', 'page', 'total', 'list'); + expect(apiResponse.success).to.eql(true); + expect(apiResponse.total).to.eql(4); + }); + it('should return the list of agents when requesting as a user with fleet read permissions', async () => { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents`) + .auth(users.fleet_user.username, users.fleet_user.password) + .expect(200); + expect(apiResponse).to.have.keys('success', 'page', 'total', 'list'); + expect(apiResponse.success).to.eql(true); + expect(apiResponse.total).to.eql(4); + }); + it('should not return the list of agents when requesting as a user without fleet permissions', async () => { + await supertest + .get(`/api/ingest_manager/fleet/agents`) + .auth(users.kibana_basic_user.username, users.kibana_basic_user.password) + .expect(404); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts new file mode 100644 index 0000000000000..4b6b28e3d6350 --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_unenroll_agent', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should not allow both ids and kuery in the payload', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + ids: ['agent:1'], + kuery: ['agents.id:1'], + }) + .expect(400); + }); + + it('should not allow no ids or kuery in the payload', async () => { + await supertest + .post(`/api/ingest_manager/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({}) + .expect(400); + }); + + it('allow to unenroll using a list of ids', async () => { + const { body } = await supertest + .post(`/api/ingest_manager/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + ids: ['agent1'], + }) + .expect(200); + + expect(body).to.have.keys('results', 'success'); + expect(body.success).to.be(true); + expect(body.results).to.have.length(1); + expect(body.results[0].success).to.be(true); + }); + + it('allow to unenroll using a kibana query', async () => { + const { body } = await supertest + .post(`/api/ingest_manager/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + kuery: 'agents.shared_id:agent2_filebeat OR agents.shared_id:agent3_metricbeat', + }) + .expect(200); + + expect(body).to.have.keys('results', 'success'); + expect(body.success).to.be(true); + expect(body.results).to.have.length(2); + expect(body.results[0].success).to.be(true); + + const agentsUnenrolledIds = body.results.map((r: { id: string }) => r.id); + + expect(agentsUnenrolledIds).to.contain('agent2'); + expect(agentsUnenrolledIds).to.contain('agent3'); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 5af941dde525f..0a87dcb4b5bb0 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -27,6 +27,8 @@ export default function({ loadTestFile }) { loadTestFile(require.resolve('./siem')); loadTestFile(require.resolve('./short_urls')); loadTestFile(require.resolve('./lens')); + loadTestFile(require.resolve('./fleet')); + loadTestFile(require.resolve('./ingest')); loadTestFile(require.resolve('./endpoint')); loadTestFile(require.resolve('./ml')); }); diff --git a/x-pack/test/api_integration/apis/ingest/index.js b/x-pack/test/api_integration/apis/ingest/index.js new file mode 100644 index 0000000000000..5dac999a86167 --- /dev/null +++ b/x-pack/test/api_integration/apis/ingest/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function loadTests({ loadTestFile }) { + describe('Ingest Endpoints', () => { + loadTestFile(require.resolve('./policies')); + }); +} diff --git a/x-pack/test/api_integration/apis/ingest/policies.ts b/x-pack/test/api_integration/apis/ingest/policies.ts new file mode 100644 index 0000000000000..bfc77952ca325 --- /dev/null +++ b/x-pack/test/api_integration/apis/ingest/policies.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + function useFixtures() { + before(async () => { + await esArchiver.loadIfNeeded('ingest/policies'); + }); + after(async () => { + await esArchiver.unload('ingest/policies'); + }); + } + + describe.skip('ingest_policies', () => { + describe('POST /api/ingest/policies', () => { + useFixtures(); + + it('should return a 400 if the request is not valid', async () => { + await supertest + .post(`/api/ingest/policies`) + .set('kbn-xsrf', 'xxx') + .send({}) + .expect(400); + }); + + it('should allow to create a new policy', async () => { + const { body: apiResponse } = await supertest + .post(`/api/ingest/policies`) + .set('kbn-xsrf', 'xxx') + .send({ + name: 'Policy from test 1', + description: 'I am a policy', + }) + .expect(200); + + expect(apiResponse.success).to.eql(true); + expect(apiResponse).to.have.keys('success', 'item', 'action'); + expect(apiResponse.item).to.have.keys('id', 'name', 'status', 'description'); + }); + }); + describe('GET /api/ingest/policies', () => { + useFixtures(); + it('should return the list of policies grouped by shared id', async () => { + const { body: apiResponse } = await supertest.get(`/api/ingest/policies`).expect(200); + expect(apiResponse).to.have.keys('success', 'page', 'total', 'list'); + expect(apiResponse.success).to.eql(true); + const policiesIds = (apiResponse.list as Array<{ id: string }>).map(i => i.id); + expect(policiesIds.length).to.eql(3); + expect(policiesIds).to.contain('1'); + expect(policiesIds).to.contain('3'); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 4068b88cd30bc..0b29fc1cac7de 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -35,6 +35,7 @@ export default function({ getService }: FtrProviderContext) { apm: ['all', 'read'], siem: ['all', 'read'], endpoint: ['all', 'read'], + ingestManager: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/api_integration/config.js b/x-pack/test/api_integration/config.js index 70a7763bca6c5..182a9105a7df8 100644 --- a/x-pack/test/api_integration/config.js +++ b/x-pack/test/api_integration/config.js @@ -26,6 +26,8 @@ export async function getApiIntegrationConfig({ readConfigFile }) { '--xpack.security.session.idleTimeout=3600000', // 1 hour '--optimize.enabled=false', '--xpack.endpoint.enabled=true', + '--xpack.ingestManager.enabled=true', + '--xpack.ingestManager.fleet.enabled=true', '--xpack.endpoint.alertResultListDefaultDateRange.from=2018-01-10T00:00:00.000Z', ], }, diff --git a/x-pack/test/epm_api_integration/apis/file.ts b/x-pack/test/epm_api_integration/apis/file.ts new file mode 100644 index 0000000000000..2989263af40a7 --- /dev/null +++ b/x-pack/test/epm_api_integration/apis/file.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import ServerMock from 'mock-http-server'; +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('package file', () => { + const server = new ServerMock({ host: 'localhost', port: 6666 }); + beforeEach(() => { + server.start(() => {}); + }); + afterEach(() => { + server.stop(() => {}); + }); + it('fetches a .png screenshot image', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png', + reply: { + headers: { 'content-type': 'image/png' }, + }, + }); + + const supertest = getService('supertest'); + await supertest + .get( + '/api/ingest_manager/epm/packages/auditd-2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/png') + .expect(200); + }); + + it('fetches an .svg icon image', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/img/icon.svg', + reply: { + headers: { 'content-type': 'image/svg' }, + }, + }); + + const supertest = getService('supertest'); + await supertest + .get('/api/ingest_manager/epm/packages/auditd-2.0.4/img/icon.svg') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'image/svg'); + }); + + it('fetches an auditbeat .conf rule file', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf', + }); + + const supertest = getService('supertest'); + await supertest + .get( + '/api/ingest_manager/epm/packages/auditd-2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + + it('fetches an auditbeat .yml config file', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/auditbeat/config/config.yml', + reply: { + headers: { 'content-type': 'text/yaml; charset=UTF-8' }, + }, + }); + + const supertest = getService('supertest'); + await supertest + .get('/api/ingest_manager/epm/packages/auditd-2.0.4/auditbeat/config/config.yml') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'text/yaml; charset=UTF-8') + .expect(200); + }); + + it('fetches a .json kibana visualization file', async () => { + server.on({ + method: 'GET', + path: + '/package/auditd-2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json', + }); + + const supertest = getService('supertest'); + await supertest + .get( + '/api/ingest_manager/epm/packages/auditd-2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + + it('fetches a .json kibana dashboard file', async () => { + server.on({ + method: 'GET', + path: + '/package/auditd-2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json', + }); + + const supertest = getService('supertest'); + await supertest + .get( + '/api/ingest_manager/epm/packages/auditd-2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + + it('fetches an .json index pattern file', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/kibana/index-pattern/auditbeat-*.json', + }); + + const supertest = getService('supertest'); + await supertest + .get('/api/ingest_manager/epm/packages/auditd-2.0.4/kibana/index-pattern/auditbeat-*.json') + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + + it('fetches a .json search file', async () => { + server.on({ + method: 'GET', + path: '/package/auditd-2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json', + }); + + const supertest = getService('supertest'); + await supertest + .get( + '/api/ingest_manager/epm/packages/auditd-2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json' + ) + .set('kbn-xsrf', 'xxx') + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200); + }); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz b/x-pack/test/epm_api_integration/apis/fixtures/packages/epr/yamlpipeline_1.0.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..ca8695f111d023b24c6ebf0e5c230a6cc79dd4a5 GIT binary patch literal 1996 zcmV;-2Q&B|iwFP&aOPbA1MOPtZyUK0_vig9SRDe%ZM>1JSD_$v4|fOjbxG0Om%=p! zYL}8&Q_D5Ub>ay6-#aAt?bRz$bPbL_AhARaXGqR)9u%MOip4Z0j7H?D=Xd??tBX^k z3m6ZF<}aZB*L?2vhvVL03?By<-QM+RJib7~lh339iBwo1bRjrbyXf}yf1`MMuKyK| z=$uI9KdsnFWM~DC27|5oAN2dF{zv0s?;7+!ydGR%pzcYe@4;_e|8p)@SWO>^kd#Lg zWK6*GBD^9KR5lJzQN^I`-_VBsnKq&r2lseEypYI1&{!EBfASFeWl3e$ivk`gOe2Y~ zVTm%HzE_hQU_};OP$DPjmhpwW^8{f8OAtIG3VR--0g234ENS4Wrx-rd2!;u)rF$^o zA)$h-NTen(5yG%kG>`;~V5u7rN`?9>3WCRW!QY{`98s94^vwSg&-=Aia~3q5{}3zK zaCN#kaJc`^&OdmBk@NrOzz+Sx`8$mb9IyXjujk_bS+Ga{74P}E)^NQ3$K!s_>Hi!! zO8+!kKwfy2(I09LN9+H(-@P6>{htG0r2l;2ei35(|33$= zuCA)dd!E`uWq?8|B?%Ph9sR%s`SI<0^tbo#-Xfiv`(7+~K&0eC>b&|231Z3ylVc+^ zr-X$Qv;qoUA=pOP>jhEMw2wSOlI}ykzn~FjDG6OfAZj|tlqCX^dnFQL*lQ!JF>hp0 zm7zzO;ptjx9E{~w=NMz9h=8qVzgQ~@eG0GQ4Z3}?hGqKi+f)Q9Lbmqr5gj-os?QU z`I3Gr@y`0-AFOrC@AZJ2Su!_dtyGR6zzmA0X~090311Q%5;2`KypYcsW<#vJvZTb; zT^rfnZUYUQTvbxFZ>t4tXMUf|KxIS`*~tG{_YM&&{#X-{e#vZb7YiQc4Tc@~(YnbB z!9{H|9x+RRLug3&akMVn3Q^fl>e{6CyRu*GcwV2}SF4Tqz;{~z|d&i|hS zyZwKpZ-*HDqyxa;^D~skg61%gSw&{}bUs0W`k2|gA1tx>UUj;c=*=6{(cdmRt##`% zCAU{k?e+Sv#@h1vv?|#~3ywqkNO8aWJaI9@`hw}BDrDOI$|N!zEhZ2)Xv9Ef+GqoV zy$rL^ld=IT5Ckg{qBpwjw*BWk%CzrZm&vPL8TmG9-}O0o^r@$M!K zczQ3Rgt1+Vy=~iOGxLaiK!3q<`7@3?m<{98{9>PFXkG$8GEK60P%b8X=jZ*ltQ? zJdHx~@f|R3-?dAkvLUQt2qV!#Eju-8O_dVhFoX~&8-~hcCY68#(&@cK@pcY6eD53{ z|Ka*2VkHI}M3^MUExOa5oOR9JFI{u5w&rGoCYdcaMbvBu;%Cvcx)2>ds}^nhZ}OEE zMt!C4tRIQkB17&eXzQ0&W=8I3_y6xQ8-I@OzJbKT89 zHJTsa9qZoWL^G1EDm1Kg zit8Jy-vq14rNg=BnOO{Wo8;x*OJ$ z+_3)k{FB4i#UB5ElDB|+{67SMVg2|20gyZYe+Hzl`u@Qe_GMrG+|SD{2B1Xttk;-A zr@3iG)r)`UHojdnoPTqb=<7@N6Uo^7`~P@Qd;dKc_gwyW7U;66rL&H)>2Zbdk%XK{GTWl3%*B>Dn0DA9mNA-=)N`iO-s8deIs~h zC`!+Nfy7pYt$RAd5!T;z;|?%&~FQ&$BsSr=hv^qKYQNu zmDpFR$3m0Ur|EU`vSJGTW%Ykyvd>~#I{lKt!z7Ew^rg9OFB&sTG)9)U7V*$B) { + it('setup policy', async () => { + const policyName = 'foo'; + const es = getService('es'); + const policy = { + policy: { + phases: { + hot: { + actions: { + rollover: { + max_size: '50gb', + max_age: '30d', + }, + }, + }, + }, + }, + }; + + const data = await es.transport.request({ + method: 'PUT', + path: '/_ilm/policy/' + policyName, + body: policy, + }); + + expect(data.body.acknowledged).to.eql(true); + expect(data.statusCode).to.eql(200); + }); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/index.js b/x-pack/test/epm_api_integration/apis/index.js new file mode 100644 index 0000000000000..cfdfd5baf1e59 --- /dev/null +++ b/x-pack/test/epm_api_integration/apis/index.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function({ loadTestFile }) { + describe('EPM Endpoints', function() { + this.tags('ciGroup7'); + loadTestFile(require.resolve('./list')); + loadTestFile(require.resolve('./file')); + loadTestFile(require.resolve('./template')); + loadTestFile(require.resolve('./ilm')); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/list.ts b/x-pack/test/epm_api_integration/apis/list.ts new file mode 100644 index 0000000000000..d0d921af6016b --- /dev/null +++ b/x-pack/test/epm_api_integration/apis/list.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import ServerMock from 'mock-http-server'; +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + describe('list', () => { + const server = new ServerMock({ host: 'localhost', port: 6666 }); + beforeEach(() => { + server.start(() => {}); + }); + afterEach(() => { + server.stop(() => {}); + }); + it('lists all packages from the registry', async () => { + const searchResponse = [ + { + description: 'First integration package', + download: '/package/first-1.0.1.tar.gz', + name: 'first', + title: 'First', + type: 'integration', + version: '1.0.1', + }, + { + description: 'Second integration package', + download: '/package/second-2.0.4.tar.gz', + icons: [ + { + src: '/package/second-2.0.4/img/icon.svg', + type: 'image/svg+xml', + }, + ], + name: 'second', + title: 'Second', + type: 'integration', + version: '2.0.4', + }, + ]; + server.on({ + method: 'GET', + path: '/search', + reply: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(searchResponse), + }, + }); + + const supertest = getService('supertest'); + const fetchPackageList = async () => { + const response = await supertest + .get('/api/ingest_manager/epm/packages') + .set('kbn-xsrf', 'xxx') + .expect(200); + return response.body; + }; + + const listResponse = await fetchPackageList(); + expect(listResponse.response.length).to.be(2); + expect(listResponse.response[0]).to.eql({ ...searchResponse[0], status: 'not_installed' }); + expect(listResponse.response[1]).to.eql({ ...searchResponse[1], status: 'not_installed' }); + }); + + it('sorts the packages even if the registry sends them unsorted', async () => { + const searchResponse = [ + { + description: 'BBB integration package', + download: '/package/bbb-1.0.1.tar.gz', + name: 'bbb', + title: 'BBB', + type: 'integration', + version: '1.0.1', + }, + { + description: 'CCC integration package', + download: '/package/ccc-2.0.4.tar.gz', + name: 'ccc', + title: 'CCC', + type: 'integration', + version: '2.0.4', + }, + { + description: 'AAA integration package', + download: '/package/aaa-0.0.1.tar.gz', + name: 'aaa', + title: 'AAA', + type: 'integration', + version: '0.0.1', + }, + ]; + server.on({ + method: 'GET', + path: '/search', + reply: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(searchResponse), + }, + }); + + const supertest = getService('supertest'); + const fetchPackageList = async () => { + const response = await supertest + .get('/api/ingest_manager/epm/packages') + .set('kbn-xsrf', 'xxx') + .expect(200); + return response.body; + }; + + const listResponse = await fetchPackageList(); + + expect(listResponse.response.length).to.be(3); + expect(listResponse.response[0].name).to.eql('aaa'); + expect(listResponse.response[1].name).to.eql('bbb'); + expect(listResponse.response[2].name).to.eql('ccc'); + }); + }); +} diff --git a/x-pack/test/epm_api_integration/apis/mock_http_server.d.ts b/x-pack/test/epm_api_integration/apis/mock_http_server.d.ts new file mode 100644 index 0000000000000..b037445893c95 --- /dev/null +++ b/x-pack/test/epm_api_integration/apis/mock_http_server.d.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// No types for mock-http-server available, but we don't need them. + +declare module 'mock-http-server'; diff --git a/x-pack/test/epm_api_integration/apis/template.ts b/x-pack/test/epm_api_integration/apis/template.ts new file mode 100644 index 0000000000000..923dd1d3676b5 --- /dev/null +++ b/x-pack/test/epm_api_integration/apis/template.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; +import { getTemplate } from '../../../plugins/ingest_manager/server/services/epm/elasticsearch/template/template'; + +export default function({ getService }: FtrProviderContext) { + const indexPattern = 'foo'; + const templateName = 'bar'; + const es = getService('es'); + const mappings = { + properties: { + foo: { + type: 'keyword', + }, + }, + }; + // This test was inspired by https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/monitoring/common/mappings_exist.js + describe('template', async () => { + it('can be loaded', async () => { + const template = getTemplate('logs', indexPattern, mappings); + + // This test is not an API integration test with Kibana + // We want to test here if the template is valid and for this we need a running ES instance. + // If the ES instance takes the template, we assume it is a valid template. + const { body: response1 } = await es.indices.putTemplate({ + name: templateName, + body: template, + }); + // Checks if template loading worked as expected + expect(response1).to.eql({ acknowledged: true }); + + const { body: response2 } = await es.indices.getTemplate({ name: templateName }); + // Checks if the content of the template that was loaded is as expected + // We already know based on the above test that the template was valid + // but we check here also if we wrote the index pattern inside the template as expected + expect(response2[templateName].index_patterns).to.eql([`${indexPattern}-*`]); + }); + }); +} diff --git a/x-pack/test/epm_api_integration/config.ts b/x-pack/test/epm_api_integration/config.ts new file mode 100644 index 0000000000000..e95a389ef20ed --- /dev/null +++ b/x-pack/test/epm_api_integration/config.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + + return { + testFiles: [require.resolve('./apis')], + servers: xPackAPITestsConfig.get('servers'), + services: { + supertest: xPackAPITestsConfig.get('services.supertest'), + es: xPackAPITestsConfig.get('services.es'), + }, + junit: { + reportName: 'X-Pack EPM API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.ingestManager.epm.enabled=true', + '--xpack.ingestManager.epm.registryUrl=http://localhost:6666', + ], + }, + }; +} diff --git a/x-pack/test/functional/es_archives/fleet/agents/data.json b/x-pack/test/functional/es_archives/fleet/agents/data.json new file mode 100644 index 0000000000000..36928018d15a0 --- /dev/null +++ b/x-pack/test/functional/es_archives/fleet/agents/data.json @@ -0,0 +1,147 @@ +{ + "type": "doc", + "value": { + "id": "agents:agent1", + "index": ".kibana", + "source": { + "type": "agents", + "agents": { + "access_api_key_id": "api-key-2", + "active": true, + "shared_id": "agent1_filebeat", + "config_id": "1", + "type": "PERMANENT", + "local_metadata": "{}", + "user_provided_metadata": "{}", + "actions": [{ + "id": "37ed51ff-e80f-4f2a-a62d-f4fa975e7d85", + "created_at": "2019-09-04T15:04:07+0000", + "type": "RESUME" + }, + { + "id": "b400439c-bbbf-43d5-83cb-cf8b7e32506f", + "type": "PAUSE", + "created_at": "2019-09-04T15:01:07+0000", + "sent_at": "2019-09-04T15:03:07+0000" + }] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "agents:agent2", + "index": ".kibana", + "source": { + "type": "agents", + "agents": { + "access_api_key_id": "api-key-2", + "active": true, + "shared_id": "agent2_filebeat", + "type": "PERMANENT", + "local_metadata": "{}", + "user_provided_metadata": "{}", + "actions": [] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "agents:agent3", + "index": ".kibana", + "source": { + "type": "agents", + "agents": { + "access_api_key_id": "api-key-3", + "active": true, + "shared_id": "agent3_metricbeat", + "type": "PERMANENT", + "local_metadata": "{}", + "user_provided_metadata": "{}", + "actions": [] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "agents:agent4", + "index": ".kibana", + "source": { + "type": "agents", + "agents": { + "access_api_key_id": "api-key-4", + "active": true, + "shared_id": "agent4_metricbeat", + "type": "PERMANENT", + "local_metadata": "{}", + "user_provided_metadata": "{}", + "actions": [] + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "enrollment_api_keys:ed22ca17-e178-4cfe-8b02-54ea29fbd6d0", + "index": ".kibana", + "source": { + "enrollment_api_keys" : { + "created_at" : "2019-10-10T16:31:12.518Z", + "name": "FleetEnrollmentKey:1", + "api_key_id" : "key", + "config_id" : "policy:1", + "active" : true + }, + "type" : "enrollment_api_keys", + "references": [] + } + } +} + +{ + "type": "doc", + "value": { + "id": "events:event1", + "index": ".kibana", + "source": { + "type": "agent_events", + "agent_events": { + "agent_id": "agent1", + "type": "STATE", + "subtype": "STARTED", + "message": "State changed from STOPPED to STARTED", + "payload": "{\"previous_state\": \"STOPPED\"}", + "timestamp": "2019-09-20T17:30:22.950Z" + } + } + } +} + +{ + "type": "doc", + "value": { + "id": "events:event2", + "index": ".kibana", + "source": { + "type": "agent_events", + "agent_events": { + "agent_id": "agent1", + "type": "STATE", + "subtype": "STOPPED", + "message": "State changed from RUNNING to STOPPED", + "payload": "{\"previous_state\": \"RUNNING\"}", + "timestamp": "2019-09-20T17:30:25.950Z" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/fleet/agents/mappings.json b/x-pack/test/functional/es_archives/fleet/agents/mappings.json new file mode 100644 index 0000000000000..0f632b7333ee7 --- /dev/null +++ b/x-pack/test/functional/es_archives/fleet/agents/mappings.json @@ -0,0 +1,1549 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": {} + }, + "index": ".kibana_1", + "mappings": { + "dynamic": "strict", + "_meta": { + "migrationMappingPropertyHashes": { + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "visualization": "52d7a13ad68a150c4525b292d23e12cc", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "policies": "1a096b98c98c2efebfdba77cefcfe54a", + "type": "2f4316de49999235636386fe51dc06c1", + "lens": "21c3ea0763beb1ecb0162529706b88c5", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "apm-services-telemetry": "07ee1939fa4302c62ddc052ec03fed90", + "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd", + "epm": "abf5b64aa599932bd181efc86dce14a7", + "siem-ui-timeline": "6485ab095be8d15246667b98a1a34295", + "agent_events": "8060c5567d33f6697164e1fd5c81b8ed", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "apm-indices": "c69b68f3fe2bf27b4788d4191c1d6011", + "agents": "1c8e942384219bd899f381fd40e407d7", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "inventory-view": "84b320fd67209906333ffce261128462", + "enrollment_api_keys": "90e66b79e8e948e9c15434fdb3ae576e", + "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "canvas-element": "7390014e1091044523666d97247392fc", + "datasources": "2fed9e9883b9622cd59a73ee5550ef4f", + "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "namespace": "2f4316de49999235636386fe51dc06c1", + "telemetry": "358ffaa88ba34a97d55af0933a117de4", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "config": "87aca8fdb053154f11383fce3dbf3edf", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327" + } + }, + "properties": { + "agent_events": { + "properties": { + "agent_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "actions": { + "type": "nested", + "properties": { + "created_at": { + "type": "date" + }, + "data": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "active": { + "type": "boolean" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "text" + }, + "config_id": { + "type": "keyword" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "text" + }, + "current_error_events": { + "type": "text" + }, + "version": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "apmAgentConfigurationIndex": { + "type": "keyword" + }, + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-services-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "dotnet": { + "type": "long", + "null_value": 0 + }, + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + }, + "rum-js": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "datasources": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "package": { + "properties": { + "assets": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "description": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "read_alias": { + "type": "keyword" + }, + "streams": { + "properties": { + "config": { + "type": "flattened" + }, + "id": { + "type": "keyword" + }, + "input": { + "properties": { + "config": { + "type": "flattened" + }, + "fields": { + "type": "flattened" + }, + "id": { + "type": "keyword" + }, + "ilm_policy": { + "type": "keyword" + }, + "index_template": { + "type": "keyword" + }, + "ingest_pipelines": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "output_id": { + "type": "keyword" + }, + "processors": { + "type": "keyword" + } + } + } + } + }, + "enrollment_api_keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "enrollment_rules": { + "type": "nested", + "properties": { + "created_at": { + "type": "date" + }, + "id": { + "type": "keyword" + }, + "ip_ranges": { + "type": "keyword" + }, + "types": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "window_duration": { + "type": "nested", + "properties": { + "from": { + "type": "date" + }, + "to": { + "type": "date" + } + } + } + } + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "epm": { + "properties": { + "installed": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "type": "nested", + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + } + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "inventory-view": { + "properties": { + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customOptions": { + "type": "nested", + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + } + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "type": "nested", + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + } + }, + "metric": { + "properties": { + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "time": { + "type": "integer" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "expression": { + "type": "keyword", + "index": false + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "type": "object", + "dynamic": "true" + }, + "layerTypesCount": { + "type": "object", + "dynamic": "true" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "mapsTotalCount": { + "type": "long" + }, + "timeCaptured": { + "type": "date" + } + } + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "type": "nested", + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + } + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "policies": { + "properties": { + "datasources": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "status": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "updated_on": { + "type": "keyword" + } + } + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "type": "object", + "enabled": false + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "timefilter": { + "type": "object", + "enabled": false + }, + "title": { + "type": "text" + } + } + }, + "references": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "type": "text", + "index": false + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword", + "ignore_above": 256 + }, + "sendUsageFrom": { + "type": "keyword", + "ignore_above": 256 + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "dynamic": "true", + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "type": "boolean", + "null_value": true + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "type": "long", + "null_value": 0 + }, + "indices": { + "type": "long", + "null_value": 0 + }, + "overview": { + "type": "long", + "null_value": 0 + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "type": "long", + "null_value": 0 + }, + "open": { + "type": "long", + "null_value": 0 + }, + "start": { + "type": "long", + "null_value": 0 + }, + "stop": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/ingest/policies/data.json b/x-pack/test/functional/es_archives/ingest/policies/data.json new file mode 100644 index 0000000000000..78cf18d501a3e --- /dev/null +++ b/x-pack/test/functional/es_archives/ingest/policies/data.json @@ -0,0 +1,59 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "policies:1", + "source": { + "policies": { + "name": "Policy 1", + "description": "Amazing policy", + "status": "active", + "updated_on": "2019-09-20T17:35:09+0000", + "updated_by": "nchaulet" + }, + "type": "policies", + "references": [], + "updated_at": "2019-09-20T17:30:22.950Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "policies:2", + "source": { + "policies": { + "name": "Policy", + "description": "Amazing policy", + "status": "active", + "updated_on": "2019-09-20T17:35:09+0000", + "updated_by": "nchaulet" + }, + "type": "policies", + "references": [], + "updated_at": "2019-09-20T17:30:22.950Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "policies:3", + "source": { + "policies": { + "name": "Policy 3", + "description": "Amazing policy", + "status": "active", + "updated_on": "2019-09-20T17:35:09+0000", + "updated_by": "nchaulet" + }, + "type": "policies", + "references": [], + "updated_at": "2019-09-20T17:30:22.950Z" + } + } +} diff --git a/x-pack/test/functional/es_archives/ingest/policies/mappings.json b/x-pack/test/functional/es_archives/ingest/policies/mappings.json new file mode 100644 index 0000000000000..878d6aa58c225 --- /dev/null +++ b/x-pack/test/functional/es_archives/ingest/policies/mappings.json @@ -0,0 +1,1545 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": {} + }, + "index": ".kibana_1", + "mappings": { + "dynamic": "strict", + "_meta": { + "migrationMappingPropertyHashes": { + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "server": "ec97f1c5da1a19609a60874e5af1100c", + "visualization": "52d7a13ad68a150c4525b292d23e12cc", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "policies": "1a096b98c98c2efebfdba77cefcfe54a", + "type": "2f4316de49999235636386fe51dc06c1", + "lens": "21c3ea0763beb1ecb0162529706b88c5", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "infrastructure-ui-source": "ddc0ecb18383f6b26101a2fadb2dab0c", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "map": "23d7aa4a720d4938ccde3983f87bd58d", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "apm-services-telemetry": "07ee1939fa4302c62ddc052ec03fed90", + "metrics-explorer-view": "53c5365793677328df0ccb6138bf3cdd", + "epm": "abf5b64aa599932bd181efc86dce14a7", + "siem-ui-timeline": "6485ab095be8d15246667b98a1a34295", + "agent_events": "8060c5567d33f6697164e1fd5c81b8ed", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "apm-indices": "c69b68f3fe2bf27b4788d4191c1d6011", + "agents": "1c8e942384219bd899f381fd40e407d7", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "inventory-view": "84b320fd67209906333ffce261128462", + "enrollment_api_keys": "90e66b79e8e948e9c15434fdb3ae576e", + "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "canvas-element": "7390014e1091044523666d97247392fc", + "datasources": "2fed9e9883b9622cd59a73ee5550ef4f", + "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", + "namespace": "2f4316de49999235636386fe51dc06c1", + "telemetry": "358ffaa88ba34a97d55af0933a117de4", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "config": "87aca8fdb053154f11383fce3dbf3edf", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327" + } + }, + "properties": { + "agent_events": { + "properties": { + "agent_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "actions": { + "type": "nested", + "properties": { + "created_at": { + "type": "date" + }, + "data": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "active": { + "type": "boolean" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "text" + }, + "config_id": { + "type": "keyword" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "text" + }, + "version": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "apmAgentConfigurationIndex": { + "type": "keyword" + }, + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-services-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "dotnet": { + "type": "long", + "null_value": 0 + }, + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + }, + "rum-js": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "datasources": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "package": { + "properties": { + "assets": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "description": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "read_alias": { + "type": "keyword" + }, + "streams": { + "properties": { + "config": { + "type": "flattened" + }, + "id": { + "type": "keyword" + }, + "input": { + "properties": { + "config": { + "type": "flattened" + }, + "fields": { + "type": "flattened" + }, + "id": { + "type": "keyword" + }, + "ilm_policy": { + "type": "keyword" + }, + "index_template": { + "type": "keyword" + }, + "ingest_pipelines": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "output_id": { + "type": "keyword" + }, + "processors": { + "type": "keyword" + } + } + } + } + }, + "enrollment_api_keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "enrollment_rules": { + "type": "nested", + "properties": { + "created_at": { + "type": "date" + }, + "id": { + "type": "keyword" + }, + "ip_ranges": { + "type": "keyword" + }, + "types": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "window_duration": { + "type": "nested", + "properties": { + "from": { + "type": "date" + }, + "to": { + "type": "date" + } + } + } + } + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "config_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "epm": { + "properties": { + "installed": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "properties": { + "description": { + "type": "text" + }, + "fields": { + "properties": { + "container": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "pod": { + "type": "keyword" + }, + "tiebreaker": { + "type": "keyword" + }, + "timestamp": { + "type": "keyword" + } + } + }, + "logAlias": { + "type": "keyword" + }, + "logColumns": { + "type": "nested", + "properties": { + "fieldColumn": { + "properties": { + "field": { + "type": "keyword" + }, + "id": { + "type": "keyword" + } + } + }, + "messageColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + }, + "timestampColumn": { + "properties": { + "id": { + "type": "keyword" + } + } + } + } + }, + "metricAlias": { + "type": "keyword" + }, + "name": { + "type": "text" + } + } + }, + "inventory-view": { + "properties": { + "autoBounds": { + "type": "boolean" + }, + "autoReload": { + "type": "boolean" + }, + "boundsOverride": { + "properties": { + "max": { + "type": "integer" + }, + "min": { + "type": "integer" + } + } + }, + "customOptions": { + "type": "nested", + "properties": { + "field": { + "type": "keyword" + }, + "text": { + "type": "keyword" + } + } + }, + "filterQuery": { + "properties": { + "expression": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + } + } + }, + "groupBy": { + "type": "nested", + "properties": { + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + } + }, + "metric": { + "properties": { + "type": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "nodeType": { + "type": "keyword" + }, + "time": { + "type": "integer" + }, + "view": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "expression": { + "type": "keyword", + "index": false + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "type": "object", + "dynamic": "true" + }, + "layerTypesCount": { + "type": "object", + "dynamic": "true" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "mapsTotalCount": { + "type": "long" + }, + "timeCaptured": { + "type": "date" + } + } + }, + "metrics-explorer-view": { + "properties": { + "chartOptions": { + "properties": { + "stack": { + "type": "boolean" + }, + "type": { + "type": "keyword" + }, + "yAxisMode": { + "type": "keyword" + } + } + }, + "currentTimerange": { + "properties": { + "from": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "to": { + "type": "keyword" + } + } + }, + "name": { + "type": "keyword" + }, + "options": { + "properties": { + "aggregation": { + "type": "keyword" + }, + "filterQuery": { + "type": "keyword" + }, + "groupBy": { + "type": "keyword" + }, + "limit": { + "type": "integer" + }, + "metrics": { + "type": "nested", + "properties": { + "aggregation": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "field": { + "type": "keyword" + }, + "label": { + "type": "keyword" + } + } + } + } + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "policies": { + "properties": { + "datasources": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "label": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "status": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "updated_on": { + "type": "keyword" + } + } + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "type": "object", + "enabled": false + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "timefilter": { + "type": "object", + "enabled": false + }, + "title": { + "type": "text" + } + } + }, + "references": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "type": "text", + "index": false + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword", + "ignore_above": 256 + }, + "sendUsageFrom": { + "type": "keyword", + "ignore_above": 256 + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "dynamic": "true", + "properties": { + "indexName": { + "type": "keyword" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "type": "boolean", + "null_value": true + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "type": "long", + "null_value": 0 + }, + "indices": { + "type": "long", + "null_value": 0 + }, + "overview": { + "type": "long", + "null_value": 0 + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "type": "long", + "null_value": 0 + }, + "open": { + "type": "long", + "null_value": 0 + }, + "start": { + "type": "long", + "null_value": 0 + }, + "stop": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test_utils/jest/contract_tests/__memorize_snapshots__/example.contract.test.ts.snap b/x-pack/test_utils/jest/contract_tests/__memorize_snapshots__/example.contract.test.ts.snap index 547fe640b6951..9a48e4d2889f7 100644 --- a/x-pack/test_utils/jest/contract_tests/__memorize_snapshots__/example.contract.test.ts.snap +++ b/x-pack/test_utils/jest/contract_tests/__memorize_snapshots__/example.contract.test.ts.snap @@ -103,3 +103,108 @@ exports['Example contract tests should run online or offline - example_test_snap "serverExists": true } } + +exports['Example contract tests should run online or offline - example_test_snapshot'] = { + "results": { + "serverExists": true + } +} + +exports['Example contract tests should have loaded sample data use esArchive - sample_data'] = { + "results": { + "took": 2, + "timed_out": false, + "_shards": { + "total": 1, + "successful": 1, + "skipped": 0, + "failed": 0 + }, + "hits": { + "total": { + "value": 4, + "relation": "eq" + }, + "max_score": 0.90445626, + "hits": [ + { + "_index": ".management-beats", + "_type": "_doc", + "_id": "beat:qux", + "_score": 0.90445626, + "_source": { + "beat": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI", + "active": true, + "host_ip": "1.2.3.4", + "host_name": "foo.bar.com", + "id": "qux", + "name": "qux_filebeat", + "type": "filebeat" + }, + "type": "beat" + } + }, + { + "_index": ".management-beats", + "_type": "_doc", + "_id": "beat:baz", + "_score": 0.90445626, + "_source": { + "beat": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI", + "active": true, + "host_ip": "22.33.11.44", + "host_name": "baz.bar.com", + "id": "baz", + "name": "baz_metricbeat", + "type": "metricbeat" + }, + "type": "beat" + } + }, + { + "_index": ".management-beats", + "_type": "_doc", + "_id": "beat:foo", + "_score": 0.90445626, + "_source": { + "beat": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI", + "active": true, + "host_ip": "1.2.3.4", + "host_name": "foo.bar.com", + "id": "foo", + "name": "foo_metricbeat", + "tags": [ + "production", + "qa" + ], + "type": "metricbeat", + "verified_on": "2018-05-15T16:25:38.924Z" + }, + "type": "beat" + } + }, + { + "_index": ".management-beats", + "_type": "_doc", + "_id": "beat:bar", + "_score": 0.90445626, + "_source": { + "beat": { + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjcmVhdGVkIjoiMjAxOC0wNi0zMFQwMzo0MjoxNS4yMzBaIiwiaWF0IjoxNTMwMzMwMTM1fQ.SSsX2Byyo1B1bGxV8C3G4QldhE5iH87EY_1r21-bwbI", + "active": true, + "host_ip": "11.22.33.44", + "host_name": "foo.com", + "id": "bar", + "name": "bar_filebeat", + "type": "filebeat" + }, + "type": "beat" + } + } + ] + } + } +} diff --git a/x-pack/test_utils/jest/contract_tests/servers.ts b/x-pack/test_utils/jest/contract_tests/servers.ts index c458d65b6a11d..415603b3adb77 100644 --- a/x-pack/test_utils/jest/contract_tests/servers.ts +++ b/x-pack/test_utils/jest/contract_tests/servers.ts @@ -64,7 +64,13 @@ export async function _createSharedServer() { const servers = await kbnTestServer.createTestServers({ // adjustTimeout function is required by createTestServers fn adjustTimeout: (t: number) => {}, - settings: TestKbnServerConfig, + settings: { + ...TestKbnServerConfig, + es: { + ...TestKbnServerConfig.es, + esArgs: ['xpack.security.authc.api_key.enabled=true'], + }, + }, }); ESServer = await servers.startES(); const { hosts, username, password } = ESServer; @@ -101,8 +107,9 @@ export function getSharedESServer(): ESServerConfig { export async function createKibanaServer(xpackOption = {}) { if (jest && jest.setTimeout) { // Allow kibana to start - jest.setTimeout(120000); + jest.setTimeout(240000); } + const root = kbnTestServer.createRootWithCorePlugins( { elasticsearch: { ...getSharedESServer() }, @@ -118,7 +125,7 @@ export async function createKibanaServer(xpackOption = {}) { const { server } = (root as any).server.legacy.kbnServer; return { - shutdown: () => root.shutdown(), + shutdown: async () => await root.shutdown(), kbnServer: server, root, }; diff --git a/yarn.lock b/yarn.lock index a87d877f89727..c7aee59bbb50e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2786,10 +2786,10 @@ resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== -"@mattapperson/slapshot@1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@mattapperson/slapshot/-/slapshot-1.4.0.tgz#d1ba04ad23ca139601fcf7a74ef43ec3dd184d5e" - integrity sha512-P73fBpfevcMKAtDFsq2Z2VVW+tUZ2yNeNje3du5kb+ZH9RJUYFmpo+lCQpTJGyq9lzyhKlF3JIhfoGhz5RzgWg== +"@mattapperson/slapshot@1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@mattapperson/slapshot/-/slapshot-1.4.3.tgz#f5b81b297a3708f43f7d9242b46b37c60c1dd9ed" + integrity sha512-5BgwWHAzpethrotEFErzYtWhWyZSq6y+Yek3wzgOquCIqH+/2QoCQT3ru2ina+oIqdtSp3+4BDUMMkCKIa2uhg== dependencies: caller-callsite "^4.0.0" get-caller-file "^2.0.5" @@ -4662,6 +4662,11 @@ dependencies: "@types/sizzle" "*" +"@types/js-search@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@types/js-search/-/js-search-1.4.0.tgz#f2d4afa176a4fc7b17fb46a1593847887fa1fb7b" + integrity sha1-8tSvoXak/HsX+0ahWThHiH+h+3s= + "@types/js-yaml@^3.11.1", "@types/js-yaml@^3.12.1": version "3.12.1" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.1.tgz#5c6f4a1eabca84792fbd916f0cb40847f123c656" @@ -4809,6 +4814,13 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-2.0.29.tgz#5002e14f75e2d71e564281df0431c8c1b4a2a36a" integrity sha1-UALhT3Xi1x5WQoHfBDHIwbSio2o= +"@types/minipass@*": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/minipass/-/minipass-2.2.0.tgz#51ad404e8eb1fa961f75ec61205796807b6f9651" + integrity sha512-wuzZksN4w4kyfoOv/dlpov4NOunwutLA/q7uc00xU02ZyUY+aoM5PWIXEKBMnm0NHd4a+N71BMjq+x7+2Af1fg== + dependencies: + "@types/node" "*" + "@types/mocha@^5.2.7": version "5.2.7" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" @@ -5283,6 +5295,14 @@ dependencies: "@types/node" "*" +"@types/tar@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/tar/-/tar-4.0.3.tgz#e2cce0b8ff4f285293243f5971bd7199176ac489" + integrity sha512-Z7AVMMlkI8NTWF0qGhC4QIX0zkV/+y0J8x7b/RsHrN0310+YNjoJd8UrApCiGBCWtKjxS9QhNqLi2UJNToh5hA== + dependencies: + "@types/minipass" "*" + "@types/node" "*" + "@types/tempy@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@types/tempy/-/tempy-0.2.0.tgz#8b7a93f6912aef25cc0b8d8a80ff974151478685" @@ -7993,7 +8013,7 @@ body-parser@1.18.3: raw-body "2.3.3" type-is "~1.6.16" -body-parser@1.19.0, body-parser@^1.18.3: +body-parser@1.19.0, body-parser@^1.18.1, body-parser@^1.18.3: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -9856,6 +9876,16 @@ connect-history-api-fallback@^1.6.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== +connect@^3.4.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + connect@^3.6.0: version "3.6.6" resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.6.tgz#09eff6c55af7236e137135a72574858b6786f524" @@ -13697,6 +13727,13 @@ fbjs@^0.8.4, fbjs@^0.8.9: setimmediate "^1.0.5" ua-parser-js "^0.7.9" +fd-slicer@1.1.0, fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= + dependencies: + pend "~1.2.0" + fd-slicer@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.0.1.tgz#8b5bcbd9ec327c5041bf9ab023fd6750f1177e65" @@ -13704,13 +13741,6 @@ fd-slicer@~1.0.1: dependencies: pend "~1.2.0" -fd-slicer@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" - integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= - dependencies: - pend "~1.2.0" - fecha@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd" @@ -13927,7 +13957,7 @@ finalhandler@1.1.1: statuses "~1.4.0" unpipe "~1.0.0" -finalhandler@~1.1.2: +finalhandler@1.1.2, finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== @@ -16338,6 +16368,17 @@ http-errors@1.7.2, http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-errors@~1.7.0: + version "1.7.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" + integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.1.1" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.0" + http-headers@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/http-headers/-/http-headers-3.0.2.tgz#5147771292f0b39d6778d930a3a59a76fc7ef44d" @@ -16725,7 +16766,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -18446,6 +18487,11 @@ js-levenshtein@^1.1.3: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.3.tgz#3ef627df48ec8cf24bacf05c0f184ff30ef413c5" integrity sha512-/812MXr9RBtMObviZ8gQBhHO8MOrGj8HlEE+4ccMTElNA/6I3u39u+bhny55Lk921yn44nSZFy9naNLElL5wgQ== +js-search@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/js-search/-/js-search-1.4.3.tgz#23a86d7e064ca53a473930edc48615b6b1c1954a" + integrity sha512-Sny5pf00kX1sM1KzvUC9nGYWXOvBfy30rmvZWeRktpg+esQKedIXrXNee/I2CAnsouCyaTjitZpRflDACx4toA== + js-sha3@0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" @@ -20898,6 +20944,16 @@ mochawesome@^4.1.0: strip-ansi "^5.0.0" uuid "^3.3.2" +mock-http-server@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/mock-http-server/-/mock-http-server-1.3.0.tgz#d2c2ffe65f77d3a4da8302c91d3bf687e5b51519" + integrity sha512-WC1fQ4kfOiiRZZ6IEOispJcfvz66m7VVbVFmnWsv1pOwL3psqYyLQGjFXg//zjPeZ//y/rxa8e2eh1Bb58cN7g== + dependencies: + body-parser "^1.18.1" + connect "^3.4.0" + multiparty "^4.1.2" + underscore "^1.8.3" + module-definition@^3.0.0, module-definition@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/module-definition/-/module-definition-3.2.0.tgz#a1741d5ddf60d76c60d5b1f41ba8744ba08d3ef4" @@ -21028,6 +21084,16 @@ multimatch@^4.0.0: arrify "^2.0.1" minimatch "^3.0.4" +multiparty@^4.1.2: + version "4.2.1" + resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-4.2.1.tgz#d9b6c46d8b8deab1ee70c734b0af771dd46e0b13" + integrity sha512-AvESCnNoQlZiOfP9R4mxN8M9csy2L16EIbWIkt3l4FuGti9kXBS8QVzlfyg4HEnarJhrzZilgNFlZtqmoiAIIA== + dependencies: + fd-slicer "1.1.0" + http-errors "~1.7.0" + safe-buffer "5.1.2" + uid-safe "2.1.5" + multistream@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/multistream/-/multistream-2.1.1.tgz#629d3a29bd76623489980d04519a2c365948148c" @@ -23985,6 +24051,11 @@ randexp@0.4.6: discontinuous-range "1.0.0" ret "~0.1.10" +random-bytes@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" + integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs= + random-poly-fill@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/random-poly-fill/-/random-poly-fill-1.0.1.tgz#13634dc0255a31ecf85d4a182d92c40f9bbcf5ed" @@ -24522,6 +24593,20 @@ react-markdown@^4.0.6: unist-util-visit "^1.3.0" xtend "^4.0.1" +react-markdown@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-4.3.1.tgz#39f0633b94a027445b86c9811142d05381300f2f" + integrity sha512-HQlWFTbDxTtNY6bjgp3C3uv1h2xcjCSi1zAEzfBW9OwJJvENSYiLXWNXN5hHLsoqai7RnZiiHzcnWdXk2Splzw== + dependencies: + html-to-react "^1.3.4" + mdast-add-list-metadata "1.0.1" + prop-types "^15.7.2" + react-is "^16.8.6" + remark-parse "^5.0.0" + unified "^6.1.5" + unist-util-visit "^1.3.0" + xtend "^4.0.1" + react-moment-proptypes@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/react-moment-proptypes/-/react-moment-proptypes-1.7.0.tgz#89881479840a76c13574a86e3bb214c4ba564e7a" @@ -29954,6 +30039,13 @@ uid-number@0.0.5: resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.5.tgz#5a3db23ef5dbd55b81fce0ec9a2ac6fccdebb81e" integrity sha1-Wj2yPvXb1VuB/ODsmirG/M3ruB4= +uid-safe@2.1.5: + version "2.1.5" + resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" + integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== + dependencies: + random-bytes "~1.0.0" + ultron@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" @@ -29980,6 +30072,11 @@ underscore.string@~3.3.4: sprintf-js "^1.0.3" util-deprecate "^1.0.2" +underscore@^1.8.3: + version "1.9.1" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961" + integrity sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg== + underscore@~1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"