Skip to content

Commit 6722f14

Browse files
authored
Support local file path for xpack.productDocBase.artifactRepositoryUrl (#217046)
## Summary Closes #216583 Adds support for a local file path in `xpack.productDocBase.artifactRepositoryUrl` setting. If local path with `file://` protocol is provided, it has to contain a path to a directory with the artifacts and the `index.xml` file. #### How to test 1. Download the XML and zip files from https://kibana-knowledge-base-artifacts.elastic.co 2. Create a folder, e.g. `mkdir /Users/<my_user>/test_artifacts` and place all the files there. The XML file has to be called `index.xml` 3. Add `xpack.productDocBase.artifactRepositoryUrl: 'file:///Users/<my_user>/test_artifacts'` to your `kibana.dev.yml` 4. Go to `/app/management/kibana/observabilityAiAssistantManagement` in Kibana and install Elastic documentation 5. Kibana dev server should report `[2025-04-07T14:05:10.640+02:00][INFO ][plugins.productDocBase.package-installer] Documentation installation successful for product [security] and version [8.17]` 6. Check `data/ai-kb-artifacts` folder in your Kibana repo, it should contain zip files with docs ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
1 parent 5d96f36 commit 6722f14

File tree

6 files changed

+218
-11
lines changed

6 files changed

+218
-11
lines changed

docs/reference/configuration-reference/ai-assistant-settings.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ applies_to:
1010
# AI Assistant settings in {{kib}} [ai-assistant-settings-kb]
1111

1212
`xpack.productDocBase.artifactRepositoryUrl`
13-
: Url of the repository to use to download and install the Elastic product documentation artifacts for the AI assistants. Defaults to `https://kibana-knowledge-base-artifacts.elastic.co`
13+
: Url of the repository to use to download and install the Elastic product documentation artifacts for the AI assistants. Supports both HTTP(S) URLs and local file paths (`file://`). Defaults to `https://kibana-knowledge-base-artifacts.elastic.co`
1414

1515
## Configuring product documentation for air-gapped environments [configuring-product-doc-for-airgap]
1616

17-
Installing product documentation requires network access to its artifact repository. For air-gapped environments, or environments where remote network traffic is blocked or filtered, the artifact repository must be manually deployed somewhere accessible by the Kibana deployment.
17+
Installing product documentation requires network access to its artifact repository. In air-gapped environments, or environments where remote network traffic is blocked or filtered, you can use a local artifact repository by specifying the path with the `file://` URI scheme.
1818

1919
Deploying a custom product documentation repository can be done in 2 ways: using a S3 bucket, or using a CDN.
2020

x-pack/platform/plugins/shared/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
* 2.0.
66
*/
77

8+
import * as fs from 'fs';
89
import fetch, { Response } from 'node-fetch';
910
import { fetchArtifactVersions } from './fetch_artifact_versions';
1011
import { getArtifactName, DocumentationProduct, ProductName } from '@kbn/product-doc-common';
1112

1213
jest.mock('node-fetch');
14+
jest.mock('fs');
15+
1316
const fetchMock = fetch as jest.MockedFn<typeof fetch>;
1417

1518
const createResponse = ({
@@ -41,6 +44,7 @@ const createResponse = ({
4144
};
4245

4346
const artifactRepositoryUrl = 'https://lost.com';
47+
const localArtifactRepositoryUrl = 'file://usr/local/local_artifacts';
4448

4549
const expectVersions = (
4650
versions: Partial<Record<ProductName, string[]>>
@@ -58,6 +62,7 @@ const expectVersions = (
5862
describe('fetchArtifactVersions', () => {
5963
beforeEach(() => {
6064
fetchMock.mockReset();
65+
jest.clearAllMocks();
6166
});
6267

6368
const mockResponse = (responseText: string) => {
@@ -67,6 +72,13 @@ describe('fetchArtifactVersions', () => {
6772
fetchMock.mockResolvedValue(response as Response);
6873
};
6974

75+
const mockFileResponse = (responseText: string) => {
76+
const mockData = Buffer.from(responseText);
77+
(fs.readFile as unknown as jest.Mock).mockImplementation((path, callback) => {
78+
callback(null, mockData);
79+
});
80+
};
81+
7082
it('calls fetch with the right parameters', async () => {
7183
mockResponse(createResponse({ artifactNames: [] }));
7284

@@ -76,6 +88,56 @@ describe('fetchArtifactVersions', () => {
7688
expect(fetchMock).toHaveBeenCalledWith(`${artifactRepositoryUrl}?max-keys=1000`);
7789
});
7890

91+
it('parses the local file', async () => {
92+
const artifactNames = [
93+
getArtifactName({ productName: 'kibana', productVersion: '8.16' }),
94+
getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }),
95+
];
96+
mockFileResponse(createResponse({ artifactNames }));
97+
98+
const result = await fetchArtifactVersions({
99+
artifactRepositoryUrl: localArtifactRepositoryUrl,
100+
});
101+
102+
expect(fs.readFile as unknown as jest.Mock).toHaveBeenCalledWith(
103+
'/local/local_artifacts/index.xml',
104+
expect.any(Function)
105+
);
106+
107+
expect(result).toEqual({
108+
elasticsearch: ['8.16'],
109+
kibana: ['8.16'],
110+
observability: [],
111+
security: [],
112+
});
113+
});
114+
115+
it('supports win32 env', async () => {
116+
const artifactNames = [
117+
getArtifactName({ productName: 'kibana', productVersion: '8.16' }),
118+
getArtifactName({ productName: 'elasticsearch', productVersion: '8.16' }),
119+
];
120+
mockFileResponse(createResponse({ artifactNames }));
121+
122+
const originalPlatform = process.platform;
123+
Object.defineProperty(process, 'platform', {
124+
value: 'win32',
125+
});
126+
127+
await fetchArtifactVersions({
128+
artifactRepositoryUrl: 'file:///C:/path/local_artifacts',
129+
});
130+
131+
expect(fs.readFile as unknown as jest.Mock).toHaveBeenCalledWith(
132+
'C:/path/local_artifacts/index.xml',
133+
expect.any(Function)
134+
);
135+
136+
Object.defineProperty(process, 'platform', {
137+
value: originalPlatform,
138+
});
139+
});
140+
79141
it('returns the list of versions from the repository', async () => {
80142
const artifactNames = [
81143
getArtifactName({ productName: 'kibana', productVersion: '8.16' }),

x-pack/platform/plugins/shared/ai_infra/product_doc_base/server/services/package_installer/steps/fetch_artifact_versions.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
* 2.0.
66
*/
77

8+
import { DocumentationProduct, parseArtifactName, type ProductName } from '@kbn/product-doc-common';
9+
import * as fs from 'fs';
810
import fetch from 'node-fetch';
11+
import Path from 'path';
12+
import { URL } from 'url';
913
import { parseString } from 'xml2js';
10-
import { type ProductName, DocumentationProduct, parseArtifactName } from '@kbn/product-doc-common';
14+
import { resolveLocalArtifactsPath } from '../utils/local_artifacts';
1115

1216
type ArtifactAvailableVersions = Record<ProductName, string[]>;
1317

@@ -16,8 +20,17 @@ export const fetchArtifactVersions = async ({
1620
}: {
1721
artifactRepositoryUrl: string;
1822
}): Promise<ArtifactAvailableVersions> => {
19-
const res = await fetch(`${artifactRepositoryUrl}?max-keys=1000`);
20-
const xml = await res.text();
23+
const parsedUrl = new URL(artifactRepositoryUrl);
24+
25+
let xml: string;
26+
if (parsedUrl.protocol === 'file:') {
27+
const file = await fetchLocalFile(parsedUrl);
28+
xml = file.toString();
29+
} else {
30+
const res = await fetch(`${artifactRepositoryUrl}?max-keys=1000`);
31+
xml = await res.text();
32+
}
33+
2134
return new Promise((resolve, reject) => {
2235
parseString(xml, (err, result: ListBucketResponse) => {
2336
if (err) {
@@ -50,6 +63,21 @@ export const fetchArtifactVersions = async ({
5063
});
5164
};
5265

66+
function fetchLocalFile(parsedUrl: URL): Promise<Buffer> {
67+
return new Promise((resolve, reject) => {
68+
const normalizedPath = resolveLocalArtifactsPath(parsedUrl);
69+
const xmlFilePath = Path.join(normalizedPath, 'index.xml');
70+
71+
fs.readFile(xmlFilePath, (err, data) => {
72+
if (err) {
73+
reject(err);
74+
} else {
75+
resolve(data);
76+
}
77+
});
78+
});
79+
}
80+
5381
interface ListBucketResponse {
5482
ListBucketResult: {
5583
Name?: string[];
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { createReadStream } from 'fs';
9+
import { mkdir } from 'fs/promises';
10+
import fetch from 'node-fetch';
11+
import { downloadToDisk } from './download';
12+
13+
jest.mock('fs', () => ({
14+
createReadStream: jest.fn().mockReturnValue({
15+
on: jest.fn(),
16+
pipe: jest.fn(),
17+
}),
18+
createWriteStream: jest.fn(() => ({
19+
on: jest.fn((event, callback) => {
20+
if (event === 'finish') {
21+
callback();
22+
}
23+
}),
24+
pipe: jest.fn(),
25+
})),
26+
}));
27+
28+
jest.mock('fs/promises', () => ({
29+
mkdir: jest.fn(),
30+
}));
31+
32+
jest.mock('node-fetch', () => jest.fn());
33+
34+
describe('downloadToDisk', () => {
35+
const mockFileUrl = 'http://example.com/file.txt';
36+
const mockFilePath = '/path/to/file.txt';
37+
const mockDirPath = '/path/to';
38+
const mockLocalPath = '/local/path/to/file.txt';
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
});
43+
44+
it('should create the directory if it does not exist', async () => {
45+
(fetch as unknown as jest.Mock).mockResolvedValue({
46+
body: {
47+
pipe: jest.fn(),
48+
on: jest.fn(),
49+
},
50+
});
51+
52+
await downloadToDisk(mockFileUrl, mockFilePath);
53+
54+
expect(mkdir).toHaveBeenCalledWith(mockDirPath, { recursive: true });
55+
});
56+
57+
it('should download a file from a remote URL', async () => {
58+
const mockResponseBody = {
59+
pipe: jest.fn(),
60+
on: jest.fn((event, callback) => {}),
61+
};
62+
63+
(fetch as unknown as jest.Mock).mockResolvedValue({
64+
body: mockResponseBody,
65+
});
66+
67+
await downloadToDisk(mockFileUrl, mockFilePath);
68+
69+
expect(fetch).toHaveBeenCalledWith(mockFileUrl);
70+
});
71+
72+
it('should copy a file from a local file URL', async () => {
73+
const mockLocalFileUrl = 'file:///local/path/to/file.txt';
74+
75+
await downloadToDisk(mockLocalFileUrl, mockFilePath);
76+
77+
expect(createReadStream).toHaveBeenCalledWith(mockLocalPath);
78+
});
79+
80+
it('should handle errors during the download process', async () => {
81+
const mockError = new Error('Download failed');
82+
(fetch as unknown as jest.Mock).mockRejectedValue(mockError);
83+
84+
await expect(downloadToDisk(mockFileUrl, mockFilePath)).rejects.toThrow('Download failed');
85+
});
86+
});

x-pack/platform/plugins/shared/ai_infra/product_doc_base/server/services/package_installer/utils/download.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,32 @@
55
* 2.0.
66
*/
77

8-
import { createWriteStream } from 'fs';
8+
import { type ReadStream, createReadStream, createWriteStream } from 'fs';
99
import { mkdir } from 'fs/promises';
1010
import Path from 'path';
1111
import fetch from 'node-fetch';
12+
import { resolveLocalArtifactsPath } from './local_artifacts';
1213

1314
export const downloadToDisk = async (fileUrl: string, filePath: string) => {
1415
const dirPath = Path.dirname(filePath);
1516
await mkdir(dirPath, { recursive: true });
16-
const res = await fetch(fileUrl);
17-
const fileStream = createWriteStream(filePath);
17+
const writeStream = createWriteStream(filePath);
18+
let readStream: ReadStream | NodeJS.ReadableStream;
19+
20+
const parsedUrl = new URL(fileUrl);
21+
22+
if (parsedUrl.protocol === 'file:') {
23+
const path = resolveLocalArtifactsPath(parsedUrl);
24+
readStream = createReadStream(path);
25+
} else {
26+
const res = await fetch(fileUrl);
27+
28+
readStream = res.body;
29+
}
30+
1831
await new Promise((resolve, reject) => {
19-
res.body.pipe(fileStream);
20-
res.body.on('error', reject);
21-
fileStream.on('finish', resolve);
32+
readStream.pipe(writeStream);
33+
readStream.on('error', reject);
34+
writeStream.on('finish', resolve);
2235
});
2336
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
/** Resolve a path to the artifacts folder */
9+
export function resolveLocalArtifactsPath(parsedUrl: URL): string {
10+
if (parsedUrl.protocol !== 'file:') {
11+
throw new Error(`Expected file URL, got ${parsedUrl.protocol}`);
12+
}
13+
const filePath = parsedUrl.pathname;
14+
// On Windows, remove leading "/" (e.g., file:///C:/path should be C:/path)
15+
const normalizedPath =
16+
process.platform === 'win32' && filePath.startsWith('/') ? filePath.substring(1) : filePath;
17+
return normalizedPath;
18+
}

0 commit comments

Comments
 (0)