flisk-import 0.4.1 → 0.5.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/README.md +3 -2
- package/dist/detectors/compose.js +203 -4
- package/dist/flisk.js +1 -1
- package/dist/merge.js +13 -0
- package/dist/write.js +38 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,9 +13,10 @@ flisk import . --yes # write service.json files and project setup
|
|
|
13
13
|
- **Reads one file:** the root `docker-compose.yml`. Not `compose.yaml`, override files, nested Compose files, Dockerfiles on their own, `package.json`, or Procfiles.
|
|
14
14
|
- **Writes:** one bring-your-own `service.json` per Compose service, plus `.flisk/project.json` if the repo is not a Flisk project yet. It never edits the Compose file or overwrites a `service.json` without `--force`.
|
|
15
15
|
- **Keeps:** the service names (`db` stays `db`, so it stays the hostname), the build context and Dockerfile (or the image), the container port as written (3000, 80, 5432), `depends_on`, and an HTTP health path. Databases and brokers are private, with no proxy route.
|
|
16
|
+
- **Runtime keys:** named volumes and bind mounts inside the project (`volumes`), `cap_add`, `cap_drop`, `init`, `read_only`, `user`, `security_opt` and `shm_size` (`container`), extra published ports (`extraPorts`, on 127.0.0.1 only), `network_mode: service:<name>` (`sidecarOf`) and `restart`.
|
|
16
17
|
- **Environment:** names go into `service.json` as `secrets.required`; literal values are copied to the project `.env`, which is added to `.gitignore`. Values in `.env` already are kept, and `${VAR}` values are left for you.
|
|
17
|
-
- **Drops, and says so:**
|
|
18
|
-
- **Does not start anything.** Run `flisk up` with Flisk core **1.4.
|
|
18
|
+
- **Drops, and says so:** `command`, `env_file` and every other untranslated key; anonymous volumes and paths outside the project; `network_mode` other than `service:<name>`; services with `profiles`, `extends`, an out-of-tree build or a `${VAR}` image.
|
|
19
|
+
- **Does not start anything.** Run `flisk up` with Flisk core **1.4.24 or later**. The importer does not check the core version.
|
|
19
20
|
- **Exit codes:** `0` when the plan succeeds (`--dry-run`, files written, or all `service.json` files already exist), `1` when no root `docker-compose.yml` or mappable service is found, and `2` when the write prompt is declined or closed without an answer (`Nothing written.`).
|
|
20
21
|
|
|
21
22
|
See [docs/import.md](docs/import.md) for the full mapping and limits. `flisk import` is the core CLI command backed by this package.
|
|
@@ -22,8 +22,10 @@ const NON_HTTP_PORTS = new Set([
|
|
|
22
22
|
]);
|
|
23
23
|
/**
|
|
24
24
|
* Service keys this detector translates. Every other key is named in the report: Flisk runs a
|
|
25
|
-
* bring-your-own container without
|
|
26
|
-
* `healthcheck` only contributes an HTTP path; `environment` only contributes names.
|
|
25
|
+
* bring-your-own container without command overrides, env files and the rest.
|
|
26
|
+
* `healthcheck` only contributes an HTTP path; `environment` only contributes names. The runtime keys
|
|
27
|
+
* (capabilities, volumes, network_mode, restart) map onto service.json fields; a value that cannot be
|
|
28
|
+
* carried over is named on its own line instead.
|
|
27
29
|
*/
|
|
28
30
|
const TRANSLATED_KEYS = new Set([
|
|
29
31
|
"image",
|
|
@@ -32,7 +34,155 @@ const TRANSLATED_KEYS = new Set([
|
|
|
32
34
|
"environment",
|
|
33
35
|
"depends_on",
|
|
34
36
|
"healthcheck",
|
|
37
|
+
"cap_add",
|
|
38
|
+
"cap_drop",
|
|
39
|
+
"init",
|
|
40
|
+
"read_only",
|
|
41
|
+
"user",
|
|
42
|
+
"security_opt",
|
|
43
|
+
"shm_size",
|
|
44
|
+
"network_mode",
|
|
45
|
+
"volumes",
|
|
46
|
+
"restart",
|
|
35
47
|
]);
|
|
48
|
+
const CAPABILITY = /^[A-Z_]+$/;
|
|
49
|
+
const USER = /^[A-Za-z0-9_.:-]+$/;
|
|
50
|
+
const SECURITY_OPT = /^[A-Za-z0-9_.:=/-]+$/;
|
|
51
|
+
const VOLUME_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
52
|
+
const CONTAINER_PATH = /^\/[A-Za-z0-9_./-]*$/;
|
|
53
|
+
const HOST_PATH = /^[A-Za-z0-9_./-]+$/;
|
|
54
|
+
const RESTART = new Set(["no", "on-failure", "unless-stopped", "always"]);
|
|
55
|
+
/** Compose `shm_size` as a size string such as 256mb; a number is bytes. */
|
|
56
|
+
function shmSizeOf(value) {
|
|
57
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0)
|
|
58
|
+
return `${value}b`;
|
|
59
|
+
if (typeof value === "string" && /^[0-9]+([bkmg]b?)?$/i.test(value))
|
|
60
|
+
return value;
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/** `container` for service.json, and the values that could not be carried over. */
|
|
64
|
+
function containerOf(raw) {
|
|
65
|
+
const container = {};
|
|
66
|
+
const dropped = [];
|
|
67
|
+
for (const [key, field] of [
|
|
68
|
+
["cap_add", "capAdd"],
|
|
69
|
+
["cap_drop", "capDrop"],
|
|
70
|
+
]) {
|
|
71
|
+
if (raw[key] === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
const caps = Array.isArray(raw[key]) ? raw[key] : [];
|
|
74
|
+
const kept = caps.filter((c) => typeof c === "string" && CAPABILITY.test(c));
|
|
75
|
+
if (kept.length !== caps.length || caps.length === 0)
|
|
76
|
+
dropped.push(key);
|
|
77
|
+
if (kept.length)
|
|
78
|
+
container[field] = kept;
|
|
79
|
+
}
|
|
80
|
+
if (raw.init !== undefined) {
|
|
81
|
+
if (typeof raw.init === "boolean")
|
|
82
|
+
container.init = raw.init;
|
|
83
|
+
else
|
|
84
|
+
dropped.push("init");
|
|
85
|
+
}
|
|
86
|
+
if (raw.read_only !== undefined) {
|
|
87
|
+
if (typeof raw.read_only === "boolean")
|
|
88
|
+
container.readOnly = raw.read_only;
|
|
89
|
+
else
|
|
90
|
+
dropped.push("read_only");
|
|
91
|
+
}
|
|
92
|
+
if (raw.user !== undefined) {
|
|
93
|
+
const user = typeof raw.user === "number" ? String(raw.user) : raw.user;
|
|
94
|
+
if (typeof user === "string" && USER.test(user))
|
|
95
|
+
container.user = user;
|
|
96
|
+
else
|
|
97
|
+
dropped.push("user");
|
|
98
|
+
}
|
|
99
|
+
if (raw.security_opt !== undefined) {
|
|
100
|
+
const opts = Array.isArray(raw.security_opt) ? raw.security_opt : [];
|
|
101
|
+
const kept = opts.filter((o) => typeof o === "string" && SECURITY_OPT.test(o));
|
|
102
|
+
if (kept.length !== opts.length || opts.length === 0)
|
|
103
|
+
dropped.push("security_opt");
|
|
104
|
+
if (kept.length)
|
|
105
|
+
container.securityOpt = kept;
|
|
106
|
+
}
|
|
107
|
+
if (raw.shm_size !== undefined) {
|
|
108
|
+
const size = shmSizeOf(raw.shm_size);
|
|
109
|
+
if (size)
|
|
110
|
+
container.shmSize = size;
|
|
111
|
+
else
|
|
112
|
+
dropped.push("shm_size");
|
|
113
|
+
}
|
|
114
|
+
return { ...(Object.keys(container).length ? { container } : {}), dropped };
|
|
115
|
+
}
|
|
116
|
+
/** One published port: container port, host port when Compose names a literal one, and protocol. */
|
|
117
|
+
function publishedPort(entry) {
|
|
118
|
+
const port = containerPort(entry);
|
|
119
|
+
if (port === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
let host;
|
|
122
|
+
let protocol = "tcp";
|
|
123
|
+
if (isObj(entry)) {
|
|
124
|
+
const published = typeof entry.published === "string" ? Number(entry.published) : entry.published;
|
|
125
|
+
if (typeof published === "number" && Number.isInteger(published))
|
|
126
|
+
host = published;
|
|
127
|
+
if (entry.protocol === "udp")
|
|
128
|
+
protocol = "udp";
|
|
129
|
+
}
|
|
130
|
+
else if (typeof entry === "string") {
|
|
131
|
+
const [mapping, proto] = entry.split("/");
|
|
132
|
+
if (proto === "udp")
|
|
133
|
+
protocol = "udp";
|
|
134
|
+
const parts = (mapping ?? "").split(":");
|
|
135
|
+
const hostPart = parts.length >= 2 ? parts[parts.length - 2] : undefined;
|
|
136
|
+
if (hostPart && /^\d+$/.test(hostPart))
|
|
137
|
+
host = Number.parseInt(hostPart, 10);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
port,
|
|
141
|
+
...(host !== undefined && host >= 1 && host <= 65535 && host !== port
|
|
142
|
+
? { hostPort: host }
|
|
143
|
+
: {}),
|
|
144
|
+
...(protocol === "udp" ? { protocol } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** One `volumes:` entry. Bind sources are resolved against the Compose file's directory. */
|
|
148
|
+
function volumeOf(entry, composeDir) {
|
|
149
|
+
let source;
|
|
150
|
+
let target;
|
|
151
|
+
let readOnly = false;
|
|
152
|
+
let type;
|
|
153
|
+
if (typeof entry === "string") {
|
|
154
|
+
const parts = entry.split(":");
|
|
155
|
+
if (parts.length < 2 || parts.length > 3)
|
|
156
|
+
return undefined;
|
|
157
|
+
[source, target] = parts;
|
|
158
|
+
readOnly = parts[2] === "ro";
|
|
159
|
+
if (parts[2] !== undefined && parts[2] !== "ro" && parts[2] !== "rw")
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
else if (isObj(entry)) {
|
|
163
|
+
({ source, target, type } = entry);
|
|
164
|
+
readOnly = entry.read_only === true;
|
|
165
|
+
if (type !== undefined && type !== "volume" && type !== "bind")
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (typeof source !== "string" || typeof target !== "string" || !CONTAINER_PATH.test(target))
|
|
172
|
+
return undefined;
|
|
173
|
+
const bind = type === "bind" || source.startsWith(".") || source.startsWith("/");
|
|
174
|
+
if (!bind) {
|
|
175
|
+
return VOLUME_NAME.test(source)
|
|
176
|
+
? { source, target, ...(readOnly ? { readOnly } : {}), bind }
|
|
177
|
+
: undefined;
|
|
178
|
+
}
|
|
179
|
+
if (isAbsolute(source) || !HOST_PATH.test(source))
|
|
180
|
+
return undefined;
|
|
181
|
+
const resolved = normalize(join(composeDir, source));
|
|
182
|
+
if (resolved === ".." || resolved.startsWith("../"))
|
|
183
|
+
return undefined;
|
|
184
|
+
return { source: resolved, target, ...(readOnly ? { readOnly } : {}), bind };
|
|
185
|
+
}
|
|
36
186
|
function envNames(environment) {
|
|
37
187
|
if (Array.isArray(environment)) {
|
|
38
188
|
return environment
|
|
@@ -180,13 +330,13 @@ registerDetector({
|
|
|
180
330
|
if (ports.length > 1 && selectedPort === undefined) {
|
|
181
331
|
skips.push({
|
|
182
332
|
...base,
|
|
183
|
-
reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified`,
|
|
333
|
+
reason: `service "${name}" lists several ports (${ports.join(", ")}); no single HTTP container port could be identified, so none is routed (all are published on 127.0.0.1 as extraPorts)`,
|
|
184
334
|
});
|
|
185
335
|
}
|
|
186
336
|
else if (ports.length > 1) {
|
|
187
337
|
skips.push({
|
|
188
338
|
...base,
|
|
189
|
-
reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort}`,
|
|
339
|
+
reason: `service "${name}" publishes extra container ports (${ports.filter((port) => port !== selectedPort).join(", ")}); Flisk routes only ${selectedPort} and publishes the others on 127.0.0.1 as extraPorts`,
|
|
190
340
|
});
|
|
191
341
|
}
|
|
192
342
|
const dependsOn = Array.isArray(raw.depends_on)
|
|
@@ -207,6 +357,50 @@ registerDetector({
|
|
|
207
357
|
reason: `service "${name}" health path "${foundHealthPath}" has characters Flisk does not accept (only a-z, 0-9, / and -); not used`,
|
|
208
358
|
});
|
|
209
359
|
}
|
|
360
|
+
const { container, dropped } = containerOf(raw);
|
|
361
|
+
for (const key of dropped) {
|
|
362
|
+
skips.push({
|
|
363
|
+
...base,
|
|
364
|
+
reason: `service "${name}" ${key} ${JSON.stringify(raw[key])} has values Flisk does not accept; not used`,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Every published port Flisk does not route stays published, on 127.0.0.1 (service.json extraPorts).
|
|
368
|
+
const extraPorts = (Array.isArray(raw.ports) ? raw.ports : [])
|
|
369
|
+
.map(publishedPort)
|
|
370
|
+
.filter((p) => p !== undefined && p.port !== selectedPort);
|
|
371
|
+
let sidecarOf;
|
|
372
|
+
if (raw.network_mode !== undefined) {
|
|
373
|
+
const mode = typeof raw.network_mode === "string" ? raw.network_mode : "";
|
|
374
|
+
if (mode.startsWith("service:") && mode.length > "service:".length) {
|
|
375
|
+
sidecarOf = mode.slice("service:".length);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
skips.push({
|
|
379
|
+
...base,
|
|
380
|
+
reason: `service "${name}" network_mode "${mode}" is not translated; Flisk only shares another service's network (service:<name>)`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const volumes = [];
|
|
385
|
+
for (const entry of Array.isArray(raw.volumes) ? raw.volumes : []) {
|
|
386
|
+
const volume = volumeOf(entry, composeDir);
|
|
387
|
+
if (volume)
|
|
388
|
+
volumes.push(volume);
|
|
389
|
+
else
|
|
390
|
+
skips.push({
|
|
391
|
+
...base,
|
|
392
|
+
reason: `service "${name}" volume ${JSON.stringify(entry)} is not translated (an anonymous volume, a path outside the imported directory, or Compose variables); not mounted`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const restart = typeof raw.restart === "string" && RESTART.has(raw.restart)
|
|
396
|
+
? raw.restart
|
|
397
|
+
: undefined;
|
|
398
|
+
if (raw.restart !== undefined && !restart) {
|
|
399
|
+
skips.push({
|
|
400
|
+
...base,
|
|
401
|
+
reason: `service "${name}" restart ${JSON.stringify(raw.restart)} is not a policy Flisk accepts; not used`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
210
404
|
const untranslated = Object.keys(raw).filter((key) => !TRANSLATED_KEYS.has(key));
|
|
211
405
|
if (untranslated.length) {
|
|
212
406
|
skips.push({
|
|
@@ -231,6 +425,11 @@ registerDetector({
|
|
|
231
425
|
...(envKeys.length ? { envKeys } : {}),
|
|
232
426
|
...(Object.keys(env).length ? { env } : {}),
|
|
233
427
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
428
|
+
...(container ? { container } : {}),
|
|
429
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
430
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
431
|
+
...(volumes.length ? { volumes } : {}),
|
|
432
|
+
...(restart ? { restart } : {}),
|
|
234
433
|
confidence: "high",
|
|
235
434
|
sources: [base],
|
|
236
435
|
});
|
package/dist/flisk.js
CHANGED
|
@@ -9,7 +9,7 @@ export const SERVICE_MANIFEST_SCHEMA_URL = "https://flisk.sh/schema.service.json
|
|
|
9
9
|
* release after 1.4.10 (flisk#2646) accepts a bring-your-own port outside 4000-5999 and two-letter
|
|
10
10
|
* names, which this importer now writes. Stated, not checked: it does not read the installed version.
|
|
11
11
|
*/
|
|
12
|
-
export const MIN_CORE_VERSION = "1.4.
|
|
12
|
+
export const MIN_CORE_VERSION = "1.4.24";
|
|
13
13
|
/**
|
|
14
14
|
* The core change that runs a registered Tiltfile (`orchestration.source: "tiltfile"`) from `flisk up`. Stated, not
|
|
15
15
|
* checked, like MIN_CORE_VERSION.
|
package/dist/merge.js
CHANGED
|
@@ -49,6 +49,10 @@ const SCALAR_FIELDS = [
|
|
|
49
49
|
function rank(candidate) {
|
|
50
50
|
return Math.min(...candidate.sources.map((s) => precedence(s.detector)));
|
|
51
51
|
}
|
|
52
|
+
function firstOf(ordered, key) {
|
|
53
|
+
const holder = ordered.find((c) => c[key] !== undefined);
|
|
54
|
+
return holder ? { [key]: holder[key] } : {};
|
|
55
|
+
}
|
|
52
56
|
function mergeGroup(name, dir, members, resolve) {
|
|
53
57
|
const ordered = [...members].sort((a, b) => rank(a) - rank(b));
|
|
54
58
|
const originalName = ordered.find((c) => c.name !== undefined && c.name !== name)?.name;
|
|
@@ -61,6 +65,14 @@ function mergeGroup(name, dir, members, resolve) {
|
|
|
61
65
|
env: Object.assign({}, ...[...ordered].reverse().map((c) => c.env ?? {})),
|
|
62
66
|
dependsOn: [...new Set(ordered.flatMap((c) => (c.dependsOn ?? []).map(resolve)))].sort(),
|
|
63
67
|
exposeViaProxy: ordered.find((c) => c.exposeViaProxy !== undefined)?.exposeViaProxy,
|
|
68
|
+
// Only Compose states these, so the first source that has one wins.
|
|
69
|
+
...firstOf(ordered, "container"),
|
|
70
|
+
...firstOf(ordered, "extraPorts"),
|
|
71
|
+
...firstOf(ordered, "volumes"),
|
|
72
|
+
...firstOf(ordered, "restart"),
|
|
73
|
+
...(ordered.find((c) => c.sidecarOf)?.sidecarOf
|
|
74
|
+
? { sidecarOf: resolve(ordered.find((c) => c.sidecarOf)?.sidecarOf) }
|
|
75
|
+
: {}),
|
|
64
76
|
procfileCommands: ordered.flatMap((c) => c.sources.some((source) => source.detector === "procfile") && c.command ? [c.command] : []),
|
|
65
77
|
conflicts: [],
|
|
66
78
|
sources: [],
|
|
@@ -104,6 +116,7 @@ export function mergeCandidates(candidates, rootName) {
|
|
|
104
116
|
const table = assignResourceNames([
|
|
105
117
|
...candidates.flatMap((c) => (c.name === undefined ? [] : [c.name])),
|
|
106
118
|
...candidates.flatMap((c) => c.dependsOn ?? []),
|
|
119
|
+
...candidates.flatMap((c) => (c.sidecarOf ? [c.sidecarOf] : [])),
|
|
107
120
|
...candidates.flatMap((c) => (c.name === undefined ? [basename(c.dir) || rootName] : [])),
|
|
108
121
|
]);
|
|
109
122
|
const resolve = (raw) => table.get(raw) ?? toResourceName(raw);
|
package/dist/write.js
CHANGED
|
@@ -160,6 +160,31 @@ export function planWrites(plan, force) {
|
|
|
160
160
|
dockerfile: service.dockerfile,
|
|
161
161
|
}
|
|
162
162
|
: { image: service.image };
|
|
163
|
+
// A sidecar shares another written service's network; one whose host is not written stays a normal service.
|
|
164
|
+
const sidecarOf = service.sidecarOf && service.sidecarOf !== service.name && names.has(service.sidecarOf)
|
|
165
|
+
? service.sidecarOf
|
|
166
|
+
: undefined;
|
|
167
|
+
if (service.sidecarOf && !sidecarOf)
|
|
168
|
+
notes.push(`network_mode not written: "${service.sidecarOf}" is not in this import`);
|
|
169
|
+
const extraPorts = sidecarOf ? [] : (service.extraPorts ?? []);
|
|
170
|
+
if (sidecarOf && service.extraPorts?.length)
|
|
171
|
+
notes.push(`ports not written: a sidecar has none of its own; publish them on "${sidecarOf}"`);
|
|
172
|
+
if (extraPorts.length)
|
|
173
|
+
notes.push(`published on 127.0.0.1 only: ${extraPorts.map((p) => `${p.hostPort ?? p.port}->${p.port}/${p.protocol ?? "tcp"}`).join(", ")}`);
|
|
174
|
+
// Bind mounts are relative to the service directory in service.json, and must stay inside the project.
|
|
175
|
+
const serviceDir = dirname(path);
|
|
176
|
+
const volumes = (service.volumes ?? []).flatMap((volume) => {
|
|
177
|
+
if (!volume.bind)
|
|
178
|
+
return [volume];
|
|
179
|
+
const absolute = resolve(plan.root, volume.source);
|
|
180
|
+
const fromProject = relative(projectRoot, absolute).split("\\").join("/");
|
|
181
|
+
if (fromProject === ".." || fromProject.startsWith("../")) {
|
|
182
|
+
notes.push(`volume ${volume.source}:${volume.target} not written: it is outside the project`);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const rel = relative(serviceDir, absolute).split("\\").join("/") || ".";
|
|
186
|
+
return [{ ...volume, source: rel.startsWith(".") ? rel : `./${rel}` }];
|
|
187
|
+
});
|
|
163
188
|
const manifest = {
|
|
164
189
|
$schema: SERVICE_MANIFEST_SCHEMA_URL,
|
|
165
190
|
schemaVersion: SERVICE_MANIFEST_SCHEMA_VERSION,
|
|
@@ -172,6 +197,19 @@ export function planWrites(plan, force) {
|
|
|
172
197
|
...(isPrivate ? { exposeViaProxy: false } : {}),
|
|
173
198
|
...(dependsOn.length ? { dependsOn } : {}),
|
|
174
199
|
...(secretNames.length ? { secrets: { required: secretNames } } : {}),
|
|
200
|
+
...(service.restart ? { restart: service.restart } : {}),
|
|
201
|
+
...(service.container ? { container: service.container } : {}),
|
|
202
|
+
...(extraPorts.length ? { extraPorts } : {}),
|
|
203
|
+
...(sidecarOf ? { sidecarOf } : {}),
|
|
204
|
+
...(volumes.length
|
|
205
|
+
? {
|
|
206
|
+
volumes: volumes.map(({ source, target, readOnly }) => ({
|
|
207
|
+
source,
|
|
208
|
+
target,
|
|
209
|
+
...(readOnly ? { readOnly } : {}),
|
|
210
|
+
})),
|
|
211
|
+
}
|
|
212
|
+
: {}),
|
|
175
213
|
};
|
|
176
214
|
items.push({ service, path, manifest, status, notes });
|
|
177
215
|
}
|