@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 +45 -0
- package/src/cache/index.ts +339 -0
- package/src/cache/metadata.ts +25 -0
- package/src/cache/ttl.ts +71 -0
- package/src/fetchers/git.ts +213 -0
- package/src/fetchers/http.ts +217 -0
- package/src/fetchers/index.ts +32 -0
- package/src/fetchers/oci.ts +206 -0
- package/src/fetchers/types.ts +33 -0
- package/src/index.ts +46 -0
- package/src/loader.ts +139 -0
- package/src/resolver.ts +175 -0
- package/src/types.ts +194 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { exec } from 'node:child_process';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import {
|
|
8
|
+
Errors,
|
|
9
|
+
type KustodianErrorType,
|
|
10
|
+
type ResultType,
|
|
11
|
+
failure,
|
|
12
|
+
success,
|
|
13
|
+
} from '@kustodian/core';
|
|
14
|
+
import { type TemplateSourceType, is_http_source } from '@kustodian/schema';
|
|
15
|
+
import type { FetchOptionsType, FetchResultType, RemoteVersionType } from '../types.js';
|
|
16
|
+
import type { SourceFetcherType } from './types.js';
|
|
17
|
+
|
|
18
|
+
const exec_async = promisify(exec);
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT = 120_000; // 2 minutes
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Creates an HTTP source fetcher.
|
|
24
|
+
*/
|
|
25
|
+
export function create_http_fetcher(): SourceFetcherType {
|
|
26
|
+
return new HttpFetcher();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class HttpFetcher implements SourceFetcherType {
|
|
30
|
+
readonly type = 'http' as const;
|
|
31
|
+
|
|
32
|
+
is_mutable(source: TemplateSourceType): boolean {
|
|
33
|
+
if (!is_http_source(source)) return true;
|
|
34
|
+
// HTTP sources with checksums are immutable (content is verified)
|
|
35
|
+
// Without checksum, we assume mutable
|
|
36
|
+
return source.http.checksum === undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async fetch(
|
|
40
|
+
source: TemplateSourceType,
|
|
41
|
+
options?: FetchOptionsType,
|
|
42
|
+
): Promise<ResultType<FetchResultType, KustodianErrorType>> {
|
|
43
|
+
if (!is_http_source(source)) {
|
|
44
|
+
return failure(Errors.invalid_argument('source', 'Expected an http source'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { url, checksum, headers } = source.http;
|
|
48
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
49
|
+
|
|
50
|
+
// Create temp directory for download
|
|
51
|
+
const temp_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kustodian-http-'));
|
|
52
|
+
const archive_path = path.join(temp_dir, 'archive');
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Download the archive
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timeout_id = setTimeout(() => controller.abort(), timeout);
|
|
58
|
+
|
|
59
|
+
const fetch_options: RequestInit = {
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (headers) {
|
|
64
|
+
fetch_options.headers = headers;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const response = await fetch(url, fetch_options);
|
|
68
|
+
clearTimeout(timeout_id);
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
if (response.status === 401 || response.status === 403) {
|
|
72
|
+
return failure(Errors.source_auth_error(source.name));
|
|
73
|
+
}
|
|
74
|
+
return failure(
|
|
75
|
+
Errors.source_fetch_error(
|
|
76
|
+
source.name,
|
|
77
|
+
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Write to file
|
|
83
|
+
const buffer = await response.arrayBuffer();
|
|
84
|
+
await fs.writeFile(archive_path, new Uint8Array(buffer));
|
|
85
|
+
|
|
86
|
+
// Verify checksum if provided
|
|
87
|
+
if (checksum) {
|
|
88
|
+
const verify_result = await this.verify_checksum(archive_path, checksum, source.name);
|
|
89
|
+
if (!verify_result.success) {
|
|
90
|
+
return verify_result;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Compute checksum for version identifier
|
|
95
|
+
const actual_checksum = await this.compute_checksum(archive_path);
|
|
96
|
+
|
|
97
|
+
// Extract archive
|
|
98
|
+
const extract_dir = path.join(temp_dir, 'extracted');
|
|
99
|
+
await fs.mkdir(extract_dir, { recursive: true });
|
|
100
|
+
|
|
101
|
+
const extract_result = await this.extract_archive(
|
|
102
|
+
archive_path,
|
|
103
|
+
extract_dir,
|
|
104
|
+
url,
|
|
105
|
+
source.name,
|
|
106
|
+
);
|
|
107
|
+
if (!extract_result.success) {
|
|
108
|
+
return extract_result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Find the actual content directory (archives often have a root folder)
|
|
112
|
+
const content_dir = await this.find_content_dir(extract_dir);
|
|
113
|
+
|
|
114
|
+
// Create clean output directory
|
|
115
|
+
const output_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kustodian-templates-'));
|
|
116
|
+
await fs.cp(content_dir, output_dir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
// Cleanup
|
|
119
|
+
await fs.rm(temp_dir, { recursive: true, force: true });
|
|
120
|
+
|
|
121
|
+
return success({
|
|
122
|
+
path: output_dir,
|
|
123
|
+
version: checksum ?? `sha256:${actual_checksum.slice(0, 16)}`,
|
|
124
|
+
from_cache: false,
|
|
125
|
+
fetched_at: new Date(),
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
// Cleanup on error
|
|
129
|
+
await fs.rm(temp_dir, { recursive: true, force: true }).catch(() => {});
|
|
130
|
+
|
|
131
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
132
|
+
return failure(Errors.source_timeout(source.name, timeout));
|
|
133
|
+
}
|
|
134
|
+
return failure(Errors.source_fetch_error(source.name, error));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async list_versions(
|
|
139
|
+
_source: TemplateSourceType,
|
|
140
|
+
): Promise<ResultType<RemoteVersionType[], KustodianErrorType>> {
|
|
141
|
+
// HTTP sources don't have version listing capability
|
|
142
|
+
return success([]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async verify_checksum(
|
|
146
|
+
file_path: string,
|
|
147
|
+
expected: string,
|
|
148
|
+
source_name: string,
|
|
149
|
+
): Promise<ResultType<void, KustodianErrorType>> {
|
|
150
|
+
// Parse expected checksum (format: algorithm:hash or just hash for sha256)
|
|
151
|
+
const [algorithm, hash] = expected.includes(':')
|
|
152
|
+
? (expected.split(':') as [string, string])
|
|
153
|
+
: ['sha256', expected];
|
|
154
|
+
|
|
155
|
+
const actual = await this.compute_checksum(file_path, algorithm);
|
|
156
|
+
|
|
157
|
+
if (actual.toLowerCase() !== hash.toLowerCase()) {
|
|
158
|
+
return failure(
|
|
159
|
+
Errors.source_checksum_mismatch(source_name, expected, `${algorithm}:${actual}`),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return success(undefined);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async compute_checksum(file_path: string, algorithm = 'sha256'): Promise<string> {
|
|
167
|
+
const content = await fs.readFile(file_path);
|
|
168
|
+
return crypto.createHash(algorithm).update(new Uint8Array(content)).digest('hex');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private async extract_archive(
|
|
172
|
+
archive_path: string,
|
|
173
|
+
dest_dir: string,
|
|
174
|
+
url: string,
|
|
175
|
+
source_name: string,
|
|
176
|
+
): Promise<ResultType<void, KustodianErrorType>> {
|
|
177
|
+
const lower_url = url.toLowerCase();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
if (lower_url.endsWith('.tar.gz') || lower_url.endsWith('.tgz')) {
|
|
181
|
+
await exec_async(`tar -xzf "${archive_path}" -C "${dest_dir}"`);
|
|
182
|
+
} else if (lower_url.endsWith('.tar')) {
|
|
183
|
+
await exec_async(`tar -xf "${archive_path}" -C "${dest_dir}"`);
|
|
184
|
+
} else if (lower_url.endsWith('.zip')) {
|
|
185
|
+
await exec_async(`unzip -q "${archive_path}" -d "${dest_dir}"`);
|
|
186
|
+
} else {
|
|
187
|
+
// Try to detect format from content
|
|
188
|
+
const { stdout } = await exec_async(`file "${archive_path}"`);
|
|
189
|
+
if (stdout.includes('gzip')) {
|
|
190
|
+
await exec_async(`tar -xzf "${archive_path}" -C "${dest_dir}"`);
|
|
191
|
+
} else if (stdout.includes('Zip')) {
|
|
192
|
+
await exec_async(`unzip -q "${archive_path}" -d "${dest_dir}"`);
|
|
193
|
+
} else if (stdout.includes('tar')) {
|
|
194
|
+
await exec_async(`tar -xf "${archive_path}" -C "${dest_dir}"`);
|
|
195
|
+
} else {
|
|
196
|
+
return failure(Errors.invalid_argument('source', `Unknown archive format for ${url}`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return success(undefined);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return failure(Errors.source_fetch_error(source_name, error));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async find_content_dir(extract_dir: string): Promise<string> {
|
|
206
|
+
const entries = await fs.readdir(extract_dir, { withFileTypes: true });
|
|
207
|
+
const dirs = entries.filter((e) => e.isDirectory());
|
|
208
|
+
|
|
209
|
+
// If there's exactly one directory, assume it's the content root
|
|
210
|
+
if (dirs.length === 1 && entries.length === 1 && dirs[0]) {
|
|
211
|
+
return path.join(extract_dir, dirs[0].name);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Otherwise, use the extract directory itself
|
|
215
|
+
return extract_dir;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type { SourceFetcherType } from './types.js';
|
|
2
|
+
export { create_git_fetcher } from './git.js';
|
|
3
|
+
export { create_http_fetcher } from './http.js';
|
|
4
|
+
export { create_oci_fetcher } from './oci.js';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type TemplateSourceType,
|
|
8
|
+
is_git_source,
|
|
9
|
+
is_http_source,
|
|
10
|
+
is_oci_source,
|
|
11
|
+
} from '@kustodian/schema';
|
|
12
|
+
import { create_git_fetcher } from './git.js';
|
|
13
|
+
import { create_http_fetcher } from './http.js';
|
|
14
|
+
import { create_oci_fetcher } from './oci.js';
|
|
15
|
+
import type { SourceFetcherType } from './types.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Gets the appropriate fetcher for a source type.
|
|
19
|
+
*/
|
|
20
|
+
export function get_fetcher_for_source(source: TemplateSourceType): SourceFetcherType {
|
|
21
|
+
if (is_git_source(source)) {
|
|
22
|
+
return create_git_fetcher();
|
|
23
|
+
}
|
|
24
|
+
if (is_http_source(source)) {
|
|
25
|
+
return create_http_fetcher();
|
|
26
|
+
}
|
|
27
|
+
if (is_oci_source(source)) {
|
|
28
|
+
return create_oci_fetcher();
|
|
29
|
+
}
|
|
30
|
+
// This should never happen due to schema validation
|
|
31
|
+
throw new Error(`Unknown source type for source: ${source.name}`);
|
|
32
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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_oci_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 an OCI source fetcher.
|
|
23
|
+
*/
|
|
24
|
+
export function create_oci_fetcher(): SourceFetcherType {
|
|
25
|
+
return new OciFetcher();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class OciFetcher implements SourceFetcherType {
|
|
29
|
+
readonly type = 'oci' as const;
|
|
30
|
+
|
|
31
|
+
is_mutable(source: TemplateSourceType): boolean {
|
|
32
|
+
if (!is_oci_source(source)) return true;
|
|
33
|
+
// Digests are immutable, 'latest' tag is mutable, other tags are treated as immutable
|
|
34
|
+
if (source.oci.digest) return false;
|
|
35
|
+
return source.oci.tag === 'latest';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async fetch(
|
|
39
|
+
source: TemplateSourceType,
|
|
40
|
+
options?: FetchOptionsType,
|
|
41
|
+
): Promise<ResultType<FetchResultType, KustodianErrorType>> {
|
|
42
|
+
if (!is_oci_source(source)) {
|
|
43
|
+
return failure(Errors.invalid_argument('source', 'Expected an oci source'));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { registry, repository, tag, digest } = source.oci;
|
|
47
|
+
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
48
|
+
|
|
49
|
+
// Build OCI reference
|
|
50
|
+
const ref = digest ?? tag;
|
|
51
|
+
if (!ref) {
|
|
52
|
+
return failure(Errors.invalid_argument('source', 'No OCI tag or digest specified'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const oci_ref = `${registry}/${repository}:${ref}`;
|
|
56
|
+
|
|
57
|
+
// Create temp directory for pull
|
|
58
|
+
const output_dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kustodian-oci-'));
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Try flux first, then oras
|
|
62
|
+
let pull_result = await this.try_flux_pull(oci_ref, output_dir, timeout, source.name);
|
|
63
|
+
|
|
64
|
+
if (!pull_result.success && pull_result.error.code === 'SOURCE_FETCH_ERROR') {
|
|
65
|
+
// Flux not available, try oras
|
|
66
|
+
pull_result = await this.try_oras_pull(oci_ref, output_dir, timeout, source.name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!pull_result.success) {
|
|
70
|
+
return pull_result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get the actual digest for version identifier
|
|
74
|
+
const version =
|
|
75
|
+
digest ?? (await this.get_digest(registry, repository, ref, timeout, source.name));
|
|
76
|
+
|
|
77
|
+
return success({
|
|
78
|
+
path: output_dir,
|
|
79
|
+
version: version ?? ref,
|
|
80
|
+
from_cache: false,
|
|
81
|
+
fetched_at: new Date(),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Cleanup on error
|
|
85
|
+
await fs.rm(output_dir, { recursive: true, force: true }).catch(() => {});
|
|
86
|
+
return failure(Errors.source_fetch_error(source.name, error));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async list_versions(
|
|
91
|
+
source: TemplateSourceType,
|
|
92
|
+
): Promise<ResultType<RemoteVersionType[], KustodianErrorType>> {
|
|
93
|
+
if (!is_oci_source(source)) {
|
|
94
|
+
return failure(Errors.invalid_argument('source', 'Expected an oci source'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { registry, repository } = source.oci;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Try using oras to list tags
|
|
101
|
+
const { stdout } = await exec_async(`oras repo tags ${registry}/${repository}`, {
|
|
102
|
+
timeout: DEFAULT_TIMEOUT,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const versions: RemoteVersionType[] = stdout
|
|
106
|
+
.split('\n')
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter((line) => line.length > 0)
|
|
109
|
+
.map((version) => ({ version }));
|
|
110
|
+
|
|
111
|
+
return success(versions);
|
|
112
|
+
} catch {
|
|
113
|
+
// If oras fails, try crane
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await exec_async(`crane ls ${registry}/${repository}`, {
|
|
116
|
+
timeout: DEFAULT_TIMEOUT,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const versions: RemoteVersionType[] = stdout
|
|
120
|
+
.split('\n')
|
|
121
|
+
.map((line) => line.trim())
|
|
122
|
+
.filter((line) => line.length > 0)
|
|
123
|
+
.map((version) => ({ version }));
|
|
124
|
+
|
|
125
|
+
return success(versions);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return failure(Errors.source_fetch_error(source.name, error));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async try_flux_pull(
|
|
133
|
+
oci_ref: string,
|
|
134
|
+
output_dir: string,
|
|
135
|
+
timeout: number,
|
|
136
|
+
source_name: string,
|
|
137
|
+
): Promise<ResultType<void, KustodianErrorType>> {
|
|
138
|
+
try {
|
|
139
|
+
await exec_async(`flux pull artifact oci://${oci_ref} --output="${output_dir}"`, { timeout });
|
|
140
|
+
return success(undefined);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof Error && error.message.includes('not found')) {
|
|
143
|
+
return failure(Errors.source_fetch_error(source_name, new Error('flux CLI not found')));
|
|
144
|
+
}
|
|
145
|
+
if (error instanceof Error && error.message.includes('unauthorized')) {
|
|
146
|
+
return failure(Errors.source_auth_error(source_name, error));
|
|
147
|
+
}
|
|
148
|
+
if (error instanceof Error && 'killed' in error && error.killed) {
|
|
149
|
+
return failure(Errors.source_timeout(source_name, timeout));
|
|
150
|
+
}
|
|
151
|
+
return failure(Errors.source_fetch_error(source_name, error));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async try_oras_pull(
|
|
156
|
+
oci_ref: string,
|
|
157
|
+
output_dir: string,
|
|
158
|
+
timeout: number,
|
|
159
|
+
source_name: string,
|
|
160
|
+
): Promise<ResultType<void, KustodianErrorType>> {
|
|
161
|
+
try {
|
|
162
|
+
await exec_async(`oras pull ${oci_ref} --output="${output_dir}"`, { timeout });
|
|
163
|
+
return success(undefined);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof Error && error.message.includes('not found')) {
|
|
166
|
+
return failure(
|
|
167
|
+
Errors.source_fetch_error(source_name, new Error('Neither flux nor oras CLI found')),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (error instanceof Error && error.message.includes('unauthorized')) {
|
|
171
|
+
return failure(Errors.source_auth_error(source_name, error));
|
|
172
|
+
}
|
|
173
|
+
if (error instanceof Error && 'killed' in error && error.killed) {
|
|
174
|
+
return failure(Errors.source_timeout(source_name, timeout));
|
|
175
|
+
}
|
|
176
|
+
return failure(Errors.source_fetch_error(source_name, error));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async get_digest(
|
|
181
|
+
registry: string,
|
|
182
|
+
repository: string,
|
|
183
|
+
tag: string,
|
|
184
|
+
timeout: number,
|
|
185
|
+
_source_name: string,
|
|
186
|
+
): Promise<string | null> {
|
|
187
|
+
try {
|
|
188
|
+
// Try crane first
|
|
189
|
+
const { stdout } = await exec_async(`crane digest ${registry}/${repository}:${tag}`, {
|
|
190
|
+
timeout,
|
|
191
|
+
});
|
|
192
|
+
return stdout.trim();
|
|
193
|
+
} catch {
|
|
194
|
+
// If crane fails, try oras
|
|
195
|
+
try {
|
|
196
|
+
const { stdout } = await exec_async(
|
|
197
|
+
`oras manifest fetch ${registry}/${repository}:${tag} --descriptor | jq -r .digest`,
|
|
198
|
+
{ timeout },
|
|
199
|
+
);
|
|
200
|
+
return stdout.trim() || null;
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { KustodianErrorType, ResultType } from '@kustodian/core';
|
|
2
|
+
import type { TemplateSourceType } from '@kustodian/schema';
|
|
3
|
+
import type { FetchOptionsType, FetchResultType, RemoteVersionType } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base interface for source fetchers.
|
|
7
|
+
* Each source type (git, http, oci) implements this interface.
|
|
8
|
+
*/
|
|
9
|
+
export interface SourceFetcherType {
|
|
10
|
+
/** Unique identifier for this fetcher type */
|
|
11
|
+
readonly type: 'git' | 'http' | 'oci';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetches templates from the source to a temporary directory.
|
|
15
|
+
* The caller is responsible for caching the result.
|
|
16
|
+
*/
|
|
17
|
+
fetch(
|
|
18
|
+
source: TemplateSourceType,
|
|
19
|
+
options?: FetchOptionsType,
|
|
20
|
+
): Promise<ResultType<FetchResultType, KustodianErrorType>>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Lists available versions from the remote.
|
|
24
|
+
*/
|
|
25
|
+
list_versions(
|
|
26
|
+
source: TemplateSourceType,
|
|
27
|
+
): Promise<ResultType<RemoteVersionType[], KustodianErrorType>>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Determines if this source reference is mutable.
|
|
31
|
+
*/
|
|
32
|
+
is_mutable(source: TemplateSourceType): boolean;
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
CacheEntryType,
|
|
4
|
+
CacheManagerType,
|
|
5
|
+
FetchOptionsType,
|
|
6
|
+
FetchResultType,
|
|
7
|
+
RemoteVersionType,
|
|
8
|
+
ResolvedSourceType,
|
|
9
|
+
ResolverOptionsType,
|
|
10
|
+
SourceFetcherType,
|
|
11
|
+
SourceResolverType,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
// Cache
|
|
15
|
+
export {
|
|
16
|
+
create_cache_manager,
|
|
17
|
+
parse_ttl,
|
|
18
|
+
calculate_expiry,
|
|
19
|
+
is_expired,
|
|
20
|
+
DEFAULT_TTL,
|
|
21
|
+
META_FILENAME,
|
|
22
|
+
TEMPLATES_DIRNAME,
|
|
23
|
+
} from './cache/index.js';
|
|
24
|
+
|
|
25
|
+
// Fetchers
|
|
26
|
+
export {
|
|
27
|
+
create_git_fetcher,
|
|
28
|
+
create_http_fetcher,
|
|
29
|
+
create_oci_fetcher,
|
|
30
|
+
get_fetcher_for_source,
|
|
31
|
+
} from './fetchers/index.js';
|
|
32
|
+
|
|
33
|
+
// Resolver
|
|
34
|
+
export {
|
|
35
|
+
create_source_resolver,
|
|
36
|
+
DEFAULT_CACHE_DIR,
|
|
37
|
+
type CreateResolverOptionsType,
|
|
38
|
+
} from './resolver.js';
|
|
39
|
+
|
|
40
|
+
// Template loader integration
|
|
41
|
+
export {
|
|
42
|
+
load_templates_from_sources,
|
|
43
|
+
type LoadedSourcesResultType,
|
|
44
|
+
type LoadSourcesOptionsType,
|
|
45
|
+
type SourcedTemplateType,
|
|
46
|
+
} from './loader.js';
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
type KustodianErrorType,
|
|
4
|
+
type ResultType,
|
|
5
|
+
failure,
|
|
6
|
+
is_success,
|
|
7
|
+
success,
|
|
8
|
+
} from '@kustodian/core';
|
|
9
|
+
import { type LoadedTemplateType, list_directories, load_template } from '@kustodian/loader';
|
|
10
|
+
import type { TemplateSourceType } from '@kustodian/schema';
|
|
11
|
+
import { type CreateResolverOptionsType, create_source_resolver } from './resolver.js';
|
|
12
|
+
import type { FetchOptionsType, ResolvedSourceType } from './types.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A loaded template with source information.
|
|
16
|
+
*/
|
|
17
|
+
export interface SourcedTemplateType extends LoadedTemplateType {
|
|
18
|
+
/** Name of the source this template came from */
|
|
19
|
+
source_name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result of loading templates from sources.
|
|
24
|
+
*/
|
|
25
|
+
export interface LoadedSourcesResultType {
|
|
26
|
+
/** All loaded templates from all sources */
|
|
27
|
+
templates: SourcedTemplateType[];
|
|
28
|
+
/** Successfully resolved sources */
|
|
29
|
+
resolved: ResolvedSourceType[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Options for loading templates from sources.
|
|
34
|
+
*/
|
|
35
|
+
export interface LoadSourcesOptionsType extends FetchOptionsType, CreateResolverOptionsType {
|
|
36
|
+
/** Run fetches in parallel (default: true) */
|
|
37
|
+
parallel?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Loads templates from all configured sources.
|
|
42
|
+
* Fetches from remote (or cache) and loads all template.yaml files found.
|
|
43
|
+
*/
|
|
44
|
+
export async function load_templates_from_sources(
|
|
45
|
+
sources: TemplateSourceType[],
|
|
46
|
+
options?: LoadSourcesOptionsType,
|
|
47
|
+
): Promise<ResultType<LoadedSourcesResultType, KustodianErrorType>> {
|
|
48
|
+
if (sources.length === 0) {
|
|
49
|
+
return success({ templates: [], resolved: [] });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fetch all sources
|
|
53
|
+
const resolver = create_source_resolver(options);
|
|
54
|
+
const fetch_result = await resolver.resolve_all(sources, options);
|
|
55
|
+
|
|
56
|
+
if (!fetch_result.success) {
|
|
57
|
+
return fetch_result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Load templates from each fetched source
|
|
61
|
+
const all_templates: SourcedTemplateType[] = [];
|
|
62
|
+
const errors: string[] = [];
|
|
63
|
+
|
|
64
|
+
for (const resolved of fetch_result.value) {
|
|
65
|
+
const templates_result = await load_templates_from_path(resolved.fetch_result.path);
|
|
66
|
+
|
|
67
|
+
if (is_success(templates_result)) {
|
|
68
|
+
// Add source name to each template
|
|
69
|
+
for (const loaded of templates_result.value) {
|
|
70
|
+
all_templates.push({
|
|
71
|
+
...loaded,
|
|
72
|
+
source_name: resolved.source.name,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
errors.push(`${resolved.source.name}: ${templates_result.error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (errors.length > 0) {
|
|
81
|
+
return failure({
|
|
82
|
+
code: 'VALIDATION_ERROR',
|
|
83
|
+
message: `Failed to load templates from sources:\n${errors.join('\n')}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return success({
|
|
88
|
+
templates: all_templates,
|
|
89
|
+
resolved: fetch_result.value,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Loads all templates from a directory path.
|
|
95
|
+
* Looks for subdirectories containing template.yaml files.
|
|
96
|
+
*/
|
|
97
|
+
async function load_templates_from_path(
|
|
98
|
+
source_path: string,
|
|
99
|
+
): Promise<ResultType<LoadedTemplateType[], KustodianErrorType>> {
|
|
100
|
+
// List all subdirectories that might be templates
|
|
101
|
+
const dirs_result = await list_directories(source_path);
|
|
102
|
+
|
|
103
|
+
if (!is_success(dirs_result)) {
|
|
104
|
+
// Check if this path itself is a template
|
|
105
|
+
const direct_result = await load_template(source_path);
|
|
106
|
+
if (is_success(direct_result)) {
|
|
107
|
+
return success([direct_result.value]);
|
|
108
|
+
}
|
|
109
|
+
return failure({
|
|
110
|
+
code: 'NOT_FOUND',
|
|
111
|
+
message: `No templates found in ${source_path}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const templates: LoadedTemplateType[] = [];
|
|
116
|
+
const errors: string[] = [];
|
|
117
|
+
|
|
118
|
+
for (const dir of dirs_result.value) {
|
|
119
|
+
const result = await load_template(dir);
|
|
120
|
+
if (is_success(result)) {
|
|
121
|
+
templates.push(result.value);
|
|
122
|
+
} else {
|
|
123
|
+
// Only log errors for directories that look like templates
|
|
124
|
+
// (i.e., they have a template.yaml that failed validation)
|
|
125
|
+
if (result.error.code === 'SCHEMA_VALIDATION_ERROR') {
|
|
126
|
+
errors.push(`${path.basename(dir)}: ${result.error.message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (templates.length === 0 && errors.length > 0) {
|
|
132
|
+
return failure({
|
|
133
|
+
code: 'VALIDATION_ERROR',
|
|
134
|
+
message: `Failed to load templates:\n${errors.join('\n')}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return success(templates);
|
|
139
|
+
}
|