@kustodian/sources 1.1.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.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kustodian/sources",
3
+ "version": "1.1.0",
4
+ "description": "Template source fetching and caching for remote Git, HTTP, and OCI sources",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "scripts": {
18
+ "test": "bun test",
19
+ "test:watch": "bun test --watch",
20
+ "typecheck": "bun run tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "kustodian",
24
+ "sources",
25
+ "git",
26
+ "oci",
27
+ "templates"
28
+ ],
29
+ "author": "Luca Silverentand <luca@onezero.company>",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/lucasilverentand/kustodian.git",
34
+ "directory": "packages/sources"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://npm.pkg.github.com"
38
+ },
39
+ "dependencies": {
40
+ "@kustodian/core": "workspace:*",
41
+ "@kustodian/loader": "workspace:*",
42
+ "@kustodian/schema": "workspace:*",
43
+ "zod": "3.25.30"
44
+ }
45
+ }
@@ -0,0 +1,339 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import {
4
+ Errors,
5
+ type KustodianErrorType,
6
+ type ResultType,
7
+ failure,
8
+ success,
9
+ } from '@kustodian/core';
10
+ import type { CacheEntryType, CacheManagerType } from '../types.js';
11
+ import {
12
+ type CacheMetaType,
13
+ META_FILENAME,
14
+ TEMPLATES_DIRNAME,
15
+ cache_meta_schema,
16
+ } from './metadata.js';
17
+ import { calculate_expiry, is_expired } from './ttl.js';
18
+
19
+ export { parse_ttl, calculate_expiry, is_expired, DEFAULT_TTL } from './ttl.js';
20
+ export {
21
+ META_FILENAME,
22
+ TEMPLATES_DIRNAME,
23
+ cache_meta_schema,
24
+ type CacheMetaType,
25
+ } from './metadata.js';
26
+ export type { CacheManagerType } from '../types.js';
27
+
28
+ /**
29
+ * Creates a safe directory name from a source name.
30
+ */
31
+ function sanitize_name(name: string): string {
32
+ return name.replace(/[^a-zA-Z0-9-_]/g, '_');
33
+ }
34
+
35
+ /**
36
+ * Creates a safe directory name from a version.
37
+ */
38
+ function sanitize_version(version: string): string {
39
+ return version.replace(/[^a-zA-Z0-9-_.]/g, '_');
40
+ }
41
+
42
+ /**
43
+ * Creates a cache manager instance.
44
+ */
45
+ export function create_cache_manager(cache_dir: string): CacheManagerType {
46
+ return new CacheManager(cache_dir);
47
+ }
48
+
49
+ class CacheManager implements CacheManagerType {
50
+ readonly cache_dir: string;
51
+
52
+ constructor(cache_dir: string) {
53
+ this.cache_dir = cache_dir;
54
+ }
55
+
56
+ private get_entry_path(source_name: string, version: string): string {
57
+ return path.join(this.cache_dir, sanitize_name(source_name), sanitize_version(version));
58
+ }
59
+
60
+ private get_meta_path(source_name: string, version: string): string {
61
+ return path.join(this.get_entry_path(source_name, version), META_FILENAME);
62
+ }
63
+
64
+ private get_templates_path(source_name: string, version: string): string {
65
+ return path.join(this.get_entry_path(source_name, version), TEMPLATES_DIRNAME);
66
+ }
67
+
68
+ async get(
69
+ source_name: string,
70
+ version: string,
71
+ ): Promise<ResultType<CacheEntryType | null, KustodianErrorType>> {
72
+ const meta_path = this.get_meta_path(source_name, version);
73
+
74
+ try {
75
+ const meta_content = await fs.readFile(meta_path, 'utf-8');
76
+ const meta_json = JSON.parse(meta_content);
77
+ const parse_result = cache_meta_schema.safeParse(meta_json);
78
+
79
+ if (!parse_result.success) {
80
+ return failure(Errors.cache_corrupt(meta_path));
81
+ }
82
+
83
+ const meta = parse_result.data;
84
+ const expires_at = meta.expires_at ? new Date(meta.expires_at) : null;
85
+
86
+ // Check if expired (only for mutable refs)
87
+ if (is_expired(expires_at)) {
88
+ return success(null);
89
+ }
90
+
91
+ const templates_path = this.get_templates_path(source_name, version);
92
+
93
+ // Verify templates directory exists
94
+ try {
95
+ await fs.access(templates_path);
96
+ } catch {
97
+ return success(null);
98
+ }
99
+
100
+ const entry: CacheEntryType = {
101
+ source_name: meta.source_name,
102
+ source_type: meta.source_type,
103
+ version: meta.version,
104
+ path: templates_path,
105
+ fetched_at: new Date(meta.fetched_at),
106
+ expires_at,
107
+ };
108
+ if (meta.checksum) {
109
+ entry.checksum = meta.checksum;
110
+ }
111
+ return success(entry);
112
+ } catch (error) {
113
+ // File doesn't exist - not an error, just no cache entry
114
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
115
+ return success(null);
116
+ }
117
+ return failure(Errors.cache_read_error(meta_path, error));
118
+ }
119
+ }
120
+
121
+ async put(
122
+ source_name: string,
123
+ source_type: 'git' | 'http' | 'oci',
124
+ version: string,
125
+ content_path: string,
126
+ mutable: boolean,
127
+ ttl?: string,
128
+ ): Promise<ResultType<CacheEntryType, KustodianErrorType>> {
129
+ const entry_path = this.get_entry_path(source_name, version);
130
+ const meta_path = this.get_meta_path(source_name, version);
131
+ const templates_path = this.get_templates_path(source_name, version);
132
+
133
+ try {
134
+ // Create directory structure
135
+ await fs.mkdir(entry_path, { recursive: true });
136
+
137
+ // Copy content to templates directory
138
+ await fs.cp(content_path, templates_path, { recursive: true });
139
+
140
+ const fetched_at = new Date();
141
+ const expires_at = calculate_expiry(mutable, ttl);
142
+
143
+ const meta: CacheMetaType = {
144
+ source_name,
145
+ source_type,
146
+ version,
147
+ fetched_at: fetched_at.toISOString(),
148
+ expires_at: expires_at?.toISOString() ?? null,
149
+ };
150
+
151
+ // Write metadata
152
+ await fs.writeFile(meta_path, JSON.stringify(meta, null, 2));
153
+
154
+ return success({
155
+ source_name,
156
+ source_type,
157
+ version,
158
+ path: templates_path,
159
+ fetched_at,
160
+ expires_at,
161
+ });
162
+ } catch (error) {
163
+ return failure(Errors.cache_write_error(entry_path, error));
164
+ }
165
+ }
166
+
167
+ async invalidate(
168
+ source_name: string,
169
+ version?: string,
170
+ ): Promise<ResultType<void, KustodianErrorType>> {
171
+ try {
172
+ if (version) {
173
+ // Invalidate specific version
174
+ const entry_path = this.get_entry_path(source_name, version);
175
+ await fs.rm(entry_path, { recursive: true, force: true });
176
+ } else {
177
+ // Invalidate all versions for this source
178
+ const source_path = path.join(this.cache_dir, sanitize_name(source_name));
179
+ await fs.rm(source_path, { recursive: true, force: true });
180
+ }
181
+ return success(undefined);
182
+ } catch (error) {
183
+ return failure(Errors.cache_write_error(this.cache_dir, error));
184
+ }
185
+ }
186
+
187
+ async prune(): Promise<ResultType<number, KustodianErrorType>> {
188
+ let pruned = 0;
189
+
190
+ try {
191
+ // Check if cache directory exists
192
+ try {
193
+ await fs.access(this.cache_dir);
194
+ } catch {
195
+ return success(0);
196
+ }
197
+
198
+ const sources = await fs.readdir(this.cache_dir);
199
+
200
+ for (const source of sources) {
201
+ const source_path = path.join(this.cache_dir, source);
202
+ const stat = await fs.stat(source_path);
203
+ if (!stat.isDirectory()) continue;
204
+
205
+ const versions = await fs.readdir(source_path);
206
+
207
+ for (const version of versions) {
208
+ const entry_path = path.join(source_path, version);
209
+ const meta_path = path.join(entry_path, META_FILENAME);
210
+
211
+ try {
212
+ const meta_content = await fs.readFile(meta_path, 'utf-8');
213
+ const meta_json = JSON.parse(meta_content);
214
+ const parse_result = cache_meta_schema.safeParse(meta_json);
215
+
216
+ if (parse_result.success) {
217
+ const expires_at = parse_result.data.expires_at
218
+ ? new Date(parse_result.data.expires_at)
219
+ : null;
220
+ if (is_expired(expires_at)) {
221
+ await fs.rm(entry_path, { recursive: true, force: true });
222
+ pruned++;
223
+ }
224
+ } else {
225
+ // Corrupt metadata, remove entry
226
+ await fs.rm(entry_path, { recursive: true, force: true });
227
+ pruned++;
228
+ }
229
+ } catch {
230
+ // Can't read metadata, skip
231
+ }
232
+ }
233
+ }
234
+
235
+ return success(pruned);
236
+ } catch (error) {
237
+ return failure(Errors.cache_read_error(this.cache_dir, error));
238
+ }
239
+ }
240
+
241
+ async list(): Promise<ResultType<CacheEntryType[], KustodianErrorType>> {
242
+ const entries: CacheEntryType[] = [];
243
+
244
+ try {
245
+ // Check if cache directory exists
246
+ try {
247
+ await fs.access(this.cache_dir);
248
+ } catch {
249
+ return success([]);
250
+ }
251
+
252
+ const sources = await fs.readdir(this.cache_dir);
253
+
254
+ for (const source of sources) {
255
+ const source_path = path.join(this.cache_dir, source);
256
+ const stat = await fs.stat(source_path);
257
+ if (!stat.isDirectory()) continue;
258
+
259
+ const versions = await fs.readdir(source_path);
260
+
261
+ for (const version of versions) {
262
+ const entry_path = path.join(source_path, version);
263
+ const meta_path = path.join(entry_path, META_FILENAME);
264
+ const templates_path = path.join(entry_path, TEMPLATES_DIRNAME);
265
+
266
+ try {
267
+ const meta_content = await fs.readFile(meta_path, 'utf-8');
268
+ const meta_json = JSON.parse(meta_content);
269
+ const parse_result = cache_meta_schema.safeParse(meta_json);
270
+
271
+ if (parse_result.success) {
272
+ const meta = parse_result.data;
273
+ const entry: CacheEntryType = {
274
+ source_name: meta.source_name,
275
+ source_type: meta.source_type,
276
+ version: meta.version,
277
+ path: templates_path,
278
+ fetched_at: new Date(meta.fetched_at),
279
+ expires_at: meta.expires_at ? new Date(meta.expires_at) : null,
280
+ };
281
+ if (meta.checksum) {
282
+ entry.checksum = meta.checksum;
283
+ }
284
+ entries.push(entry);
285
+ }
286
+ } catch {
287
+ // Can't read metadata, skip
288
+ }
289
+ }
290
+ }
291
+
292
+ return success(entries);
293
+ } catch (error) {
294
+ return failure(Errors.cache_read_error(this.cache_dir, error));
295
+ }
296
+ }
297
+
298
+ async size(): Promise<ResultType<number, KustodianErrorType>> {
299
+ const get_dir_size = async (dir_path: string): Promise<number> => {
300
+ let total = 0;
301
+ const entries = await fs.readdir(dir_path, { withFileTypes: true });
302
+
303
+ for (const entry of entries) {
304
+ const full_path = path.join(dir_path, entry.name);
305
+ if (entry.isDirectory()) {
306
+ total += await get_dir_size(full_path);
307
+ } else {
308
+ const stat = await fs.stat(full_path);
309
+ total += stat.size;
310
+ }
311
+ }
312
+
313
+ return total;
314
+ };
315
+
316
+ try {
317
+ // Check if cache directory exists
318
+ try {
319
+ await fs.access(this.cache_dir);
320
+ } catch {
321
+ return success(0);
322
+ }
323
+
324
+ const total = await get_dir_size(this.cache_dir);
325
+ return success(total);
326
+ } catch (error) {
327
+ return failure(Errors.cache_read_error(this.cache_dir, error));
328
+ }
329
+ }
330
+
331
+ async clear(): Promise<ResultType<void, KustodianErrorType>> {
332
+ try {
333
+ await fs.rm(this.cache_dir, { recursive: true, force: true });
334
+ return success(undefined);
335
+ } catch (error) {
336
+ return failure(Errors.cache_write_error(this.cache_dir, error));
337
+ }
338
+ }
339
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Schema for cache entry metadata stored in _meta.json files.
5
+ */
6
+ export const cache_meta_schema = z.object({
7
+ source_name: z.string(),
8
+ source_type: z.enum(['git', 'http', 'oci']),
9
+ version: z.string(),
10
+ fetched_at: z.string().datetime(),
11
+ expires_at: z.string().datetime().nullable(),
12
+ checksum: z.string().optional(),
13
+ });
14
+
15
+ export type CacheMetaType = z.infer<typeof cache_meta_schema>;
16
+
17
+ /**
18
+ * Filename for cache metadata.
19
+ */
20
+ export const META_FILENAME = '_meta.json';
21
+
22
+ /**
23
+ * Directory name for cached templates within a version directory.
24
+ */
25
+ export const TEMPLATES_DIRNAME = 'templates';
@@ -0,0 +1,71 @@
1
+ import {
2
+ Errors,
3
+ type KustodianErrorType,
4
+ type ResultType,
5
+ failure,
6
+ success,
7
+ } from '@kustodian/core';
8
+
9
+ /**
10
+ * Default TTL for mutable sources (1 hour).
11
+ */
12
+ export const DEFAULT_TTL = '1h';
13
+
14
+ /**
15
+ * Parses a TTL duration string into milliseconds.
16
+ * Supported formats: 30m, 1h, 24h, 7d
17
+ */
18
+ export function parse_ttl(ttl: string): ResultType<number, KustodianErrorType> {
19
+ const match = ttl.match(/^(\d+)(m|h|d)$/);
20
+ if (!match) {
21
+ return failure(
22
+ Errors.invalid_argument(
23
+ 'ttl',
24
+ `Invalid TTL format: ${ttl}. Expected format: <number>(m|h|d)`,
25
+ ),
26
+ );
27
+ }
28
+
29
+ const value = match[1] ?? '0';
30
+ const unit = match[2] as 'm' | 'h' | 'd';
31
+ const multipliers: Record<'m' | 'h' | 'd', number> = {
32
+ m: 60 * 1000,
33
+ h: 60 * 60 * 1000,
34
+ d: 24 * 60 * 60 * 1000,
35
+ };
36
+
37
+ return success(Number.parseInt(value, 10) * multipliers[unit]);
38
+ }
39
+
40
+ /**
41
+ * Calculates the expiration date from a TTL string.
42
+ * Returns null for immutable sources (no expiration).
43
+ */
44
+ export function calculate_expiry(mutable: boolean, ttl?: string): Date | null {
45
+ if (!mutable) {
46
+ return null;
47
+ }
48
+
49
+ const ttl_ms_result = parse_ttl(ttl ?? DEFAULT_TTL);
50
+ if (!ttl_ms_result.success) {
51
+ // Fall back to default TTL on parse error
52
+ const default_ms_result = parse_ttl(DEFAULT_TTL);
53
+ if (!default_ms_result.success) {
54
+ // This should never happen with the hardcoded default
55
+ return new Date(Date.now() + 60 * 60 * 1000);
56
+ }
57
+ return new Date(Date.now() + default_ms_result.value);
58
+ }
59
+
60
+ return new Date(Date.now() + ttl_ms_result.value);
61
+ }
62
+
63
+ /**
64
+ * Checks if a cache entry has expired.
65
+ */
66
+ export function is_expired(expires_at: Date | null): boolean {
67
+ if (expires_at === null) {
68
+ return false; // Immutable entries never expire
69
+ }
70
+ return new Date() > expires_at;
71
+ }
@@ -0,0 +1,213 @@
1
+ import { exec } from 'node:child_process';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import {
7
+ Errors,
8
+ type KustodianErrorType,
9
+ type ResultType,
10
+ failure,
11
+ success,
12
+ } from '@kustodian/core';
13
+ import { type TemplateSourceType, is_git_source } from '@kustodian/schema';
14
+ import type { FetchOptionsType, FetchResultType, RemoteVersionType } from '../types.js';
15
+ import type { SourceFetcherType } from './types.js';
16
+
17
+ const exec_async = promisify(exec);
18
+
19
+ const DEFAULT_TIMEOUT = 120_000; // 2 minutes
20
+
21
+ /**
22
+ * Creates a Git source fetcher.
23
+ */
24
+ export function create_git_fetcher(): SourceFetcherType {
25
+ return new GitFetcher();
26
+ }
27
+
28
+ class GitFetcher implements SourceFetcherType {
29
+ readonly type = 'git' as const;
30
+
31
+ is_mutable(source: TemplateSourceType): boolean {
32
+ if (!is_git_source(source)) return true;
33
+ // Branches are mutable, tags and commits are immutable
34
+ return source.git.ref.branch !== undefined;
35
+ }
36
+
37
+ async fetch(
38
+ source: TemplateSourceType,
39
+ options?: FetchOptionsType,
40
+ ): Promise<ResultType<FetchResultType, KustodianErrorType>> {
41
+ if (!is_git_source(source)) {
42
+ return failure(Errors.invalid_argument('source', 'Expected a git source'));
43
+ }
44
+
45
+ const { url, ref, path: subpath } = source.git;
46
+ const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
47
+
48
+ // Determine the ref to fetch
49
+ const git_ref = ref.branch ?? ref.tag ?? ref.commit;
50
+ if (!git_ref) {
51
+ return failure(Errors.invalid_argument('source', 'No git ref specified'));
52
+ }
53
+
54
+ // Create temp directory for clone
55
+ const temp_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kustodian-git-'));
56
+
57
+ try {
58
+ // Clone with depth=1 for efficiency (shallow clone)
59
+ // For commits, we need a full clone to checkout specific commits
60
+ const is_commit = ref.commit !== undefined;
61
+ const depth_flag = is_commit ? '' : '--depth=1';
62
+ const branch_flag = ref.branch || ref.tag ? `--branch=${git_ref}` : '';
63
+
64
+ const clone_cmd =
65
+ `git clone ${depth_flag} ${branch_flag} --single-branch "${url}" "${temp_dir}"`.trim();
66
+
67
+ await this.exec_git(clone_cmd, timeout, source.name);
68
+
69
+ // If fetching a specific commit, checkout that commit
70
+ if (is_commit) {
71
+ await this.exec_git(`git -C "${temp_dir}" checkout ${git_ref}`, timeout, source.name);
72
+ }
73
+
74
+ // Get the actual commit SHA for versioning
75
+ const version = await this.get_commit_sha(temp_dir, timeout, source.name);
76
+ if (!version.success) {
77
+ return failure(version.error);
78
+ }
79
+
80
+ // Determine final content path
81
+ let content_path = temp_dir;
82
+ if (subpath) {
83
+ content_path = path.join(temp_dir, subpath);
84
+ // Verify subpath exists
85
+ try {
86
+ await fs.access(content_path);
87
+ } catch {
88
+ return failure(Errors.not_found('path', `${url}:${subpath}`));
89
+ }
90
+ }
91
+
92
+ // Create a clean output directory with just the templates
93
+ const output_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kustodian-templates-'));
94
+
95
+ // Copy content (excluding .git directory)
96
+ await this.copy_excluding_git(content_path, output_dir);
97
+
98
+ // Cleanup original clone
99
+ await fs.rm(temp_dir, { recursive: true, force: true });
100
+
101
+ return success({
102
+ path: output_dir,
103
+ version: version.value,
104
+ from_cache: false,
105
+ fetched_at: new Date(),
106
+ });
107
+ } catch (error) {
108
+ // Cleanup on error
109
+ await fs.rm(temp_dir, { recursive: true, force: true }).catch(() => {});
110
+
111
+ if (error instanceof Error && error.message.includes('Authentication')) {
112
+ return failure(Errors.source_auth_error(source.name, error));
113
+ }
114
+ return failure(Errors.source_fetch_error(source.name, error));
115
+ }
116
+ }
117
+
118
+ async list_versions(
119
+ source: TemplateSourceType,
120
+ ): Promise<ResultType<RemoteVersionType[], KustodianErrorType>> {
121
+ if (!is_git_source(source)) {
122
+ return failure(Errors.invalid_argument('source', 'Expected a git source'));
123
+ }
124
+
125
+ const { url } = source.git;
126
+
127
+ try {
128
+ // Use ls-remote to list refs without cloning
129
+ const { stdout } = await exec_async(`git ls-remote --tags --heads "${url}"`, {
130
+ timeout: DEFAULT_TIMEOUT,
131
+ });
132
+
133
+ const versions: RemoteVersionType[] = [];
134
+
135
+ for (const line of stdout.split('\n')) {
136
+ if (!line.trim()) continue;
137
+
138
+ const [sha, ref] = line.split('\t');
139
+ if (!sha || !ref) continue;
140
+
141
+ // Parse ref name
142
+ let version: string;
143
+ if (ref.startsWith('refs/tags/')) {
144
+ version = ref.replace('refs/tags/', '').replace(/\^{}$/, '');
145
+ } else if (ref.startsWith('refs/heads/')) {
146
+ version = ref.replace('refs/heads/', '');
147
+ } else {
148
+ continue;
149
+ }
150
+
151
+ // Deduplicate (annotated tags appear twice)
152
+ if (!versions.some((v) => v.version === version)) {
153
+ versions.push({
154
+ version,
155
+ digest: sha,
156
+ });
157
+ }
158
+ }
159
+
160
+ return success(versions);
161
+ } catch (error) {
162
+ return failure(Errors.source_fetch_error(source.name, error));
163
+ }
164
+ }
165
+
166
+ private async exec_git(
167
+ command: string,
168
+ timeout: number,
169
+ source_name: string,
170
+ ): Promise<ResultType<string, KustodianErrorType>> {
171
+ try {
172
+ const { stdout } = await exec_async(command, { timeout });
173
+ return success(stdout);
174
+ } catch (error) {
175
+ if (error instanceof Error && 'killed' in error && error.killed) {
176
+ return failure(Errors.source_timeout(source_name, timeout));
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+
182
+ private async get_commit_sha(
183
+ repo_path: string,
184
+ timeout: number,
185
+ source_name: string,
186
+ ): Promise<ResultType<string, KustodianErrorType>> {
187
+ const result = await this.exec_git(
188
+ `git -C "${repo_path}" rev-parse HEAD`,
189
+ timeout,
190
+ source_name,
191
+ );
192
+ if (!result.success) return result;
193
+ return success(result.value.trim());
194
+ }
195
+
196
+ private async copy_excluding_git(src: string, dest: string): Promise<void> {
197
+ const entries = await fs.readdir(src, { withFileTypes: true });
198
+
199
+ for (const entry of entries) {
200
+ if (entry.name === '.git') continue;
201
+
202
+ const src_path = path.join(src, entry.name);
203
+ const dest_path = path.join(dest, entry.name);
204
+
205
+ if (entry.isDirectory()) {
206
+ await fs.mkdir(dest_path, { recursive: true });
207
+ await this.copy_excluding_git(src_path, dest_path);
208
+ } else {
209
+ await fs.copyFile(src_path, dest_path);
210
+ }
211
+ }
212
+ }
213
+ }