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