@xen-orchestra/backups 0.70.0 → 0.71.1
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/_otherConfig.mjs +1 -0
- package/_runners/_vmRunners/_AbstractXapi.mjs +6 -8
- package/_runners/_writers/FullRemoteWriter.mjs +1 -1
- package/_runners/_writers/FullXapiWriter.mjs +1 -1
- package/_runners/_writers/IncrementalRemoteWriter.mjs +2 -2
- package/_runners/_writers/IncrementalXapiWriter.mjs +122 -61
- package/_runners/_writers/_AbstractWriter.mjs +2 -2
- package/_runners/_writers/_MixinRemoteWriter.mjs +1 -1
- package/disks/RemoteVhdDisk.mjs +5 -1
- package/package.json +2 -2
package/_otherConfig.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export async function getVmDeltaChainLength(xapi, vmRef) {
|
|
|
85
85
|
export function resetVmOtherConfig(xapi, vmRef) {
|
|
86
86
|
return applyToVmAndVdis(xapi, vmRef, (type, ref) => {
|
|
87
87
|
return xapi.setFieldEntries(type, ref, 'other_config', {
|
|
88
|
+
[COPY_OF]: null,
|
|
88
89
|
[DATETIME]: null,
|
|
89
90
|
[DELTA_CHAIN_LENGTH]: null,
|
|
90
91
|
[EXPORTED_SUCCESSFULLY]: null,
|
|
@@ -105,7 +105,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
105
105
|
config,
|
|
106
106
|
healthCheckSr,
|
|
107
107
|
job,
|
|
108
|
-
|
|
108
|
+
schedule,
|
|
109
109
|
vmUuid: vm.uuid,
|
|
110
110
|
settings,
|
|
111
111
|
})
|
|
@@ -123,7 +123,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
123
123
|
config,
|
|
124
124
|
healthCheckSr,
|
|
125
125
|
job,
|
|
126
|
-
|
|
126
|
+
schedule,
|
|
127
127
|
vmUuid: vm.uuid,
|
|
128
128
|
remoteId,
|
|
129
129
|
settings: targetSettings,
|
|
@@ -141,7 +141,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
141
141
|
healthCheckSr,
|
|
142
142
|
job,
|
|
143
143
|
ReplicationWriter,
|
|
144
|
-
|
|
144
|
+
schedule,
|
|
145
145
|
vmUuid: vm.uuid,
|
|
146
146
|
srs,
|
|
147
147
|
settings,
|
|
@@ -159,7 +159,7 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
159
159
|
config,
|
|
160
160
|
healthCheckSr,
|
|
161
161
|
job,
|
|
162
|
-
|
|
162
|
+
schedule,
|
|
163
163
|
vmUuid: vm.uuid,
|
|
164
164
|
sr,
|
|
165
165
|
settings: targetSettings,
|
|
@@ -174,11 +174,9 @@ export const AbstractXapi = class AbstractXapiVmBackupRunner extends Abstract {
|
|
|
174
174
|
|
|
175
175
|
// ensure the VM itself does not have any backup metadata which would be
|
|
176
176
|
// copied on manual snapshots and interfere with the backup jobs
|
|
177
|
+
|
|
177
178
|
async _cleanMetadata() {
|
|
178
|
-
|
|
179
|
-
if (JOB_ID in vm.other_config) {
|
|
180
|
-
await resetVmOtherConfig(this._xapi, vm.$ref)
|
|
181
|
-
}
|
|
179
|
+
await resetVmOtherConfig(this._xapi, this._vm.$ref)
|
|
182
180
|
}
|
|
183
181
|
|
|
184
182
|
async _snapshot() {
|
|
@@ -27,7 +27,7 @@ export class FullRemoteWriter extends MixinRemoteWriter(AbstractFullWriter) {
|
|
|
27
27
|
async _run({ maxStreamLength, timestamp, sizeContainer, stream, streamLength, vm, vmSnapshot }) {
|
|
28
28
|
const settings = this._settings
|
|
29
29
|
const job = this._job
|
|
30
|
-
const scheduleId = this.
|
|
30
|
+
const scheduleId = this._schedule.id
|
|
31
31
|
|
|
32
32
|
const adapter = this._adapter
|
|
33
33
|
let metadata = await this._isAlreadyTransferred(timestamp)
|
|
@@ -34,7 +34,7 @@ export class FullXapiWriter extends MixinXapiWriter(AbstractFullWriter) {
|
|
|
34
34
|
const sr = this._sr
|
|
35
35
|
const settings = this._settings
|
|
36
36
|
const job = this._job
|
|
37
|
-
const scheduleId = this.
|
|
37
|
+
const scheduleId = this._schedule.id
|
|
38
38
|
|
|
39
39
|
const { uuid: srUuid, $xapi: xapi } = sr
|
|
40
40
|
|
|
@@ -90,7 +90,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
|
|
90
90
|
async _prepare() {
|
|
91
91
|
const adapter = this._adapter
|
|
92
92
|
const settings = this._settings
|
|
93
|
-
const scheduleId = this.
|
|
93
|
+
const scheduleId = this._schedule.id
|
|
94
94
|
const vmUuid = this._vmUuid
|
|
95
95
|
|
|
96
96
|
const oldEntries = getOldEntries(
|
|
@@ -180,7 +180,7 @@ export class IncrementalRemoteWriter extends MixinRemoteWriter(AbstractIncrement
|
|
|
180
180
|
async _transfer($defer, { isVhdDifferencing, timestamp, deltaExport, vm, vmSnapshot }) {
|
|
181
181
|
const adapter = this._adapter
|
|
182
182
|
const job = this._job
|
|
183
|
-
const scheduleId = this.
|
|
183
|
+
const scheduleId = this._schedule.id
|
|
184
184
|
const settings = this._settings
|
|
185
185
|
const jobId = job.id
|
|
186
186
|
const handler = adapter.handler
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import humanFormat from 'human-format'
|
|
2
|
+
|
|
1
3
|
import { asyncMapSettled } from '@xen-orchestra/async-map'
|
|
2
4
|
import ignoreErrors from 'promise-toolbox/ignoreErrors'
|
|
3
5
|
|
|
@@ -18,49 +20,119 @@ import {
|
|
|
18
20
|
DATETIME,
|
|
19
21
|
VM_UUID,
|
|
20
22
|
} from '../../_otherConfig.mjs'
|
|
21
|
-
import assert from 'node:assert'
|
|
22
23
|
import { formatFilenameDate } from '../../_filenameDate.mjs'
|
|
24
|
+
import { XapiDiskSource } from '@xen-orchestra/xapi'
|
|
25
|
+
import { asyncEach } from '@vates/async-each'
|
|
26
|
+
import { createLogger } from '@xen-orchestra/log'
|
|
27
|
+
|
|
28
|
+
const { debug } = createLogger('xo:backups:IncrementalXapiWriter')
|
|
23
29
|
|
|
24
30
|
export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWriter) {
|
|
31
|
+
// Map of source VDI UUID (COPY_OF) → validated active VDI on the target SR.
|
|
32
|
+
// Built by checkBaseVdis, consumed by #decorateVmMetadata to set baseVdi.
|
|
33
|
+
#baseVdisBySourceUuid = new Map()
|
|
34
|
+
|
|
25
35
|
async checkBaseVdis(baseUuidToSrcVdi) {
|
|
26
36
|
const sr = this._sr
|
|
37
|
+
this.#baseVdisBySourceUuid = new Map()
|
|
38
|
+
|
|
27
39
|
if (baseUuidToSrcVdi.size === 0) {
|
|
28
40
|
// searching for the vdis is expensive
|
|
29
41
|
// don't do it if there is nothing to find
|
|
30
42
|
return
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
if (!vdi?.managed || vdi?.is_a_snapshot || !baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])) {
|
|
38
|
-
return false
|
|
39
|
-
}
|
|
40
|
-
const userVbds = vdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
|
|
41
|
-
if (userVbds.length !== 1) {
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
const vm = userVbds[0].$VM
|
|
45
|
+
// look for the same snapshot
|
|
46
|
+
// ensure there are no data between the snapshot and the active disk
|
|
47
|
+
|
|
48
|
+
const snapshotCandidates = sr.$VDIs.filter(vdi => {
|
|
45
49
|
return (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
vdi?.managed &&
|
|
51
|
+
vdi?.is_a_snapshot &&
|
|
52
|
+
vdi.other_config[JOB_ID] === this._job.id &&
|
|
53
|
+
vdi.other_config[VM_UUID] === this._vmUuid &&
|
|
54
|
+
baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
|
|
49
55
|
)
|
|
50
56
|
})
|
|
57
|
+
debug('checkBaseVdis, got snapshot candidates,', snapshotCandidates.length)
|
|
51
58
|
|
|
52
|
-
|
|
59
|
+
if (snapshotCandidates.length > 0) {
|
|
60
|
+
// New snapshot-based flow (6.3+): verify no data was written between
|
|
61
|
+
// the target snapshot and its active VDI.
|
|
62
|
+
let targetVmRef
|
|
63
|
+
let canChainToTargetVm = true
|
|
64
|
+
await asyncEach(
|
|
65
|
+
snapshotCandidates,
|
|
66
|
+
async snapshot => {
|
|
67
|
+
let diffDisk
|
|
68
|
+
try {
|
|
69
|
+
const activeVdi = sr.$xapi.getObject(snapshot.$snapshot_of)
|
|
70
|
+
const userVbds = activeVdi.$VBDs?.filter(vbd => vbd.$VM && !vbd.$VM.is_control_domain) ?? []
|
|
71
|
+
if (userVbds.length !== 1) {
|
|
72
|
+
debug('checkBaseVdis, share vbd ', { ref: snapshot.$ref, userVbds })
|
|
73
|
+
// shared vdi ignore
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const vm = userVbds[0].$VM
|
|
77
|
+
if (!('start' in vm.blocked_operations)) {
|
|
78
|
+
debug('checkBaseVdis, vm not blocked', { vmRef: vm.$ref })
|
|
79
|
+
// vm start unlocked
|
|
80
|
+
// not really an issue since we have check the delta
|
|
81
|
+
// but it indicates the users played with the blocked operations
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
diffDisk = new XapiDiskSource({ xapi: sr.$xapi, vdiRef: activeVdi.$ref, baseRef: snapshot.$ref })
|
|
85
|
+
await diffDisk.init()
|
|
86
|
+
if (diffDisk.getBlockIndexes().length === 0) {
|
|
87
|
+
const sourceUuid = snapshot.other_config?.[COPY_OF]
|
|
88
|
+
if (sourceUuid) {
|
|
89
|
+
this.#baseVdisBySourceUuid.set(sourceUuid, activeVdi)
|
|
90
|
+
}
|
|
91
|
+
// Track the target VM (the replicated VM to update on the next transfer).
|
|
92
|
+
targetVmRef = vm.$ref
|
|
93
|
+
} else {
|
|
94
|
+
// not empty, we will create a new VM
|
|
95
|
+
canChainToTargetVm = false
|
|
96
|
+
debug('checkBaseVdis, data between snapshot and active disk', {
|
|
97
|
+
vdiRef: snapshot.$ref,
|
|
98
|
+
nbBlocks: diffDisk.getBlockIndexes().length,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
debug('checkBaseVdis, skipping snapshot', { ref: snapshot.$ref, error })
|
|
103
|
+
return
|
|
104
|
+
} finally {
|
|
105
|
+
await diffDisk?.close().catch(error => debug('checkBaseVdis, error closing', error))
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
concurrency: 4,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
53
112
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
113
|
+
if (canChainToTargetVm && targetVmRef !== undefined) {
|
|
114
|
+
debug('checkBaseVdis,got a valid vm target', targetVmRef)
|
|
115
|
+
this._targetVmRef = targetVmRef
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// Legacy fallback (upgrade from pre-6.3): no target snapshots exist yet,
|
|
119
|
+
// look for active (non-snapshot) VDIs with matching COPY_OF, like the old code did.
|
|
120
|
+
debug('checkBaseVdis, no snapshot candidates, falling back to legacy active VDI lookup')
|
|
121
|
+
const legacyVdis = sr.$VDIs.filter(vdi => {
|
|
122
|
+
return vdi?.managed && !vdi?.is_a_snapshot && baseUuidToSrcVdi.has(vdi?.other_config[COPY_OF])
|
|
123
|
+
})
|
|
124
|
+
for (const vdi of legacyVdis) {
|
|
125
|
+
const sourceUuid = vdi.other_config[COPY_OF]
|
|
126
|
+
if (sourceUuid) {
|
|
127
|
+
this.#baseVdisBySourceUuid.set(sourceUuid, vdi)
|
|
128
|
+
}
|
|
57
129
|
}
|
|
58
130
|
}
|
|
59
131
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
132
|
+
for (const uuid of baseUuidToSrcVdi.keys()) {
|
|
133
|
+
if (!this.#baseVdisBySourceUuid.has(uuid)) {
|
|
134
|
+
baseUuidToSrcVdi.delete(uuid)
|
|
135
|
+
}
|
|
64
136
|
}
|
|
65
137
|
}
|
|
66
138
|
updateUuidAndChain() {
|
|
@@ -89,7 +161,7 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
89
161
|
const settings = this._settings
|
|
90
162
|
const { uuid: srUuid, $xapi: xapi } = this._sr
|
|
91
163
|
const vmUuid = this._vmUuid
|
|
92
|
-
const scheduleId = this.
|
|
164
|
+
const scheduleId = this._schedule.id
|
|
93
165
|
|
|
94
166
|
// delete previous interrupted copies
|
|
95
167
|
ignoreErrors.call(asyncMapSettled(listReplicatedVms(xapi, scheduleId, undefined, vmUuid), vm => vm.$destroy))
|
|
@@ -132,9 +204,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
132
204
|
const sr = this._sr
|
|
133
205
|
const vm = backup.vm
|
|
134
206
|
const job = this._job
|
|
135
|
-
const scheduleId = this.
|
|
207
|
+
const scheduleId = this._schedule.id
|
|
136
208
|
|
|
137
|
-
vm.name_label = `${vm.name_label} - ${job.name}`
|
|
138
209
|
// update other_config data as soon as possible to ensure the next job
|
|
139
210
|
// will be able to detect any partial transfer and lean them
|
|
140
211
|
vm.other_config[COPY_OF] = vm.uuid
|
|
@@ -152,18 +223,6 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
152
223
|
if (!_warmMigration) {
|
|
153
224
|
vm.tags.push('Continuous Replication')
|
|
154
225
|
}
|
|
155
|
-
// extracting the uuid of each delta vdi on the source
|
|
156
|
-
// get all in one pass, since there is a lot of objects
|
|
157
|
-
const sourceVdiUuids = Object.values(backup.vdis)
|
|
158
|
-
.map(({ other_config }) => other_config[BASE_DELTA_VDI])
|
|
159
|
-
// full vdi don't have a base
|
|
160
|
-
.filter(_ => !!_)
|
|
161
|
-
// @todo use index ?
|
|
162
|
-
|
|
163
|
-
const replicatedVdis = sr.$VDIs.filter(vdi => {
|
|
164
|
-
// REPLICATED_TO_SR_UUID is not used here since we are already filtering from sr.$VDIs
|
|
165
|
-
return vdi?.managed && !vdi?.is_a_snapshot && sourceVdiUuids.includes(vdi?.other_config[COPY_OF])
|
|
166
|
-
})
|
|
167
226
|
|
|
168
227
|
Object.values(backup.vdis).forEach(vdi => {
|
|
169
228
|
vdi.other_config[COPY_OF] = vdi.uuid
|
|
@@ -172,18 +231,12 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
172
231
|
vdi.other_config[REPLICATED_TO_SR_UUID] = sr.uuid
|
|
173
232
|
vdi.other_config[VM_UUID] = vm.uuid
|
|
174
233
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
179
|
-
assert.ok(
|
|
180
|
-
baseReplicatedTo.length <= 1,
|
|
181
|
-
`Target of a replication must be unique, got ${baseReplicatedTo.length} candidates`
|
|
182
|
-
)
|
|
183
|
-
// baseReplicatedTo can be undefined if a new disk is added and other are already replicated
|
|
184
|
-
vdi.baseVdi = baseReplicatedTo[0]
|
|
234
|
+
const baseDeltaVdiUuid = vdi.other_config[BASE_DELTA_VDI]
|
|
235
|
+
if (baseDeltaVdiUuid !== undefined) {
|
|
236
|
+
// reuse the validated mapping built by checkBaseVdis
|
|
237
|
+
vdi.baseVdi = this.#baseVdisBySourceUuid.get(baseDeltaVdiUuid)
|
|
185
238
|
} else {
|
|
186
|
-
// first replication of this disk
|
|
239
|
+
// first replication of this disk (full, no base)
|
|
187
240
|
vdi.baseVdi = undefined
|
|
188
241
|
}
|
|
189
242
|
// ensure the VDI are created on the target SR
|
|
@@ -197,7 +250,8 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
197
250
|
const { _warmMigration } = this._settings
|
|
198
251
|
const sr = this._sr
|
|
199
252
|
const job = this._job
|
|
200
|
-
const
|
|
253
|
+
const schedule = this._schedule
|
|
254
|
+
const scheduleId = schedule.id
|
|
201
255
|
const { uuid: srUuid, $xapi: xapi } = sr
|
|
202
256
|
|
|
203
257
|
let targetVmRef
|
|
@@ -213,19 +267,26 @@ export class IncrementalXapiWriter extends MixinXapiWriter(AbstractIncrementalWr
|
|
|
213
267
|
vmUuid: vm.uuid,
|
|
214
268
|
srUuid,
|
|
215
269
|
})
|
|
216
|
-
|
|
270
|
+
const size = Object.values(deltaExport.disks).reduce(
|
|
271
|
+
(sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
|
|
272
|
+
0
|
|
273
|
+
)
|
|
274
|
+
await xapi.setField(
|
|
275
|
+
'VM',
|
|
276
|
+
targetVmRef,
|
|
277
|
+
'name_description',
|
|
278
|
+
deltaExport.vm.name_description +
|
|
279
|
+
` -- last replication: ${formatFilenameDate(timestamp)} ${humanFormat.bytes(size)} read`
|
|
280
|
+
)
|
|
217
281
|
// take a snapshot to ensure these data are not modified until next snapshot
|
|
218
|
-
await Task.run({ name: 'target snapshot' }, () =>
|
|
219
|
-
xapi.VM_snapshot(targetVmRef, {
|
|
220
|
-
name_label: `${vm.name_label} - ${job.name}
|
|
282
|
+
await Task.run({ name: 'target snapshot' }, async () => {
|
|
283
|
+
await xapi.VM_snapshot(targetVmRef, {
|
|
284
|
+
name_label: `${vm.name_label} - ${job.name} / ${schedule.name} ${formatFilenameDate(timestamp)}`,
|
|
221
285
|
})
|
|
222
|
-
)
|
|
223
|
-
|
|
286
|
+
})
|
|
287
|
+
|
|
224
288
|
return {
|
|
225
|
-
size
|
|
226
|
-
(sum, disk) => sum + disk.getNbGeneratedBlock() * disk.getBlockSize(),
|
|
227
|
-
0
|
|
228
|
-
),
|
|
289
|
+
size,
|
|
229
290
|
}
|
|
230
291
|
})
|
|
231
292
|
this._targetVmRef = targetVmRef
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export class AbstractWriter {
|
|
2
|
-
constructor({ config, healthCheckSr, job, vmUuid,
|
|
2
|
+
constructor({ config, healthCheckSr, job, vmUuid, schedule, settings }) {
|
|
3
3
|
this._config = config
|
|
4
4
|
this._healthCheckSr = healthCheckSr
|
|
5
5
|
this._job = job
|
|
6
|
-
this.
|
|
6
|
+
this._schedule = schedule
|
|
7
7
|
this._settings = settings
|
|
8
8
|
this._vmUuid = vmUuid
|
|
9
9
|
}
|
|
@@ -50,7 +50,7 @@ export const MixinRemoteWriter = (BaseClass = Object) =>
|
|
|
50
50
|
|
|
51
51
|
async getLongTermRetentionTags(currentEntry) {
|
|
52
52
|
const settings = this._settings
|
|
53
|
-
const scheduleId = this.
|
|
53
|
+
const scheduleId = this._schedule.id
|
|
54
54
|
const vmUuid = this._vmUuid
|
|
55
55
|
const adapter = this._adapter
|
|
56
56
|
|
package/disks/RemoteVhdDisk.mjs
CHANGED
|
@@ -95,10 +95,14 @@ export class RemoteVhdDisk extends RemoteDisk {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* Closes the VHD.
|
|
98
|
+
* We replace the dispose function call so the disk can only be closed once.
|
|
99
|
+
*
|
|
98
100
|
* @returns {Promise<void>}
|
|
99
101
|
*/
|
|
100
102
|
async close() {
|
|
101
|
-
|
|
103
|
+
const dispose = this.#dispose
|
|
104
|
+
this.#dispose = () => Promise.resolve()
|
|
105
|
+
await dispose()
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/**
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.
|
|
11
|
+
"version": "0.71.1",
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=14.18"
|
|
14
14
|
},
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"tmp": "^0.2.1"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@xen-orchestra/xapi": "^8.
|
|
65
|
+
"@xen-orchestra/xapi": "^8.7.0"
|
|
66
66
|
},
|
|
67
67
|
"license": "AGPL-3.0-or-later",
|
|
68
68
|
"author": {
|