From 644a1a5b5f79ec0803ded1d408961a01e6deaccf Mon Sep 17 00:00:00 2001 From: Alen <78027095+aabeshov@users.noreply.github.com> Date: Mon, 12 Aug 2024 15:58:36 +0500 Subject: [PATCH] Observability namespace (#474) * Observability api and query api Signed-off-by: alen_abeshov * Fixing wrong linting Signed-off-by: alen_abeshov * added tests Signed-off-by: alen_abeshov * removed docker file Signed-off-by: alen_abeshov * Added tests Signed-off-by: alen_abeshov * Removed spaces, added exclude file Signed-off-by: alen_abeshov * Changed lycheeignore Signed-off-by: alen_abeshov * Fixing path Signed-off-by: alen_abeshov * Change URL Signed-off-by: alen_abeshov * add verbosity Signed-off-by: alen_abeshov * Checking version Signed-off-by: alen_abeshov * changin operation version Signed-off-by: alen_abeshov * Replacing http Signed-off-by: alen_abeshov * Changed desc Signed-off-by: alen_abeshov * Changed desc Signed-off-by: alen_abeshov * Fixing bug Signed-off-by: alen_abeshov --------- Signed-off-by: alen_abeshov Signed-off-by: Alen <78027095+aabeshov@users.noreply.github.com> --- .cspell | 3 +- .lycheeignore | 2 +- CHANGELOG.md | 1 + spec/namespaces/observability.yaml | 224 +++++++++++++++ spec/namespaces/query.yaml | 140 ++++++++++ spec/schemas/observability._common.yaml | 255 ++++++++++++++++++ spec/schemas/query._common.yaml | 131 +++++++++ .../default/observability/observability.yaml | 155 +++++++++++ tests/default/query/datasources.yaml | 95 +++++++ 9 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 spec/namespaces/observability.yaml create mode 100644 spec/namespaces/query.yaml create mode 100644 spec/schemas/observability._common.yaml create mode 100644 spec/schemas/query._common.yaml create mode 100644 tests/default/observability/observability.yaml create mode 100644 tests/default/query/datasources.yaml diff --git a/.cspell b/.cspell index 1542876a8..900b6c40a 100644 --- a/.cspell +++ b/.cspell @@ -185,4 +185,5 @@ urldecode vectory whoamiprotected wordnet -Yrtsd \ No newline at end of file +Yrtsd +localstats \ No newline at end of file diff --git a/.lycheeignore b/.lycheeignore index f437ebb39..c859dd4da 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -1 +1 @@ -https://localhost:9200* +https://localhost:* diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ecf4f75..a941ea033 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Added metadata additionalProperties to `ErrorCause` ([#462](https://github.com/opensearch-project/opensearch-api-specification/pull/462)) - Added `creation_date` field to `DanglingIndex` ([#462](https://github.com/opensearch-project/opensearch-api-specification/pull/462)) - Added doc on `cluster create-index blocked` workaround ([#465](https://github.com/opensearch-project/opensearch-api-specification/pull/465)) +- Added `observability` namespace API specifications ([#474](https://github.com/opensearch-project/opensearch-api-specification/pull/474)) - Added support for reusing output variables as keys in payload expectations ([#471](https://github.com/opensearch-project/opensearch-api-specification/pull/471)) - Added support for running tests against Amazon OpenSearch ([#476](https://github.com/opensearch-project/opensearch-api-specification/pull/476)) diff --git a/spec/namespaces/observability.yaml b/spec/namespaces/observability.yaml new file mode 100644 index 000000000..a8553dbe1 --- /dev/null +++ b/spec/namespaces/observability.yaml @@ -0,0 +1,224 @@ +openapi: 3.1.0 +info: + title: OpenSearch Observability Object API + description: API for retrieving and managing Observability Objects. + version: 1.0.0 +paths: + /_plugins/_observability/_local/stats: + get: + operationId: observability.get_localstats.0 + x-operation-group: observability.get_localstats + x-version-added: '1.1' + description: Retrieves Local Stats of all observability objects. + responses: + '200': + $ref: '#/components/responses/observability.get_localstats@200' + /_plugins/_observability/object: + get: + operationId: observability.list_objects.0 + x-operation-group: observability.list_objects + x-version-added: '1.1' + description: Retrieves list of all observability objects. + responses: + '200': + $ref: '#/components/responses/observability.list_objects@200' + post: + operationId: observability.create_object.0 + x-operation-group: observability.create_object + x-version-added: '1.1' + description: Creates a new observability object. + requestBody: + $ref: '#/components/requestBodies/observability.create_object' + responses: + '200': + $ref: '#/components/responses/observability.create_object@200' + delete: + operationId: observability.delete_objects.0 + x-operation-group: observability.delete_objects + x-version-added: '1.1' + description: Deletes specific observability objects specified by ID or a list of IDs. + parameters: + - $ref: '#/components/parameters/observability.delete_objects::query.objectId' + - $ref: '#/components/parameters/observability.delete_objects::query.objectIdList' + responses: + '200': + $ref: '#/components/responses/observability.delete_objects@200' + '404': + $ref: '#/components/responses/observability.delete_objects@404' + /_plugins/_observability/object/{object_id}: + get: + operationId: observability.get_object.0 + x-operation-group: observability.get_object + x-version-added: '1.1' + description: Retrieves specific observability object specified by ID. + parameters: + - $ref: '#/components/parameters/observability.get_object::path.object_id' + responses: + '200': + $ref: '#/components/responses/observability.get_object@200' + '404': + $ref: '#/components/responses/observability.get_object@404' + put: + operationId: observability.update_object.0 + x-operation-group: observability.update_object + x-version-added: '1.1' + description: Updates an existing observability object. + parameters: + - $ref: '#/components/parameters/observability.update_object::path.object_id' + requestBody: + $ref: '#/components/requestBodies/observability.update_object' + responses: + '200': + $ref: '#/components/responses/observability.update_object@200' + '404': + $ref: '#/components/responses/observability.update_object@404' + delete: + operationId: observability.delete_object.0 + x-operation-group: observability.delete_object + x-version-added: '1.1' + description: Deletes specific observability object specified by ID. + parameters: + - $ref: '#/components/parameters/observability.delete_object::path.object_id' + responses: + '200': + $ref: '#/components/responses/observability.delete_object@200' + '404': + $ref: '#/components/responses/observability.delete_object@404' +components: + requestBodies: + observability.create_object: + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/ObservabilityObject' + observability.update_object: + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/ObservabilityObject' + responses: + observability.list_objects@200: + description: Successful response of retrieving all Observability Objects. + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/ObservabilityObjectList' + observability.create_object@200: + description: Created + content: + application/json: + schema: + type: object + properties: + objectId: + type: string + observability.get_object@200: + description: Successful response of retrieving specific Observability Object. + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/ObservabilityObjectList' + observability.get_object@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/NotFoundResponse' + observability.update_object@200: + description: Updated + content: + application/json: + schema: + type: object + properties: + objectId: + type: string + observability.update_object@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/NotFoundResponse' + observability.delete_object@200: + description: Deleted + content: + application/json: + schema: + type: object + properties: + deleteResponseList: + type: object + additionalProperties: + type: string + example: OK + observability.delete_object@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/NotFoundResponse' + observability.delete_objects@200: + description: Deleted + content: + application/json: + schema: + type: object + properties: + deleteResponseList: + type: object + additionalProperties: + type: string + example: OK + observability.delete_objects@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/observability._common.yaml#/components/schemas/NotFoundResponse' + observability.get_localstats@200: + description: Retrieves + content: + application/json: + schema: + type: string + parameters: + observability.get_object::path.object_id: + in: path + name: object_id + description: The ID of the Observability Object. + required: true + schema: + type: string + style: simple + observability.update_object::path.object_id: + in: path + name: object_id + description: The ID of the Observability Object. + required: true + schema: + type: string + style: simple + observability.delete_object::path.object_id: + in: path + name: object_id + description: The ID of the Observability Object. + required: true + schema: + type: string + style: simple + observability.delete_objects::query.objectId: + in: query + name: objectId + description: The ID of a single Observability Object to delete. + required: false + schema: + type: string + style: form + observability.delete_objects::query.objectIdList: + in: query + name: objectIdList + description: A comma-separated list of Observability Object IDs to delete. + required: false + schema: + type: string + style: form diff --git a/spec/namespaces/query.yaml b/spec/namespaces/query.yaml new file mode 100644 index 000000000..b055f4666 --- /dev/null +++ b/spec/namespaces/query.yaml @@ -0,0 +1,140 @@ +openapi: 3.1.0 +info: + title: OpenSearch Query Datasources API + description: OpenSearch Query Datasources API. + version: 2.7.0 +paths: + /_plugins/_query/_datasources: + get: + operationId: query.datasources_list.0 + x-operation-group: query.datasources_list + x-version-added: '2.7' + description: Retrieves list of all datasources. + responses: + '200': + $ref: '#/components/responses/query.datasources_list@200' + post: + operationId: query.datasources_create.0 + x-operation-group: query.datasources_create + x-version-added: '2.7' + description: Creates a new query datasource. + requestBody: + $ref: '#/components/requestBodies/query.datasources_create' + responses: + '201': + $ref: '#/components/responses/query.datasources_create@201' + put: + operationId: query.datasources_update.0 + x-operation-group: query.datasources_update + x-version-added: '2.7' + description: Updates an existing query datasource. + requestBody: + $ref: '#/components/requestBodies/query.datasources_update' + responses: + '200': + $ref: '#/components/responses/query.datasources_update@200' + '404': + $ref: '#/components/responses/query.datasources_update@404' + /_plugins/_query/_datasources/{datasource_name}: + get: + operationId: query.datasource_retrieve.0 + x-operation-group: query.datasource_retrieve + x-version-added: '2.7' + description: Retrieves specific datasource specified by name. + parameters: + - $ref: '#/components/parameters/query.datasource_retrieve::path.datasource_name' + responses: + '200': + $ref: '#/components/responses/query.datasource_retrieve@200' + '404': + $ref: '#/components/responses/query.datasource_retrieve@404' + delete: + operationId: query.datasource_delete.0 + x-operation-group: query.datasource_delete + x-version-added: '2.7' + description: Deletes specific datasource specified by name. + parameters: + - $ref: '#/components/parameters/query.datasource_delete::path.datasource_name' + responses: + '204': + $ref: '#/components/responses/query.datasource_delete@204' + '404': + $ref: '#/components/responses/query.datasource_delete@404' + +components: + requestBodies: + query.datasources_create: + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSource' + query.datasources_update: + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSource' + responses: + query.datasources_list@200: + description: Successful response of retrieving all Data Sources. + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSourceList' + query.datasources_create@201: + description: Created + content: + application/json: + schema: + type: string + query.datasources_update@200: + description: Updated + content: + application/json: + schema: + type: string + query.datasources_update@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSourceNotFound' + query.datasource_retrieve@200: + description: Successful response of retrieving Data Source. + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSourceRetrieve' + query.datasource_retrieve@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSourceNotFound' + query.datasource_delete@204: + description: No Content + content: + application/json: + schema: + type: object + properties: { } + query.datasource_delete@404: + description: Not Found + content: + application/json: + schema: + $ref: '../schemas/query._common.yaml#/components/schemas/DataSourceNotFound' + parameters: + query.datasource_delete::path.datasource_name: + name: datasource_name + in: path + description: The Name of the DataSource to delete. + schema: + type: string + required: true + query.datasource_retrieve::path.datasource_name: + name: datasource_name + in: path + description: The Name of the DataSource to retrieve. + schema: + type: string + required: true diff --git a/spec/schemas/observability._common.yaml b/spec/schemas/observability._common.yaml new file mode 100644 index 000000000..fc8018f55 --- /dev/null +++ b/spec/schemas/observability._common.yaml @@ -0,0 +1,255 @@ +openapi: 3.1.0 +info: + title: Schemas for OpenSearch Observability Object API + description: Schemas for OpenSearch Observability Object API + version: 1.0.0 +paths: {} +components: + schemas: + ObservabilityObjectList: + type: object + properties: + startIndex: + type: integer + totalHits: + type: integer + totalHitRelation: + type: string + observabilityObjectList: + type: array + items: + $ref: '#/components/schemas/ObservabilityObject' + required: + - observabilityObjectList + - startIndex + - totalHitRelation + - totalHits + + ObservabilityObject: + type: object + properties: + objectId: + type: string + lastUpdatedTimeMs: + type: integer + createdTimeMs: + type: integer + tenant: + type: string + operationalPanel: + $ref: '#/components/schemas/OperationalPanel' + savedVisualization: + $ref: '#/components/schemas/SavedVisualization' + savedQuery: + $ref: '#/components/schemas/SavedQuery' + required: + - objectId + - tenant + + OperationalPanel: + type: object + properties: + name: + type: string + visualizations: + type: array + items: + $ref: '#/components/schemas/Visualization' + timeRange: + $ref: '#/components/schemas/TimeRange' + queryFilter: + $ref: '#/components/schemas/QueryFilter' + applicationId: + type: string + required: + - applicationId + - name + - queryFilter + - timeRange + - visualizations + + Visualization: + type: object + properties: + id: + type: string + savedVisualizationId: + type: string + x: + type: integer + y: + type: integer + w: + type: integer + h: + type: integer + required: + - h + - id + - savedVisualizationId + - w + - x + - y + + TimeRange: + type: object + properties: + to: + type: string + from: + type: string + required: + - from + - to + + QueryFilter: + type: object + properties: + query: + type: string + language: + type: string + required: + - language + - query + + SavedVisualization: + type: object + properties: + name: + type: string + description: + type: string + query: + type: string + type: + type: string + selected_date_range: + $ref: '#/components/schemas/SelectedDateRange' + selected_timestamp: + $ref: '#/components/schemas/SelectedTimestamp' + selected_fields: + $ref: '#/components/schemas/SelectedFields' + required: + - description + - name + - query + - selected_date_range + - selected_fields + - selected_timestamp + - type + + SelectedDateRange: + type: object + properties: + start: + type: string + end: + type: string + text: + type: string + required: + - end + - start + - text + + SelectedTimestamp: + type: object + properties: + name: + type: string + type: + type: string + required: + - name + - type + + SelectedFields: + type: object + properties: + text: + type: string + tokens: + type: array + items: + $ref: '#/components/schemas/Token' + required: + - text + - tokens + + Token: + type: object + properties: + name: + type: string + type: + type: string + required: + - name + - type + + SavedQuery: + type: object + properties: + name: + type: string + description: + type: string + query: + type: string + selected_date_range: + $ref: '#/components/schemas/SelectedDateRange' + selected_timestamp: + $ref: '#/components/schemas/SelectedTimestamp' + selected_fields: + $ref: '#/components/schemas/SelectedFields' + required: + - description + - name + - query + - selected_date_range + - selected_fields + - selected_timestamp + + NotFoundResponse: + type: object + properties: + error: + $ref: '#/components/schemas/ErrorResponse' + status: + type: integer + example: 404 + required: + - error + - status + + ErrorResponse: + type: object + properties: + root_cause: + type: array + items: + $ref: '#/components/schemas/RootCause' + type: + type: string + example: status_exception + reason: + type: string + example: 'ObservabilityObject {objectId} not found' + required: + - reason + - root_cause + - type + + RootCause: + type: object + properties: + type: + type: string + example: status_exception + reason: + type: string + example: 'ObservabilityObject {objectId} not found' + required: + - reason + - type diff --git a/spec/schemas/query._common.yaml b/spec/schemas/query._common.yaml new file mode 100644 index 000000000..080c3afa3 --- /dev/null +++ b/spec/schemas/query._common.yaml @@ -0,0 +1,131 @@ +openapi: 3.1.0 +info: + title: Schemas for OpenSearch Query Datasources API + description: Schemas for OpenSearch Query Datasources API + version: 1.0.0 +paths: {} +components: + schemas: + DataSourceList: + type: array + items: + $ref: '#/components/schemas/DataSource' + + DataSource: + type: object + properties: + name: + type: string + description: + type: string + connector: + type: string + allowedRoles: + type: array + items: + type: string + properties: + type: object + additionalProperties: true + resultIndex: + type: string + status: + type: string + configuration: + $ref: '#/components/schemas/DataSourceConfiguration' + required: + - connector + - name + - properties + - resultIndex + - status + + DataSourceConfiguration: + type: object + properties: + endpoint: + type: string + credentials: + $ref: '#/components/schemas/Credentials' + required: + - credentials + - endpoint + + Credentials: + type: object + properties: + username: + type: string + password: + type: string + required: + - password + - username + + DataSourceNotFound: + type: object + properties: + error: + $ref: '#/components/schemas/ErrorResponse' + required: + - error + + ErrorResponse: + type: object + properties: + root_cause: + type: array + items: + $ref: '#/components/schemas/RootCause' + type: + type: string + example: status_exception + reason: + type: string + example: DataSource not found + required: + - reason + - root_cause + - type + + RootCause: + type: object + properties: + type: + type: string + example: status_exception + reason: + type: string + example: DataSource not found + required: + - reason + - type + + DataSourceRetrieve: + type: object + properties: + name: + type: string + description: + type: string + connector: + type: string + allowedRoles: + type: array + items: + type: string + properties: + type: object + additionalProperties: true + resultIndex: + type: string + status: + type: string + configuration: + $ref: '#/components/schemas/DataSourceConfiguration' + required: + - connector + - name + - properties + - resultIndex + - status diff --git a/tests/default/observability/observability.yaml b/tests/default/observability/observability.yaml new file mode 100644 index 000000000..f9e37461c --- /dev/null +++ b/tests/default/observability/observability.yaml @@ -0,0 +1,155 @@ +$schema: ../../../json_schemas/test_story.schema.yaml + +description: Test various operations of the OpenSearch Observability Object API. + +prologues: + - path: /_plugins/_observability/object/{object_id} + method: DELETE + parameters: + object_id: test_object + status: [200, 404] + - path: /_plugins/_observability/object + method: POST + request: + payload: + objectId: test_object + operationalPanel: + name: test_panel + visualizations: [] + timeRange: + from: now-1h + to: now + queryFilter: + query: '' + language: ppl + applicationId: test_app + savedVisualization: + name: viz1 + description: desc1 + query: '' + type: line + selected_date_range: + start: now-1d + end: now + text: Last 24 hours + selected_timestamp: + name: timestamp + type: time + selected_fields: + text: field1 + tokens: + - name: field1 + type: text + savedQuery: + name: query1 + description: desc1 + query: '' + selected_date_range: + start: now-1d + end: now + text: Last 24 hours + selected_timestamp: + name: timestamp + type: time + selected_fields: + text: field1 + tokens: + - name: field1 + type: text + status: [200] +chapters: + - synopsis: Retrieve specific Observability object after creation. + path: /_plugins/_observability/object/{object_id} + id: observatory_object + method: GET + parameters: + object_id: test_object + response: + status: 200 + payload: + startIndex: 0 + totalHits: 1 + totalHitRelation: eq + observabilityObjectList: [] + - synopsis: Update specific Observability object. + path: /_plugins/_observability/object/{object_id} + method: PUT + parameters: + object_id: test_object + request: + payload: + objectId: test_object + tenant: '' + operationalPanel: + name: updated_test_panel + visualizations: [] + timeRange: + from: now-1h + to: now + queryFilter: + query: '' + language: ppl + applicationId: test_app + savedVisualization: + name: '[Logs] Count total requests by tags' + description: '' + query: 'source = opensearch_dashboards_sample_data_logs | stats count() by tags' + type: bar + selected_date_range: + start: now/y + end: now + text: '' + selected_timestamp: + name: timestamp + type: timestamp + selected_fields: + text: '' + tokens: [] + savedQuery: + name: query1 + description: desc1 + query: '' + selected_date_range: + start: now-1d + end: now + text: Last 24 hours + selected_timestamp: + name: timestamp + type: time + selected_fields: + text: field1 + tokens: + - name: field1 + type: text + response: + status: 200 + payload: + objectId: test_object + - synopsis: Retrieve specific Observability object after update. + path: /_plugins/_observability/object/{object_id} + method: GET + parameters: + object_id: test_object + response: + status: 200 + payload: + startIndex: 0 + totalHits: 1 + totalHitRelation: eq + observabilityObjectList: [] + - synopsis: Retrieve list of Observability objects. + path: /_plugins/_observability/object + method: GET + response: + status: 200 + payload: + startIndex: 0 + totalHits: 0 + totalHitRelation: eq + observabilityObjectList: [] +epilogues: + - path: /_plugins/_observability/object/{object_id} + method: DELETE + parameters: + object_id: test_object + status: [200, 404] \ No newline at end of file diff --git a/tests/default/query/datasources.yaml b/tests/default/query/datasources.yaml new file mode 100644 index 000000000..35c9fea04 --- /dev/null +++ b/tests/default/query/datasources.yaml @@ -0,0 +1,95 @@ +$schema: ../../../json_schemas/test_story.schema.yaml + +description: Test various operations of the OpenSearch Query Datasources API. +version: '>=2.7' + +prologues: + - path: /_plugins/_query/_datasources/{datasource_name} + method: DELETE + parameters: + datasource_name: test_datasource + status: [204, 404] +chapters: + - synopsis: Create a new query datasource. + path: /_plugins/_query/_datasources + method: POST + request: + payload: + name: test_datasource + description: '' + connector: PROMETHEUS + allowedRoles: [] + properties: + prometheus.uri: 'https://localhost:9090' + resultIndex: query_execution_result_test_datasource + status: ACTIVE + response: + status: 201 + payload: Created DataSource with name test_datasource + - synopsis: Retrieve the list of all query datasources. + path: /_plugins/_query/_datasources + method: GET + response: + status: 200 + payload: [] + - synopsis: Retrieve a specific query datasource by name. + path: /_plugins/_query/_datasources/{datasource_name} + method: GET + parameters: + datasource_name: test_datasource + response: + status: 200 + payload: + name: test_datasource + description: '' + connector: PROMETHEUS + allowedRoles: [] + properties: + prometheus.uri: 'https://localhost:9090' + resultIndex: query_execution_result_test_datasource + status: ACTIVE + - synopsis: Update an existing query datasource. + path: /_plugins/_query/_datasources + method: PUT + request: + payload: + name: test_datasource + description: Updated description + connector: PROMETHEUS + allowedRoles: [] + properties: + prometheus.uri: 'https://localhost:9091' + resultIndex: query_execution_result_test_datasource + status: ACTIVE + response: + status: 200 + payload: Updated DataSource with name test_datasource + - synopsis: Retrieve the updated query datasource by name. + path: /_plugins/_query/_datasources/{datasource_name} + method: GET + parameters: + datasource_name: test_datasource + response: + status: 200 + payload: + name: test_datasource + description: Updated description + connector: PROMETHEUS + allowedRoles: [] + properties: + prometheus.uri: 'https://localhost:9091' + resultIndex: query_execution_result_test_datasource + status: ACTIVE + - synopsis: Delete the query datasource by name. + path: /_plugins/_query/_datasources/{datasource_name} + method: DELETE + parameters: + datasource_name: test_datasource + response: + status: 204 +epilogues: + - path: /_plugins/_query/_datasources/{datasource_name} + method: DELETE + parameters: + datasource_name: test_datasource + status: [204, 404] \ No newline at end of file