@kapeta/local-cluster-service 0.15.3 → 0.16.0

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,304 @@
1
+ import chokidar, { FSWatcher } from 'chokidar';
2
+ import ClusterConfiguration, { Definition, DefinitionInfo } from '@kapeta/local-cluster-config';
3
+ import FS from 'fs-extra';
4
+ import Path from 'node:path';
5
+ import YAML from 'yaml';
6
+ import { parseKapetaUri } from '@kapeta/nodejs-utils';
7
+ import _ from 'lodash';
8
+ import { socketManager } from './socketManager';
9
+ import { definitionsManager } from './definitionsManager';
10
+ import { assetManager } from './assetManager';
11
+
12
+ function clearAllCaches() {
13
+ definitionsManager.clearCache();
14
+ assetManager.clearCache();
15
+ }
16
+
17
+ type WatchEventName = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
18
+ interface AssetIdentity {
19
+ handle: string;
20
+ name: string;
21
+ version: string;
22
+ }
23
+ const KAPETA_YML_RX = /^kapeta.ya?ml$/;
24
+ export class RepositoryWatcher {
25
+ private watcher?: FSWatcher;
26
+ private disabled: boolean = false;
27
+ private readonly baseDir: string;
28
+ private allDefinitions: DefinitionInfo[] = [];
29
+ private symbolicLinks: { [link: string]: string } = {};
30
+ private ignoredFiles: Set<string> = new Set<string>();
31
+ constructor() {
32
+ this.baseDir = ClusterConfiguration.getRepositoryBasedir();
33
+ }
34
+
35
+ setDisabled(disabled: boolean) {
36
+ this.disabled = disabled;
37
+ }
38
+ public watch() {
39
+ if (!FS.existsSync(this.baseDir)) {
40
+ FS.mkdirpSync(this.baseDir);
41
+ }
42
+
43
+ this.allDefinitions = ClusterConfiguration.getDefinitions();
44
+
45
+ try {
46
+ this.watcher = chokidar.watch(this.baseDir, {
47
+ followSymlinks: false,
48
+ ignorePermissionErrors: true,
49
+ disableGlobbing: true,
50
+ persistent: true,
51
+ depth: 2,
52
+ ignored: (path) => this.ignoreFile(path),
53
+ });
54
+ this.watcher.on('all', this.handleFileChange.bind(this));
55
+ this.watcher.on('error', (error) => {
56
+ console.log('Error watching repository', error);
57
+ });
58
+ this.watcher.on('ready', () => {
59
+ console.log('Watching local repository for provider changes: %s', this.baseDir);
60
+ });
61
+ } catch (e) {
62
+ // Fallback to run without watch mode due to potential platform issues.
63
+ // https://nodejs.org/docs/latest/api/fs.html#caveats
64
+ console.log('Unable to watch for changes. Changes to assets will not update automatically.', e);
65
+ return;
66
+ }
67
+ }
68
+
69
+ async ignoreChangesFor(file: string) {
70
+ this.ignoredFiles.add(file);
71
+ const realPath = await FS.realpath(file);
72
+ if (realPath !== file) {
73
+ this.ignoredFiles.add(realPath);
74
+ }
75
+ }
76
+
77
+ async resumeChangedFor(file: string) {
78
+ this.ignoredFiles.delete(file);
79
+ const realPath = await FS.realpath(file);
80
+ if (realPath !== file) {
81
+ this.ignoredFiles.delete(realPath);
82
+ }
83
+ }
84
+
85
+ public async unwatch() {
86
+ if (!this.watcher) {
87
+ return;
88
+ }
89
+ this.symbolicLinks = {};
90
+ await this.watcher.close();
91
+ this.watcher = undefined;
92
+ }
93
+
94
+ private async getAssetIdentity(path: string): Promise<AssetIdentity | undefined> {
95
+ const baseName = Path.basename(path);
96
+ let handle, name, version;
97
+ if (path.startsWith(this.baseDir)) {
98
+ const relativePath = Path.relative(this.baseDir, path);
99
+ // Inside the repo we can use the path to determine the handle, name and version
100
+ [handle, name, version] = relativePath.split(/\//g);
101
+ if (!handle || !name || !version) {
102
+ // Do nothing with this
103
+ return;
104
+ }
105
+
106
+ return {
107
+ handle,
108
+ name,
109
+ version,
110
+ };
111
+ }
112
+
113
+ if (!KAPETA_YML_RX.test(baseName)) {
114
+ // Do nothing with this
115
+ return;
116
+ }
117
+ // Outside the repo we need to use the file content to determine the handle, name
118
+ // Version is always 'local'
119
+ version = 'local';
120
+
121
+ try {
122
+ const definition: Definition = YAML.parse((await FS.readFile(path)).toString());
123
+ const uri = parseKapetaUri(definition.metadata.name);
124
+ handle = uri.handle;
125
+ name = uri.name;
126
+ return {
127
+ handle,
128
+ name,
129
+ version,
130
+ };
131
+ } catch (e) {
132
+ // Ignore issues in the YML file
133
+ return;
134
+ }
135
+ }
136
+
137
+ private async handleFileChange(eventName: WatchEventName, path: string) {
138
+ if (!path) {
139
+ return;
140
+ }
141
+
142
+ if (this.ignoredFiles.has(path)) {
143
+ return;
144
+ }
145
+
146
+ //console.log('File changed', eventName, path);
147
+
148
+ const assetIdentity = await this.getAssetIdentity(path);
149
+ if (!assetIdentity) {
150
+ return;
151
+ }
152
+
153
+ if (this.disabled) {
154
+ return;
155
+ }
156
+
157
+ // If this is false it's because we're watching a symlink target
158
+ const withinRepo = path.startsWith(this.baseDir);
159
+ if (withinRepo && assetIdentity.version === 'local' && path.endsWith('/local')) {
160
+ // This is likely a symlink target
161
+ if (eventName === 'add') {
162
+ //console.log('Checking if we should add symlink target', handle, name, version, path);
163
+ await this.addSymlinkTarget(path);
164
+ }
165
+
166
+ if (eventName === 'unlink') {
167
+ await this.removeSymlinkTarget(path);
168
+ }
169
+
170
+ if (eventName === 'change') {
171
+ await this.updateSymlinkTarget(path);
172
+ }
173
+ }
174
+
175
+ await this.checkForChange(assetIdentity);
176
+ }
177
+
178
+ private async checkForChange(assetIdentity: AssetIdentity) {
179
+ const ymlPath = Path.join(
180
+ this.baseDir,
181
+ assetIdentity.handle,
182
+ assetIdentity.name,
183
+ assetIdentity.version,
184
+ 'kapeta.yml'
185
+ );
186
+ const newDefinitions = ClusterConfiguration.getDefinitions();
187
+ const newDefinition = newDefinitions.find((d) => d.ymlPath === ymlPath);
188
+ let currentDefinition = this.allDefinitions.find((d) => d.ymlPath === ymlPath);
189
+ const ymlExists = await this.exists(ymlPath);
190
+ let type;
191
+ if (ymlExists) {
192
+ if (currentDefinition) {
193
+ if (newDefinition && _.isEqual(currentDefinition, newDefinition)) {
194
+ //Definition was not changed
195
+ return;
196
+ }
197
+ type = 'updated';
198
+ } else if (newDefinition) {
199
+ type = 'added';
200
+ currentDefinition = newDefinition;
201
+ } else {
202
+ //Other definition was added / updated - ignore
203
+ return;
204
+ }
205
+ } else {
206
+ if (currentDefinition) {
207
+ const ref = parseKapetaUri(
208
+ `${currentDefinition.definition.metadata.name}:${currentDefinition.version}`
209
+ ).id;
210
+ //Something was removed
211
+ type = 'removed';
212
+ } else {
213
+ //Other definition was removed - ignore
214
+ return;
215
+ }
216
+ }
217
+
218
+ const payload = {
219
+ type,
220
+ definition: newDefinition?.definition ?? currentDefinition?.definition,
221
+ asset: assetIdentity,
222
+ };
223
+
224
+ this.allDefinitions = newDefinitions;
225
+
226
+ //console.log('Asset changed', payload);
227
+ socketManager.emitGlobal('asset-change', payload);
228
+ clearAllCaches();
229
+ }
230
+
231
+ private async exists(path: string): Promise<boolean> {
232
+ try {
233
+ await FS.access(path);
234
+ return true;
235
+ } catch (e) {
236
+ return false;
237
+ }
238
+ }
239
+ private async removeSymlinkTarget(path: string) {
240
+ if (this.symbolicLinks[path]) {
241
+ //console.log('Unwatching symlink target %s => %s', path, this.symbolicLinks[path]);
242
+ this.watcher?.unwatch(this.symbolicLinks[path]);
243
+ delete this.symbolicLinks[path];
244
+ }
245
+ }
246
+
247
+ private async updateSymlinkTarget(path: string) {
248
+ if (this.symbolicLinks[path]) {
249
+ //console.log('Updating symlink target %s => %s', path, this.symbolicLinks[path]);
250
+ this.watcher?.unwatch(this.symbolicLinks[path]);
251
+ delete this.symbolicLinks[path];
252
+ await this.addSymlinkTarget(path);
253
+ }
254
+ }
255
+
256
+ private async addSymlinkTarget(path: string) {
257
+ try {
258
+ // Make sure we're not watching the symlink target
259
+ await this.removeSymlinkTarget(path);
260
+ const stat = await FS.lstat(path);
261
+ if (stat.isSymbolicLink()) {
262
+ const realPath = `${await FS.realpath(path)}/kapeta.yml`;
263
+ if (await this.exists(realPath)) {
264
+ //console.log('Watching symlink target %s => %s', path, realPath);
265
+ this.watcher?.add(realPath);
266
+ this.symbolicLinks[path] = realPath;
267
+ }
268
+ }
269
+ } catch (e) {
270
+ // Ignore
271
+ console.warn('Failed to check local symlink target', e);
272
+ }
273
+ }
274
+
275
+ private ignoreFile(path: string) {
276
+ if (!path.startsWith(this.baseDir)) {
277
+ return false;
278
+ }
279
+ if (path.includes('/node_modules/')) {
280
+ return true;
281
+ }
282
+
283
+ const filename = Path.basename(path);
284
+ if (filename.startsWith('.')) {
285
+ return true;
286
+ }
287
+
288
+ const relativePath = Path.relative(this.baseDir, path).split(Path.sep);
289
+
290
+ try {
291
+ if (FS.statSync(path).isDirectory()) {
292
+ if (relativePath.length > 3) {
293
+ return true;
294
+ }
295
+ return false;
296
+ }
297
+ } catch (e) {
298
+ // Didn't exist - dont ignore
299
+ return false;
300
+ }
301
+
302
+ return !/^kapeta\.ya?ml$/.test(filename);
303
+ }
304
+ }
@@ -1,6 +1,5 @@
1
1
  import Path from 'node:path';
2
- import FS from 'node:fs';
3
- import FSExtra from 'fs-extra';
2
+ import FS from 'fs-extra';
4
3
  import YAML from 'yaml';
5
4
  import NodeCache from 'node-cache';
6
5
  import { Definition, DefinitionInfo } from '@kapeta/local-cluster-config';
@@ -14,6 +13,11 @@ import { definitionsManager } from './definitionsManager';
14
13
  import { normalizeKapetaUri } from './utils/utils';
15
14
  import { taskManager } from './taskManager';
16
15
 
16
+ function clearAllCaches() {
17
+ definitionsManager.clearCache();
18
+ assetManager.clearCache();
19
+ }
20
+
17
21
  export interface EnrichedAsset {
18
22
  ref: string;
19
23
  editable: boolean;
@@ -133,23 +137,19 @@ class AssetManager {
133
137
  }
134
138
 
135
139
  async createAsset(path: string, yaml: BlockDefinition): Promise<EnrichedAsset[]> {
136
- if (FS.existsSync(path)) {
140
+ if (await FS.pathExists(path)) {
137
141
  throw new Error('File already exists: ' + path);
138
142
  }
139
143
 
140
144
  const dirName = Path.dirname(path);
141
- if (!FS.existsSync(dirName)) {
142
- FSExtra.mkdirpSync(dirName);
145
+ if (!(await FS.pathExists(dirName))) {
146
+ await FS.mkdirp(dirName);
143
147
  }
144
148
 
145
- console.log('Wrote to ' + path);
146
- FS.writeFileSync(path, YAML.stringify(yaml));
147
-
149
+ await FS.writeFile(path, YAML.stringify(yaml));
148
150
  const asset = await this.importFile(path);
149
- console.log('Imported');
150
151
 
151
- this.cache.flushAll();
152
- definitionsManager.clearCache();
152
+ clearAllCaches();
153
153
 
154
154
  const ref = `kapeta://${yaml.metadata.name}:local`;
155
155
 
@@ -172,12 +172,21 @@ class AssetManager {
172
172
  throw new Error('Attempted to update corrupted asset: ' + ref);
173
173
  }
174
174
 
175
- console.log('Wrote to ' + asset.ymlPath);
176
- FS.writeFileSync(asset.ymlPath, YAML.stringify(yaml));
177
- this.cache.flushAll();
178
- definitionsManager.clearCache();
179
-
180
- this.maybeGenerateCode(asset.ref, asset.ymlPath, yaml);
175
+ const path = asset.ymlPath;
176
+ try {
177
+ await repositoryManager.ignoreChangesFor(path);
178
+ await FS.writeFile(asset.ymlPath, YAML.stringify(yaml));
179
+ console.log('Wrote to ' + asset.ymlPath);
180
+ clearAllCaches();
181
+ this.maybeGenerateCode(asset.ref, asset.ymlPath, yaml);
182
+ } finally {
183
+ //We need to wait a bit for the disk to settle before we can resume watching
184
+ setTimeout(async () => {
185
+ try {
186
+ await repositoryManager.resumeChangedFor(path);
187
+ } catch (e) {}
188
+ }, 500);
189
+ }
181
190
  }
182
191
 
183
192
  private maybeGenerateCode(ref: string, ymlPath: string, block: BlockDefinition) {
@@ -203,17 +212,20 @@ class AssetManager {
203
212
  filePath = filePath.substring('file://'.length);
204
213
  }
205
214
 
206
- if (!FS.existsSync(filePath)) {
215
+ if (!(await FS.pathExists(filePath))) {
207
216
  throw new Error('File not found: ' + filePath);
208
217
  }
218
+ const content = await FS.readFile(filePath);
209
219
 
210
- const assetInfos = YAML.parseAllDocuments(FS.readFileSync(filePath).toString()).map((doc) => doc.toJSON());
220
+ const assetInfos = YAML.parseAllDocuments(content.toString()).map((doc) => doc.toJSON());
211
221
 
212
222
  await Actions.link(new ProgressListener(), Path.dirname(filePath));
213
223
 
214
224
  const version = 'local';
215
225
  const refs = assetInfos.map((assetInfo) => `kapeta://${assetInfo.metadata.name}:${version}`);
216
- this.cache.flushAll();
226
+
227
+ clearAllCaches();
228
+
217
229
  return this.getAssets().filter((a) => refs.some((ref) => compareRefs(ref, a.ref)));
218
230
  }
219
231
 
@@ -222,7 +234,9 @@ class AssetManager {
222
234
  if (!asset) {
223
235
  throw new Error('Asset does not exists: ' + ref);
224
236
  }
225
- this.cache.flushAll();
237
+
238
+ clearAllCaches();
239
+
226
240
  await Actions.uninstall(new ProgressListener(), [asset.ref]);
227
241
  }
228
242
 
@@ -6,7 +6,6 @@ import { Request, Response } from 'express';
6
6
 
7
7
  const router = Router();
8
8
 
9
-
10
9
  router.use('/', corsHandler);
11
10
 
12
11
  router.get('/current', async (req: Request, res: Response) => {
@@ -74,8 +74,11 @@ export class InstanceManager {
74
74
  }
75
75
 
76
76
  const plan = planInfo.definition as Plan;
77
+ if (!plan?.spec?.blocks) {
78
+ return [];
79
+ }
77
80
 
78
- const instanceIds = plan.spec.blocks.map((block) => block.id);
81
+ const instanceIds = plan.spec?.blocks?.map((block) => block.id) || [];
79
82
 
80
83
  return this._instances.filter(
81
84
  (instance) => instance.systemId === systemId && instanceIds.includes(instance.instanceId)
@@ -1,18 +1,19 @@
1
- import FS from 'node:fs';
2
1
  import os from 'node:os';
3
- import Path from 'node:path';
4
- import watch from 'recursive-watch';
5
- import FSExtra from 'fs-extra';
6
- import ClusterConfiguration from '@kapeta/local-cluster-config';
7
- import { parseKapetaUri } from '@kapeta/nodejs-utils';
8
2
  import { socketManager } from './socketManager';
9
3
  import { Dependency } from '@kapeta/schemas';
10
4
  import { Actions, Config, RegistryService } from '@kapeta/nodejs-registry-utils';
11
5
  import { definitionsManager } from './definitionsManager';
12
6
  import { Task, taskManager } from './taskManager';
13
7
  import { normalizeKapetaUri } from './utils/utils';
14
- import { assetManager } from './assetManager';
8
+
15
9
  import { ProgressListener } from './progressListener';
10
+ import { RepositoryWatcher } from './RepositoryWatcher';
11
+ import { assetManager } from './assetManager';
12
+
13
+ function clearAllCaches() {
14
+ definitionsManager.clearCache();
15
+ assetManager.clearCache();
16
+ }
16
17
 
17
18
  const EVENT_DEFAULT_PROVIDERS_START = 'default-providers-start';
18
19
  const EVENT_DEFAULT_PROVIDERS_END = 'default-providers-end';
@@ -35,101 +36,31 @@ const DEFAULT_PROVIDERS = [
35
36
  const INSTALL_ATTEMPTED: { [p: string]: boolean } = {};
36
37
 
37
38
  class RepositoryManager {
38
- private changeEventsEnabled: boolean;
39
39
  private _registryService: RegistryService;
40
40
  private _cache: { [key: string]: boolean };
41
- private watcher?: () => void;
41
+ private watcher: RepositoryWatcher;
42
42
 
43
43
  constructor() {
44
- this.changeEventsEnabled = true;
45
- this.listenForChanges();
46
44
  this._registryService = new RegistryService(Config.data.registry.url);
47
45
  this._cache = {};
48
- }
49
-
50
- setChangeEventsEnabled(enabled: boolean) {
51
- this.changeEventsEnabled = enabled;
46
+ this.watcher = new RepositoryWatcher();
47
+ this.listenForChanges();
52
48
  }
53
49
 
54
50
  listenForChanges() {
55
- const baseDir = ClusterConfiguration.getRepositoryBasedir();
56
- if (!FS.existsSync(baseDir)) {
57
- FSExtra.mkdirpSync(baseDir);
58
- }
59
-
60
- let allDefinitions = ClusterConfiguration.getDefinitions();
61
-
62
- console.log('Watching local repository for provider changes: %s', baseDir);
63
- try {
64
- this.watcher = watch(baseDir, (filename: string) => {
65
- if (!filename) {
66
- return;
67
- }
68
-
69
- const [handle, name, version] = filename.toString().split(/\//g);
70
- if (!name || !version) {
71
- return;
72
- }
73
-
74
- if (!this.changeEventsEnabled) {
75
- return;
76
- }
77
-
78
- const ymlPath = Path.join(baseDir, handle, name, version, 'kapeta.yml');
79
- const newDefinitions = ClusterConfiguration.getDefinitions();
80
-
81
- const newDefinition = newDefinitions.find((d) => d.ymlPath === ymlPath);
82
- let currentDefinition = allDefinitions.find((d) => d.ymlPath === ymlPath);
83
- const ymlExists = FS.existsSync(ymlPath);
84
- let type;
85
- if (ymlExists) {
86
- if (currentDefinition) {
87
- type = 'updated';
88
- } else if (newDefinition) {
89
- type = 'added';
90
- currentDefinition = newDefinition;
91
- } else {
92
- //Other definition was added / updated - ignore
93
- return;
94
- }
95
- } else {
96
- if (currentDefinition) {
97
- const ref = parseKapetaUri(
98
- `${currentDefinition.definition.metadata.name}:${currentDefinition.version}`
99
- ).id;
100
- delete INSTALL_ATTEMPTED[ref];
101
- //Something was removed
102
- type = 'removed';
103
- } else {
104
- //Other definition was removed - ignore
105
- return;
106
- }
107
- }
51
+ this.watcher.watch();
52
+ }
108
53
 
109
- const payload = {
110
- type,
111
- definition: currentDefinition?.definition,
112
- asset: { handle, name, version },
113
- };
54
+ async stopListening() {
55
+ return this.watcher.unwatch();
56
+ }
114
57
 
115
- allDefinitions = newDefinitions;
116
- socketManager.emit(`assets`, 'changed', payload);
117
- definitionsManager.clearCache();
118
- });
119
- } catch (e) {
120
- // Fallback to run without watch mode due to potential platform issues.
121
- // https://nodejs.org/docs/latest/api/fs.html#caveats
122
- console.log('Unable to watch for changes. Changes to assets will not update automatically.', e);
123
- return;
124
- }
58
+ ignoreChangesFor(file: string) {
59
+ return this.watcher.ignoreChangesFor(file);
125
60
  }
126
61
 
127
- stopListening() {
128
- if (!this.watcher) {
129
- return;
130
- }
131
- this.watcher();
132
- this.watcher = undefined;
62
+ resumeChangedFor(file: string) {
63
+ return this.watcher.resumeChangedFor(file);
133
64
  }
134
65
 
135
66
  public ensureDefaultProviders(): void {
@@ -157,17 +88,12 @@ class RepositoryManager {
157
88
  try {
158
89
  //We change to a temp dir to avoid issues with the current working directory
159
90
  process.chdir(os.tmpdir());
160
- //Disable change events while installing
161
- this.setChangeEventsEnabled(false);
162
91
  await Actions.install(new ProgressListener(), [ref], {});
163
92
  } catch (e) {
164
93
  console.error(`Failed to install asset: ${ref}`, e);
165
94
  throw e;
166
- } finally {
167
- this.setChangeEventsEnabled(true);
168
95
  }
169
- definitionsManager.clearCache();
170
- assetManager.clearCache();
96
+ clearAllCaches();
171
97
  //console.log(`Asset installed: ${ref}`);
172
98
  };
173
99
  };