770 lines
18 KiB
JavaScript
770 lines
18 KiB
JavaScript
const { sleep, stringify } = require("../../../utils/general");
|
|
const { Zetabyte } = require("../../../utils/zfs");
|
|
|
|
// used for in-memory cache of the version info
|
|
const FREENAS_SYSTEM_VERSION_CACHE_KEY = "freenas:system_version";
|
|
|
|
class Api {
|
|
constructor(client, cache, options = {}) {
|
|
this.client = client;
|
|
this.cache = cache;
|
|
this.options = options;
|
|
}
|
|
|
|
async getHttpClient() {
|
|
return this.client;
|
|
}
|
|
|
|
/**
|
|
* only here for the helpers
|
|
* @returns
|
|
*/
|
|
async getZetabyte() {
|
|
return new Zetabyte({
|
|
executor: {
|
|
spawn: function () {
|
|
throw new Error(
|
|
"cannot use the zb implementation to execute zfs commands, must use the http api"
|
|
);
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async findResourceByProperties(endpoint, match) {
|
|
if (!match) {
|
|
return;
|
|
}
|
|
|
|
if (typeof match === "object" && Object.keys(match).length < 1) {
|
|
return;
|
|
}
|
|
|
|
const httpClient = await this.getHttpClient();
|
|
let target;
|
|
let page = 0;
|
|
let lastReponse;
|
|
|
|
// loop and find target
|
|
let queryParams = {};
|
|
queryParams.limit = 100;
|
|
queryParams.offset = 0;
|
|
|
|
while (!target) {
|
|
//Content-Range: items 0-2/3 (full set)
|
|
//Content-Range: items 0--1/3 (invalid offset)
|
|
if (queryParams.hasOwnProperty("offset")) {
|
|
queryParams.offset = queryParams.limit * page;
|
|
}
|
|
|
|
// crude stoppage attempt
|
|
let response = await httpClient.get(endpoint, queryParams);
|
|
if (lastReponse) {
|
|
if (JSON.stringify(lastReponse) == JSON.stringify(response)) {
|
|
break;
|
|
}
|
|
}
|
|
lastReponse = response;
|
|
|
|
if (response.statusCode == 200) {
|
|
if (response.body.length < 1) {
|
|
break;
|
|
}
|
|
response.body.some((i) => {
|
|
let isMatch = true;
|
|
|
|
if (typeof match === "function") {
|
|
isMatch = match(i);
|
|
} else {
|
|
for (let property in match) {
|
|
if (match[property] != i[property]) {
|
|
isMatch = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isMatch) {
|
|
target = i;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
} else {
|
|
throw new Error(
|
|
"FreeNAS http error - code: " +
|
|
response.statusCode +
|
|
" body: " +
|
|
JSON.stringify(response.body)
|
|
);
|
|
}
|
|
page++;
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
async getApiVersion() {
|
|
const systemVersion = await this.getSystemVersion();
|
|
|
|
if (systemVersion.v2) {
|
|
if ((await this.getSystemVersionMajorMinor()) == 11.2) {
|
|
return 1;
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
async getIsFreeNAS() {
|
|
const systemVersion = await this.getSystemVersion();
|
|
let version;
|
|
|
|
if (systemVersion.v2) {
|
|
version = systemVersion.v2;
|
|
} else {
|
|
version = systemVersion.v1.fullversion;
|
|
}
|
|
|
|
if (version.toLowerCase().includes("freenas")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async getIsTrueNAS() {
|
|
const systemVersion = await this.getSystemVersion();
|
|
let version;
|
|
|
|
if (systemVersion.v2) {
|
|
version = systemVersion.v2;
|
|
} else {
|
|
version = systemVersion.v1.fullversion;
|
|
}
|
|
|
|
if (version.toLowerCase().includes("truenas")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async getIsScale() {
|
|
const systemVersion = await this.getSystemVersion();
|
|
|
|
if (systemVersion.v2 && systemVersion.v2.toLowerCase().includes("scale")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async getSystemVersionMajorMinor() {
|
|
const systemVersion = await this.getSystemVersion();
|
|
let parts;
|
|
let parts_i;
|
|
let version;
|
|
|
|
/*
|
|
systemVersion.v2 = "FreeNAS-11.2-U5";
|
|
systemVersion.v2 = "TrueNAS-SCALE-20.11-MASTER-20201127-092915";
|
|
systemVersion.v1 = {
|
|
fullversion: "FreeNAS-9.3-STABLE-201503200528",
|
|
fullversion: "FreeNAS-11.2-U5 (c129415c52)",
|
|
};
|
|
|
|
systemVersion.v2 = null;
|
|
*/
|
|
|
|
if (systemVersion.v2) {
|
|
version = systemVersion.v2;
|
|
} else {
|
|
version = systemVersion.v1.fullversion;
|
|
}
|
|
|
|
if (version) {
|
|
parts = version.split("-");
|
|
parts_i = [];
|
|
parts.forEach((value) => {
|
|
let i = value.replace(/[^\d.]/g, "");
|
|
if (i.length > 0) {
|
|
parts_i.push(i);
|
|
}
|
|
});
|
|
|
|
// join and resplit to deal with single elements which contain a decimal
|
|
parts_i = parts_i.join(".").split(".");
|
|
parts_i.splice(2);
|
|
return parts_i.join(".");
|
|
}
|
|
}
|
|
|
|
async getSystemVersionMajor() {
|
|
const majorMinor = await this.getSystemVersionMajorMinor();
|
|
return majorMinor.split(".")[0];
|
|
}
|
|
|
|
async setVersionInfoCache(versionInfo) {
|
|
await this.cache.set(FREENAS_SYSTEM_VERSION_CACHE_KEY, versionInfo, {
|
|
ttl: 60 * 1000,
|
|
});
|
|
}
|
|
|
|
async getSystemVersion() {
|
|
let cacheData = await this.cache.get(FREENAS_SYSTEM_VERSION_CACHE_KEY);
|
|
|
|
if (cacheData) {
|
|
return cacheData;
|
|
}
|
|
|
|
const httpClient = await this.getHttpClient(false);
|
|
const endpoint = "/system/version/";
|
|
let response;
|
|
const startApiVersion = httpClient.getApiVersion();
|
|
const versionInfo = {};
|
|
const versionErrors = {};
|
|
const versionResponses = {};
|
|
|
|
httpClient.setApiVersion(2);
|
|
/**
|
|
* FreeNAS-11.2-U5
|
|
* TrueNAS-12.0-RELEASE
|
|
* TrueNAS-SCALE-20.11-MASTER-20201127-092915
|
|
*/
|
|
try {
|
|
response = await httpClient.get(endpoint);
|
|
versionResponses.v2 = response;
|
|
if (response.statusCode == 200) {
|
|
versionInfo.v2 = response.body;
|
|
|
|
// return immediately to save on resources and silly requests
|
|
await this.setVersionInfoCache(versionInfo);
|
|
|
|
// reset apiVersion
|
|
httpClient.setApiVersion(startApiVersion);
|
|
|
|
return versionInfo;
|
|
}
|
|
} catch (e) {
|
|
// if more info is needed use e.stack
|
|
versionErrors.v2 = e.toString();
|
|
}
|
|
|
|
httpClient.setApiVersion(1);
|
|
/**
|
|
* {"fullversion": "FreeNAS-9.3-STABLE-201503200528", "name": "FreeNAS", "version": "9.3"}
|
|
* {"fullversion": "FreeNAS-11.2-U5 (c129415c52)", "name": "FreeNAS", "version": ""}
|
|
*/
|
|
try {
|
|
response = await httpClient.get(endpoint);
|
|
versionResponses.v1 = response;
|
|
if (response.statusCode == 200 && IsJsonString(response.body)) {
|
|
versionInfo.v1 = response.body;
|
|
await this.setVersionInfoCache(versionInfo);
|
|
|
|
// reset apiVersion
|
|
httpClient.setApiVersion(startApiVersion);
|
|
|
|
return versionInfo;
|
|
}
|
|
} catch (e) {
|
|
// if more info is needed use e.stack
|
|
versionErrors.v1 = e.toString();
|
|
}
|
|
|
|
// throw error if cannot get v1 or v2 data
|
|
// likely bad creds/url
|
|
throw new GrpcError(
|
|
grpc.status.UNKNOWN,
|
|
`FreeNAS error getting system version info: ${stringify({
|
|
errors: versionErrors,
|
|
responses: versionResponses,
|
|
})}`
|
|
);
|
|
}
|
|
|
|
getIsUserProperty(property) {
|
|
if (property.includes(":")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
getUserProperties(properties) {
|
|
let user_properties = {};
|
|
for (const property in properties) {
|
|
if (this.getIsUserProperty(property)) {
|
|
user_properties[property] = properties[property];
|
|
}
|
|
}
|
|
|
|
return user_properties;
|
|
}
|
|
|
|
getSystemProperties(properties) {
|
|
let system_properties = {};
|
|
for (const property in properties) {
|
|
if (!this.getIsUserProperty(property)) {
|
|
system_properties[property] = properties[property];
|
|
}
|
|
}
|
|
|
|
return system_properties;
|
|
}
|
|
|
|
getPropertiesKeyValueArray(properties) {
|
|
let arr = [];
|
|
for (const property in properties) {
|
|
arr.push({ key: property, value: properties[property] });
|
|
}
|
|
|
|
return arr;
|
|
}
|
|
|
|
normalizeProperties(dataset, properties) {
|
|
let res = {};
|
|
for (const property of properties) {
|
|
let p;
|
|
if (dataset.hasOwnProperty(property)) {
|
|
p = dataset[property];
|
|
} else if (
|
|
dataset.properties &&
|
|
dataset.properties.hasOwnProperty(property)
|
|
) {
|
|
p = dataset.properties[property];
|
|
} else if (
|
|
dataset.user_properties &&
|
|
dataset.user_properties.hasOwnProperty(property)
|
|
) {
|
|
p = dataset.user_properties[property];
|
|
} else {
|
|
p = {
|
|
value: "-",
|
|
rawvalue: "-",
|
|
source: "-",
|
|
};
|
|
}
|
|
|
|
if (typeof p === "object" && p !== null) {
|
|
// nothing, leave as is
|
|
} else {
|
|
p = {
|
|
value: p,
|
|
rawvalue: p,
|
|
source: "-",
|
|
};
|
|
}
|
|
|
|
res[property] = p;
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
async DatasetCreate(datasetName, data) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
data.name = datasetName;
|
|
|
|
endpoint = "/pool/dataset";
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("already exists")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} datasetName
|
|
* @param {*} data
|
|
* @returns
|
|
*/
|
|
async DatasetDelete(datasetName, data) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/pool/dataset/id/${encodeURIComponent(datasetName)}`;
|
|
response = await httpClient.delete(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("does not exist")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async DatasetSet(datasetName, properties) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/pool/dataset/id/${encodeURIComponent(datasetName)}`;
|
|
response = await httpClient.put(endpoint, {
|
|
...this.getSystemProperties(properties),
|
|
user_properties_update: this.getPropertiesKeyValueArray(
|
|
this.getUserProperties(properties)
|
|
),
|
|
});
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async DatasetInherit(datasetName, property) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
let system_properties = {};
|
|
let user_properties_update = [];
|
|
|
|
const isUserProperty = this.getIsUserProperty(property);
|
|
if (isUserProperty) {
|
|
user_properties_update = [{ key: property, remove: true }];
|
|
} else {
|
|
system_properties[property] = "INHERIT";
|
|
}
|
|
|
|
endpoint = `/pool/dataset/id/${encodeURIComponent(datasetName)}`;
|
|
response = await httpClient.put(endpoint, {
|
|
...system_properties,
|
|
user_properties_update,
|
|
});
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* zfs get -Hp all tank/k8s/test/PVC-111
|
|
*
|
|
* @param {*} datasetName
|
|
* @param {*} properties
|
|
* @returns
|
|
*/
|
|
async DatasetGet(datasetName, properties) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/pool/dataset/id/${encodeURIComponent(datasetName)}`;
|
|
response = await httpClient.get(endpoint);
|
|
|
|
if (response.statusCode == 200) {
|
|
return this.normalizeProperties(response.body, properties);
|
|
}
|
|
|
|
if (response.statusCode == 404) {
|
|
throw new Error("dataset does not exist");
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async DatasetDestroySnapshots(datasetName, data = {}) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
data.name = datasetName;
|
|
|
|
endpoint = "/pool/dataset/destroy_snapshots";
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return response.body;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("already exists")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async SnapshotSet(snapshotName, properties) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/zfs/snapshot/id/${encodeURIComponent(snapshotName)}`;
|
|
response = await httpClient.put(endpoint, {
|
|
//...this.getSystemProperties(properties),
|
|
user_properties_update: this.getPropertiesKeyValueArray(
|
|
this.getUserProperties(properties)
|
|
),
|
|
});
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* zfs get -Hp all tank/k8s/test/PVC-111
|
|
*
|
|
* @param {*} snapshotName
|
|
* @param {*} properties
|
|
* @returns
|
|
*/
|
|
async SnapshotGet(snapshotName, properties) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/zfs/snapshot/id/${encodeURIComponent(snapshotName)}`;
|
|
response = await httpClient.get(endpoint);
|
|
|
|
if (response.statusCode == 200) {
|
|
return this.normalizeProperties(response.body, properties);
|
|
}
|
|
|
|
if (response.statusCode == 404) {
|
|
throw new Error("dataset does not exist");
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async SnapshotCreate(snapshotName, data = {}) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
const zb = await this.getZetabyte();
|
|
|
|
let response;
|
|
let endpoint;
|
|
|
|
const dataset = zb.helpers.extractDatasetName(snapshotName);
|
|
const snapshot = zb.helpers.extractSnapshotName(snapshotName);
|
|
|
|
data.dataset = dataset;
|
|
data.name = snapshot;
|
|
|
|
endpoint = "/zfs/snapshot";
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("already exists")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async SnapshotDelete(snapshotName, data = {}) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
const zb = await this.getZetabyte();
|
|
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/zfs/snapshot/id/${encodeURIComponent(snapshotName)}`;
|
|
response = await httpClient.delete(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
if (response.statusCode == 404) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("not found")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async CloneCreate(snapshotName, datasetName, data = {}) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
const zb = await this.getZetabyte();
|
|
|
|
let response;
|
|
let endpoint;
|
|
|
|
data.snapshot = snapshotName;
|
|
data.dataset_dst = datasetName;
|
|
|
|
endpoint = "/zfs/snapshot/clone";
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
response.statusCode == 422 &&
|
|
JSON.stringify(response.body).includes("already exists")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
// get all dataset snapshots
|
|
// https://github.com/truenas/middleware/pull/6934
|
|
// then use core.bulk to delete all
|
|
|
|
/**
|
|
*
|
|
* /usr/lib/python3/dist-packages/middlewared/plugins/replication.py
|
|
* readonly enum=["SET", "REQUIRE", "IGNORE"]
|
|
*
|
|
* @param {*} data
|
|
* @returns
|
|
*/
|
|
async ReplicationRunOnetime(data) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = "/replication/run_onetime";
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
// 200 means the 'job' was accepted only
|
|
// must continue to check the status of the job to know when it has finished and if it was successful
|
|
// /core/get_jobs [["id", "=", jobidhere]]
|
|
if (response.statusCode == 200) {
|
|
return response.body;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
async CoreWaitForJob(job_id, timeout = 0) {
|
|
if (!job_id) {
|
|
throw new Error("invalid job_id");
|
|
}
|
|
|
|
const startTime = Date.now() / 1000;
|
|
let currentTime;
|
|
|
|
let job;
|
|
|
|
// wait for job to finish
|
|
while (!job || !["SUCCESS", "ABORTED", "FAILED"].includes(job.state)) {
|
|
job = await this.CoreGetJobs({ id: job_id });
|
|
job = job[0];
|
|
await sleep(3000);
|
|
|
|
currentTime = Date.now() / 1000;
|
|
if (timeout > 0 && currentTime > startTime + timeout) {
|
|
throw new Error("timeout waiting for job to complete");
|
|
}
|
|
}
|
|
|
|
return job;
|
|
}
|
|
|
|
async CoreGetJobs(data) {
|
|
const httpClient = await this.getHttpClient(false);
|
|
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = "/core/get_jobs";
|
|
response = await httpClient.get(endpoint, data);
|
|
|
|
// 200 means the 'job' was accepted only
|
|
// must continue to check the status of the job to know when it has finished and if it was successful
|
|
// /core/get_jobs [["id", "=", jobidhere]]
|
|
// state = SUCCESS/ABORTED/FAILED means finality has been reached
|
|
// state = RUNNING
|
|
if (response.statusCode == 200) {
|
|
return response.body;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} data
|
|
*/
|
|
async FilesystemSetperm(data) {
|
|
/*
|
|
{
|
|
"path": "string",
|
|
"mode": "string",
|
|
"uid": 0,
|
|
"gid": 0,
|
|
"options": {
|
|
"stripacl": false,
|
|
"recursive": false,
|
|
"traverse": false
|
|
}
|
|
}
|
|
*/
|
|
|
|
const httpClient = await this.getHttpClient(false);
|
|
let response;
|
|
let endpoint;
|
|
|
|
endpoint = `/filesystem/setperm`;
|
|
response = await httpClient.post(endpoint, data);
|
|
|
|
if (response.statusCode == 200) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(JSON.stringify(response.body));
|
|
}
|
|
}
|
|
|
|
function IsJsonString(str) {
|
|
try {
|
|
JSON.parse(str);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
module.exports.Api = Api;
|