@cognite/dune 0.3.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { type CogniteClient, HttpError } from '@cognite/sdk';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { AppHostingDeployer } from './apphosting-deployer';
5
+
6
+ vi.mock('node:fs', () => ({
7
+ default: {
8
+ readFileSync: vi.fn(() => Buffer.from('fake-zip-content')),
9
+ },
10
+ }));
11
+
12
+ vi.mock('node:path', () => ({
13
+ default: {
14
+ basename: vi.fn(() => 'app.zip'),
15
+ },
16
+ }));
17
+
18
+ const BASE_URL = 'https://cognite.test';
19
+ const PROJECT = 'test-project';
20
+ const TOKEN = 'test-token';
21
+
22
+ function makeDeployer() {
23
+ const mockClient = {
24
+ project: PROJECT,
25
+ authenticate: vi.fn(() => Promise.resolve(TOKEN)),
26
+ getBaseUrl: vi.fn(() => BASE_URL),
27
+ post: vi.fn(),
28
+ };
29
+ const deployer = new AppHostingDeployer(
30
+ mockClient as Partial<CogniteClient> as CogniteClient,
31
+ );
32
+ return { deployer, mockClient };
33
+ }
34
+
35
+ function mockFetchResponse(ok: boolean, status: number, body = '') {
36
+ return vi.fn(() =>
37
+ Promise.resolve({
38
+ ok,
39
+ status,
40
+ text: vi.fn(() => Promise.resolve(body)),
41
+ } as Partial<Response> as Response)
42
+ );
43
+ }
44
+
45
+ function makeHttpError(status: number, message = 'Error') {
46
+ return new HttpError(status, { error: { code: status, message } }, {});
47
+ }
48
+
49
+ describe(AppHostingDeployer.name, () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ afterEach(() => {
55
+ vi.unstubAllGlobals();
56
+ });
57
+
58
+ describe('ensureApp', () => {
59
+ it('resolves when app already exists (409)', async () => {
60
+ const { deployer, mockClient } = makeDeployer();
61
+ mockClient.post.mockRejectedValueOnce(makeHttpError(409, 'Conflict'));
62
+
63
+ await expect(
64
+ deployer.ensureApp('my-app', 'My App', 'A description')
65
+ ).resolves.toBeUndefined();
66
+ });
67
+
68
+ it('throws on unexpected API error', async () => {
69
+ const { deployer, mockClient } = makeDeployer();
70
+ mockClient.post.mockRejectedValueOnce(makeHttpError(500, 'Internal Server Error'));
71
+
72
+ await expect(
73
+ deployer.ensureApp('my-app', 'My App', 'A description')
74
+ ).rejects.toThrow('Request failed | status code: 500');
75
+ });
76
+
77
+ it('sends POST to the apps endpoint with correct path and data', async () => {
78
+ const { deployer, mockClient } = makeDeployer();
79
+ mockClient.post.mockResolvedValueOnce({ data: {}, status: 201, headers: {} });
80
+
81
+ await deployer.ensureApp('my-app', 'My App', 'A description');
82
+
83
+ expect(mockClient.post).toHaveBeenCalledWith(
84
+ `/api/v1/projects/${PROJECT}/apphosting/apps`,
85
+ expect.objectContaining({
86
+ data: { items: [{ externalId: 'my-app', name: 'My App', description: 'A description' }] },
87
+ })
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('uploadVersion', () => {
93
+ it('throws on upload failure', async () => {
94
+ const { deployer } = makeDeployer();
95
+ vi.stubGlobal('fetch', mockFetchResponse(false, 413, 'Payload Too Large'));
96
+
97
+ await expect(
98
+ deployer.uploadVersion('my-app', '1.0.0', '/tmp/app.zip')
99
+ ).rejects.toThrow('Upload failed: 413');
100
+ });
101
+
102
+ it('sends POST to the versions endpoint with auth header and form data', async () => {
103
+ const { deployer, mockClient } = makeDeployer();
104
+ const fetchMock = mockFetchResponse(true, 200);
105
+ vi.stubGlobal('fetch', fetchMock);
106
+
107
+ await deployer.uploadVersion('my-app', '1.0.0', '/tmp/app.zip');
108
+
109
+ expect(fetchMock).toHaveBeenCalledWith(
110
+ `${BASE_URL}/api/v1/projects/${PROJECT}/apphosting/apps/my-app/versions`,
111
+ expect.objectContaining({
112
+ method: 'POST',
113
+ headers: expect.objectContaining({ Authorization: `Bearer ${TOKEN}` }),
114
+ body: expect.any(FormData),
115
+ })
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('publishAndActivate', () => {
121
+ it('throws on publish failure', async () => {
122
+ const { deployer, mockClient } = makeDeployer();
123
+ mockClient.post.mockRejectedValueOnce(makeHttpError(400, 'Bad Request'));
124
+
125
+ await expect(
126
+ deployer.publishAndActivate('my-app', '1.0.0')
127
+ ).rejects.toThrow('Request failed | status code: 400');
128
+ });
129
+
130
+ it('sends POST to the versions/update endpoint', async () => {
131
+ const { deployer, mockClient } = makeDeployer();
132
+ mockClient.post.mockResolvedValueOnce({ data: {}, status: 200, headers: {} });
133
+
134
+ await deployer.publishAndActivate('my-app', '1.0.0');
135
+
136
+ expect(mockClient.post).toHaveBeenCalledWith(
137
+ `/api/v1/projects/${PROJECT}/apphosting/apps/my-app/versions/update`,
138
+ expect.any(Object)
139
+ );
140
+ });
141
+ });
142
+
143
+ describe('deploy', () => {
144
+ it('calls ensureApp and uploadVersion but not publishAndActivate when published is false', async () => {
145
+ const { deployer, mockClient } = makeDeployer();
146
+ mockClient.post.mockResolvedValue({ data: {}, status: 200, headers: {} });
147
+ vi.stubGlobal('fetch', mockFetchResponse(true, 200));
148
+
149
+ await deployer.deploy('my-app', 'My App', 'A description', '1.0.0', '/tmp/app.zip', false);
150
+
151
+ // ensureApp → 1 client.post, uploadVersion → 1 fetch, publishAndActivate → not called
152
+ expect(mockClient.post).toHaveBeenCalledTimes(1);
153
+ expect(global.fetch).toHaveBeenCalledTimes(1);
154
+ });
155
+
156
+ it('calls publishAndActivate as third step when published is true', async () => {
157
+ const { deployer, mockClient } = makeDeployer();
158
+ mockClient.post.mockResolvedValue({ data: {}, status: 200, headers: {} });
159
+ vi.stubGlobal('fetch', mockFetchResponse(true, 200));
160
+
161
+ await deployer.deploy('my-app', 'My App', 'A description', '1.0.0', '/tmp/app.zip', true);
162
+
163
+ // ensureApp + publishAndActivate → 2 client.post, uploadVersion → 1 fetch
164
+ expect(mockClient.post).toHaveBeenCalledTimes(2);
165
+ expect(global.fetch).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it('wraps errors with "Deployment failed:" prefix', async () => {
169
+ const { deployer, mockClient } = makeDeployer();
170
+ mockClient.post.mockRejectedValueOnce(makeHttpError(500, 'Server Error'));
171
+
172
+ await expect(
173
+ deployer.deploy('my-app', 'My App', 'A description', '1.0.0', '/tmp/app.zip')
174
+ ).rejects.toThrow('Deployment failed:');
175
+ });
176
+ });
177
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * App Hosting API Deployment
3
+ *
4
+ * Handles deployment of packaged applications via the new App Hosting API
5
+ * instead of CDF Files API + dataset.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { type CogniteClient } from '@cognite/sdk';
12
+
13
+ export class AppHostingDeployer {
14
+ private client: CogniteClient;
15
+
16
+ constructor(client: CogniteClient) {
17
+ this.client = client;
18
+ }
19
+
20
+ private get appsBasePath(): string {
21
+ return `/api/v1/projects/${encodeURIComponent(this.client.project)}/apphosting/apps`;
22
+ }
23
+
24
+ /**
25
+ * Ensure the app exists in the App Hosting service. Creates it if missing.
26
+ */
27
+ async ensureApp(externalId: string, name: string, description: string): Promise<void> {
28
+ console.log('šŸ” Ensuring app exists...');
29
+
30
+ try {
31
+ await this.client.post(this.appsBasePath, {
32
+ data: { items: [{ externalId, name, description }] },
33
+ });
34
+ console.log(`āœ… App '${externalId}' created`);
35
+ } catch (error) {
36
+ if (error instanceof Error && 'status' in error && error.status === 409) {
37
+ console.log(`āœ… App '${externalId}' already exists`);
38
+ return;
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Upload a new version of the app as a zip file.
46
+ *
47
+ * `entryPath` must be a relative path (no leading slash). The App
48
+ * Hosting backend rejects absolute paths as a security measure against
49
+ * archive traversal, see `ZipCentralDirectory.normalizeEntryPath` in
50
+ * infrastructure/services/app-hosting.
51
+ */
52
+ async uploadVersion(
53
+ appExternalId: string,
54
+ version: string,
55
+ zipPath: string,
56
+ entryPath = 'index.html',
57
+ ): Promise<void> {
58
+ console.log(`šŸ“¤ Uploading version ${version}...`);
59
+
60
+ const fileBuffer = fs.readFileSync(zipPath);
61
+ const fileName = path.basename(zipPath);
62
+
63
+ const formData = new FormData();
64
+ formData.append('file', new Blob([fileBuffer]), fileName);
65
+ formData.append('version', version);
66
+ formData.append('entryPath', entryPath);
67
+
68
+ const encodedAppExternalId = encodeURIComponent(appExternalId);
69
+ const uploadPath = `${this.appsBasePath}/${encodedAppExternalId}/versions`;
70
+
71
+ // client.post always sends application/json; use fetch directly so FormData
72
+ // sets the correct multipart/form-data Content-Type with boundary
73
+ const token = await this.client.authenticate();
74
+ const url = `${this.client.getBaseUrl()}${uploadPath}`;
75
+
76
+ const controller = new AbortController();
77
+ const timeout = setTimeout(() => controller.abort(), 5 * 60 * 1000);
78
+
79
+ let response: Response;
80
+ try {
81
+ response = await fetch(url, {
82
+ method: 'POST',
83
+ headers: { Authorization: `Bearer ${token}` },
84
+ body: formData,
85
+ signal: controller.signal,
86
+ });
87
+ } catch (error) {
88
+ if (error instanceof Error && error.name === 'AbortError') {
89
+ throw new Error('Upload timed out after 5 minutes');
90
+ }
91
+ throw error;
92
+ } finally {
93
+ clearTimeout(timeout);
94
+ }
95
+
96
+ if (!response.ok) {
97
+ const body = await response.text();
98
+ let message = body;
99
+ try {
100
+ const json = JSON.parse(body) as { message?: string; error?: string };
101
+ message = json.message ?? json.error ?? body;
102
+ } catch {
103
+ // not JSON, use raw text
104
+ }
105
+ throw new Error(`Upload failed: ${response.status} — ${message}`);
106
+ }
107
+
108
+ console.log(`āœ… Version ${version} uploaded`);
109
+ }
110
+
111
+ /**
112
+ * Publish the version and set it as the ACTIVE alias.
113
+ */
114
+ async publishAndActivate(appExternalId: string, version: string): Promise<void> {
115
+ console.log(`šŸš€ Publishing and activating version ${version}...`);
116
+
117
+ const encodedAppExternalId = encodeURIComponent(appExternalId);
118
+ const url = `${this.appsBasePath}/${encodedAppExternalId}/versions/update`;
119
+
120
+ await this.client.post(url, {
121
+ data: {
122
+ items: [
123
+ {
124
+ version,
125
+ update: {
126
+ lifecycleState: { set: 'PUBLISHED' },
127
+ alias: { set: 'ACTIVE' },
128
+ },
129
+ },
130
+ ],
131
+ },
132
+ });
133
+
134
+ console.log(`āœ… Version ${version} is now PUBLISHED and ACTIVE`);
135
+ }
136
+
137
+ /**
138
+ * Full deployment: ensure app exists, upload version, and optionally publish and activate.
139
+ *
140
+ * When `published` is false the version is uploaded in DRAFT state with no alias —
141
+ * it is stored but not served to end users. Pass `published: true` to transition the
142
+ * version to PUBLISHED and set it as the ACTIVE alias so it starts receiving traffic.
143
+ */
144
+ async deploy(
145
+ appExternalId: string,
146
+ name: string,
147
+ description: string,
148
+ versionTag: string,
149
+ zipPath: string,
150
+ published = false,
151
+ ): Promise<void> {
152
+ console.log('\nšŸš€ Deploying application via App Hosting API...\n');
153
+
154
+ try {
155
+ await this.ensureApp(appExternalId, name, description);
156
+ await this.uploadVersion(appExternalId, versionTag, zipPath);
157
+ if (published) {
158
+ await this.publishAndActivate(appExternalId, versionTag);
159
+ }
160
+ console.log('\nāœ… Deployment successful!');
161
+ } catch (error: unknown) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ throw Object.assign(new Error(`Deployment failed: ${message}`), { cause: error });
164
+ }
165
+ }
166
+ }
@@ -1,34 +1,39 @@
1
1
  import fs from 'node:fs';
2
2
 
3
+ import { AppHostingDeployer } from './apphosting-deployer';
3
4
  import { CdfApplicationDeployer } from './application-deployer';
4
5
  import { ApplicationPackager } from './application-packager';
5
6
  import { getSdk } from './get-sdk';
6
7
  import type { App, Deployment } from './types';
7
8
 
8
- export const deploy = async (deployment: Deployment, app: App, folder: string) => {
9
- // Step 1: Get an SDK instance
9
+ async function deployViaAppHosting(deployment: Deployment, app: App, folder: string, zipFilename: string) {
10
+ const { externalId, name, description, versionTag } = app;
10
11
  const sdk = await getSdk(deployment, folder);
12
+ const deployer = new AppHostingDeployer(sdk);
13
+ await deployer.deploy(externalId, name, description, versionTag, zipFilename, deployment.published);
14
+ }
11
15
 
12
- // Step 2: Package application (from the dist subdirectory)
13
- const distPath = `${folder}/dist`;
14
- const packager = new ApplicationPackager(distPath);
15
- const zipFilename = await packager.createZip('app.zip', true);
16
-
17
- // Step 3: Deploy to CDF
16
+ async function deployViaCdf(deployment: Deployment, app: App, folder: string, zipFilename: string) {
17
+ const { externalId, name, description, versionTag } = app;
18
+ const sdk = await getSdk(deployment, folder);
18
19
  const deployer = new CdfApplicationDeployer(sdk);
19
- await deployer.deploy(
20
- app.externalId,
21
- app.name,
22
- app.description,
23
- app.versionTag,
24
- zipFilename,
25
- deployment.published
26
- );
20
+ await deployer.deploy(externalId, name, description, versionTag, zipFilename, deployment.published);
21
+ }
22
+
23
+ export const deploy = async (deployment: Deployment, app: App, folder: string) => {
24
+ const zipFilename = await new ApplicationPackager(`${folder}/dist`).createZip('app.zip', true);
27
25
 
28
- // Step 4: Clean up zip file
29
26
  try {
30
- fs.unlinkSync(zipFilename);
31
- } catch {
32
- // Ignore cleanup errors
27
+ if (app.infra === 'appsApi') {
28
+ await deployViaAppHosting(deployment, app, folder, zipFilename);
29
+ } else {
30
+ await deployViaCdf(deployment, app, folder, zipFilename);
31
+ }
32
+ } finally {
33
+ try {
34
+ fs.unlinkSync(zipFilename);
35
+ } catch {
36
+ // Ignore cleanup errors
37
+ }
33
38
  }
34
39
  };
@@ -1,5 +1,6 @@
1
1
  // Deploy exports for CI/CD and programmatic deployment
2
2
  export { deploy } from './deploy';
3
+ export { AppHostingDeployer } from './apphosting-deployer';
3
4
  export { CdfApplicationDeployer } from './application-deployer';
4
5
  export { ApplicationPackager } from './application-packager';
5
6
  export { getSdk } from './get-sdk';
@@ -16,4 +16,6 @@ export type App = {
16
16
  name: string;
17
17
  description: string;
18
18
  versionTag: string;
19
+ /** When set to "appsApi", deploy uses the App Hosting API instead of Files API */
20
+ infra?: "appsApi";
19
21
  };
@@ -37,7 +37,16 @@ export const fusionOpenPlugin = () => {
37
37
  const parsedBaseUrl = baseUrl?.split('//')[1];
38
38
 
39
39
  if (org && project && baseUrl) {
40
- const fusionUrl = `https://${org}.fusion.cognite.com/${project}/streamlit-apps/dune/development/${port}?cluster=${parsedBaseUrl}&workspace=industrial-tools`;
40
+ let fusionUrl: string;
41
+ if (appJson.infra === 'appsApi') {
42
+ if (!appJson.externalId) {
43
+ console.warn(' āžœ app.json is missing externalId — cannot determine Fusion URL');
44
+ return;
45
+ }
46
+ fusionUrl = `https://${org}.fusion.cognite.com/${project}/dune-app-host/development/${appJson.externalId}/${port}?cluster=${parsedBaseUrl}`;
47
+ } else {
48
+ fusionUrl = `https://${org}.fusion.cognite.com/${project}/streamlit-apps/dune/development/${port}?cluster=${parsedBaseUrl}&workspace=industrial-tools`;
49
+ }
41
50
  console.log(` āžœ Fusion: ${fusionUrl}`);
42
51
  openUrl(fusionUrl);
43
52
  return;