diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4719e6f..8914139 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -115,7 +115,7 @@ jobs: SYNOLOGY_PASSWORD: ${{ secrets.SANITY_SYNOLOGY_PASSWORD }} SYNOLOGY_VOLUME: ${{ secrets.SANITY_SYNOLOGY_VOLUME }} - csi-sanity-truenas-scale-25_10: + csi-sanity-truenas-api-26: needs: - build-npm-linux-amd64 strategy: @@ -123,11 +123,11 @@ jobs: max-parallel: 1 matrix: config: - - truenas/scale/25.10/scale-iscsi.yaml - - truenas/scale/25.10/scale-nfs.yaml - - truenas/scale/25.10/scale-nvmeof.yaml + - truenas/api/26/iscsi.yaml + - truenas/api/26/nfs.yaml + - truenas/api/26/nvmeof.yaml # 80 char limit - - truenas/scale/25.10/scale-smb.yaml + - truenas/api/26/smb.yaml runs-on: - self-hosted - Linux @@ -150,7 +150,7 @@ jobs: TRUENAS_PASSWORD: ${{ secrets.SANITY_TRUENAS_PASSWORD }} # ssh-based drivers - csi-sanity-truenas-core-13_0: + csi-sanity-truenas-ssh-26: needs: - build-npm-linux-amd64 strategy: @@ -158,10 +158,11 @@ jobs: max-parallel: 1 matrix: config: - - truenas/core/13.0/core-iscsi.yaml - - truenas/core/13.0/core-nfs.yaml + - truenas/ssh/26/iscsi.yaml + - truenas/ssh/26/nfs.yaml + - truenas/ssh/26/nvmeof.yaml # 80 char limit - - truenas/core/13.0/core-smb.yaml + - truenas/ssh/26/smb.yaml runs-on: - self-hosted - Linux @@ -179,7 +180,8 @@ jobs: ci/bin/run.sh env: TEMPLATE_CONFIG_FILE: "./ci/configs/${{ matrix.config }}" - TRUENAS_HOST: ${{ secrets.SANITY_TRUENAS_CORE_13_0_HOST }} + # TRUENAS_HOST: ${{ secrets.SANITY_TRUENAS_CORE_13_0_HOST }} + TRUENAS_HOST: ${{ secrets.SANITY_TRUENAS_SCALE_HOST }} TRUENAS_USERNAME: ${{ secrets.SANITY_TRUENAS_USERNAME }} TRUENAS_PASSWORD: ${{ secrets.SANITY_TRUENAS_PASSWORD }} @@ -465,8 +467,8 @@ jobs: - determine-image-tag - csi-sanity-synology-dsm6 - csi-sanity-synology-dsm7 - - csi-sanity-truenas-scale-25_10 - - csi-sanity-truenas-core-13_0 + - csi-sanity-truenas-api-26 + - csi-sanity-truenas-ssh-26 - csi-sanity-zfs-generic-targetcli - csi-sanity-zfs-generic-pcs - csi-sanity-objectivefs @@ -507,8 +509,8 @@ jobs: needs: - csi-sanity-synology-dsm6 - csi-sanity-synology-dsm7 - - csi-sanity-truenas-scale-25_10 - - csi-sanity-truenas-core-13_0 + - csi-sanity-truenas-api-26 + - csi-sanity-truenas-ssh-26 - csi-sanity-zfs-generic-targetcli - csi-sanity-zfs-generic-pcs - csi-sanity-objectivefs diff --git a/Dockerfile b/Dockerfile index 6819fa0..d906396 100644 --- a/Dockerfile +++ b/Dockerfile @@ -147,7 +147,7 @@ RUN chmod +x /usr/local/sbin/ctr-installer.sh && ctr-installer.sh # rm -rf /var/lib/apt/lists/* # install objectivefs -ARG OBJECTIVEFS_VERSION=7.3 +ARG OBJECTIVEFS_VERSION=7.5 ADD docker/objectivefs-installer.sh /usr/local/sbin RUN chmod +x /usr/local/sbin/objectivefs-installer.sh && objectivefs-installer.sh diff --git a/Dockerfile.Windows b/Dockerfile.Windows index caf8ed6..86843a8 100644 --- a/Dockerfile.Windows +++ b/Dockerfile.Windows @@ -100,7 +100,7 @@ LABEL org.opencontainers.image.licenses MIT COPY --from=build /app /app WORKDIR /app -ADD https://github.com/democratic-csi/democratic-csi/releases/download/v1.0.0/ctr-v2.0.4-windows-amd64.exe ./bin/ctr.exe +ADD https://github.com/democratic-csi/democratic-csi/releases/download/v1.0.1/ctr-v2.0.4-windows-amd64.exe ./bin/ctr.exe COPY --from=build /nodejs/node.exe ./bin COPY --from=build /usr/local/bin/ ./bin diff --git a/ci/configs/truenas/ssh/26/nvmeof.yaml b/ci/configs/truenas/ssh/26/nvmeof.yaml new file mode 100644 index 0000000..c122bd8 --- /dev/null +++ b/ci/configs/truenas/ssh/26/nvmeof.yaml @@ -0,0 +1,32 @@ +driver: freenas-nvmeof + +httpConnection: + protocol: http + host: ${TRUENAS_HOST} + port: 80 + #apiKey: + username: ${TRUENAS_USERNAME} + password: ${TRUENAS_PASSWORD} + +zfs: + datasetParentName: tank/ci/${CI_BUILD_KEY}/v + detachedSnapshotsDatasetParentName: tank/ci/${CI_BUILD_KEY}/s + + zvolCompression: + zvolDedup: + zvolEnableReservation: false + zvolBlocksize: + +nvmeof: + transports: + - tcp://${TRUENAS_HOST}:4420 + namePrefix: "csi-ci-${CI_BUILD_KEY}-" + ports: + - 1 + +# https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 +_private: + csi: + volume: + idHash: + strategy: crc16 diff --git a/docker/ctr-installer.sh b/docker/ctr-installer.sh index 2acd09b..9b978d5 100755 --- a/docker/ctr-installer.sh +++ b/docker/ctr-installer.sh @@ -6,36 +6,36 @@ set -x PLATFORM_TYPE=${1} if [[ "${PLATFORM_TYPE}" == "build" ]]; then - PLATFORM=$BUILDPLATFORM + PLATFORM=$BUILDPLATFORM else - PLATFORM=$TARGETPLATFORM + PLATFORM=$TARGETPLATFORM fi if [[ "x${PLATFORM}" == "x" ]]; then - PLATFORM="linux/amd64" + PLATFORM="linux/amd64" fi # these come from the --platform option of buildx, indirectly from DOCKER_BUILD_PLATFORM in main.yaml # linux/amd64,linux/arm64,linux/arm/v7,linux/s390x,linux/ppc64le if [ "$PLATFORM" = "linux/amd64" ]; then - export PLATFORM_ARCH="amd64" + export PLATFORM_ARCH="amd64" elif [ "$PLATFORM" = "linux/arm64" ]; then - export PLATFORM_ARCH="arm64" + export PLATFORM_ARCH="arm64" elif [ "$PLATFORM" = "linux/arm/v7" ]; then - export PLATFORM_ARCH="arm" + export PLATFORM_ARCH="arm" elif [ "$PLATFORM" = "linux/s390x" ]; then - export PLATFORM_ARCH="s390x" + export PLATFORM_ARCH="s390x" elif [ "$PLATFORM" = "linux/ppc64le" ]; then - export PLATFORM_ARCH="ppc64le" + export PLATFORM_ARCH="ppc64le" else - echo "unsupported/unknown ctr PLATFORM ${PLATFORM}" - exit 0 + echo "unsupported/unknown ctr PLATFORM ${PLATFORM}" + exit 0 fi echo "I am installing ctr $CTR_VERSION" export CTR_FILE="ctr-${CTR_VERSION}-linux-${PLATFORM_ARCH}" -wget -O "${CTR_FILE}" "https://github.com/democratic-csi/democratic-csi/releases/download/v1.0.0/${CTR_FILE}" +wget -O "${CTR_FILE}" "https://github.com/democratic-csi/democratic-csi/releases/download/v1.0.1/${CTR_FILE}" mv ${CTR_FILE} /usr/local/bin/ctr chown root:root /usr/local/bin/ctr diff --git a/src/driver/freenas/api.js b/src/driver/freenas/api.js index 7eb94a3..1fe4291 100644 --- a/src/driver/freenas/api.js +++ b/src/driver/freenas/api.js @@ -1,36 +1,18 @@ const _ = require("lodash"); -const { GrpcError, grpc } = require("../../utils/grpc"); -const { CsiBaseDriver } = require("../index"); -const HttpClient = require("./http").Client; -const TrueNASApiClient = require("./http/api").Api; -const { Zetabyte } = require("../../utils/zfs"); -const GeneralUtils = require("../../utils/general"); - const Handlebars = require("handlebars"); const uuidv4 = require("uuid").v4; const semver = require("semver"); -// freenas properties -const FREENAS_NFS_SHARE_PROPERTY_NAME = "democratic-csi:freenas_nfs_share_id"; -const FREENAS_SMB_SHARE_PROPERTY_NAME = "democratic-csi:freenas_smb_share_id"; +const { GrpcError, grpc } = require("../../utils/grpc"); +const { CsiBaseDriver } = require("../index"); +const WebSocketClient = require("./websocket").Client; +const TrueNASWebSocketApiClient = require("./websocket/api").Api; +const FreeNASApiShareHelper = + require("./websocket/share").FreeNASApiShareHelper; +const { Zetabyte } = require("../../utils/zfs"); +const GeneralUtils = require("../../utils/general"); -// iscsi -const FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_target_id"; -const FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_extent_id"; -const FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_targettoextent_id"; -const FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_assets_name"; -// nvmeof -const FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_subsystem_id"; -const FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_namespace_id"; -const FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_assets_name"; // zfs common properties const MANAGED_PROPERTY_NAME = "democratic-csi:managed_resource"; @@ -55,6 +37,11 @@ const VOLUME_CONTEXT_PROVISIONER_INSTANCE_ID_PROPERTY_NAME = const __REGISTRY_NS__ = "FreeNASApiDriver"; +const { + ERROR_DATASET_DOES_NOT_EXIST_REGEX, + ERROR_SNAPSHOT_DOES_NOT_EXIST_REGEX, +} = require("./websocket/api"); + class FreeNASApiDriver extends CsiBaseDriver { constructor(ctx, options) { super(...arguments); @@ -113,13 +100,13 @@ class FreeNASApiDriver extends CsiBaseDriver { if (semver.satisfies(this.ctx.csiVersion, ">=1.3.0")) { options.service.controller.capabilities.rpc.push( //"VOLUME_CONDITION", - "GET_VOLUME" + "GET_VOLUME", ); } if (semver.satisfies(this.ctx.csiVersion, ">=1.5.0")) { options.service.controller.capabilities.rpc.push( - "SINGLE_NODE_MULTI_WRITER" + "SINGLE_NODE_MULTI_WRITER", ); } } @@ -170,7 +157,7 @@ class FreeNASApiDriver extends CsiBaseDriver { executor: { spawn: function () { throw new Error( - "cannot use the zb implementation to execute zfs commands, must use the http api" + "cannot use the zb implementation to execute zfs commands, must use the http api", ); }, }, @@ -178,49 +165,6 @@ class FreeNASApiDriver extends CsiBaseDriver { }); } - /** - * Check if an error response indicates a target already exists. - * This method handles variations in TrueNAS API error messages across different API versions. - * - * @param {string|Object} responseBody - The HTTP response body (string or object) - * @returns {boolean} - true if the error indicates target already exists - */ - isTargetAlreadyExistsError(responseBody) { - // Extract error message more efficiently - let errorString = ""; - - if (typeof responseBody === "string") { - errorString = responseBody; - } else if (responseBody && typeof responseBody === "object") { - // Try common error message fields first to avoid full JSON.stringify - errorString = - responseBody.message || - responseBody.error || - responseBody.detail || - JSON.stringify(responseBody); - } else { - return false; - } - - // Handle multiple variations of the target already exists error message - const targetExistsPatterns = [ - "Target name already exists", // Original pattern in code (API v1) - "Target with this name already exists", // Actual TrueNAS error message (API v2) - "Target\\b.*\\balready\\b.*\\bexists", // Flexible pattern with word boundaries - ]; - - return targetExistsPatterns.some((pattern) => { - if (pattern.includes("\\")) { - // Use regex for flexible patterns with word boundaries - const regex = new RegExp(pattern, "i"); - return regex.test(errorString); - } else { - // Use case-insensitive simple string matching for exact patterns - return errorString.toLowerCase().includes(pattern.toLowerCase()); - } - }); - } - /** * should create any necessary share resources * should set the SHARE_VOLUME_CONTEXT_PROPERTY_NAME propery @@ -229,1971 +173,14 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async createShare(call, datasetName) { const driver = this; - const driverShareType = this.getDriverShareType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - const apiVersion = httpClient.getApiVersion(); - const zb = await this.getZetabyte(); - const truenasVersion = await httpApiClient.getSystemVersionSemver(); - - if (!truenasVersion) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unable to detect TrueNAS version` - ); - } - - const isScale = await httpApiClient.getIsScale(); - - let volume_context; - let properties; - let endpoint; - let response; - let share = {}; - - switch (driverShareType) { - case "nfs": - { - properties = await httpApiClient.DatasetGet(datasetName, [ - "mountpoint", - FREENAS_NFS_SHARE_PROPERTY_NAME, - ]); - this.ctx.logger.debug("zfs props data: %j", properties); - - // create nfs share - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value - ) - ) { - let nfsShareComment; - if (this.options.nfs.shareCommentTemplate) { - nfsShareComment = Handlebars.compile( - this.options.nfs.shareCommentTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - csi: { - name: this.ctx.args.csiName, - version: this.ctx.args.csiVersion, - }, - zfs: { - datasetName: datasetName, - }, - }); - } else { - nfsShareComment = `democratic-csi (${this.ctx.args.csiName}): ${datasetName}`; - } - - switch (apiVersion) { - case 1: - case 2: - switch (apiVersion) { - case 1: - share = { - nfs_paths: [properties.mountpoint.value], - nfs_comment: nfsShareComment || "", - nfs_network: - this.options.nfs.shareAllowedNetworks.join(","), - nfs_hosts: this.options.nfs.shareAllowedHosts.join(","), - nfs_alldirs: this.options.nfs.shareAlldirs, - nfs_ro: false, - nfs_quiet: false, - nfs_maproot_user: this.options.nfs.shareMaprootUser, - nfs_maproot_group: this.options.nfs.shareMaprootGroup, - nfs_mapall_user: this.options.nfs.shareMapallUser, - nfs_mapall_group: this.options.nfs.shareMapallGroup, - nfs_security: [], - }; - break; - case 2: - share = { - paths: [properties.mountpoint.value], - comment: nfsShareComment || "", - networks: this.options.nfs.shareAllowedNetworks, - hosts: this.options.nfs.shareAllowedHosts, - alldirs: this.options.nfs.shareAlldirs, - ro: false, - quiet: false, - maproot_user: this.options.nfs.shareMaprootUser, - maproot_group: this.options.nfs.shareMaprootGroup, - mapall_user: this.options.nfs.shareMapallUser, - mapall_group: this.options.nfs.shareMapallGroup, - security: [], - }; - break; - } - - if (isScale && semver.satisfies(truenasVersion, ">=23.10")) { - delete share.quiet; - delete share.nfs_quiet; - } - - if (isScale && semver.satisfies(truenasVersion, ">=22.12")) { - share.path = share.paths[0]; - delete share.paths; - delete share.alldirs; - } - - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.post("/sharing/nfs", share); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - /** - * v1 = 201 - * v2 = 200 - */ - if ([200, 201].includes(response.statusCode)) { - let sharePaths; - switch (apiVersion) { - case 1: - sharePaths = response.body.nfs_paths; - break; - case 2: - if (response.body.path) { - sharePaths = [response.body.path]; - } else { - sharePaths = response.body.paths; - } - break; - } - - // FreeNAS responding with bad data - if ( - !Array.isArray(sharePaths) || - !sharePaths.includes(properties.mountpoint.value) - ) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - //set zfs property - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_NFS_SHARE_PROPERTY_NAME]: response.body.id, - }); - } else { - /** - * v1 = 409 - * v2 = 422 - */ - if ( - [409, 422].includes(response.statusCode) && - (JSON.stringify(response.body).includes( - "You can't share same filesystem with all hosts twice." - ) || - JSON.stringify(response.body).includes( - "Another NFS share already exports this dataset for some network" - )) - ) { - let lookupShare = - await httpApiClient.findResourceByProperties( - "/sharing/nfs", - (item) => { - if ( - (item.nfs_paths && - item.nfs_paths.includes( - properties.mountpoint.value - )) || - (item.paths && - item.paths.includes( - properties.mountpoint.value - )) || - (item.path && - item.path == properties.mountpoint.value) - ) { - return true; - } - return false; - } - ); - - if (!lookupShare) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS failed to find matching share` - ); - } - - //set zfs property - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_NFS_SHARE_PROPERTY_NAME]: lookupShare.id, - }); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating nfs share - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - volume_context = { - node_attach_driver: "nfs", - server: this.options.nfs.shareHost, - share: properties.mountpoint.value, - }; - return volume_context; - } - break; - /** - * TODO: smb need to be more defensive like iscsi and nfs - * ensuring the path is valid and the shareName - */ - case "smb": - { - properties = await httpApiClient.DatasetGet(datasetName, [ - "mountpoint", - FREENAS_SMB_SHARE_PROPERTY_NAME, - ]); - this.ctx.logger.debug("zfs props data: %j", properties); - - let smbName; - - if (this.options.smb.nameTemplate) { - smbName = Handlebars.compile(this.options.smb.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - smbName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.smb.namePrefix) { - smbName = this.options.smb.namePrefix + smbName; - } - - if (this.options.smb.nameSuffix) { - smbName += this.options.smb.nameSuffix; - } - - smbName = smbName.toLowerCase(); - - this.ctx.logger.info( - "FreeNAS creating smb share with name: " + smbName - ); - - // create smb share - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value - ) - ) { - /** - * The only required parameters are: - * - path - * - name - * - * Note that over time it appears the list of available parameters has increased - * so in an effort to best support old versions of FreeNAS we should check the - * presense of each parameter in the config and set the corresponding parameter in - * the API request *only* if present in the config. - */ - switch (apiVersion) { - case 1: - case 2: - share = { - name: smbName, - path: properties.mountpoint.value, - }; - - let propertyMapping = { - shareAuxiliaryConfigurationTemplate: "auxsmbconf", - shareHome: "home", - shareAllowedHosts: "hostsallow", - shareDeniedHosts: "hostsdeny", - shareDefaultPermissions: "default_permissions", - shareGuestOk: "guestok", - shareGuestOnly: "guestonly", - shareShowHiddenFiles: "showhiddenfiles", - shareRecycleBin: "recyclebin", - shareBrowsable: "browsable", - shareAccessBasedEnumeration: "abe", - shareTimeMachine: "timemachine", - shareStorageTask: "storage_task", - }; - - for (const key in propertyMapping) { - if (this.options.smb.hasOwnProperty(key)) { - let value; - switch (key) { - case "shareAuxiliaryConfigurationTemplate": - value = Handlebars.compile( - this.options.smb.shareAuxiliaryConfigurationTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - }); - break; - default: - value = this.options.smb[key]; - break; - } - share[propertyMapping[key]] = value; - } - } - - if (isScale && semver.satisfies(truenasVersion, ">=25.10")) { - let topLevelProperties = [ - "purpose", - "name", - "path", - "enabled", - "comment", - "readonly", - "browsable", - "access_based_share_enumeration", - "audit", - ]; - let disallowedOptions = ["abe"]; - share.purpose = "LEGACY_SHARE"; - share.options = { - purpose: "LEGACY_SHARE", - }; - for (const key in share) { - switch (key) { - case "options": - // ignore - break; - default: - if (!topLevelProperties.includes(key)) { - if (!disallowedOptions.includes(key)) { - share.options[key] = share[key]; - } - delete share[key]; - } - break; - } - } - } - - switch (apiVersion) { - case 1: - endpoint = "/sharing/cifs"; - - // rename keys with cifs_ prefix - for (const key in share) { - share["cifs_" + key] = share[key]; - delete share[key]; - } - - // convert to comma-separated list - if (share.cifs_hostsallow) { - share.cifs_hostsallow = share.cifs_hostsallow.join(","); - } - - // convert to comma-separated list - if (share.cifs_hostsdeny) { - share.cifs_hostsdeny = share.cifs_hostsdeny.join(","); - } - break; - case 2: - endpoint = "/sharing/smb"; - break; - } - - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.post(endpoint, share); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - /** - * v1 = 201 - * v2 = 200 - */ - if ([200, 201].includes(response.statusCode)) { - share = response.body; - let sharePath; - let shareName; - switch (apiVersion) { - case 1: - sharePath = response.body.cifs_path; - shareName = response.body.cifs_name; - break; - case 2: - sharePath = response.body.path; - shareName = response.body.name; - break; - } - - if (shareName != smbName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - if (sharePath != properties.mountpoint.value) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - //set zfs property - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_SMB_SHARE_PROPERTY_NAME]: response.body.id, - }); - } else { - /** - * v1 = 409 - * v2 = 422 - */ - if ( - [409, 422].includes(response.statusCode) && - JSON.stringify(response.body).includes( - "A share with this name already exists." - ) - ) { - let lookupShare = - await httpApiClient.findResourceByProperties( - endpoint, - (item) => { - if ( - (item.cifs_path && - item.cifs_path == properties.mountpoint.value && - item.cifs_name && - item.cifs_name == smbName) || - (item.path && - item.path == properties.mountpoint.value && - item.name && - item.name == smbName) - ) { - return true; - } - return false; - } - ); - - if (!lookupShare) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS failed to find matching share` - ); - } - - //set zfs property - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_SMB_SHARE_PROPERTY_NAME]: lookupShare.id, - }); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating smb share - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - volume_context = { - node_attach_driver: "smb", - server: this.options.smb.shareHost, - share: smbName, - }; - return volume_context; - } - break; - case "iscsi": - { - properties = await httpApiClient.DatasetGet(datasetName, [ - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, - ]); - this.ctx.logger.debug("zfs props data: %j", properties); - - let basename; - let iscsiName; - - if (this.options.iscsi.nameTemplate) { - iscsiName = Handlebars.compile(this.options.iscsi.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - iscsiName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.iscsi.namePrefix) { - iscsiName = this.options.iscsi.namePrefix + iscsiName; - } - - if (this.options.iscsi.nameSuffix) { - iscsiName += this.options.iscsi.nameSuffix; - } - - // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' - // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name - // https://tools.ietf.org/html/rfc3720 - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - iscsiName = iscsiName.toLowerCase(); - - let extentDiskName = "zvol/" + datasetName; - let maxZvolNameLength = await driver.getMaxZvolNameLength(); - driver.ctx.logger.debug( - "max zvol name length: %s", - maxZvolNameLength - ); - - /** - * limit is a FreeBSD limitation - * https://www.ixsystems.com/documentation/freenas/11.2-U5/storage.html#zfs-zvol-config-opts-tab - */ - if (extentDiskName.length > maxZvolNameLength) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `extent disk name cannot exceed ${maxZvolNameLength} characters: ${extentDiskName}` - ); - } - - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - if (isScale && iscsiName.length > 64) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `extent name cannot exceed 64 characters: ${iscsiName}` - ); - } - - this.ctx.logger.info( - "FreeNAS creating iscsi assets with name: " + iscsiName - ); - - let extentComment; - if (this.options.iscsi.extentCommentTemplate) { - extentComment = Handlebars.compile( - this.options.iscsi.extentCommentTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - csi: { - name: this.ctx.args.csiName, - version: this.ctx.args.csiVersion, - }, - zfs: { - datasetName: datasetName, - }, - }); - } else { - extentComment = ""; - } - - const extentInsecureTpc = this.options.iscsi.hasOwnProperty( - "extentInsecureTpc" - ) - ? this.options.iscsi.extentInsecureTpc - : true; - - const extentXenCompat = this.options.iscsi.hasOwnProperty( - "extentXenCompat" - ) - ? this.options.iscsi.extentXenCompat - : false; - - const extentBlocksize = this.options.iscsi.hasOwnProperty( - "extentBlocksize" - ) - ? this.options.iscsi.extentBlocksize - : 512; - - const extentDisablePhysicalBlocksize = - this.options.iscsi.hasOwnProperty("extentDisablePhysicalBlocksize") - ? this.options.iscsi.extentDisablePhysicalBlocksize - : true; - - const extentRpm = this.options.iscsi.hasOwnProperty("extentRpm") - ? this.options.iscsi.extentRpm - : "SSD"; - - let extentAvailThreshold = this.options.iscsi.hasOwnProperty( - "extentAvailThreshold" - ) - ? Number(this.options.iscsi.extentAvailThreshold) - : null; - - if (!(extentAvailThreshold > 0 && extentAvailThreshold <= 100)) { - extentAvailThreshold = null; - } - - switch (apiVersion) { - case 1: - response = await httpClient.get( - "/services/iscsi/globalconfiguration" - ); - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error getting iscsi configuration - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - basename = response.body.iscsi_basename; - this.ctx.logger.verbose("FreeNAS ISCSI BASENAME: " + basename); - break; - case 2: - response = await httpClient.get("/iscsi/global"); - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error getting iscsi configuration - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - basename = response.body.basename; - this.ctx.logger.verbose("FreeNAS ISCSI BASENAME: " + basename); - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - - // if we got all the way to the TARGETTOEXTENT then we fully finished - // otherwise we must do all assets every time due to the interdependence of IDs etc - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME].value - ) - ) { - switch (apiVersion) { - case 1: { - // create target - let target = { - iscsi_target_name: iscsiName, - iscsi_target_alias: "", // TODO: allow template for this - }; - - response = await httpClient.post( - "/services/iscsi/target", - target - ); - - // 409 Conflict - target already exists or other validation errors - if (response.statusCode != 201) { - target = null; - if ( - response.statusCode == 409 && - this.isTargetAlreadyExistsError(response.body) - ) { - this.ctx.logger.debug( - "iSCSI target already exists, attempting to find existing target with name: %s", - iscsiName - ); - target = await httpApiClient.findResourceByProperties( - "/services/iscsi/target", - { - iscsi_target_name: iscsiName, - } - ); - if (target) { - this.ctx.logger.debug( - "Found existing iSCSI target with ID: %s, name: %s", - target.id, - target.iscsi_target_name - ); - } - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi target - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - target = response.body; - } - - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.iscsi_target_name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI TARGET: %j", target); - - // set target.id on zvol - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME]: target.id, - }); - - // create targetgroup(s) - // targetgroups do have IDs - for (let targetGroupConfig of this.options.iscsi.targetGroups) { - let targetGroup = { - iscsi_target: target.id, - iscsi_target_authgroup: - targetGroupConfig.targetGroupAuthGroup, - iscsi_target_authtype: targetGroupConfig.targetGroupAuthType - ? targetGroupConfig.targetGroupAuthType - : "None", - iscsi_target_portalgroup: - targetGroupConfig.targetGroupPortalGroup, - iscsi_target_initiatorgroup: - targetGroupConfig.targetGroupInitiatorGroup, - iscsi_target_initialdigest: "Auto", - }; - response = await httpClient.post( - "/services/iscsi/targetgroup", - targetGroup - ); - - // 409 if invalid - if (response.statusCode != 201) { - targetGroup = null; - /** - * 404 gets returned with an unable to process response when the DB is corrupted (has invalid entries in essense) - * - * To resolve properly the DB should be cleaned up - * /usr/local/etc/rc.d/django stop - * /usr/local/etc/rc.d/nginx stop - * sqlite3 /data/freenas-v1.db - * - * // this deletes everything, probably not what you want - * // should have a better query to only find entries where associated assets no longer exist - * DELETE from services_iscsitargetgroups; - * - * /usr/local/etc/rc.d/django restart - * /usr/local/etc/rc.d/nginx restart - */ - if ( - response.statusCode == 404 || - (response.statusCode == 409 && - JSON.stringify(response.body).includes( - "cannot be duplicated on a target" - )) - ) { - targetGroup = - await httpApiClient.findResourceByProperties( - "/services/iscsi/targetgroup", - { - iscsi_target: target.id, - iscsi_target_portalgroup: - targetGroupConfig.targetGroupPortalGroup, - iscsi_target_initiatorgroup: - targetGroupConfig.targetGroupInitiatorGroup, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targetgroup - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetGroup = response.body; - } - - if (!targetGroup) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targetgroup` - ); - } - - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_GROUP: %j", - targetGroup - ); - } - - let extent = { - iscsi_target_extent_comment: extentComment, - iscsi_target_extent_type: "Disk", // Disk/File, after save Disk becomes "ZVOL" - iscsi_target_extent_name: iscsiName, - iscsi_target_extent_insecure_tpc: extentInsecureTpc, - //iscsi_target_extent_naa: "0x3822690834aae6c5", - iscsi_target_extent_disk: extentDiskName, - iscsi_target_extent_xen: extentXenCompat, - iscsi_target_extent_avail_threshold: extentAvailThreshold, - iscsi_target_extent_blocksize: Number(extentBlocksize), - iscsi_target_extent_pblocksize: - extentDisablePhysicalBlocksize, - iscsi_target_extent_rpm: isNaN(Number(extentRpm)) - ? "SSD" - : Number(extentRpm), - iscsi_target_extent_ro: false, - }; - response = await httpClient.post( - "/services/iscsi/extent", - extent - ); - - // 409 if invalid - if (response.statusCode != 201) { - extent = null; - if ( - response.statusCode == 409 && - JSON.stringify(response.body).includes( - "Extent name must be unique" - ) - ) { - extent = await httpApiClient.findResourceByProperties( - "/services/iscsi/extent", - { iscsi_target_extent_name: iscsiName } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi extent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - extent = response.body; - } - - if (!extent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi extent` - ); - } - - if (extent.iscsi_target_extent_name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi extent` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI EXTENT: %j", extent); - - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME]: extent.id, - }); - - // create targettoextent - let targetToExtent = { - iscsi_target: target.id, - iscsi_extent: extent.id, - iscsi_lunid: 0, - }; - response = await httpClient.post( - "/services/iscsi/targettoextent", - targetToExtent - ); - - // 409 if invalid - if (response.statusCode != 201) { - targetToExtent = null; - - // LUN ID is already being used for this target. - // Extent is already in this target. - if ( - response.statusCode == 409 && - (JSON.stringify(response.body).includes( - "Extent is already in this target." - ) || - JSON.stringify(response.body).includes( - "LUN ID is already being used for this target." - )) - ) { - targetToExtent = - await httpApiClient.findResourceByProperties( - "/services/iscsi/targettoextent", - { - iscsi_target: target.id, - iscsi_extent: extent.id, - iscsi_lunid: 0, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targettoextent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetToExtent = response.body; - } - - if (!targetToExtent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targettoextent` - ); - } - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_TO_EXTENT: %j", - targetToExtent - ); - - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME]: - targetToExtent.id, - }); - - break; - } - case 2: - // create target and targetgroup - //let targetId; - let targetGroups = []; - for (let targetGroupConfig of this.options.iscsi.targetGroups) { - targetGroups.push({ - portal: targetGroupConfig.targetGroupPortalGroup, - initiator: targetGroupConfig.targetGroupInitiatorGroup, - auth: - targetGroupConfig.targetGroupAuthGroup > 0 - ? targetGroupConfig.targetGroupAuthGroup - : null, - authmethod: - targetGroupConfig.targetGroupAuthType.length > 0 - ? targetGroupConfig.targetGroupAuthType - .toUpperCase() - .replace(" ", "_") - : "NONE", - }); - } - let target = { - name: iscsiName, - alias: null, // cannot send "" error: handler error - driver: FreeNASDriver method: CreateVolume error: {"name":"GrpcError","code":2,"message":"received error creating iscsi target - code: 422 body: {\"iscsi_target_create.alias\":[{\"message\":\"Alias already exists\",\"errno\":22}]}"} - mode: "ISCSI", - groups: targetGroups, - }; - - response = await httpClient.post("/iscsi/target", target); - - // 422 Unprocessable Entity - validation errors including duplicate targets - if (response.statusCode != 200) { - target = null; - if ( - response.statusCode == 422 && - this.isTargetAlreadyExistsError(response.body) - ) { - this.ctx.logger.debug( - "iSCSI target already exists, attempting to find existing target with name: %s", - iscsiName - ); - target = await httpApiClient.findResourceByProperties( - "/iscsi/target", - { - name: iscsiName, - } - ); - if (target) { - this.ctx.logger.debug( - "Found existing iSCSI target with ID: %s, name: %s", - target.id, - target.name - ); - } - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi target - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - target = response.body; - } - - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - // handle situations/race conditions where groups failed to be added/created on the target - // groups":[{"portal":1,"initiator":1,"auth":null,"authmethod":"NONE"},{"portal":2,"initiator":1,"auth":null,"authmethod":"NONE"}] - // TODO: this logic could be more intelligent but this should do for now as it appears in the failure scenario no groups are added - // in other words, I have never seen them invalid, only omitted so this should be enough - if (target.groups.length != targetGroups.length) { - response = await httpClient.put( - `/iscsi/target/id/${target.id}`, - { - groups: targetGroups, - } - ); - - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `failed setting target groups` - ); - } else { - target = response.body; - - // re-run sanity checks - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - if (target.groups.length != targetGroups.length) { - throw new GrpcError( - grpc.status.UNKNOWN, - `failed setting target groups` - ); - } - } - } - - this.ctx.logger.verbose("FreeNAS ISCSI TARGET: %j", target); - - // set target.id on zvol - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME]: target.id, - }); - - let extent = { - comment: extentComment, - type: "DISK", // Disk/File, after save Disk becomes "ZVOL" - name: iscsiName, - //iscsi_target_extent_naa: "0x3822690834aae6c5", - disk: extentDiskName, - insecure_tpc: extentInsecureTpc, - xen: extentXenCompat, - avail_threshold: extentAvailThreshold, - blocksize: Number(extentBlocksize), - pblocksize: extentDisablePhysicalBlocksize, - rpm: "" + extentRpm, // should be a string - ro: false, - }; - - response = await httpClient.post("/iscsi/extent", extent); - - // 409 if invalid - if (response.statusCode != 200) { - extent = null; - if ( - response.statusCode == 422 && - JSON.stringify(response.body).includes( - "Extent name must be unique" - ) - ) { - extent = await httpApiClient.findResourceByProperties( - "/iscsi/extent", - { - name: iscsiName, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi extent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - extent = response.body; - } - - if (!extent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi extent` - ); - } - - if (extent.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi extent` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI EXTENT: %j", extent); - - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME]: extent.id, - }); - - // create targettoextent - let targetToExtent = { - target: target.id, - extent: extent.id, - lunid: 0, - }; - response = await httpClient.post( - "/iscsi/targetextent", - targetToExtent - ); - - if (response.statusCode != 200) { - targetToExtent = null; - - // LUN ID is already being used for this target. - // Extent is already in this target. - if ( - response.statusCode == 422 && - (JSON.stringify(response.body).includes( - "Extent is already in this target." - ) || - JSON.stringify(response.body).includes( - "LUN ID is already being used for this target." - )) - ) { - targetToExtent = - await httpApiClient.findResourceByProperties( - "/iscsi/targetextent", - { - target: target.id, - extent: extent.id, - lunid: 0, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targetextent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetToExtent = response.body; - } - - if (!targetToExtent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targetextent` - ); - } - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_TO_EXTENT: %j", - targetToExtent - ); - - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME]: - targetToExtent.id, - }); - - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - // iqn = target - let iqn = basename + ":" + iscsiName; - this.ctx.logger.info("FreeNAS iqn: " + iqn); - - // store this off to make delete process more bullet proof - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME]: iscsiName, - }); - - volume_context = { - node_attach_driver: "iscsi", - portal: this.options.iscsi.targetPortal || "", - portals: this.options.iscsi.targetPortals - ? this.options.iscsi.targetPortals.join(",") - : "", - interface: this.options.iscsi.interface || "", - iqn: iqn, - lun: 0, - }; - return volume_context; - } - break; - - case "nvmeof": - { - switch (apiVersion) { - case 1: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with version 2 of the api` - ); - break; - } - - if (!isScale || semver.satisfies(truenasVersion, "<25.10")) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with TrueNAS version 25.10 and above` - ); - } - - properties = await httpApiClient.DatasetGet(datasetName, [ - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, - FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, - ]); - this.ctx.logger.debug("zfs props data: %j", properties); - - let nvmeofName; - - if (this.options.nvmeof.nameTemplate) { - nvmeofName = Handlebars.compile(this.options.nvmeof.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - nvmeofName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.nvmeof.namePrefix) { - nvmeofName = this.options.nvmeof.namePrefix + nvmeofName; - } - - if (this.options.nvmeof.nameSuffix) { - nvmeofName += this.options.nvmeof.nameSuffix; - } - - // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' - // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name - // https://tools.ietf.org/html/rfc3720 - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - nvmeofName = nvmeofName.toLowerCase(); - - let namespaceDiskName = "zvol/" + datasetName; - let maxZvolNameLength = await driver.getMaxZvolNameLength(); - driver.ctx.logger.debug( - "max zvol name length: %s", - maxZvolNameLength - ); - - if (namespaceDiskName.length > maxZvolNameLength) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `namespace disk name cannot exceed ${maxZvolNameLength} characters: ${namespaceDiskName}` - ); - } - - // TODO: get basenqn from global config, add nvemofName to it and ensure full nqn is <= 223 - // // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - // if (isScale && nvmeofName.length > 64) { - // throw new GrpcError( - // grpc.status.FAILED_PRECONDITION, - // `extent name cannot exceed 64 characters: ${nvmeofName}` - // ); - // } - - this.ctx.logger.info( - "FreeNAS creating nvmeof assets with name: " + nvmeofName - ); - - // http:///api/docs/current/api_methods_nvmet.subsys.create.html - let subsystemTemplate = _.get( - this.options, - "nvmeof.subsystemTemplate", - {} - ); - subsystemTemplate = subsystemTemplate || {}; - - // http:///api/docs/current/api_methods_nvmet.namespace.create.html - let namespaceTemplate = _.get( - this.options, - "nvmeof.namespaceTemplate", - {} - ); - namespaceTemplate = namespaceTemplate || {}; - - // create subsystem - let subsystem; - switch (apiVersion) { - case 2: - subsystem = await httpApiClient.NvmetSubsysCreate( - nvmeofName, - subsystemTemplate - ); - - break; - } - if (!subsystem) { - throw new GrpcError( - grpc.status.NOT_FOUND, - `unable to find nvmeof subsystem: ${nvmeofName}` - ); - } - this.ctx.logger.verbose("FreeNAS NVMEOF SUBSYSTEM: %j", subsystem); - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME]: subsystem.id, - }); - - // create subsystem - let namespace; - switch (apiVersion) { - case 2: - namespace = await httpApiClient.NvmetNamespaceCreate( - namespaceDiskName, - subsystem.id, - namespaceTemplate - ); - - break; - } - if (!namespace) { - throw new GrpcError( - grpc.status.NOT_FOUND, - `unable to find nvmeof namespace: ${namespaceDiskName}` - ); - } - this.ctx.logger.verbose("FreeNAS NVMEOF NAMESPACE: %j", namespace); - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME]: namespace.id, - }); - - // assign ports to subsystem - let ports = _.get(this.options, "nvmeof.ports", []); - for (const port_i of ports) { - const port = await httpApiClient.NvmetPortSubsysCreate( - port_i, - subsystem.id - ); - this.ctx.logger.verbose("FreeNAS NVMEOF PORT: %j", port); - } - - // TODO: assign hosts - - // store this off to make delete process more bullet proof - await httpApiClient.DatasetSet(datasetName, { - [FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME]: nvmeofName, - }); - - volume_context = { - node_attach_driver: "nvmeof", - transport: this.options.nvmeof.transport || "", - transports: this.options.nvmeof.transports - ? this.options.nvmeof.transports.join(",") - : "", - nqn: subsystem.subnqn, - nsid: namespace.nsid, - }; - return volume_context; - } - break; - - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown driverShareType ${driverShareType}` - ); - } + const helper = new FreeNASApiShareHelper(driver); + return helper.createShare(call, datasetName); } async deleteShare(call, datasetName) { - const driverShareType = this.getDriverShareType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - const apiVersion = httpClient.getApiVersion(); - const zb = await this.getZetabyte(); - const truenasVersion = await httpApiClient.getSystemVersionSemver(); - - if (!truenasVersion) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unable to detect TrueNAS version` - ); - } - - const isScale = await httpApiClient.getIsScale(); - - let properties; - let response; - let endpoint; - let shareId; - let deleteAsset; - let sharePaths; - - switch (driverShareType) { - case "nfs": - { - try { - properties = await httpApiClient.DatasetGet(datasetName, [ - "mountpoint", - FREENAS_NFS_SHARE_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - this.ctx.logger.debug("zfs props data: %j", properties); - - shareId = properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value; - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(shareId)) { - // remove nfs share - switch (apiVersion) { - case 1: - case 2: - endpoint = "/sharing/nfs/"; - if (apiVersion == 2) { - endpoint += "id/"; - } - endpoint += shareId; - - response = await httpClient.get(endpoint); - - // assume share is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - switch (apiVersion) { - case 1: - sharePaths = response.body.nfs_paths; - break; - case 2: - if (response.body.path) { - sharePaths = [response.body.path]; - } else { - sharePaths = response.body.paths; - } - break; - } - - deleteAsset = sharePaths.some((value) => { - return value == properties.mountpoint.value; - }); - - if (deleteAsset) { - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.delete(endpoint); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - // returns a 500 if does not exist - // v1 = 204 - // v2 = 200 - if (![200, 204].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting nfs share - share: ${shareId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_NFS_SHARE_PROPERTY_NAME - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - } - break; - case "smb": - { - try { - properties = await httpApiClient.DatasetGet(datasetName, [ - "mountpoint", - FREENAS_SMB_SHARE_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - this.ctx.logger.debug("zfs props data: %j", properties); - - shareId = properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value; - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(shareId)) { - // remove smb share - switch (apiVersion) { - case 1: - case 2: - switch (apiVersion) { - case 1: - endpoint = `/sharing/cifs/${shareId}`; - break; - case 2: - endpoint = `/sharing/smb/id/${shareId}`; - break; - } - - response = await httpClient.get(endpoint); - - // assume share is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - switch (apiVersion) { - case 1: - sharePaths = [response.body.cifs_path]; - break; - case 2: - sharePaths = [response.body.path]; - break; - } - - deleteAsset = sharePaths.some((value) => { - return value == properties.mountpoint.value; - }); - - if (deleteAsset) { - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.delete(endpoint); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - // returns a 500 if does not exist - // v1 = 204 - // v2 = 200 - if ( - ![200, 204].includes(response.statusCode) && - !JSON.stringify(response.body).includes("does not exist") - ) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting smb share - share: ${shareId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_SMB_SHARE_PROPERTY_NAME - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - } - break; - case "iscsi": - { - // Delete target - // NOTE: deleting a target inherently deletes associated targetgroup(s) and targettoextent(s) - - // Delete extent - try { - properties = await httpApiClient.DatasetGet(datasetName, [ - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - - this.ctx.logger.debug("zfs props data: %j", properties); - - let targetId = - properties[FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME].value; - let extentId = - properties[FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME].value; - let iscsiName = - properties[FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME].value; - let assetName; - - switch (apiVersion) { - case 1: - case 2: - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(targetId)) { - // https://jira.ixsystems.com/browse/NAS-103952 - - // v1 - /services/iscsi/target/{id}/ - // v2 - /iscsi/target/id/{id} - endpoint = ""; - if (apiVersion == 1) { - endpoint += "/services"; - } - endpoint += "/iscsi/target/"; - if (apiVersion == 2) { - endpoint += "id/"; - } - endpoint += targetId; - response = await httpClient.get(endpoint); - - // assume is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - deleteAsset = true; - assetName = null; - - // checking if set for backwards compatibility - if (zb.helpers.isPropertyValueSet(iscsiName)) { - switch (apiVersion) { - case 1: - assetName = response.body.iscsi_target_name; - break; - case 2: - assetName = response.body.name; - break; - } - - if (assetName != iscsiName) { - deleteAsset = false; - } - } - - if (deleteAsset) { - let retries = 0; - let maxRetries = 5; - let retryWait = 1000; - response = await httpClient.delete(endpoint); - - // sometimes after an initiator has detached it takes a moment for TrueNAS to settle - // code: 422 body: {\"message\":\"Target csi-ci-55877e95sanity-node-expand-volume-e54f81fa-cd38e798 is in use.\",\"errno\":14} - while ( - response.statusCode == 422 && - retries < maxRetries && - _.get(response, "body.message").includes("Target") && - _.get(response, "body.message").includes("is in use") && - _.get(response, "body.errno") == 14 - ) { - retries++; - this.ctx.logger.debug( - "target: %s is in use, retry %s shortly", - targetId, - retries - ); - await GeneralUtils.sleep(retryWait); - response = await httpClient.delete(endpoint); - } - - if (![200, 204, 404].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting iscsi target - target: ${targetId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME - ); - } else { - this.ctx.logger.debug( - "not deleting iscsitarget asset as it appears ID %s has been re-used: zfs name - %s, iscsitarget name - %s", - targetId, - iscsiName, - assetName - ); - } - } - } - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(extentId)) { - // v1 - /services/iscsi/targettoextent/{id}/ - // v2 - /iscsi/targetextent/id/{id} - if (apiVersion == 1) { - endpoint = "/services/iscsi/extent/"; - } else { - endpoint = "/iscsi/extent/id/"; - } - endpoint += extentId; - response = await httpClient.get(endpoint); - - // assume is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - deleteAsset = true; - assetName = null; - - // checking if set for backwards compatibility - if (zb.helpers.isPropertyValueSet(iscsiName)) { - switch (apiVersion) { - case 1: - assetName = response.body.iscsi_target_extent_name; - break; - case 2: - assetName = response.body.name; - break; - } - - if (assetName != iscsiName) { - deleteAsset = false; - } - } - - if (deleteAsset) { - response = await httpClient.delete(endpoint); - if (![200, 204, 404].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting iscsi extent - extent: ${extentId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME - ); - } else { - this.ctx.logger.debug( - "not deleting iscsiextent asset as it appears ID %s has been re-used: zfs name - %s, iscsiextent name - %s", - extentId, - iscsiName, - assetName - ); - } - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - break; - - case "nvmeof": - { - switch (apiVersion) { - case 1: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with version 2 of the api` - ); - break; - } - - if (!isScale || semver.satisfies(truenasVersion, "<25.10")) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with TrueNAS version 25.10 and above` - ); - } - - try { - properties = await httpApiClient.DatasetGet(datasetName, [ - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, - FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - this.ctx.logger.debug("zfs props data: %j", properties); - - let subsystemId = - properties[FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME].value; - let namespaceId = - properties[FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME].value; - - // remove namespace - if (zb.helpers.isPropertyValueSet(namespaceId)) { - await GeneralUtils.retry( - 15, - 2000, - async () => { - await httpApiClient.NvmetNamespaceDeleteById(namespaceId); - }, - { - retryCondition: (err) => { - return true; - }, - } - ); - - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME - ); - } - - // remove subsystem - if (zb.helpers.isPropertyValueSet(subsystemId)) { - await GeneralUtils.retry( - 15, - 2000, - async () => { - await httpApiClient.NvmetSubsysDeleteById(subsystemId, { - force: true, - }); - }, - { - retryCondition: (err) => { - return true; - }, - } - ); - - await httpApiClient.DatasetInherit( - datasetName, - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME - ); - } - } - break; - - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown driverShareType ${driverShareType}` - ); - } - } - - async removeSnapshotsFromDatatset(datasetName) { - const httpApiClient = await this.getTrueNASHttpApiClient(); - // const httpClient = await this.getHttpClient(); - // const major = await httpApiClient.getSystemVersionMajor(); - - let job_id = await httpApiClient.DatasetDestroySnapshots(datasetName); - if (job_id) { - await httpApiClient.CoreWaitForJob(job_id, 30); - } + const driver = this; + const helper = new FreeNASApiShareHelper(driver); + return helper.deleteShare(call, datasetName); } /** @@ -2204,44 +191,14 @@ class FreeNASApiDriver extends CsiBaseDriver { * @returns */ async expandVolume(call, datasetName) { - // TODO: fix me - return; - const driverShareType = this.getDriverShareType(); - const sshClient = this.getSshClient(); + const driver = this; + const helper = new FreeNASApiShareHelper(driver); + return helper.expandVolume(call, datasetName); + } - switch (driverShareType) { - case "iscsi": - const isScale = await this.getIsScale(); - let command; - let reload = false; - if (isScale) { - command = sshClient.buildCommand("systemctl", ["reload", "scst"]); - reload = true; - } else { - command = sshClient.buildCommand("/etc/rc.d/ctld", ["reload"]); - reload = true; - } - - if (reload) { - if ((await this.getWhoAmI()) != "root") { - command = (await this.getSudoPath()) + " " + command; - } - - this.ctx.logger.verbose( - "FreeNAS reloading iscsi daemon: %s", - command - ); - - let response = await sshClient.exec(command); - if (response.code != 0) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error reloading iscsi daemon: ${JSON.stringify(response)}` - ); - } - } - break; - } + async removeSnapshotsFromDatatset(datasetName) { + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); + return webSocketApiClient.DatasetDestroySnapshots(datasetName); } async getVolumeStatus(volume_id) { @@ -2259,7 +216,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( semver.satisfies(driver.ctx.csiVersion, ">=1.2.0") && driver.options.service.controller.capabilities.rpc.includes( - "LIST_VOLUMES_PUBLISHED_NODES" + "LIST_VOLUMES_PUBLISHED_NODES", ) ) { // TODO: let drivers fill this in @@ -2270,7 +227,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( semver.satisfies(driver.ctx.csiVersion, ">=1.3.0") && driver.options.service.controller.capabilities.rpc.includes( - "VOLUME_CONDITION" + "VOLUME_CONDITION", ) ) { // TODO: let drivers fill ths in @@ -2303,7 +260,7 @@ class FreeNASApiDriver extends CsiBaseDriver { let volume_context = JSON.parse(row[SHARE_VOLUME_CONTEXT_PROPERTY_NAME]); if ( zb.helpers.isPropertyValueSet( - row[VOLUME_CONTEXT_PROVISIONER_DRIVER_PROPERTY_NAME] + row[VOLUME_CONTEXT_PROVISIONER_DRIVER_PROPERTY_NAME], ) ) { volume_context["provisioner_driver"] = @@ -2312,7 +269,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( zb.helpers.isPropertyValueSet( - row[VOLUME_CONTEXT_PROVISIONER_INSTANCE_ID_PROPERTY_NAME] + row[VOLUME_CONTEXT_PROVISIONER_INSTANCE_ID_PROPERTY_NAME], ) ) { volume_context["provisioner_driver_instance_id"] = @@ -2321,7 +278,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( zb.helpers.isPropertyValueSet( - row[VOLUME_CONTENT_SOURCE_TYPE_PROPERTY_NAME] + row[VOLUME_CONTENT_SOURCE_TYPE_PROPERTY_NAME], ) ) { volume_content_source = {}; @@ -2414,13 +371,20 @@ class FreeNASApiDriver extends CsiBaseDriver { return datasetParentName; } - async getHttpClient() { - return this.ctx.registry.get(`${__REGISTRY_NS__}:http_client`, () => { - const client = new HttpClient(this.options.httpConnection); - client.logger = this.ctx.logger; - client.setApiVersion(2); // requires version 2 - return client; - }); + async getWebSocketClient() { + return this.ctx.registry.get( + `${__REGISTRY_NS__}:websocket_client`, + async () => { + const client = new WebSocketClient( + this.options.httpConnection, + this.ctx.logger, + ); + await client.connect(); + await client.waitauthenticated(); + + return client; + }, + ); } async getMinimumVolumeSize() { @@ -2431,13 +395,13 @@ class FreeNASApiDriver extends CsiBaseDriver { } } - async getTrueNASHttpApiClient() { + async getTrueNASWebSocketApiClient() { return this.ctx.registry.getAsync( - `${__REGISTRY_NS__}:api_client`, + `${__REGISTRY_NS__}:websocket_api_client`, async () => { - const httpClient = await this.getHttpClient(); - return new TrueNASApiClient(httpClient, this.ctx.cache); - } + const webSocketClient = await this.getWebSocketClient(); + return new TrueNASWebSocketApiClient(webSocketClient); + }, ); } @@ -2508,7 +472,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( !this.getAccessModes(capability).includes( - capability.access_mode.mode + capability.access_mode.mode, ) ) { message = `invalid access_mode, ${capability.access_mode.mode}`; @@ -2521,7 +485,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( capability.mount.fs_type && !GeneralUtils.default_supported_block_filesystems().includes( - capability.mount.fs_type + capability.mount.fs_type, ) ) { message = `invalid fs_type ${capability.mount.fs_type}`; @@ -2531,7 +495,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if ( !this.getAccessModes(capability).includes( - capability.access_mode.mode + capability.access_mode.mode, ) ) { message = `invalid access_mode, ${capability.access_mode.mode}`; @@ -2553,22 +517,10 @@ class FreeNASApiDriver extends CsiBaseDriver { * https://www.ixsystems.com/documentation/freenas/11.3-BETA1/intro.html#path-and-name-lengths */ async getMaxZvolNameLength() { - const driver = this; - const httpApiClient = await driver.getTrueNASHttpApiClient(); - // Linux is 255 (probably larger 4096) but scst may have a 255 limit // https://ngelinux.com/what-is-the-maximum-file-name-length-in-linux-and-how-to-see-this-is-this-really-255-characters-answer-is-no/ // https://github.com/dmeister/scst/blob/master/iscsi-scst/include/iscsi_scst.h#L28 - if (await httpApiClient.getIsScale()) { - return 255; - } - - let major = await httpApiClient.getSystemVersionMajor(); - if (parseInt(major) >= 13) { - return 255; - } else { - return 63; - } + return 255; } /** @@ -2581,9 +533,11 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async Probe(call) { const driver = this; - const httpApiClient = await driver.getTrueNASHttpApiClient(); if (driver.ctx.args.csiMode.includes("controller")) { + const webSocketClient = await driver.getWebSocketClient(); + const apiClient = await driver.getTrueNASWebSocketApiClient(); + let datasetParentName = this.getVolumeParentDatasetName() + "/"; let snapshotParentDatasetName = this.getDetachedSnapshotParentDatasetName() + "/"; @@ -2593,23 +547,33 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `datasetParentName and detachedSnapshotsDatasetParentName must not overlap` + `datasetParentName and detachedSnapshotsDatasetParentName must not overlap`, ); } try { - await httpApiClient.getSystemVersion(); + await webSocketClient.waitauthenticated(10 * 1000); } catch (err) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `TrueNAS api is unavailable: ${String(err)}` + `TrueNAS api is not authenticated: ${String(err)}`, ); } - if (!(await httpApiClient.getIsScale())) { + let version; + try { + version = await apiClient.getSystemVersionSemver(); + } catch (err) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `driver is only available with TrueNAS SCALE` + `TrueNAS api is unavailable: ${String(err)}`, + ); + } + + if (!semver.satisfies(version, ">=26")) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `driver is only available with TrueNAS version >=26`, ); } @@ -2633,7 +597,7 @@ class FreeNASApiDriver extends CsiBaseDriver { async CreateVolume(call) { const driver = this; const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let datasetParentName = this.getVolumeParentDatasetName(); @@ -2648,7 +612,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } @@ -2663,7 +627,7 @@ class FreeNASApiDriver extends CsiBaseDriver { } else { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - "missing volume_capabilities" + "missing volume_capabilities", ); } @@ -2685,7 +649,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.OUT_OF_RANGE, - `required_bytes is greather than limit_bytes` + `required_bytes is greather than limit_bytes`, ); } @@ -2697,7 +661,7 @@ class FreeNASApiDriver extends CsiBaseDriver { //should never happen, value must be set throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `volume capacity is required (either required_bytes or limit_bytes)` + `volume capacity is required (either required_bytes or limit_bytes)`, ); } @@ -2719,7 +683,7 @@ class FreeNASApiDriver extends CsiBaseDriver { //volume size must be a multiple of volume block size capacity_bytes = zb.helpers.generateZvolSize( capacity_bytes, - zvolBlocksize + zvolBlocksize, ); } @@ -2731,7 +695,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.OUT_OF_RANGE, - `required volume capacity is greater than limit` + `required volume capacity is greater than limit`, ); } @@ -2743,7 +707,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // ensure volumes with the same name being requested a 2nd time but with a different size fails try { - let properties = await httpApiClient.DatasetGet(datasetName, [ + let properties = await webSocketApiClient.DatasetGet(datasetName, [ "volsize", "refquota", ]); @@ -2757,7 +721,7 @@ class FreeNASApiDriver extends CsiBaseDriver { break; default: throw new Error( - `unknown zfs resource type: ${driverZfsResourceType}` + `unknown zfs resource type: ${driverZfsResourceType}`, ); } @@ -2784,12 +748,12 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.ALREADY_EXISTS, - `volume has already been created with a different size, existing size: ${size}, required_bytes: ${call.request.capacity_range.required_bytes}, limit_bytes: ${call.request.capacity_range.limit_bytes}` + `volume has already been created with a different size, existing size: ${size}, required_bytes: ${call.request.capacity_range.required_bytes}, limit_bytes: ${call.request.capacity_range.limit_bytes}`, ); } } } catch (err) { - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { // does NOT already exist } else { throw err; @@ -2809,12 +773,12 @@ class FreeNASApiDriver extends CsiBaseDriver { if (extentDiskName.length > maxZvolNameLength) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `extent disk name cannot exceed ${maxZvolNameLength} characters: ${extentDiskName}` + `extent disk name cannot exceed ${maxZvolNameLength} characters: ${extentDiskName}`, ); } } - let response, command; + let response; let volume_content_source_snapshot_id; let volume_content_source_volume_id; let fullSnapshotName; @@ -2870,8 +834,8 @@ class FreeNASApiDriver extends CsiBaseDriver { let tmpDetachedClone = JSON.parse( driver.getNormalizedParameterValue( call.request.parameters, - "detachedVolumesFromSnapshots" - ) + "detachedVolumesFromSnapshots", + ), ); if (typeof tmpDetachedClone === "boolean") { detachedClone = tmpDetachedClone; @@ -2901,15 +865,16 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!zb.helpers.isZfsSnapshot(volume_content_source_snapshot_id)) { try { - await httpApiClient.SnapshotCreate(fullSnapshotName); + await webSocketApiClient.SnapshotCreate(fullSnapshotName); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || + ERROR_SNAPSHOT_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.NOT_FOUND, - `snapshot source_snapshot_id ${volume_content_source_snapshot_id} does not exist` + `snapshot source_snapshot_id ${volume_content_source_snapshot_id} does not exist`, ); } @@ -2919,7 +884,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (detachedClone) { try { - response = await httpApiClient.ReplicationRunOnetime({ + response = await webSocketApiClient.ReplicationRunOnetime({ direction: "PUSH", transport: "LOCAL", source_datasets: [ @@ -2927,7 +892,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ], target_dataset: datasetName, name_regex: `^${zb.helpers.extractSnapshotName( - fullSnapshotName + fullSnapshotName, )}$`, recursive: false, retention_policy: "NONE", @@ -2940,15 +905,7 @@ class FreeNASApiDriver extends CsiBaseDriver { let job; // wait for job to finish - while ( - !job || - !["SUCCESS", "ABORTED", "FAILED"].includes(job.state) - ) { - job = await httpApiClient.CoreGetJobs({ id: job_id }); - job = job[0]; - await GeneralUtils.sleep(3000); - } - + job = await webSocketApiClient.CoreWaitForJob(job_id); job.error = job.error || ""; switch (job.state) { @@ -2961,15 +918,15 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!job.error.includes("already exists")) { throw new GrpcError( grpc.status.UNKNOWN, - `failed to run replication task (${job.state}): ${job.error}` + `failed to run replication task (${job.state}): ${job.error}`, ); } break; } - response = await httpApiClient.DatasetSet( + response = await webSocketApiClient.DatasetSet( datasetName, - volumeProperties + volumeProperties, ); } catch (err) { if ( @@ -2986,21 +943,21 @@ class FreeNASApiDriver extends CsiBaseDriver { await this.removeSnapshotsFromDatatset(datasetName); } else { try { - response = await httpApiClient.CloneCreate( + response = await webSocketApiClient.CloneCreate( fullSnapshotName, datasetName, { dataset_properties: volumeProperties, - } + }, ); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.NOT_FOUND, - "dataset does not exists" + "dataset does not exists", ); } @@ -3011,17 +968,17 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!zb.helpers.isZfsSnapshot(volume_content_source_snapshot_id)) { try { // schedule snapshot removal from source - await httpApiClient.SnapshotDelete(fullSnapshotName, { + await webSocketApiClient.SnapshotDelete(fullSnapshotName, { defer: true, }); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.NOT_FOUND, - `snapshot source_snapshot_id ${volume_content_source_snapshot_id} does not exist` + `snapshot source_snapshot_id ${volume_content_source_snapshot_id} does not exist`, ); } @@ -3037,8 +994,8 @@ class FreeNASApiDriver extends CsiBaseDriver { let tmpDetachedClone = JSON.parse( driver.getNormalizedParameterValue( call.request.parameters, - "detachedVolumesFromVolumes" - ) + "detachedVolumesFromVolumes", + ), ); if (typeof tmpDetachedClone === "boolean") { detachedClone = tmpDetachedClone; @@ -3062,15 +1019,16 @@ class FreeNASApiDriver extends CsiBaseDriver { // create snapshot try { - response = await httpApiClient.SnapshotCreate(fullSnapshotName); + response = + await webSocketApiClient.SnapshotCreate(fullSnapshotName); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.NOT_FOUND, - "dataset does not exists" + "dataset does not exists", ); } @@ -3079,7 +1037,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (detachedClone) { try { - response = await httpApiClient.ReplicationRunOnetime({ + response = await webSocketApiClient.ReplicationRunOnetime({ direction: "PUSH", transport: "LOCAL", source_datasets: [ @@ -3087,7 +1045,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ], target_dataset: datasetName, name_regex: `^${zb.helpers.extractSnapshotName( - fullSnapshotName + fullSnapshotName, )}$`, recursive: false, retention_policy: "NONE", @@ -3100,15 +1058,7 @@ class FreeNASApiDriver extends CsiBaseDriver { let job; // wait for job to finish - while ( - !job || - !["SUCCESS", "ABORTED", "FAILED"].includes(job.state) - ) { - job = await httpApiClient.CoreGetJobs({ id: job_id }); - job = job[0]; - await GeneralUtils.sleep(3000); - } - + job = await webSocketApiClient.CoreWaitForJob(job_id); job.error = job.error || ""; switch (job.state) { @@ -3121,7 +1071,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!job.error.includes("already exists")) { throw new GrpcError( grpc.status.UNKNOWN, - `failed to run replication task (${job.state}): ${job.error}` + `failed to run replication task (${job.state}): ${job.error}`, ); } break; @@ -3137,37 +1087,37 @@ class FreeNASApiDriver extends CsiBaseDriver { } } - response = await httpApiClient.DatasetSet( + response = await webSocketApiClient.DatasetSet( datasetName, - volumeProperties + volumeProperties, ); // remove snapshots from target await this.removeSnapshotsFromDatatset(datasetName); // remove snapshot from source - await httpApiClient.SnapshotDelete(fullSnapshotName, { + await webSocketApiClient.SnapshotDelete(fullSnapshotName, { defer: true, }); } else { // create clone // zfs origin property contains parent info, ie: pool0/k8s/test/PVC-111@clone-test try { - response = await httpApiClient.CloneCreate( + response = await webSocketApiClient.CloneCreate( fullSnapshotName, datasetName, { dataset_properties: volumeProperties, - } + }, ); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.NOT_FOUND, - "dataset does not exists" + "dataset does not exists", ); } @@ -3178,7 +1128,7 @@ class FreeNASApiDriver extends CsiBaseDriver { default: throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `invalid volume_content_source type: ${volume_content_source.type}` + `invalid volume_content_source type: ${volume_content_source.type}`, ); break; } @@ -3188,8 +1138,8 @@ class FreeNASApiDriver extends CsiBaseDriver { volumeProperties.volblocksize = zvolBlocksize; } - await httpApiClient.DatasetCreate(datasetName, { - ...httpApiClient.getSystemProperties(volumeProperties), + await webSocketApiClient.DatasetCreate(datasetName, { + ...webSocketApiClient.getSystemProperties(volumeProperties), type: driverZfsResourceType.toUpperCase(), volsize: driverZfsResourceType == "volume" ? capacity_bytes : undefined, sparse: driverZfsResourceType == "volume" ? sparse : undefined, @@ -3197,8 +1147,8 @@ class FreeNASApiDriver extends CsiBaseDriver { share_type: driver.getDriverShareType().includes("smb") ? "SMB" : "GENERIC", - user_properties: httpApiClient.getPropertiesKeyValueArray( - httpApiClient.getUserProperties(volumeProperties) + user_properties: webSocketApiClient.getPropertiesKeyValueArray( + webSocketApiClient.getUserProperties(volumeProperties), ), }); } @@ -3231,11 +1181,11 @@ class FreeNASApiDriver extends CsiBaseDriver { // set properties if (setProps) { - await httpApiClient.DatasetSet(datasetName, properties); + await webSocketApiClient.DatasetSet(datasetName, properties); } // get properties needed for remaining calls - properties = await httpApiClient.DatasetGet(datasetName, [ + properties = await webSocketApiClient.DatasetGet(datasetName, [ "mountpoint", "refquota", "compression", @@ -3270,7 +1220,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `datasetPermissionsUser must be numeric: ${this.options.zfs.datasetPermissionsUser}` + `datasetPermissionsUser must be numeric: ${this.options.zfs.datasetPermissionsUser}`, ); } perms.uid = Number(this.options.zfs.datasetPermissionsUser); @@ -3280,29 +1230,29 @@ class FreeNASApiDriver extends CsiBaseDriver { if (this.options.zfs.hasOwnProperty("datasetPermissionsGroup")) { if ( String(this.options.zfs.datasetPermissionsGroup).match( - /^[0-9]+$/ + /^[0-9]+$/, ) == null ) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `datasetPermissionsGroup must be numeric: ${this.options.zfs.datasetPermissionsGroup}` + `datasetPermissionsGroup must be numeric: ${this.options.zfs.datasetPermissionsGroup}`, ); } perms.gid = Number(this.options.zfs.datasetPermissionsGroup); } if (setPerms) { - response = await httpApiClient.FilesystemSetperm(perms); - await httpApiClient.CoreWaitForJob(response, 30); + response = await webSocketApiClient.FilesystemSetperm(perms); + await webSocketApiClient.CoreWaitForJob(response, 30); // SetPerm does not alter ownership with extended ACLs // run this in addition just for good measure if (perms.uid || perms.gid) { - response = await httpApiClient.FilesystemChown({ + response = await webSocketApiClient.FilesystemChown({ path: perms.path, uid: perms.uid, gid: perms.gid, }); - await httpApiClient.CoreWaitForJob(response, 30); + await webSocketApiClient.CoreWaitForJob(response, 30); } } @@ -3353,14 +1303,14 @@ class FreeNASApiDriver extends CsiBaseDriver { } if (setProps) { - await httpApiClient.DatasetSet(datasetName, properties); + await webSocketApiClient.DatasetSet(datasetName, properties); } break; } volume_context = await this.createShare(call, datasetName); - await httpApiClient.DatasetSet(datasetName, { + await webSocketApiClient.DatasetSet(datasetName, { [SHARE_VOLUME_CONTEXT_PROPERTY_NAME]: JSON.stringify(volume_context), }); @@ -3372,7 +1322,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // set this just before sending out response so we know if volume completed // this should give us a relatively sane way to clean up artifacts over time - await httpApiClient.DatasetSet(datasetName, { + await webSocketApiClient.DatasetSet(datasetName, { [SUCCESS_PROPERTY_NAME]: "true", }); @@ -3404,7 +1354,7 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async DeleteVolume(call) { const driver = this; - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let datasetParentName = this.getVolumeParentDatasetName(); @@ -3413,14 +1363,14 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } if (!name) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `volume_id is required` + `volume_id is required`, ); } @@ -3429,25 +1379,17 @@ class FreeNASApiDriver extends CsiBaseDriver { // get properties needed for remaining calls try { - properties = await httpApiClient.DatasetGet( - datasetName, - [ - "id", - "mountpoint", - "origin", - "refquota", - "compression", - VOLUME_CSI_NAME_PROPERTY_NAME, - "snapshots", - ], - { - "extra.snapshots": "true", - "extra.retrieve_children": "false", - } - ); + properties = await webSocketApiClient.DatasetGet(datasetName, [ + "id", + "mountpoint", + "origin", + "refquota", + "compression", + VOLUME_CSI_NAME_PROPERTY_NAME, + ]); } catch (err) { let ignore = false; - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { ignore = true; } @@ -3456,13 +1398,15 @@ class FreeNASApiDriver extends CsiBaseDriver { } } + let snapshots = await webSocketApiClient.DatasetGetSnapshots(datasetName); + driver.ctx.logger.debug("dataset properties: %j", properties); // deleteStrategy const delete_strategy = _.get( driver.options, "_private.csi.volume.deleteStrategy", - "" + "", ); if (delete_strategy == "retain") { @@ -3483,18 +1427,18 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { driver.ctx.logger.debug( "removing with defer source snapshot: %s", - properties.origin.parsed + properties.origin.parsed, ); try { - await httpApiClient.SnapshotDelete(properties.origin.parsed, { + await webSocketApiClient.SnapshotDelete(properties.origin.parsed, { defer: true, }); } catch (err) { if (err.toString().includes("snapshot has dependent clones")) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - "snapshot has dependent clones" + "snapshot has dependent clones", ); } throw err; @@ -3506,8 +1450,8 @@ class FreeNASApiDriver extends CsiBaseDriver { // have been created the destroy will succeed undesirably let hasManagedSnapshot = false; try { - for (const snapshot of _.get(properties, "snapshots", [])) { - let snapshotData = await httpApiClient.SnapshotGet(snapshot.name, [ + for (const snapshot of snapshots) { + let snapshotData = await webSocketApiClient.SnapshotGet(snapshot.name, [ MANAGED_PROPERTY_NAME, // "democratic-csi:csi_snapshot_name", // "democratic-csi:csi_snapshot_source_volume_id", @@ -3523,10 +1467,10 @@ class FreeNASApiDriver extends CsiBaseDriver { } } catch (err) { // ignore errors when the dataset is already deleted - if (!err.toString().includes("dataset does not exist")) { + if (!ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { throw new GrpcError( grpc.status.UNKNOWN, - `failed to test for snapshots: ${err.toString()}` + `failed to test for snapshots: ${err.toString()}`, ); } } @@ -3534,7 +1478,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (hasManagedSnapshot) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - "filesystem has dependent snapshots" + "filesystem has dependent snapshots", ); } @@ -3546,7 +1490,7 @@ class FreeNASApiDriver extends CsiBaseDriver { 12, 5000, async () => { - await httpApiClient.DatasetDelete(datasetName, { + await webSocketApiClient.DatasetDelete(datasetName, { recursive: true, force: true, }); @@ -3561,13 +1505,13 @@ class FreeNASApiDriver extends CsiBaseDriver { } return false; }, - } + }, ); } catch (err) { if (err.toString().includes("filesystem has dependent clones")) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - "filesystem has dependent clones" + "filesystem has dependent clones", ); } @@ -3584,7 +1528,7 @@ class FreeNASApiDriver extends CsiBaseDriver { async ControllerExpandVolume(call) { const driver = this; const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let datasetParentName = this.getVolumeParentDatasetName(); @@ -3593,14 +1537,14 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } if (!name) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `volume_id is required` + `volume_id is required`, ); } @@ -3613,19 +1557,19 @@ class FreeNASApiDriver extends CsiBaseDriver { //should never happen, value must be set throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `volume capacity is required (either required_bytes or limit_bytes)` + `volume capacity is required (either required_bytes or limit_bytes)`, ); } if (capacity_bytes && driverZfsResourceType == "volume") { //make sure to align capacity_bytes with zvol blocksize //volume size must be a multiple of volume block size - let properties = await httpApiClient.DatasetGet(datasetName, [ + let properties = await webSocketApiClient.DatasetGet(datasetName, [ "volblocksize", ]); capacity_bytes = zb.helpers.generateZvolSize( capacity_bytes, - properties.volblocksize.rawvalue + properties.volblocksize.rawvalue, ); } @@ -3637,7 +1581,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `required_bytes is greather than limit_bytes` + `required_bytes is greather than limit_bytes`, ); } @@ -3649,7 +1593,7 @@ class FreeNASApiDriver extends CsiBaseDriver { ) { throw new GrpcError( grpc.status.OUT_OF_RANGE, - `required volume capacity is greater than limit` + `required volume capacity is greater than limit`, ); } @@ -3682,7 +1626,7 @@ class FreeNASApiDriver extends CsiBaseDriver { } if (setProps) { - await httpApiClient.DatasetSet(datasetName, properties); + await webSocketApiClient.DatasetSet(datasetName, properties); } await this.expandVolume(call, datasetName); @@ -3704,7 +1648,7 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async GetCapacity(call) { const driver = this; - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let datasetParentName = this.getVolumeParentDatasetName(); @@ -3712,7 +1656,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } @@ -3726,12 +1670,14 @@ class FreeNASApiDriver extends CsiBaseDriver { const datasetName = datasetParentName; - await httpApiClient.DatasetCreate(datasetName, { + await webSocketApiClient.DatasetCreate(datasetName, { create_ancestors: true, }); let properties; - properties = await httpApiClient.DatasetGet(datasetName, ["available"]); + properties = await webSocketApiClient.DatasetGet(datasetName, [ + "available", + ]); let minimum_volume_size = await driver.getMinimumVolumeSize(); return { @@ -3753,7 +1699,7 @@ class FreeNASApiDriver extends CsiBaseDriver { async ControllerGetVolume(call) { const driver = this; const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let datasetParentName = this.getVolumeParentDatasetName(); @@ -3763,21 +1709,21 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } if (!name) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `volume_id is required` + `volume_id is required`, ); } const datasetName = datasetParentName + "/" + name; try { - response = await httpApiClient.DatasetGet(datasetName, [ + response = await webSocketApiClient.DatasetGet(datasetName, [ "name", "mountpoint", "refquota", @@ -3794,7 +1740,7 @@ class FreeNASApiDriver extends CsiBaseDriver { VOLUME_CONTEXT_PROVISIONER_DRIVER_PROPERTY_NAME, ]); } catch (err) { - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { throw new GrpcError(grpc.status.NOT_FOUND, `volume_id is missing`); } @@ -3826,10 +1772,7 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async ListVolumes(call) { const driver = this; - const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - const zb = await this.getZetabyte(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); let datasetParentName = this.getVolumeParentDatasetName(); let entries = []; @@ -3869,7 +1812,7 @@ class FreeNASApiDriver extends CsiBaseDriver { } else { throw new GrpcError( grpc.status.ABORTED, - `invalid starting_token: ${starting_token}` + `invalid starting_token: ${starting_token}`, ); } } @@ -3877,50 +1820,49 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } const datasetName = datasetParentName; const rows = []; - endpoint = `/pool/dataset/id/${encodeURIComponent(datasetName)}`; - response = await httpClient.get(endpoint); + try { + response = await webSocketApiClient.DatasetGetResource(datasetName); + } catch (err) { + if (_.get(err, "message", "").includes("not found")) + return { + entries: [], + next_token: null, + }; - //console.log(response); - - if (response.statusCode == 404) { - return { - entries: [], - next_token: null, - }; + throw err; } - if (response.statusCode == 200) { - for (let child of response.body.children) { - let child_properties = httpApiClient.normalizeProperties(child, [ - "name", - "mountpoint", - "refquota", - "available", - "used", - VOLUME_CSI_NAME_PROPERTY_NAME, - VOLUME_CONTENT_SOURCE_TYPE_PROPERTY_NAME, - VOLUME_CONTENT_SOURCE_ID_PROPERTY_NAME, - "volsize", - MANAGED_PROPERTY_NAME, - SHARE_VOLUME_CONTEXT_PROPERTY_NAME, - SUCCESS_PROPERTY_NAME, - VOLUME_CONTEXT_PROVISIONER_INSTANCE_ID_PROPERTY_NAME, - VOLUME_CONTEXT_PROVISIONER_DRIVER_PROPERTY_NAME, - ]); - let row = {}; - for (let p in child_properties) { - row[p] = child_properties[p].rawvalue; - } + for (let child of response.children) { + let child_properties = webSocketApiClient.normalizeProperties(child, [ + "name", + "mountpoint", + "refquota", + "available", + "used", + VOLUME_CSI_NAME_PROPERTY_NAME, + VOLUME_CONTENT_SOURCE_TYPE_PROPERTY_NAME, + VOLUME_CONTENT_SOURCE_ID_PROPERTY_NAME, + "volsize", + MANAGED_PROPERTY_NAME, + SHARE_VOLUME_CONTEXT_PROPERTY_NAME, + SUCCESS_PROPERTY_NAME, + VOLUME_CONTEXT_PROVISIONER_INSTANCE_ID_PROPERTY_NAME, + VOLUME_CONTEXT_PROVISIONER_DRIVER_PROPERTY_NAME, + ]); - rows.push(row); + let row = {}; + for (let p in child_properties) { + row[p] = child_properties[p].rawvalue; } + + rows.push(row); } driver.ctx.logger.debug("list volumes result: %j", rows); @@ -3934,7 +1876,7 @@ class FreeNASApiDriver extends CsiBaseDriver { let volume_id = row["name"].replace( new RegExp("^" + datasetName + "/"), - "" + "", ); let volume = await driver.populateCsiVolumeFromData(row); @@ -3969,10 +1911,8 @@ class FreeNASApiDriver extends CsiBaseDriver { async ListSnapshots(call) { const driver = this; const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); - const truenasVersion = await httpApiClient.getSystemVersionSemver(); let entries = []; let entries_length = 0; @@ -4015,7 +1955,7 @@ class FreeNASApiDriver extends CsiBaseDriver { } else { throw new GrpcError( grpc.status.ABORTED, - `invalid starting_token: ${starting_token}` + `invalid starting_token: ${starting_token}`, ); } } @@ -4024,7 +1964,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // throw error throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } @@ -4085,6 +2025,7 @@ class FreeNASApiDriver extends CsiBaseDriver { } let rows = []; + let snapshots = []; try { let zfsProperties = [ @@ -4115,9 +2056,9 @@ class FreeNASApiDriver extends CsiBaseDriver { switch (operativeFilesystemType) { case 3: // get explicit snapshot - response = await httpApiClient.SnapshotGet( + response = await webSocketApiClient.SnapshotGet( operativeFilesystem, - zfsProperties + zfsProperties, ); let row = {}; @@ -4128,72 +2069,51 @@ class FreeNASApiDriver extends CsiBaseDriver { break; case 2: // get snapshots connected to the to source_volume_id - endpoint = `/pool/dataset/id/${encodeURIComponent( - operativeFilesystem - )}`; - response = await httpClient.get(endpoint, { - "extra.snapshots": 1, - "extra.snapshots_properties": JSON.stringify(zfsProperties), - //"extra.snapshots_properties": "null", - }); - if (response.statusCode == 404) { - throw new Error("dataset does not exist"); - } else if (response.statusCode == 200) { - for (let snapshot of response.body.snapshots) { - if (semver.satisfies(truenasVersion, ">=25.10")) { - // request the snapshot because fetching properties is broken with the dataset is broken - snapshot.properties = await httpApiClient.SnapshotGet( - snapshot.id, - zfsProperties - ); - } - let row = {}; - for (let p in snapshot.properties) { - row[p] = snapshot.properties[p].rawvalue; - } - rows.push(row); + snapshots = await webSocketApiClient.DatasetGetSnapshots( + operativeFilesystem, + zfsProperties, + ); + + for (let snapshot of snapshots) { + snapshot.properties = + await webSocketApiClient.normalizeProperties( + snapshot, + zfsProperties, + ); + + let row = {}; + for (let p in snapshot.properties) { + row[p] = snapshot.properties[p].rawvalue; } - } else { - throw new Error(`unhandled statusCode: ${response.statusCode}`); + rows.push(row); } + break; case 1: // get all snapshot recursively from the parent dataset - endpoint = `/pool/dataset/id/${encodeURIComponent( - operativeFilesystem - )}`; - response = await httpClient.get(endpoint, { - "extra.snapshots": 1, - "extra.snapshots_properties": JSON.stringify(zfsProperties), - //"extra.snapshots_properties": "null", - }); - if (response.statusCode == 404) { - throw new Error("dataset does not exist"); - } else if (response.statusCode == 200) { - for (let child of response.body.children) { - for (let snapshot of child.snapshots) { - if (semver.satisfies(truenasVersion, ">=25.10")) { - // request the snapshot because fetching properties is broken with the dataset is broken - snapshot.properties = await httpApiClient.SnapshotGet( - snapshot.id, - zfsProperties - ); - } - let row = {}; - for (let p in snapshot.properties) { - row[p] = snapshot.properties[p].rawvalue; - } - rows.push(row); - } + snapshots = await webSocketApiClient.DatasetGetSnapshots( + operativeFilesystem, + zfsProperties, + ); + + for (let snapshot of snapshots) { + snapshot.properties = + await webSocketApiClient.normalizeProperties( + snapshot, + zfsProperties, + ); + let row = {}; + for (let p in snapshot.properties) { + row[p] = snapshot.properties[p].rawvalue; } - } else { - throw new Error(`unhandled statusCode: ${response.statusCode}`); + rows.push(row); } + break; default: throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid operativeFilesystemType [${operativeFilesystemType}]` + `invalid operativeFilesystemType [${operativeFilesystemType}]`, ); break; } @@ -4201,9 +2121,9 @@ class FreeNASApiDriver extends CsiBaseDriver { switch (operativeFilesystemType) { case 3: // get explicit snapshot - response = await httpApiClient.DatasetGet( + response = await webSocketApiClient.DatasetGet( operativeFilesystem, - zfsProperties + zfsProperties, ); let row = {}; @@ -4214,17 +2134,16 @@ class FreeNASApiDriver extends CsiBaseDriver { break; case 2: // get snapshots connected to the to source_volume_id - endpoint = `/pool/dataset/id/${encodeURIComponent( - operativeFilesystem - )}`; - response = await httpClient.get(endpoint); - if (response.statusCode == 404) { - throw new Error("dataset does not exist"); - } else if (response.statusCode == 200) { - for (let child of response.body.children) { - let i_response = httpApiClient.normalizeProperties( + try { + response = + await webSocketApiClient.DatasetGetResource( + operativeFilesystem, + ); + + for (let child of response.children) { + let i_response = webSocketApiClient.normalizeProperties( child, - zfsProperties + zfsProperties, ); let row = {}; for (let p in i_response) { @@ -4232,52 +2151,46 @@ class FreeNASApiDriver extends CsiBaseDriver { } rows.push(row); } - } else { - throw new Error(`unhandled statusCode: ${response.statusCode}`); + } catch (err) { + throw err; } break; case 1: - // get all snapshot recursively from the parent dataset - endpoint = `/pool/dataset/id/${encodeURIComponent( - operativeFilesystem - )}`; - response = await httpClient.get(endpoint); - if (response.statusCode == 404) { - throw new Error("dataset does not exist"); - } else if (response.statusCode == 200) { - for (let child of response.body.children) { - for (let grandchild of child.children) { - let i_response = httpApiClient.normalizeProperties( - grandchild, - zfsProperties - ); - let row = {}; - for (let p in i_response) { - row[p] = i_response[p].rawvalue; - } - rows.push(row); - } + // get all detached snapshots (datasets) recursively from the parent dataset + snapshots = await webSocketApiClient.DatasetGetDatasets( + operativeFilesystem, + zfsProperties, + ); + + for (let snapshot of snapshots) { + snapshot.properties = + await webSocketApiClient.normalizeProperties( + snapshot, + zfsProperties, + ); + let row = {}; + for (let p in snapshot.properties) { + row[p] = snapshot.properties[p].rawvalue; } - } else { - throw new Error(`unhandled statusCode: ${response.statusCode}`); + rows.push(row); } break; default: throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid operativeFilesystemType [${operativeFilesystemType}]` + `invalid operativeFilesystemType [${operativeFilesystemType}]`, ); break; } } else { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid zfs types [${types.join(",")}]` + `invalid zfs types [${types.join(",")}]`, ); } } catch (err) { let message; - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { switch (operativeFilesystemType) { case 1: //message = `invalid configuration: datasetParentName ${datasetParentName} does not exist`; @@ -4313,19 +2226,19 @@ class FreeNASApiDriver extends CsiBaseDriver { // strip parent dataset let source_volume_id = row["name"].replace( new RegExp("^" + datasetParentName + "/"), - "" + "", ); // strip snapshot details (@snapshot-name) if (source_volume_id.includes("@")) { source_volume_id = source_volume_id.substring( 0, - source_volume_id.indexOf("@") + source_volume_id.indexOf("@"), ); } else { source_volume_id = source_volume_id.replace( new RegExp("/" + row[SNAPSHOT_CSI_NAME_PROPERTY_NAME] + "$"), - "" + "", ); } @@ -4343,7 +2256,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // value of a derived volume size_bytes = GeneralUtils.getLargestNumber( row.referenced, - row.logicalreferenced + row.logicalreferenced, ); } else { // get the size of the parent volume @@ -4365,7 +2278,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // remove parent dataset details snapshot_id: row["name"].replace( new RegExp("^" + datasetParentName + "/"), - "" + "", ), source_volume_id: source_volume_id, //https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/timestamp.proto @@ -4403,8 +2316,7 @@ class FreeNASApiDriver extends CsiBaseDriver { async CreateSnapshot(call) { const driver = this; const driverZfsResourceType = this.getDriverZfsResourceType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); let size_bytes = 0; @@ -4413,8 +2325,8 @@ class FreeNASApiDriver extends CsiBaseDriver { let tmpDetachedSnapshot = JSON.parse( driver.getNormalizedParameterValue( call.request.parameters, - "detachedSnapshots" - ) + "detachedSnapshots", + ), ); // snapshot class parameter if (typeof tmpDetachedSnapshot === "boolean") { detachedSnapshot = tmpDetachedSnapshot; @@ -4442,7 +2354,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } @@ -4453,14 +2365,14 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!source_volume_id) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `snapshot source_volume_id is required` + `snapshot source_volume_id is required`, ); } if (!name) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `snapshot name is required` + `snapshot name is required`, ); } @@ -4489,11 +2401,11 @@ class FreeNASApiDriver extends CsiBaseDriver { invalid_chars = name.match(/[^a-z0-9_\-:.+]+/gi); if (invalid_chars) { invalid_chars = String.prototype.concat( - ...new Set(invalid_chars.join("")) + ...new Set(invalid_chars.join("")), ); throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `snapshot name contains invalid characters: ${invalid_chars}` + `snapshot name contains invalid characters: ${invalid_chars}`, ); } @@ -4504,75 +2416,54 @@ class FreeNASApiDriver extends CsiBaseDriver { // check for other snapshopts with the same name on other volumes and fail as appropriate { - let endpoint; let response; + try { + response = await webSocketApiClient.DatasetGetResource( + this.getDetachedSnapshotParentDatasetName(), + ); - let datasets = []; - endpoint = `/pool/dataset/id/${encodeURIComponent( - this.getDetachedSnapshotParentDatasetName() - )}`; - response = await httpClient.get(endpoint); - - switch (response.statusCode) { - case 200: - for (let child of response.body.children) { - datasets = datasets.concat(child.children); + for (let dataset of response.children) { + let parts = dataset.name.split("/").slice(-2); + if (parts[1] != name) { + continue; } - //console.log(datasets); - for (let dataset of datasets) { - let parts = dataset.name.split("/").slice(-2); - if (parts[1] != name) { - continue; - } - if (parts[0] != source_volume_id) { - throw new GrpcError( - grpc.status.ALREADY_EXISTS, - `snapshot name: ${name} is incompatible with source_volume_id: ${source_volume_id} due to being used with another source_volume_id` - ); - } + if (parts[0] != source_volume_id) { + throw new GrpcError( + grpc.status.ALREADY_EXISTS, + `snapshot name: ${name} is incompatible with source_volume_id: ${source_volume_id} due to being used with another source_volume_id`, + ); } - break; - case 404: - break; - default: - throw new Error(JSON.stringify(response.body)); + } + } catch (err) { + if (_.get(err, "message", "").includes("not exist")) { + // ignore + } else { + throw err; + } } // get all snapshot recursively from the parent dataset - let snapshots = []; - endpoint = `/pool/dataset/id/${encodeURIComponent( - this.getVolumeParentDatasetName() - )}`; - response = await httpClient.get(endpoint, { - "extra.snapshots": 1, - //"extra.snapshots_properties": JSON.stringify(zfsProperties), - }); + try { + response = await webSocketApiClient.DatasetGetSnapshots( + this.getVolumeParentDatasetName(), + ); - switch (response.statusCode) { - case 200: - for (let child of response.body.children) { - snapshots = snapshots.concat(child.snapshots); + for (let snapshot of response) { + let parts = zb.helpers.extractLeafName(snapshot.name).split("@"); + if (parts[1] != name) { + continue; } - //console.log(snapshots); - for (let snapshot of snapshots) { - let parts = zb.helpers.extractLeafName(snapshot.name).split("@"); - if (parts[1] != name) { - continue; - } - if (parts[0] != source_volume_id) { - throw new GrpcError( - grpc.status.ALREADY_EXISTS, - `snapshot name: ${name} is incompatible with source_volume_id: ${source_volume_id} due to being used with another source_volume_id` - ); - } + if (parts[0] != source_volume_id) { + throw new GrpcError( + grpc.status.ALREADY_EXISTS, + `snapshot name: ${name} is incompatible with source_volume_id: ${source_volume_id} due to being used with another source_volume_id`, + ); } - break; - case 404: - break; - default: - throw new Error(JSON.stringify(response.body)); + } + } catch (err) { + throw err; } } @@ -4598,18 +2489,18 @@ class FreeNASApiDriver extends CsiBaseDriver { snapshotDatasetName = datasetName + "/" + name; // create target dataset parent - await httpApiClient.DatasetCreate(datasetName, { + await webSocketApiClient.DatasetCreate(datasetName, { create_ancestors: true, }); // create snapshot on source try { - await httpApiClient.SnapshotCreate(tmpSnapshotName); + await webSocketApiClient.SnapshotCreate(tmpSnapshotName); } catch (err) { - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `snapshot source_volume_id ${source_volume_id} does not exist` + `snapshot source_volume_id ${source_volume_id} does not exist`, ); } @@ -4617,13 +2508,12 @@ class FreeNASApiDriver extends CsiBaseDriver { } try { - // copy data from source snapshot to target dataset - response = await httpApiClient.ReplicationRunOnetime({ + response = await webSocketApiClient.ReplicationRunOnetime({ direction: "PUSH", transport: "LOCAL", - source_datasets: [zb.helpers.extractDatasetName(tmpSnapshotName)], - target_dataset: snapshotDatasetName, - name_regex: `^${zb.helpers.extractSnapshotName(tmpSnapshotName)}$`, + source_datasets: [zb.helpers.extractDatasetName(fullSnapshotName)], + target_dataset: datasetName, + name_regex: `^${zb.helpers.extractSnapshotName(fullSnapshotName)}$`, recursive: false, retention_policy: "NONE", readonly: "IGNORE", @@ -4635,12 +2525,7 @@ class FreeNASApiDriver extends CsiBaseDriver { let job; // wait for job to finish - while (!job || !["SUCCESS", "ABORTED", "FAILED"].includes(job.state)) { - job = await httpApiClient.CoreGetJobs({ id: job_id }); - job = job[0]; - await GeneralUtils.sleep(3000); - } - + job = await webSocketApiClient.CoreWaitForJob(job_id); job.error = job.error || ""; switch (job.state) { @@ -4653,18 +2538,16 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!job.error.includes("already exists")) { throw new GrpcError( grpc.status.UNKNOWN, - `failed to run replication task (${job.state}): ${job.error}` + `failed to run replication task (${job.state}): ${job.error}`, ); } break; } - //throw new Error("foobar"); - // set properties on target dataset - response = await httpApiClient.DatasetSet( + response = await webSocketApiClient.DatasetSet( snapshotDatasetName, - snapshotProperties + snapshotProperties, ); } catch (err) { if ( @@ -4678,32 +2561,32 @@ class FreeNASApiDriver extends CsiBaseDriver { } // remove snapshot from target - await httpApiClient.SnapshotDelete( + await webSocketApiClient.SnapshotDelete( snapshotDatasetName + "@" + zb.helpers.extractSnapshotName(tmpSnapshotName), { defer: true, - } + }, ); // remove snapshot from source - await httpApiClient.SnapshotDelete(tmpSnapshotName, { + await webSocketApiClient.SnapshotDelete(tmpSnapshotName, { defer: true, }); } else { try { - await httpApiClient.SnapshotCreate(fullSnapshotName, { + await webSocketApiClient.SnapshotCreate(fullSnapshotName, { properties: snapshotProperties, }); } catch (err) { if ( - err.toString().includes("dataset does not exist") || + ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString()) || err.toString().includes("not found") ) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `snapshot source_volume_id ${source_volume_id} does not exist` + `snapshot source_volume_id ${source_volume_id} does not exist`, ); } @@ -4733,14 +2616,14 @@ class FreeNASApiDriver extends CsiBaseDriver { // TODO: let things settle to ensure proper size_bytes is reported // sysctl -d vfs.zfs.txg.timeout # vfs.zfs.txg.timeout: Max seconds worth of delta per txg if (detachedSnapshot) { - properties = await httpApiClient.DatasetGet( + properties = await webSocketApiClient.DatasetGet( fullSnapshotName, - fetchProperties + fetchProperties, ); } else { - properties = await httpApiClient.SnapshotGet( + properties = await webSocketApiClient.SnapshotGet( fullSnapshotName, - fetchProperties + fetchProperties, ); } @@ -4755,7 +2638,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // value of a derived volume size_bytes = GeneralUtils.getLargestNumber( properties.referenced.rawvalue, - properties.logicalreferenced.rawvalue + properties.logicalreferenced.rawvalue, // TODO: perhaps include minimum volume size here? ); } else { @@ -4767,11 +2650,11 @@ class FreeNASApiDriver extends CsiBaseDriver { // this should give us a relatively sane way to clean up artifacts over time //await zb.zfs.set(fullSnapshotName, { [SUCCESS_PROPERTY_NAME]: "true" }); if (detachedSnapshot) { - await httpApiClient.DatasetSet(fullSnapshotName, { + await webSocketApiClient.DatasetSet(fullSnapshotName, { [SUCCESS_PROPERTY_NAME]: "true", }); } else { - await httpApiClient.SnapshotSet(fullSnapshotName, { + await webSocketApiClient.SnapshotSet(fullSnapshotName, { [SUCCESS_PROPERTY_NAME]: "true", }); } @@ -4790,7 +2673,7 @@ class FreeNASApiDriver extends CsiBaseDriver { // remove parent dataset details snapshot_id: properties.name.value.replace( new RegExp("^" + datasetParentName + "/"), - "" + "", ), source_volume_id: source_volume_id, //https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/timestamp.proto @@ -4813,7 +2696,7 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async DeleteSnapshot(call) { const driver = this; - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const zb = await this.getZetabyte(); const snapshot_id = call.request.snapshot_id; @@ -4821,7 +2704,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!snapshot_id) { throw new GrpcError( grpc.status.INVALID_ARGUMENT, - `snapshot_id is required` + `snapshot_id is required`, ); } @@ -4837,7 +2720,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } @@ -4847,7 +2730,7 @@ class FreeNASApiDriver extends CsiBaseDriver { if (detachedSnapshot) { try { - await httpApiClient.DatasetDelete(fullSnapshotName, { + await webSocketApiClient.DatasetDelete(fullSnapshotName, { recursive: true, force: true, }); @@ -4856,14 +2739,14 @@ class FreeNASApiDriver extends CsiBaseDriver { } } else { try { - await httpApiClient.SnapshotDelete(fullSnapshotName, { + await webSocketApiClient.SnapshotDelete(fullSnapshotName, { defer: true, }); } catch (err) { if (err.toString().includes("snapshot has dependent clones")) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - "snapshot has dependent clones" + "snapshot has dependent clones", ); } throw err; @@ -4874,12 +2757,16 @@ class FreeNASApiDriver extends CsiBaseDriver { if (detachedSnapshot) { let containerDataset = zb.helpers.extractParentDatasetName(fullSnapshotName); - try { - await this.removeSnapshotsFromDatatset(containerDataset); - await httpApiClient.DatasetDelete(containerDataset); - } catch (err) { - if (!err.toString().includes("filesystem has children")) { - throw err; + + // this conditional protects against bad input leading to deleting the whole parent dataset + if (containerDataset != this.getDetachedSnapshotParentDatasetName()) { + try { + await webSocketApiClient.DatasetDestroySnapshots(containerDataset); + await webSocketApiClient.DatasetDelete(containerDataset); + } catch (err) { + if (!err.toString().includes("filesystem has children")) { + throw err; + } } } } @@ -4893,7 +2780,7 @@ class FreeNASApiDriver extends CsiBaseDriver { */ async ValidateVolumeCapabilities(call) { const driver = this; - const httpApiClient = await this.getTrueNASHttpApiClient(); + const webSocketApiClient = await this.getTrueNASWebSocketApiClient(); const volume_id = call.request.volume_id; if (!volume_id) { @@ -4910,18 +2797,18 @@ class FreeNASApiDriver extends CsiBaseDriver { if (!datasetParentName) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `invalid configuration: missing datasetParentName` + `invalid configuration: missing datasetParentName`, ); } const datasetName = datasetParentName + "/" + name; try { - await httpApiClient.DatasetGet(datasetName, []); + await webSocketApiClient.DatasetGet(datasetName, []); } catch (err) { - if (err.toString().includes("dataset does not exist")) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { throw new GrpcError( grpc.status.NOT_FOUND, - `invalid volume_id: ${volume_id}` + `invalid volume_id: ${volume_id}`, ); } else { throw err; diff --git a/src/driver/freenas/http/api.js b/src/driver/freenas/http/api.js index de78bf6..89595e4 100644 --- a/src/driver/freenas/http/api.js +++ b/src/driver/freenas/http/api.js @@ -30,7 +30,7 @@ class Api { executor: { spawn: function () { throw new Error( - "cannot use the zb implementation to execute zfs commands, must use the http api" + "cannot use the zb implementation to execute zfs commands, must use the http api", ); }, }, @@ -103,7 +103,7 @@ class Api { "FreeNAS http error - code: " + response.statusCode + " body: " + - JSON.stringify(response.body) + JSON.stringify(response.body), ); } page++; @@ -242,7 +242,7 @@ class Api { `FreeNAS error getting system version info: ${stringify({ errors: versionErrors, responses: versionResponses, - })}` + })}`, ); } @@ -393,7 +393,7 @@ class Api { response = await httpClient.put(endpoint, { ...this.getSystemProperties(properties), user_properties_update: this.getPropertiesKeyValueArray( - this.getUserProperties(properties) + this.getUserProperties(properties), ), }); @@ -479,7 +479,7 @@ class Api { { "extra.snapshots": "true", "extra.retrieve_children": "false", - } + }, ); for (const snapshot of _.get(response, "snapshots", [])) { @@ -530,7 +530,7 @@ class Api { response = await httpClient.put(endpoint, { //...this.getSystemProperties(properties), user_properties_update: this.getPropertiesKeyValueArray( - this.getUserProperties(properties) + this.getUserProperties(properties), ), }); @@ -905,13 +905,6 @@ class Api { return response.body; } - if ( - response.statusCode == 422 && - JSON.stringify(response.body).includes("already exists") - ) { - return this.NvmetSubsysGetByName(subsysName); - } - if ( response.statusCode == 422 && JSON.stringify(response.body).includes("already used by subsystem") diff --git a/src/driver/freenas/http/index.js b/src/driver/freenas/http/index.js index cafd6ff..e080fe3 100644 --- a/src/driver/freenas/http/index.js +++ b/src/driver/freenas/http/index.js @@ -102,7 +102,7 @@ class Client { return options; } - log_repsonse(error, response, body, options) { + log_response(error, response, body, options) { let prop; let val; @@ -130,18 +130,18 @@ class Client { let duration = parseFloat( Math.round((_.get(response, "duration", 0) + Number.EPSILON) * 100) / 100 / - 1000 + 1000, ).toFixed(2); this.logger.debug("FREENAS HTTP REQUEST DETAILS: " + stringify(options)); this.logger.debug("FREENAS HTTP REQUEST DURATION: " + duration + "s"); this.logger.debug("FREENAS HTTP ERROR: " + error); this.logger.debug( - "FREENAS HTTP RESPONSE STATUS CODE: " + _.get(response, "statusCode", "") + "FREENAS HTTP RESPONSE STATUS CODE: " + _.get(response, "statusCode", ""), ); this.logger.debug( "FREENAS HTTP RESPONSE HEADERS: " + - stringify(_.get(response, "headers", "")) + stringify(_.get(response, "headers", "")), ); this.logger.debug("FREENAS HTTP RESPONSE BODY: " + stringify(body)); } @@ -159,7 +159,7 @@ class Client { options.params = data; axios_request(options, function (err, res, body) { - client.log_repsonse(...arguments, options); + client.log_response(...arguments, options); if (err) { reject(err); } @@ -181,7 +181,7 @@ class Client { options.data = data; axios_request(options, function (err, res, body) { - client.log_repsonse(...arguments, options); + client.log_response(...arguments, options); if (err) { reject(err); } @@ -204,7 +204,7 @@ class Client { options.data = data; axios_request(options, function (err, res, body) { - client.log_repsonse(...arguments, options); + client.log_response(...arguments, options); if (err) { reject(err); } @@ -227,7 +227,7 @@ class Client { options.data = data; axios_request(options, function (err, res, body) { - client.log_repsonse(...arguments, options); + client.log_response(...arguments, options); if (err) { reject(err); } diff --git a/src/driver/freenas/ssh.js b/src/driver/freenas/ssh.js index 37a593f..f1b049d 100644 --- a/src/driver/freenas/ssh.js +++ b/src/driver/freenas/ssh.js @@ -1,39 +1,15 @@ const _ = require("lodash"); -const { ControllerZfsBaseDriver } = require("../controller-zfs"); -const { GrpcError, grpc } = require("../../utils/grpc"); -const SshClient = require("../../utils/zfs_ssh_exec_client").SshClient; -const HttpClient = require("./http").Client; -const TrueNASApiClient = require("./http/api").Api; -const { Zetabyte, ZfsSshProcessManager } = require("../../utils/zfs"); -const GeneralUtils = require("../../utils/general"); - -const Handlebars = require("handlebars"); const semver = require("semver"); -// freenas properties -const FREENAS_NFS_SHARE_PROPERTY_NAME = "democratic-csi:freenas_nfs_share_id"; -const FREENAS_SMB_SHARE_PROPERTY_NAME = "democratic-csi:freenas_smb_share_id"; - -// iscsi -const FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_target_id"; -const FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_extent_id"; -const FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_targettoextent_id"; -const FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME = - "democratic-csi:freenas_iscsi_assets_name"; - -// nvmeof -const FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_subsystem_id"; -const FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_namespace_id"; -const FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME = - "democratic-csi:freenas_nvmeof_assets_name"; +const { ControllerZfsBaseDriver } = require("../controller-zfs"); +const SshClient = require("../../utils/zfs_ssh_exec_client").SshClient; +const { Zetabyte, ZfsSshProcessManager } = require("../../utils/zfs"); +const WebSocketClient = require("./websocket").Client; +const TrueNASWebSocketApiClient = require("./websocket/api").Api; +const FreeNASApiShareHelper = + require("./websocket/share").FreeNASApiShareHelper; // used for in-memory cache of the version info -const FREENAS_SYSTEM_VERSION_CACHE_KEY = "freenas:system_version"; const __REGISTRY_NS__ = "FreeNASSshDriver"; class FreeNASSshDriver extends ControllerZfsBaseDriver { @@ -49,13 +25,32 @@ class FreeNASSshDriver extends ControllerZfsBaseDriver { const driver = this; if (driver.ctx.args.csiMode.includes("controller")) { - const httpApiClient = await driver.getTrueNASHttpApiClient(); + const webSocketClient = await driver.getWebSocketClient(); + const apiClient = await driver.getTrueNASWebSocketApiClient(); + try { - await httpApiClient.getSystemVersion(); + await webSocketClient.waitauthenticated(10 * 1000); } catch (err) { throw new GrpcError( grpc.status.FAILED_PRECONDITION, - `TrueNAS api is unavailable: ${String(err)}` + `TrueNAS api is not authenticated: ${String(err)}`, + ); + } + + let version; + try { + version = await apiClient.getSystemVersionSemver(); + } catch (err) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `TrueNAS api is unavailable: ${String(err)}`, + ); + } + + if (!semver.satisfies(version, ">=26")) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `driver is only available with TrueNAS version >=26`, ); } @@ -89,7 +84,7 @@ class FreeNASSshDriver extends ControllerZfsBaseDriver { options.paths = Object.assign( {}, options.paths, - _.get(this.options, "zfs.cli.paths", {}) + _.get(this.options, "zfs.cli.paths", {}), ); return new Zetabyte(options); @@ -118,61 +113,32 @@ class FreeNASSshDriver extends ControllerZfsBaseDriver { } async setZetabyteCustomOptions(options) { - const major = await this.getSystemVersionMajor(); - const isScale = await this.getIsScale(); - - if (!isScale && Number(major) >= 12) { - options.paths = { - zfs: "/usr/local/sbin/zfs", - zpool: "/usr/local/sbin/zpool", - sudo: "/usr/local/bin/sudo", - chroot: "/usr/sbin/chroot", - }; - } else if (isScale) { - if (Number(major) >= 25) { - options.paths = { - zfs: "/usr/sbin/zfs", - zpool: "/usr/sbin/zpool", - sudo: "/usr/bin/sudo", - chroot: "/usr/sbin/chroot", - }; - } else { - options.paths = { - zfs: "/usr/local/sbin/zfs", - zpool: "/usr/local/sbin/zpool", - sudo: "/usr/bin/sudo", - chroot: "/usr/sbin/chroot", - }; - } - } - } - - async getHttpClient(autoDetectVersion = true) { - const autodetectkey = autoDetectVersion === true ? 1 : 0; - return this.ctx.registry.getAsync( - `${__REGISTRY_NS__}:http_client:autoDetectVersion_${autodetectkey}`, - async () => { - const client = new HttpClient(this.options.httpConnection); - client.logger = this.ctx.logger; - - if (autoDetectVersion && !!!this.options.httpConnection.apiVersion) { - const apiVersion = await this.getApiVersion(); - client.setApiVersion(apiVersion); - } - - return client; - } - ); - } - - async getTrueNASHttpApiClient() { - return this.ctx.registry.getAsync( - `${__REGISTRY_NS__}:api_client`, - async () => { - const httpClient = await this.getHttpClient(); - return new TrueNASApiClient(httpClient, this.ctx.cache); - } - ); + // const major = await this.getSystemVersionMajor(); + // const isScale = await this.getIsScale(); + // if (!isScale && Number(major) >= 12) { + // options.paths = { + // zfs: "/usr/local/sbin/zfs", + // zpool: "/usr/local/sbin/zpool", + // sudo: "/usr/local/bin/sudo", + // chroot: "/usr/sbin/chroot", + // }; + // } else if (isScale) { + // if (Number(major) >= 25) { + // options.paths = { + // zfs: "/usr/sbin/zfs", + // zpool: "/usr/sbin/zpool", + // sudo: "/usr/bin/sudo", + // chroot: "/usr/sbin/chroot", + // }; + // } else { + // options.paths = { + // zfs: "/usr/local/sbin/zfs", + // zpool: "/usr/local/sbin/zpool", + // sudo: "/usr/bin/sudo", + // chroot: "/usr/sbin/chroot", + // }; + // } + // } } getDriverShareType() { @@ -194,126 +160,30 @@ class FreeNASSshDriver extends ControllerZfsBaseDriver { } } - 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 = {}; - // TODO: relax this using getSystemVersion perhaps - // https://jira.ixsystems.com/browse/NAS-103916 - // NOTE: if using apiVersion 2 with 11.2 you will have issues - if (httpClient.getApiVersion() == 1 || httpClient.getApiVersion() == 2) { - 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.body) == JSON.stringify(response.body)) { - 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) + async getWebSocketClient() { + return this.ctx.registry.get( + `${__REGISTRY_NS__}:websocket_client`, + async () => { + const client = new WebSocketClient( + this.options.httpConnection, + this.ctx.logger, ); - } - page++; - } + await client.connect(); + await client.waitauthenticated(); - return target; + return client; + }, + ); } - /** - * Check if an error response indicates a target already exists. - * This method handles variations in TrueNAS API error messages across different API versions. - * - * @param {string|Object} responseBody - The HTTP response body (string or object) - * @returns {boolean} - true if the error indicates target already exists - */ - isTargetAlreadyExistsError(responseBody) { - // Extract error message more efficiently - let errorString = ""; - - if (typeof responseBody === "string") { - errorString = responseBody; - } else if (responseBody && typeof responseBody === "object") { - // Try common error message fields first to avoid full JSON.stringify - errorString = - responseBody.message || - responseBody.error || - responseBody.detail || - JSON.stringify(responseBody); - } else { - return false; - } - - // Handle multiple variations of the target already exists error message - const targetExistsPatterns = [ - "Target name already exists", // Original pattern in code (API v1) - "Target with this name already exists", // Actual TrueNAS error message (API v2) - "Target\\b.*\\balready\\b.*\\bexists", // Flexible pattern with word boundaries - ]; - - return targetExistsPatterns.some((pattern) => { - if (pattern.includes("\\")) { - // Use regex for flexible patterns with word boundaries - const regex = new RegExp(pattern, "i"); - return regex.test(errorString); - } else { - // Use case-insensitive simple string matching for exact patterns - return errorString.toLowerCase().includes(pattern.toLowerCase()); - } - }); + async getTrueNASWebSocketApiClient() { + return this.ctx.registry.getAsync( + `${__REGISTRY_NS__}:websocket_api_client`, + async () => { + const webSocketClient = await this.getWebSocketClient(); + return new TrueNASWebSocketApiClient(webSocketClient); + }, + ); } /** @@ -324,2409 +194,21 @@ class FreeNASSshDriver extends ControllerZfsBaseDriver { */ async createShare(call, datasetName) { const driver = this; - const driverShareType = this.getDriverShareType(); - const execClient = this.getExecClient(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - const apiVersion = httpClient.getApiVersion(); - const zb = await this.getZetabyte(); - const truenasVersion = await httpApiClient.getSystemVersionSemver(); - - if (!truenasVersion) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unable to detect TrueNAS version` - ); - } - - const isScale = await httpApiClient.getIsScale(); - - let volume_context; - let properties; - let endpoint; - let response; - let share = {}; - - switch (driverShareType) { - case "nfs": - { - properties = await zb.zfs.get(datasetName, [ - "mountpoint", - FREENAS_NFS_SHARE_PROPERTY_NAME, - ]); - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - // create nfs share - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value - ) - ) { - let nfsShareComment; - if (this.options.nfs.shareCommentTemplate) { - nfsShareComment = Handlebars.compile( - this.options.nfs.shareCommentTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - csi: { - name: this.ctx.args.csiName, - version: this.ctx.args.csiVersion, - }, - zfs: { - datasetName: datasetName, - }, - }); - } else { - nfsShareComment = `democratic-csi (${this.ctx.args.csiName}): ${datasetName}`; - } - - switch (apiVersion) { - case 1: - case 2: - switch (apiVersion) { - case 1: - share = { - nfs_paths: [properties.mountpoint.value], - nfs_comment: nfsShareComment || "", - nfs_network: - this.options.nfs.shareAllowedNetworks.join(","), - nfs_hosts: this.options.nfs.shareAllowedHosts.join(","), - nfs_alldirs: this.options.nfs.shareAlldirs, - nfs_ro: false, - nfs_quiet: false, - nfs_maproot_user: this.options.nfs.shareMaprootUser, - nfs_maproot_group: this.options.nfs.shareMaprootGroup, - nfs_mapall_user: this.options.nfs.shareMapallUser, - nfs_mapall_group: this.options.nfs.shareMapallGroup, - nfs_security: [], - }; - break; - case 2: - share = { - paths: [properties.mountpoint.value], - comment: nfsShareComment || "", - networks: this.options.nfs.shareAllowedNetworks, - hosts: this.options.nfs.shareAllowedHosts, - alldirs: this.options.nfs.shareAlldirs, - ro: false, - quiet: false, - maproot_user: this.options.nfs.shareMaprootUser, - maproot_group: this.options.nfs.shareMaprootGroup, - mapall_user: this.options.nfs.shareMapallUser, - mapall_group: this.options.nfs.shareMapallGroup, - security: [], - }; - break; - } - - if (isScale && semver.satisfies(truenasVersion, ">=23.10")) { - delete share.quiet; - delete share.nfs_quiet; - } - - if (isScale && semver.satisfies(truenasVersion, ">=22.12")) { - share.path = share.paths[0]; - delete share.paths; - delete share.alldirs; - } - - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.post("/sharing/nfs", share); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - /** - * v1 = 201 - * v2 = 200 - */ - if ([200, 201].includes(response.statusCode)) { - let sharePaths; - switch (apiVersion) { - case 1: - sharePaths = response.body.nfs_paths; - break; - case 2: - if (response.body.path) { - sharePaths = [response.body.path]; - } else { - sharePaths = response.body.paths; - } - break; - } - - // FreeNAS responding with bad data - if ( - !Array.isArray(sharePaths) || - !sharePaths.includes(properties.mountpoint.value) - ) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - //set zfs property - await zb.zfs.set(datasetName, { - [FREENAS_NFS_SHARE_PROPERTY_NAME]: response.body.id, - }); - } else { - /** - * v1 = 409 - * v2 = 422 - */ - if ( - [409, 422].includes(response.statusCode) && - (JSON.stringify(response.body).includes( - "You can't share same filesystem with all hosts twice." - ) || - JSON.stringify(response.body).includes( - "Another NFS share already exports this dataset for some network" - )) - ) { - let lookupShare = await this.findResourceByProperties( - "/sharing/nfs", - (item) => { - if ( - (item.nfs_paths && - item.nfs_paths.includes( - properties.mountpoint.value - )) || - (item.paths && - item.paths.includes(properties.mountpoint.value)) || - (item.path && - item.path == properties.mountpoint.value) - ) { - return true; - } - return false; - } - ); - - if (!lookupShare) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS failed to find matching share` - ); - } - - //set zfs property - await zb.zfs.set(datasetName, { - [FREENAS_NFS_SHARE_PROPERTY_NAME]: lookupShare.id, - }); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating nfs share - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - volume_context = { - node_attach_driver: "nfs", - server: this.options.nfs.shareHost, - share: properties.mountpoint.value, - }; - return volume_context; - } - - break; - /** - * TODO: smb need to be more defensive like iscsi and nfs - * ensuring the path is valid and the shareName - */ - case "smb": - { - properties = await zb.zfs.get(datasetName, [ - "mountpoint", - FREENAS_SMB_SHARE_PROPERTY_NAME, - ]); - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - let smbName; - - if (this.options.smb.nameTemplate) { - smbName = Handlebars.compile(this.options.smb.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - smbName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.smb.namePrefix) { - smbName = this.options.smb.namePrefix + smbName; - } - - if (this.options.smb.nameSuffix) { - smbName += this.options.smb.nameSuffix; - } - - smbName = smbName.toLowerCase(); - - this.ctx.logger.info( - "FreeNAS creating smb share with name: " + smbName - ); - - // create smb share - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value - ) - ) { - /** - * The only required parameters are: - * - path - * - name - * - * Note that over time it appears the list of available parameters has increased - * so in an effort to best support old versions of FreeNAS we should check the - * presense of each parameter in the config and set the corresponding parameter in - * the API request *only* if present in the config. - */ - switch (apiVersion) { - case 1: - case 2: - share = { - name: smbName, - path: properties.mountpoint.value, - }; - - let propertyMapping = { - shareAuxiliaryConfigurationTemplate: "auxsmbconf", - shareHome: "home", - shareAllowedHosts: "hostsallow", - shareDeniedHosts: "hostsdeny", - shareDefaultPermissions: "default_permissions", - shareGuestOk: "guestok", - shareGuestOnly: "guestonly", - shareShowHiddenFiles: "showhiddenfiles", - shareRecycleBin: "recyclebin", - shareBrowsable: "browsable", - shareAccessBasedEnumeration: "abe", - shareTimeMachine: "timemachine", - shareStorageTask: "storage_task", - }; - - for (const key in propertyMapping) { - if (this.options.smb.hasOwnProperty(key)) { - let value; - switch (key) { - case "shareAuxiliaryConfigurationTemplate": - value = Handlebars.compile( - this.options.smb.shareAuxiliaryConfigurationTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - }); - break; - default: - value = this.options.smb[key]; - break; - } - share[propertyMapping[key]] = value; - } - } - - if (isScale && semver.satisfies(truenasVersion, ">=25.10")) { - let topLevelProperties = [ - "purpose", - "name", - "path", - "enabled", - "comment", - "readonly", - "browsable", - "access_based_share_enumeration", - "audit", - ]; - let disallowedOptions = ["abe"]; - share.purpose = "LEGACY_SHARE"; - share.options = { - purpose: "LEGACY_SHARE", - }; - for (const key in share) { - switch (key) { - case "options": - // ignore - break; - default: - if (!topLevelProperties.includes(key)) { - if (!disallowedOptions.includes(key)) { - share.options[key] = share[key]; - } - delete share[key]; - } - break; - } - } - } - - switch (apiVersion) { - case 1: - endpoint = "/sharing/cifs"; - - // rename keys with cifs_ prefix - for (const key in share) { - share["cifs_" + key] = share[key]; - delete share[key]; - } - - // convert to comma-separated list - if (share.cifs_hostsallow) { - share.cifs_hostsallow = share.cifs_hostsallow.join(","); - } - - // convert to comma-separated list - if (share.cifs_hostsdeny) { - share.cifs_hostsdeny = share.cifs_hostsdeny.join(","); - } - break; - case 2: - endpoint = "/sharing/smb"; - break; - } - - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.post(endpoint, share); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - /** - * v1 = 201 - * v2 = 200 - */ - if ([200, 201].includes(response.statusCode)) { - share = response.body; - let sharePath; - let shareName; - switch (apiVersion) { - case 1: - sharePath = response.body.cifs_path; - shareName = response.body.cifs_name; - break; - case 2: - sharePath = response.body.path; - shareName = response.body.name; - break; - } - - if (shareName != smbName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - if (sharePath != properties.mountpoint.value) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS responded with incorrect share data: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - //set zfs property - await zb.zfs.set(datasetName, { - [FREENAS_SMB_SHARE_PROPERTY_NAME]: response.body.id, - }); - } else { - /** - * v1 = 409 - * v2 = 422 - */ - if ( - [409, 422].includes(response.statusCode) && - JSON.stringify(response.body).includes( - "A share with this name already exists." - ) - ) { - let lookupShare = await this.findResourceByProperties( - endpoint, - (item) => { - if ( - (item.cifs_path && - item.cifs_path == properties.mountpoint.value && - item.cifs_name && - item.cifs_name == smbName) || - (item.path && - item.path == properties.mountpoint.value && - item.name && - item.name == smbName) - ) { - return true; - } - return false; - } - ); - - if (!lookupShare) { - throw new GrpcError( - grpc.status.UNKNOWN, - `FreeNAS failed to find matching share` - ); - } - - //set zfs property - await zb.zfs.set(datasetName, { - [FREENAS_SMB_SHARE_PROPERTY_NAME]: lookupShare.id, - }); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating smb share - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - volume_context = { - node_attach_driver: "smb", - server: this.options.smb.shareHost, - share: smbName, - }; - return volume_context; - } - - break; - case "iscsi": - { - properties = await zb.zfs.get(datasetName, [ - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, - ]); - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - let basename; - let iscsiName; - - if (this.options.iscsi.nameTemplate) { - iscsiName = Handlebars.compile(this.options.iscsi.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - iscsiName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.iscsi.namePrefix) { - iscsiName = this.options.iscsi.namePrefix + iscsiName; - } - - if (this.options.iscsi.nameSuffix) { - iscsiName += this.options.iscsi.nameSuffix; - } - - // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' - // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name - // https://tools.ietf.org/html/rfc3720 - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - iscsiName = iscsiName.toLowerCase(); - - let extentDiskName = "zvol/" + datasetName; - let maxZvolNameLength = await driver.getMaxZvolNameLength(); - driver.ctx.logger.debug( - "max zvol name length: %s", - maxZvolNameLength - ); - - /** - * limit is a FreeBSD limitation - * https://www.ixsystems.com/documentation/freenas/11.2-U5/storage.html#zfs-zvol-config-opts-tab - */ - - if (extentDiskName.length > maxZvolNameLength) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `extent disk name cannot exceed ${maxZvolNameLength} characters: ${extentDiskName}` - ); - } - - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - if (isScale && iscsiName.length > 64) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `extent name cannot exceed 64 characters: ${iscsiName}` - ); - } - - this.ctx.logger.info( - "FreeNAS creating iscsi assets with name: " + iscsiName - ); - - let extentComment; - if (this.options.iscsi.extentCommentTemplate) { - extentComment = Handlebars.compile( - this.options.iscsi.extentCommentTemplate - )({ - name: call.request.name, - parameters: call.request.parameters, - csi: { - name: this.ctx.args.csiName, - version: this.ctx.args.csiVersion, - }, - zfs: { - datasetName: datasetName, - }, - }); - } else { - extentComment = ""; - } - - const extentInsecureTpc = this.options.iscsi.hasOwnProperty( - "extentInsecureTpc" - ) - ? this.options.iscsi.extentInsecureTpc - : true; - - const extentXenCompat = this.options.iscsi.hasOwnProperty( - "extentXenCompat" - ) - ? this.options.iscsi.extentXenCompat - : false; - - const extentBlocksize = this.options.iscsi.hasOwnProperty( - "extentBlocksize" - ) - ? this.options.iscsi.extentBlocksize - : 512; - - const extentDisablePhysicalBlocksize = - this.options.iscsi.hasOwnProperty("extentDisablePhysicalBlocksize") - ? this.options.iscsi.extentDisablePhysicalBlocksize - : true; - - const extentRpm = this.options.iscsi.hasOwnProperty("extentRpm") - ? this.options.iscsi.extentRpm - : "SSD"; - - let extentAvailThreshold = this.options.iscsi.hasOwnProperty( - "extentAvailThreshold" - ) - ? Number(this.options.iscsi.extentAvailThreshold) - : null; - - if (!(extentAvailThreshold > 0 && extentAvailThreshold <= 100)) { - extentAvailThreshold = null; - } - - switch (apiVersion) { - case 1: - response = await httpClient.get( - "/services/iscsi/globalconfiguration" - ); - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error getting iscsi configuration - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - basename = response.body.iscsi_basename; - this.ctx.logger.verbose("FreeNAS ISCSI BASENAME: " + basename); - break; - case 2: - response = await httpClient.get("/iscsi/global"); - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error getting iscsi configuration - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - basename = response.body.basename; - this.ctx.logger.verbose("FreeNAS ISCSI BASENAME: " + basename); - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - - // if we got all the way to the TARGETTOEXTENT then we fully finished - // otherwise we must do all assets every time due to the interdependence of IDs etc - if ( - !zb.helpers.isPropertyValueSet( - properties[FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME].value - ) - ) { - switch (apiVersion) { - case 1: { - // create target - let target = { - iscsi_target_name: iscsiName, - iscsi_target_alias: "", // TODO: allow template for this - }; - - response = await httpClient.post( - "/services/iscsi/target", - target - ); - - // 409 Conflict - target already exists or other validation errors - if (response.statusCode != 201) { - target = null; - if ( - response.statusCode == 409 && - this.isTargetAlreadyExistsError(response.body) - ) { - this.ctx.logger.debug( - "iSCSI target already exists, attempting to find existing target with name: %s", - iscsiName - ); - target = await this.findResourceByProperties( - "/services/iscsi/target", - { - iscsi_target_name: iscsiName, - } - ); - if (target) { - this.ctx.logger.debug( - "Found existing iSCSI target with ID: %s, name: %s", - target.id, - target.iscsi_target_name - ); - } - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi target - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - target = response.body; - } - - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.iscsi_target_name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI TARGET: %j", target); - - // set target.id on zvol - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME]: target.id, - }); - - // create targetgroup(s) - // targetgroups do have IDs - for (let targetGroupConfig of this.options.iscsi.targetGroups) { - let targetGroup = { - iscsi_target: target.id, - iscsi_target_authgroup: - targetGroupConfig.targetGroupAuthGroup, - iscsi_target_authtype: targetGroupConfig.targetGroupAuthType - ? targetGroupConfig.targetGroupAuthType - : "None", - iscsi_target_portalgroup: - targetGroupConfig.targetGroupPortalGroup, - iscsi_target_initiatorgroup: - targetGroupConfig.targetGroupInitiatorGroup, - iscsi_target_initialdigest: "Auto", - }; - response = await httpClient.post( - "/services/iscsi/targetgroup", - targetGroup - ); - - // 409 if invalid - if (response.statusCode != 201) { - targetGroup = null; - /** - * 404 gets returned with an unable to process response when the DB is corrupted (has invalid entries in essense) - * - * To resolve properly the DB should be cleaned up - * /usr/local/etc/rc.d/django stop - * /usr/local/etc/rc.d/nginx stop - * sqlite3 /data/freenas-v1.db - * - * // this deletes everything, probably not what you want - * // should have a better query to only find entries where associated assets no longer exist - * DELETE from services_iscsitargetgroups; - * - * /usr/local/etc/rc.d/django restart - * /usr/local/etc/rc.d/nginx restart - */ - if ( - response.statusCode == 404 || - (response.statusCode == 409 && - JSON.stringify(response.body).includes( - "cannot be duplicated on a target" - )) - ) { - targetGroup = await this.findResourceByProperties( - "/services/iscsi/targetgroup", - { - iscsi_target: target.id, - iscsi_target_portalgroup: - targetGroupConfig.targetGroupPortalGroup, - iscsi_target_initiatorgroup: - targetGroupConfig.targetGroupInitiatorGroup, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targetgroup - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetGroup = response.body; - } - - if (!targetGroup) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targetgroup` - ); - } - - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_GROUP: %j", - targetGroup - ); - } - - let extent = { - iscsi_target_extent_comment: extentComment, - iscsi_target_extent_type: "Disk", // Disk/File, after save Disk becomes "ZVOL" - iscsi_target_extent_name: iscsiName, - iscsi_target_extent_insecure_tpc: extentInsecureTpc, - //iscsi_target_extent_naa: "0x3822690834aae6c5", - iscsi_target_extent_disk: extentDiskName, - iscsi_target_extent_xen: extentXenCompat, - iscsi_target_extent_avail_threshold: extentAvailThreshold, - iscsi_target_extent_blocksize: Number(extentBlocksize), - iscsi_target_extent_pblocksize: - extentDisablePhysicalBlocksize, - iscsi_target_extent_rpm: isNaN(Number(extentRpm)) - ? "SSD" - : Number(extentRpm), - iscsi_target_extent_ro: false, - }; - response = await httpClient.post( - "/services/iscsi/extent", - extent - ); - - // 409 if invalid - if (response.statusCode != 201) { - extent = null; - if ( - response.statusCode == 409 && - JSON.stringify(response.body).includes( - "Extent name must be unique" - ) - ) { - extent = await this.findResourceByProperties( - "/services/iscsi/extent", - { iscsi_target_extent_name: iscsiName } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi extent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - extent = response.body; - } - - if (!extent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi extent` - ); - } - - if (extent.iscsi_target_extent_name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi extent` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI EXTENT: %j", extent); - - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME]: extent.id, - }); - - // create targettoextent - let targetToExtent = { - iscsi_target: target.id, - iscsi_extent: extent.id, - iscsi_lunid: 0, - }; - response = await httpClient.post( - "/services/iscsi/targettoextent", - targetToExtent - ); - - // 409 if invalid - if (response.statusCode != 201) { - targetToExtent = null; - - // LUN ID is already being used for this target. - // Extent is already in this target. - if ( - response.statusCode == 409 && - (JSON.stringify(response.body).includes( - "Extent is already in this target." - ) || - JSON.stringify(response.body).includes( - "LUN ID is already being used for this target." - )) - ) { - targetToExtent = await this.findResourceByProperties( - "/services/iscsi/targettoextent", - { - iscsi_target: target.id, - iscsi_extent: extent.id, - iscsi_lunid: 0, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targettoextent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetToExtent = response.body; - } - - if (!targetToExtent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targettoextent` - ); - } - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_TO_EXTENT: %j", - targetToExtent - ); - - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME]: - targetToExtent.id, - }); - - break; - } - case 2: - // create target and targetgroup - //let targetId; - let targetGroups = []; - for (let targetGroupConfig of this.options.iscsi.targetGroups) { - targetGroups.push({ - portal: targetGroupConfig.targetGroupPortalGroup, - initiator: targetGroupConfig.targetGroupInitiatorGroup, - auth: - targetGroupConfig.targetGroupAuthGroup > 0 - ? targetGroupConfig.targetGroupAuthGroup - : null, - authmethod: - targetGroupConfig.targetGroupAuthType.length > 0 - ? targetGroupConfig.targetGroupAuthType - .toUpperCase() - .replace(" ", "_") - : "NONE", - }); - } - let target = { - name: iscsiName, - alias: null, // cannot send "" error: handler error - driver: FreeNASDriver method: CreateVolume error: {"name":"GrpcError","code":2,"message":"received error creating iscsi target - code: 422 body: {\"iscsi_target_create.alias\":[{\"message\":\"Alias already exists\",\"errno\":22}]}"} - mode: "ISCSI", - groups: targetGroups, - }; - - response = await httpClient.post("/iscsi/target", target); - - // 422 Unprocessable Entity - validation errors including duplicate targets - if (response.statusCode != 200) { - target = null; - if ( - response.statusCode == 422 && - this.isTargetAlreadyExistsError(response.body) - ) { - this.ctx.logger.debug( - "iSCSI target already exists, attempting to find existing target with name: %s", - iscsiName - ); - target = await this.findResourceByProperties( - "/iscsi/target", - { - name: iscsiName, - } - ); - if (target) { - this.ctx.logger.debug( - "Found existing iSCSI target with ID: %s, name: %s", - target.id, - target.name - ); - } - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi target - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - target = response.body; - } - - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - // handle situations/race conditions where groups failed to be added/created on the target - // groups":[{"portal":1,"initiator":1,"auth":null,"authmethod":"NONE"},{"portal":2,"initiator":1,"auth":null,"authmethod":"NONE"}] - // TODO: this logic could be more intelligent but this should do for now as it appears in the failure scenario no groups are added - // in other words, I have never seen them invalid, only omitted so this should be enough - if (target.groups.length != targetGroups.length) { - response = await httpClient.put( - `/iscsi/target/id/${target.id}`, - { - groups: targetGroups, - } - ); - - if (response.statusCode != 200) { - throw new GrpcError( - grpc.status.UNKNOWN, - `failed setting target groups` - ); - } else { - target = response.body; - - // re-run sanity checks - if (!target) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi target` - ); - } - - if (target.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi target` - ); - } - - if (target.groups.length != targetGroups.length) { - throw new GrpcError( - grpc.status.UNKNOWN, - `failed setting target groups` - ); - } - } - } - - this.ctx.logger.verbose("FreeNAS ISCSI TARGET: %j", target); - - // set target.id on zvol - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME]: target.id, - }); - - let extent = { - comment: extentComment, - type: "DISK", // Disk/File, after save Disk becomes "ZVOL" - name: iscsiName, - //iscsi_target_extent_naa: "0x3822690834aae6c5", - disk: extentDiskName, - insecure_tpc: extentInsecureTpc, - xen: extentXenCompat, - avail_threshold: extentAvailThreshold, - blocksize: Number(extentBlocksize), - pblocksize: extentDisablePhysicalBlocksize, - rpm: "" + extentRpm, // should be a string - ro: false, - }; - - response = await httpClient.post("/iscsi/extent", extent); - - // 409 if invalid - if (response.statusCode != 200) { - extent = null; - if ( - response.statusCode == 422 && - JSON.stringify(response.body).includes( - "Extent name must be unique" - ) - ) { - extent = await this.findResourceByProperties( - "/iscsi/extent", - { - name: iscsiName, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi extent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - extent = response.body; - } - - if (!extent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi extent` - ); - } - - if (extent.name != iscsiName) { - throw new GrpcError( - grpc.status.UNKNOWN, - `mismatch name error creating iscsi extent` - ); - } - - this.ctx.logger.verbose("FreeNAS ISCSI EXTENT: %j", extent); - - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME]: extent.id, - }); - - // create targettoextent - let targetToExtent = { - target: target.id, - extent: extent.id, - lunid: 0, - }; - response = await httpClient.post( - "/iscsi/targetextent", - targetToExtent - ); - - if (response.statusCode != 200) { - targetToExtent = null; - - // LUN ID is already being used for this target. - // Extent is already in this target. - if ( - response.statusCode == 422 && - (JSON.stringify(response.body).includes( - "Extent is already in this target." - ) || - JSON.stringify(response.body).includes( - "LUN ID is already being used for this target." - )) - ) { - targetToExtent = await this.findResourceByProperties( - "/iscsi/targetextent", - { - target: target.id, - extent: extent.id, - lunid: 0, - } - ); - } else { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error creating iscsi targetextent - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - } else { - targetToExtent = response.body; - } - - if (!targetToExtent) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unknown error creating iscsi targetextent` - ); - } - this.ctx.logger.verbose( - "FreeNAS ISCSI TARGET_TO_EXTENT: %j", - targetToExtent - ); - - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME]: - targetToExtent.id, - }); - - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - // iqn = target - let iqn = basename + ":" + iscsiName; - this.ctx.logger.info("FreeNAS iqn: " + iqn); - - // store this off to make delete process more bullet proof - await zb.zfs.set(datasetName, { - [FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME]: iscsiName, - }); - - volume_context = { - node_attach_driver: "iscsi", - portal: this.options.iscsi.targetPortal || "", - portals: this.options.iscsi.targetPortals - ? this.options.iscsi.targetPortals.join(",") - : "", - interface: this.options.iscsi.interface || "", - iqn: iqn, - lun: 0, - }; - return volume_context; - } - break; - - case "nvmeof": - { - switch (apiVersion) { - case 1: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with version 2 of the api` - ); - break; - } - - if (!isScale || semver.satisfies(truenasVersion, "<25.10")) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with TrueNAS version 25.10 and above` - ); - } - - properties = await zb.zfs.get(datasetName, [ - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, - FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, - ]); - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - let nvmeofName; - - if (this.options.nvmeof.nameTemplate) { - nvmeofName = Handlebars.compile(this.options.nvmeof.nameTemplate)({ - name: call.request.name, - parameters: call.request.parameters, - }); - } else { - nvmeofName = zb.helpers.extractLeafName(datasetName); - } - - if (this.options.nvmeof.namePrefix) { - nvmeofName = this.options.nvmeof.namePrefix + nvmeofName; - } - - if (this.options.nvmeof.nameSuffix) { - nvmeofName += this.options.nvmeof.nameSuffix; - } - - // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' - // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name - // https://tools.ietf.org/html/rfc3720 - // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - nvmeofName = nvmeofName.toLowerCase(); - - let namespaceDiskName = "zvol/" + datasetName; - let maxZvolNameLength = await driver.getMaxZvolNameLength(); - driver.ctx.logger.debug( - "max zvol name length: %s", - maxZvolNameLength - ); - - if (namespaceDiskName.length > maxZvolNameLength) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `namespace disk name cannot exceed ${maxZvolNameLength} characters: ${namespaceDiskName}` - ); - } - - // TODO: get basenqn from global config, add nvemofName to it and ensure full nqn is <= 223 - // // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 - // if (isScale && nvmeofName.length > 64) { - // throw new GrpcError( - // grpc.status.FAILED_PRECONDITION, - // `extent name cannot exceed 64 characters: ${nvmeofName}` - // ); - // } - - this.ctx.logger.info( - "FreeNAS creating nvmeof assets with name: " + nvmeofName - ); - - // http:///api/docs/current/api_methods_nvmet.subsys.create.html - let subsystemTemplate = _.get( - this.options, - "nvmeof.subsystemTemplate", - {} - ); - subsystemTemplate = subsystemTemplate || {}; - - // http:///api/docs/current/api_methods_nvmet.namespace.create.html - let namespaceTemplate = _.get( - this.options, - "nvmeof.namespaceTemplate", - {} - ); - namespaceTemplate = namespaceTemplate || {}; - - // create subsystem - let subsystem; - switch (apiVersion) { - case 2: - subsystem = await httpApiClient.NvmetSubsysCreate( - nvmeofName, - subsystemTemplate - ); - - break; - } - if (!subsystem) { - throw new GrpcError( - grpc.status.NOT_FOUND, - `unable to find nvmeof subsystem: ${nvmeofName}` - ); - } - this.ctx.logger.verbose("FreeNAS NVMEOF SUBSYSTEM: %j", subsystem); - await zb.zfs.set(datasetName, { - [FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME]: subsystem.id, - }); - - // create subsystem - let namespace; - switch (apiVersion) { - case 2: - namespace = await httpApiClient.NvmetNamespaceCreate( - namespaceDiskName, - subsystem.id, - namespaceTemplate - ); - - break; - } - if (!namespace) { - throw new GrpcError( - grpc.status.NOT_FOUND, - `unable to find nvmeof namespace: ${namespaceDiskName}` - ); - } - this.ctx.logger.verbose("FreeNAS NVMEOF NAMESPACE: %j", namespace); - await zb.zfs.set(datasetName, { - [FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME]: namespace.id, - }); - - // assign ports to subsystem - let ports = _.get(this.options, "nvmeof.ports", []); - for (const port_i of ports) { - const port = await httpApiClient.NvmetPortSubsysCreate( - port_i, - subsystem.id - ); - this.ctx.logger.verbose("FreeNAS NVMEOF PORT: %j", port); - } - - // TODO: assign hosts - - // store this off to make delete process more bullet proof - await zb.zfs.set(datasetName, { - [FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME]: nvmeofName, - }); - - volume_context = { - node_attach_driver: "nvmeof", - transport: this.options.nvmeof.transport || "", - transports: this.options.nvmeof.transports - ? this.options.nvmeof.transports.join(",") - : "", - nqn: subsystem.subnqn, - nsid: namespace.nsid, - }; - return volume_context; - } - break; - - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown driverShareType ${driverShareType}` - ); - } + const helper = new FreeNASApiShareHelper(driver); + return helper.createShare(call, datasetName); } async deleteShare(call, datasetName) { - const driverShareType = this.getDriverShareType(); - const httpClient = await this.getHttpClient(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - const apiVersion = httpClient.getApiVersion(); - const zb = await this.getZetabyte(); - const truenasVersion = await httpApiClient.getSystemVersionSemver(); - - if (!truenasVersion) { - throw new GrpcError( - grpc.status.UNKNOWN, - `unable to detect TrueNAS version` - ); - } - - const isScale = await httpApiClient.getIsScale(); - - let properties; - let response; - let endpoint; - let shareId; - let deleteAsset; - let sharePaths; - - switch (driverShareType) { - case "nfs": - { - try { - properties = await zb.zfs.get(datasetName, [ - "mountpoint", - FREENAS_NFS_SHARE_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - shareId = properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value; - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(shareId)) { - // remove nfs share - switch (apiVersion) { - case 1: - case 2: - endpoint = "/sharing/nfs/"; - if (apiVersion == 2) { - endpoint += "id/"; - } - endpoint += shareId; - - response = await httpClient.get(endpoint); - - // assume share is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - switch (apiVersion) { - case 1: - sharePaths = response.body.nfs_paths; - break; - case 2: - if (response.body.path) { - sharePaths = [response.body.path]; - } else { - sharePaths = response.body.paths; - } - break; - } - - deleteAsset = sharePaths.some((value) => { - return value == properties.mountpoint.value; - }); - - if (deleteAsset) { - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.delete(endpoint); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - // returns a 500 if does not exist - // v1 = 204 - // v2 = 200 - if (![200, 204].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting nfs share - share: ${shareId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await zb.zfs.inherit( - datasetName, - FREENAS_NFS_SHARE_PROPERTY_NAME - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - } - break; - case "smb": - { - try { - properties = await zb.zfs.get(datasetName, [ - "mountpoint", - FREENAS_SMB_SHARE_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - shareId = properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value; - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(shareId)) { - // remove smb share - switch (apiVersion) { - case 1: - case 2: - switch (apiVersion) { - case 1: - endpoint = `/sharing/cifs/${shareId}`; - break; - case 2: - endpoint = `/sharing/smb/id/${shareId}`; - break; - } - - response = await httpClient.get(endpoint); - - // assume share is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - switch (apiVersion) { - case 1: - sharePaths = [response.body.cifs_path]; - break; - case 2: - sharePaths = [response.body.path]; - break; - } - - deleteAsset = sharePaths.some((value) => { - return value == properties.mountpoint.value; - }); - - if (deleteAsset) { - response = await GeneralUtils.retry( - 3, - 1000, - async () => { - return await httpClient.delete(endpoint); - }, - { - retryCondition: (err) => { - if (err.code == "ECONNRESET") { - return true; - } - if (err.code == "ECONNABORTED") { - return true; - } - if (err.response && err.response.statusCode == 504) { - return true; - } - return false; - }, - } - ); - - // returns a 500 if does not exist - // v1 = 204 - // v2 = 200 - if ( - ![200, 204].includes(response.statusCode) && - !JSON.stringify(response.body).includes("does not exist") - ) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting smb share - share: ${shareId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await zb.zfs.inherit( - datasetName, - FREENAS_SMB_SHARE_PROPERTY_NAME - ); - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - } - break; - case "iscsi": - { - // Delete target - // NOTE: deletting a target inherently deletes associated targetgroup(s) and targettoextent(s) - - // Delete extent - try { - properties = await zb.zfs.get(datasetName, [ - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, - FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - let targetId = - properties[FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME].value; - let extentId = - properties[FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME].value; - let iscsiName = - properties[FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME].value; - let assetName; - - switch (apiVersion) { - case 1: - case 2: - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(targetId)) { - // https://jira.ixsystems.com/browse/NAS-103952 - - // v1 - /services/iscsi/target/{id}/ - // v2 - /iscsi/target/id/{id} - endpoint = ""; - if (apiVersion == 1) { - endpoint += "/services"; - } - endpoint += "/iscsi/target/"; - if (apiVersion == 2) { - endpoint += "id/"; - } - endpoint += targetId; - response = await httpClient.get(endpoint); - - // assume is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - deleteAsset = true; - assetName = null; - - // checking if set for backwards compatibility - if (zb.helpers.isPropertyValueSet(iscsiName)) { - switch (apiVersion) { - case 1: - assetName = response.body.iscsi_target_name; - break; - case 2: - assetName = response.body.name; - break; - } - - if (assetName != iscsiName) { - deleteAsset = false; - } - } - - if (deleteAsset) { - let retries = 0; - let maxRetries = 5; - let retryWait = 1000; - response = await httpClient.delete(endpoint); - - // sometimes after an initiator has detached it takes a moment for TrueNAS to settle - // code: 422 body: {\"message\":\"Target csi-ci-55877e95sanity-node-expand-volume-e54f81fa-cd38e798 is in use.\",\"errno\":14} - while ( - response.statusCode == 422 && - retries < maxRetries && - _.get(response, "body.message").includes("Target") && - _.get(response, "body.message").includes("is in use") && - _.get(response, "body.errno") == 14 - ) { - retries++; - this.ctx.logger.debug( - "target: %s is in use, retry %s shortly", - targetId, - retries - ); - await GeneralUtils.sleep(retryWait); - response = await httpClient.delete(endpoint); - } - - if (![200, 204].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting iscsi target - target: ${targetId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await zb.zfs.inherit( - datasetName, - FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME - ); - } else { - this.ctx.logger.debug( - "not deleting iscsitarget asset as it appears ID %s has been re-used: zfs name - %s, iscsitarget name - %s", - targetId, - iscsiName, - assetName - ); - } - } - } - - // only remove if the process has not succeeded already - if (zb.helpers.isPropertyValueSet(extentId)) { - // v1 - /services/iscsi/targettoextent/{id}/ - // v2 - /iscsi/targetextent/id/{id} - if (apiVersion == 1) { - endpoint = "/services/iscsi/extent/"; - } else { - endpoint = "/iscsi/extent/id/"; - } - endpoint += extentId; - response = await httpClient.get(endpoint); - - // assume is gone for now - if ([404, 500].includes(response.statusCode)) { - } else { - deleteAsset = true; - assetName = null; - - // checking if set for backwards compatibility - if (zb.helpers.isPropertyValueSet(iscsiName)) { - switch (apiVersion) { - case 1: - assetName = response.body.iscsi_target_extent_name; - break; - case 2: - assetName = response.body.name; - break; - } - - if (assetName != iscsiName) { - deleteAsset = false; - } - } - - if (deleteAsset) { - response = await httpClient.delete(endpoint); - if (![200, 204].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `received error deleting iscsi extent - extent: ${extentId} code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - - // remove property to prevent delete race conditions - // due to id re-use by FreeNAS/TrueNAS - await zb.zfs.inherit( - datasetName, - FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME - ); - } else { - this.ctx.logger.debug( - "not deleting iscsiextent asset as it appears ID %s has been re-used: zfs name - %s, iscsiextent name - %s", - extentId, - iscsiName, - assetName - ); - } - } - } - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - break; - - case "nvmeof": - { - switch (apiVersion) { - case 1: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with version 2 of the api` - ); - break; - } - - if (!isScale || semver.satisfies(truenasVersion, "<25.10")) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `nvmeof feature is only available with TrueNAS version 25.10 and above` - ); - } - - try { - properties = await zb.zfs.get(datasetName, [ - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, - FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, - ]); - } catch (err) { - if (err.toString().includes("dataset does not exist")) { - return; - } - throw err; - } - - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - - let subsystemId = - properties[FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME].value; - let namespaceId = - properties[FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME].value; - - // remove namespace - if (zb.helpers.isPropertyValueSet(namespaceId)) { - await GeneralUtils.retry( - 15, - 2000, - async () => { - await httpApiClient.NvmetNamespaceDeleteById(namespaceId); - }, - { - retryCondition: (err) => { - return true; - }, - } - ); - - await zb.zfs.inherit( - datasetName, - FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME - ); - } - - // remove subsystem - if (zb.helpers.isPropertyValueSet(subsystemId)) { - await GeneralUtils.retry( - 15, - 2000, - async () => { - await httpApiClient.NvmetSubsysDeleteById(subsystemId, { - force: true, - }); - }, - { - retryCondition: (err) => { - return true; - }, - } - ); - - await zb.zfs.inherit( - datasetName, - FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME - ); - } - } - break; - - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown driverShareType ${driverShareType}` - ); - } - } - - async setFilesystemMode(path, mode) { - const httpClient = await this.getHttpClient(); - const apiVersion = httpClient.getApiVersion(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - - switch (apiVersion) { - case 1: - return super.setFilesystemMode(...arguments); - case 2: - let perms = { - path, - mode: String(mode), - }; - - /* - { - "path": "string", - "mode": "string", - "uid": 0, - "gid": 0, - "options": { - "stripacl": false, - "recursive": false, - "traverse": false - } - } - */ - - let response; - let endpoint; - - endpoint = `/filesystem/setperm`; - response = await httpClient.post(endpoint, perms); - - if (response.statusCode == 200) { - await httpApiClient.CoreWaitForJob(response.body, 30); - return; - } - - throw new Error(JSON.stringify(response.body)); - - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - async setFilesystemOwnership(path, user = false, group = false) { - const httpClient = await this.getHttpClient(); - const apiVersion = httpClient.getApiVersion(); - const httpApiClient = await this.getTrueNASHttpApiClient(); - - if (user === false || typeof user == "undefined" || user === null) { - user = ""; - } - - if (group === false || typeof group == "undefined" || group === null) { - group = ""; - } - - user = String(user); - group = String(group); - - if (user.length < 1 && group.length < 1) { - return; - } - - switch (apiVersion) { - case 1: - return super.setFilesystemOwnership(...arguments); - case 2: - let perms = { - path, - }; - // set ownership - - // user - if (user.length > 0) { - if (String(user).match(/^[0-9]+$/) == null) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `BREAKING CHANGE since v1.5.3! datasetPermissionsUser must be numeric: ${user} is invalid` - ); - } - perms.uid = Number(user); - } - - // group - if (group.length > 0) { - if (String(group).match(/^[0-9]+$/) == null) { - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `BREAKING CHANGE since v1.5.3! datasetPermissionsGroup must be numeric: ${group} is invalid` - ); - } - perms.gid = Number(group); - } - - /* - { - "path": "string", - "mode": "string", - "uid": 0, - "gid": 0, - "options": { - "stripacl": false, - "recursive": false, - "traverse": false - } - } - */ - - let response; - let endpoint; - - endpoint = `/filesystem/setperm`; - response = await httpClient.post(endpoint, perms); - - if (response.statusCode == 200) { - await httpApiClient.CoreWaitForJob(response.body, 30); - return; - } - - throw new Error(JSON.stringify(response.body)); - break; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } + const driver = this; + const helper = new FreeNASApiShareHelper(driver); + return helper.deleteShare(call, datasetName); } async expandVolume(call, datasetName) { - const driverShareType = this.getDriverShareType(); - const execClient = this.getExecClient(); - const httpClient = await this.getHttpClient(); - const apiVersion = httpClient.getApiVersion(); - const zb = await this.getZetabyte(); - - switch (driverShareType) { - case "iscsi": - const isScale = await this.getIsScale(); - let command; - let reload = false; - if (isScale) { - let properties; - properties = await zb.zfs.get(datasetName, [ - FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME, - ]); - properties = properties[datasetName]; - this.ctx.logger.debug("zfs props data: %j", properties); - let iscsiName = - properties[FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME].value; - - // name correlates to the extent NOT the target - let kName = iscsiName.replaceAll(".", "_"); - - /** - * command = execClient.buildCommand("systemctl", ["reload", "scst"]); - * does not help ^ - * - * echo 1 > /sys/kernel/scst_tgt/devices/${iscsiName}/resync_size - * works ^ - * - * scstadmin -resync_dev ${iscsiName} - * works but always give a exit code of 1 ^ - * - * midclt resync_lun_size_for_zvol tank/foo/bar - * works on SCALE only ^ - * - */ - command = execClient.buildCommand("sh", [ - "-c", - `"echo 1 > /sys/kernel/scst_tgt/devices/${kName}/resync_size"`, - ]); - reload = true; - } else { - switch (apiVersion) { - case 1: - // use cli for now - command = execClient.buildCommand("/etc/rc.d/ctld", ["reload"]); - reload = true; - break; - case 2: - this.ctx.logger.verbose( - "FreeNAS reloading iscsi daemon using api" - ); - // POST /service/reload - let payload = { - service: "iscsitarget", // api version of ctld, same name in SCALE as well - "service-control": { - ha_propagate: true, - }, - }; - let response = await httpClient.post("/service/reload", payload); - if (![200, 204].includes(response.statusCode)) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error reloading iscsi daemon - code: ${ - response.statusCode - } body: ${JSON.stringify(response.body)}` - ); - } - return; - default: - throw new GrpcError( - grpc.status.FAILED_PRECONDITION, - `invalid configuration: unknown apiVersion ${apiVersion}` - ); - } - } - - if (reload) { - if ((await this.getWhoAmI()) != "root") { - command = (await this.getSudoPath()) + " " + command; - } - - this.ctx.logger.verbose( - "FreeNAS reloading iscsi daemon: %s", - command - ); - - let response = await execClient.exec(command); - if (response.code != 0) { - throw new GrpcError( - grpc.status.UNKNOWN, - `error reloading iscsi daemon: ${JSON.stringify(response)}` - ); - } - } - break; - } - } - - async getApiVersion() { - const systemVersion = await this.getSystemVersion(); - - if (systemVersion.v2) { - if ((await this.getSystemVersionMajorMinor()) == 11.2) { - return 1; - } - return 2; - } - - if (systemVersion.v1) { - return 1; - } - - return 2; - } - - 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(); - const major = await this.getSystemVersionMajor(); - - // starting with version 25 the version string no longer contains `-SCALE` - if ( - systemVersion.v2 && - (systemVersion.v2.toLowerCase().includes("scale") || Number(major) >= 20) - ) { - 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) { const driver = this; - - await driver.ctx.cache.set(FREENAS_SYSTEM_VERSION_CACHE_KEY, versionInfo, { - ttl: 60 * 1000, - }); + const helper = new FreeNASApiShareHelper(driver); + return helper.expandVolume(call, datasetName); } - - async getSystemVersion() { - const driver = this; - let cacheData = await driver.ctx.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, null, { timeout: 5 * 1000 }); - 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, null, { timeout: 5 * 1000 }); - 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: ${GeneralUtils.stringify({ - errors: versionErrors, - responses: versionResponses, - })}` - ); - } -} - -function IsJsonString(str) { - try { - JSON.parse(str); - } catch (e) { - return false; - } - return true; } module.exports.FreeNASSshDriver = FreeNASSshDriver; diff --git a/src/driver/freenas/websocket/api.js b/src/driver/freenas/websocket/api.js new file mode 100644 index 0000000..9099c6d --- /dev/null +++ b/src/driver/freenas/websocket/api.js @@ -0,0 +1,1081 @@ +const _ = require("lodash"); +const semver = require("semver"); +const { sleep, is_object } = require("../../../utils/general"); +const { Zetabyte } = require("../../../utils/zfs"); +const { Registry } = require("../../../utils/registry"); + +const __REGISTRY_NS__ = "FreeNASWebSocketApi"; + +const ERROR_DATASET_DOES_NOT_EXIST_REGEX = /PoolDataset.*does not exist/i; +const ERROR_SNAPSHOT_DOES_NOT_EXIST_REGEX = /PoolSnapshot.*does not exist/i; + +class Api { + constructor(client, options = {}) { + this.client = client; + this.options = options; + this.registry = new Registry(); + } + + async getWebSocketClient() { + return this.client; + } + + /** + * only here for the helpers + * @returns + */ + async getZetabyte() { + return this.registry.get(`${__REGISTRY_NS__}:zb`, () => { + return new Zetabyte({ + executor: { + spawn: function () { + throw new Error( + "cannot use the zb implementation to execute zfs commands, must use the websocket api", + ); + }, + }, + }); + }); + } + + normalizeResourceDomain(resource) { + if (resource == "/sharing/cifs") { + resource = "/sharing/smb"; + } + + // convert /sharing/nfs to sharing.nfs.query + if (String(resource).startsWith("/")) { + resource = resource.slice(1); + if (resource.startsWith("services/")) { + resource = resource.replace("services/", ""); + } + resource = resource.replaceAll("/", "."); + resource = resource.replace("targettoextent", "targetextent"); + } + + return resource; + } + async findResourceByProperties(resource, match) { + if (!match) { + return; + } + + if (typeof match === "object" && Object.keys(match).length < 1) { + return; + } + + let method = this.normalizeResourceDomain(resource); + method += ".query"; + + const client = await this.getWebSocketClient(); + let target; + let page = 0; + let lastReponse; + + // loop and find target + let queryParams = {}; + queryParams.limit = 100; + queryParams.offset = 0; + + while (!target) { + if (queryParams.hasOwnProperty("offset")) { + queryParams.offset = queryParams.limit * page; + } + + // crude stoppage attempt + let response = await client.call(method, [[], queryParams]); + if (lastReponse) { + if (JSON.stringify(lastReponse) == JSON.stringify(response)) { + break; + } + } + lastReponse = response; + + response.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; + }); + + if (response.length < queryParams.limit) { + break; + } + + page++; + } + + return target; + } + + async getSystemVersionMajorMinor() { + const systemVersion = await this.getSystemVersion(); + let parts; + let parts_i; + let version = systemVersion; + + 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 getSystemVersion() { + /** + * FreeNAS-11.2-U5 + * TrueNAS-12.0-RELEASE + * TrueNAS-SCALE-20.11-MASTER-20201127-092915 + * TrueNAS-26.0.0-BETA.3 + */ + let systemVersion = await this.client.systemVersion(); + if (!systemVersion) { + throw new Error( + `FreeNAS error getting system version info: websocket not connected`, + ); + } + + return systemVersion; + } + + async getSystemVersionSemver() { + return semver.coerce(await this.getSystemVersionMajorMinor(), { + loose: true, + }); + } + + 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: String(properties[property]) }); + } + + return arr; + } + + getPropertiesFromKeyValueArray(kvarr) { + let properties = {}; + for (const v of kvarr) { + properties[v.key] = v.value; + } + + return properties; + } + + 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; + } + + for (const key in res) { + const property = res[key]; + if (property.hasOwnProperty("raw")) { + property.rawvalue = property.raw; + delete property.raw; + } + + if (is_object(property.source)) { + let source = property.source; + switch (source.type) { + case "NONE": + property.source = "-"; + break; + case "LOCAL": + case "INHERITED": + case "DEFAULT": + break; + default: + throw new Error(`unhanded source.type: ${source.type}`); + } + } + } + + return res; + } + + async ResourceList(resource, match) { + let method = this.normalizeResourceDomain(resource); + method += ".query"; + + const client = await this.getWebSocketClient(); + let entries = []; + let page = 0; + let lastReponse; + + // loop and find target + let queryParams = {}; + queryParams.limit = 100; + queryParams.offset = 0; + + while (true) { + if (queryParams.hasOwnProperty("offset")) { + queryParams.offset = queryParams.limit * page; + } + + // crude stoppage attempt + let response = await client.call(method, [[], queryParams]); + if (lastReponse) { + if (JSON.stringify(lastReponse) == JSON.stringify(response)) { + break; + } + } + lastReponse = response; + + response.forEach((i) => { + let isMatch = true; + + if (match) { + if (typeof match === "function") { + isMatch = match(i); + } else { + if (typeof match === "object" && Object.keys(match).length > 0) { + for (let property in match) { + if (match[property] != i[property]) { + isMatch = false; + break; + } + } + } + } + } + if (isMatch) { + entries.push(i); + } + }); + + if (response.length < queryParams.limit) { + break; + } + + page++; + } + + return entries; + } + + async ResourceDelete(resource, id, options = null) { + let method = this.normalizeResourceDomain(resource); + method += ".delete"; + + const client = await this.getWebSocketClient(); + if (options) { + return client.call(method, [parseInt(id), options]); + } else { + return client.call(method, [id]); + } + } + + async ResourceGet(resource, id, options = null) { + let method = this.normalizeResourceDomain(resource); + method += ".get_instance"; + + id = parseInt(id); + + const client = await this.getWebSocketClient(); + if (options) { + return client.call(method, [id, options]); + } else { + return client.call(method, [id]); + } + } + + async DatasetCreate(datasetName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.create"; + + data.name = datasetName; + + try { + await client.call(method, [data]); + // TODO: remove this with appropriate versions of TN + // https://ixsystems.atlassian.net/browse/NAS-144044 + let user_properties = this.getPropertiesFromKeyValueArray( + data.user_properties, + ); + await this.DatasetSet(datasetName, user_properties); + } catch (err) { + if (_.get(err, "message", "").includes("already exists")) return; + throw err; + } + } + + /** + * + * @param {*} datasetName + * @param {*} data + * @returns + */ + async DatasetDelete(datasetName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.delete"; + + try { + await client.call(method, [datasetName, data]); + } catch (err) { + if (_.get(err, "message", "").includes("does not exist")) return; + throw err; + } + } + + async DatasetSet(datasetName, properties) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.update"; + + await client.call(method, [ + datasetName, + { + ...this.getSystemProperties(properties), + user_properties_update: this.getPropertiesKeyValueArray( + this.getUserProperties(properties), + ), + }, + ]); + } + + async DatasetInherit(datasetName, property) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.update"; + + 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"; + } + + await client.call(method, [ + datasetName, + { + ...system_properties, + user_properties_update, + }, + ]); + } + + /** + * + * zfs get -Hp all tank/k8s/test/PVC-111 + * + * @param {*} datasetName + * @param {*} properties + * @returns + */ + async DatasetGet(datasetName, properties) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.get_instance"; + let response; + + response = await client.call(method, [datasetName]); + return this.normalizeProperties(response, properties); + } + + /** + * + * + * @param {*} datasetName + * @returns + */ + async DatasetGetResource(datasetName) { + const client = await this.getWebSocketClient(); + let method = "pool.dataset.get_instance"; + let response; + let options = { + extra: { + snapshots: true, + // user_properties do not come out of this call currently + snapshots_properties: ["creation", "foo:bar"], + }, + }; + options = {}; + + response = await client.call(method, [datasetName, options]); + return response; + } + + async DatasetGetSnapshots(datasetName, properties = []) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.snapshot.query"; + let response; + + let data = {}; + data.paths = [datasetName]; + data.get_user_properties = true; + data.get_source = true; + data.properties = properties; + data.recursive = true; + + //method = "pool.snapshot.get_instance"; + //response = await client.call(method, [datasetName]); + + try { + response = await client.call(method, [data]); + return response; + } catch (err) { + // return empty set of snapshots for datasets which do not exist + if (_.get(err, "message", "").includes("not found")) return []; + throw err; + } + + //return this.normalizeProperties(response[0], properties); + } + + async DatasetGetDatasets(datasetName, properties = []) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.query"; + let response; + + let data = {}; + data.paths = [datasetName]; + data.get_user_properties = true; + data.get_source = true; + data.properties = properties; + //data.recursive = true; + data.nest_results = false; + data.get_children = true; + + //method = "pool.snapshot.get_instance"; + //response = await client.call(method, [datasetName]); + + try { + response = await client.call(method, [data]); + return response; + } catch (err) { + // return empty set of snapshots for datasets which do not exist + if (_.get(err, "message", "").includes("not found")) return []; + throw err; + } + + //return this.normalizeProperties(response[0], properties); + } + + async DatasetDestroySnapshots(datasetName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.snapshot.destroy"; + data.path = datasetName; + + await client.call(method, [{ ...data, all_snapshots: true }]); + } + + async SnapshotSet(snapshotName, properties) { + const client = await this.getWebSocketClient(); + let method = "pool.snapshot.update"; + + await client.call(method, [ + snapshotName, + { + ...this.getSystemProperties(properties), + user_properties_update: this.getPropertiesKeyValueArray( + this.getUserProperties(properties), + ), + }, + ]); + } + + /** + * + * zfs get -Hp all tank/k8s/test/PVC-111 + * + * @param {*} snapshotName + * @param {*} properties + * @returns + */ + async SnapshotGet(snapshotName, properties) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.snapshot.query"; + let response; + + let data = {}; + data.paths = [snapshotName]; + data.get_user_properties = true; + data.get_source = true; + data.properties = properties; + + response = await client.call(method, [data]); + if (response.length != 1) + throw new Error(`PoolSnapshot ${snapshotName} does not exist`); + + // [ENOENT] zfs.resource.snapshot.query: 'tank/k8s/test/PVC-111@testsnapshot' not found + + return this.normalizeProperties(response[0], properties); + //return response[0]; + } + + async SnapshotCreate(snapshotName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.snapshot.create"; + + const zb = await this.getZetabyte(); + const dataset = zb.helpers.extractDatasetName(snapshotName); + const snapshot = zb.helpers.extractSnapshotName(snapshotName); + + data.dataset = dataset; + data.name = snapshot; + + // NOTE: the user properties are simply sent as normal key/value pairs in data.user_properties if needed + // NOTE: standard properties cannot be set on a snapshot at creation time + if (data.properties) { + if (Object.keys(data.properties).length > 0) { + data.user_properties = this.getUserProperties(data.properties); + + if ( + Object.keys(data.properties).length != + Object.keys(data.user_properties).length + ) { + console.log( + "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + data.properties, + data.user_properties, + ); + throw new Error("sending invalid properties to call"); + } + } + + // field is not valid for this call + delete data.properties; + } + + try { + await client.call(method, [data]); + } catch (err) { + if (String(err.message).includes("already exists")) return; + throw err; + } + } + + async SnapshotDelete(snapshotName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "zfs.resource.snapshot.destroy"; + + data.path = snapshotName; + + try { + await client.call(method, [data]); + } catch (err) { + if (String(err.message).includes("not found")) return; + throw err; + } + } + + async NfsShareDelete(shareId) { + const client = await this.getWebSocketClient(); + let method = "sharing.nfs.delete"; + + try { + await client.call(method, [parseInt(shareId)]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async NfsShareCreate(share) { + const client = await this.getWebSocketClient(); + let method = "sharing.nfs.create"; + + try { + return await client.call(method, [share]); + } catch (err) { + throw err; + } + } + + async SmbShareDelete(shareId) { + const client = await this.getWebSocketClient(); + let method = "sharing.smb.delete"; + + try { + await client.call(method, [parseInt(shareId)]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async SmbShareCreate(share) { + const client = await this.getWebSocketClient(); + let method = "sharing.smb.create"; + + try { + return await client.call(method, [share]); + } catch (err) { + throw err; + } + } + + async IscsiGlobalConfigGet() { + const client = await this.getWebSocketClient(); + let method = "iscsi.global.config"; + + return await client.call(method, []); + } + + async IscsiTargetCreate(target) { + const client = await this.getWebSocketClient(); + let method = "iscsi.target.create"; + + return client.call(method, [target]); + } + + async IscsiTargetUpdate(targetId, data) { + const client = await this.getWebSocketClient(); + let method = "iscsi.target.update"; + + targetId = parseInt(targetId); + + return client.call(method, [targetId, data]); + } + + async IscsiTargetDelete(targetId) { + const client = await this.getWebSocketClient(); + let method = "iscsi.target.delete"; + + targetId = parseInt(targetId); + let force = false; + let delete_extents = false; + + try { + await client.call(method, [targetId, force, delete_extents]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async IscsiExtentCreate(extent) { + const client = await this.getWebSocketClient(); + let method = "iscsi.extent.create"; + + return client.call(method, [extent]); + } + + async IscsiExtentUpdate(extentId, data) { + const client = await this.getWebSocketClient(); + let method = "iscsi.extent.update"; + + extentId = parseInt(extentId); + + return client.call(method, [extentId, data]); + } + + async IscsiExtentDelete(extentId) { + const client = await this.getWebSocketClient(); + let method = "iscsi.extent.delete"; + + extentId = parseInt(extentId); + // remove backing file if file-based extent + let remove = false; + let force = false; + + try { + await client.call(method, [extentId, remove, force]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async IscsiTargetExtentCreate(te) { + const client = await this.getWebSocketClient(); + let method = "iscsi.targetextent.create"; + + return client.call(method, [te]); + } + + async IscsiTargetExtentUpdate(id, data) { + const client = await this.getWebSocketClient(); + let method = "iscsi.targetextent.update"; + + id = parseInt(id); + + return client.call(method, [id, data]); + } + + async IscsiTargetExtentDelete(id) { + const client = await this.getWebSocketClient(); + let method = "iscsi.targetextent.delete"; + + id = parseInt(id); + let force = false; + + try { + await client.call(method, [id, force]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async NvmetGlobalConfigGet() { + const client = await this.getWebSocketClient(); + let method = "nvmet.global.config"; + + return await client.call(method, []); + } + + async NvmetSubsysCreate(subsys) { + const client = await this.getWebSocketClient(); + let method = "nvmet.subsys.create"; + + subsys.allow_any_host = true; + + return client.call(method, [subsys]); + } + + async NvmetSubsysUpdate(subsysId, data) { + const client = await this.getWebSocketClient(); + let method = "nvmet.subsys.update"; + + subsysId = parseInt(subsysId); + + return client.call(method, [subsysId, data]); + } + + async NvmetSubsysDelete(subsysId, data = {}) { + const client = await this.getWebSocketClient(); + let method = "nvmet.subsys.delete"; + + subsysId = parseInt(subsysId); + if (!data.hasOwnProperty("force")) { + data.force = false; + } + + try { + await client.call(method, [subsysId, data]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + NvmetNamespaceZvolPathNormalized(zvol) { + zvol = String(zvol); + if (zvol.startsWith("/dev/")) { + zvol = zvol.substring(5); + } + + if (zvol.startsWith("/")) { + zvol = zvol.substring(1); + } + + if (!zvol.startsWith("zvol/")) { + zvol = `zvol/${zvol}`; + } + + return zvol; + } + async NvmetNamespaceCreate(ns) { + const client = await this.getWebSocketClient(); + let method = "nvmet.namespace.create"; + + if (ns.device_type == "ZVOL") { + ns.device_path = this.NvmetNamespaceZvolPathNormalized(ns.device_path); + } + + return client.call(method, [ns]); + } + + async NvmetNamespaceUpdate(nsId, data) { + const client = await this.getWebSocketClient(); + let method = "nvmet.namespace.update"; + + nsId = parseInt(nsId); + + return client.call(method, [nsId, data]); + } + + async NvmetNamespaceDelete(nsId, data = {}) { + const client = await this.getWebSocketClient(); + let method = "nvmet.namespace.delete"; + + nsId = parseInt(nsId); + // remove underlying file if device_type is file + if (!data.hasOwnProperty("remove")) { + data.remove = false; + } + + try { + await client.call(method, [nsId, data]); + } catch (err) { + if (String(err.message).includes("does not exist")) return; + throw err; + } + } + + async NvmetPortSubsysCreate(port_id, subsys_id) { + const client = await this.getWebSocketClient(); + let method = "nvmet.port_subsys.create"; + + let response; + + let data = { + port_id, + subsys_id, + }; + + try { + return await client.call(method, [data]); + } catch (err) { + if (String(err.message).includes("already exists")) { + response = await this.findResourceByProperties( + "nvmet.port_subsys", + (item) => { + if (item.port.id == port_id && item.subsys.id == subsys_id) { + return true; + } + return false; + }, + ); + } + if (response) { + return response; + } + throw err; + } + } + + async CloneCreate(snapshotName, datasetName, data = {}) { + const client = await this.getWebSocketClient(); + let method = "pool.snapshot.clone"; + + data.snapshot = snapshotName; + data.dataset_dst = datasetName; + let system_properties = {}; + let user_properties = {}; + if (Object.keys(data.dataset_properties).length > 0) { + system_properties = this.getSystemProperties(data.dataset_properties); + user_properties = this.getUserProperties(data.dataset_properties); + data.dataset_properties = system_properties; + } + + try { + await client.call(method, [data]); + + // update user properties + if (Object.keys(user_properties).length > 0) { + await this.DatasetSet(datasetName, user_properties); + } + } catch (err) { + if (String(err.message).includes("already exists")) return; + throw err; + } + } + + // 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 client = await this.getWebSocketClient(); + let method = "replication.run_onetime"; + + return client.call(method, [data]); + } + + /** + * + * @param {*} job_id + * @param {*} timeout in seconds + * @returns + */ + async CoreWaitForJob(job_id, timeout = 0, check_interval = 3000) { + if (!job_id) { + throw new Error("invalid job_id"); + } + + const startTime = Date.now() / 1000; + let currentTime; + + let job; + + // wait for job to finish + // TODO: this can technically be improved to listen to job events over the socket instead of polling + // state = SUCCESS/ABORTED/FAILED means finality has been reached + // state = RUNNING + do { + currentTime = Date.now() / 1000; + if (timeout > 0 && currentTime > startTime + timeout) { + throw new Error("timeout waiting for job to complete"); + } + + if (job) { + await sleep(check_interval); + } + + job = await this.CoreGetJob(job_id); + } while (!["SUCCESS", "ABORTED", "FAILED"].includes(job.state)); + + return job; + } + + async CoreGetJob(job_id) { + let job; + job = await this.CoreGetJobs([["id", "=", job_id]]); + job = job[0]; + + return job; + } + + async CoreGetJobs(data) { + const client = await this.getWebSocketClient(); + let method = "core.get_jobs"; + + return client.call(method, [data]); + } + + /** + * + * @param {*} data + */ + async FilesystemSetperm(data) { + // { + // "path": "string", + // "mode": "string", + // "uid": 0, + // "gid": 0, + // "options": { + // "stripacl": false, + // "recursive": false, + // "traverse": false + // } + // } + + const client = await this.getWebSocketClient(); + let method = "filesystem.setperm"; + + return client.call(method, [data]); + } + + /** + * + * @param {*} data + */ + async FilesystemChown(data) { + // { + // "path": "string", + // "uid": 0, + // "gid": 0, + // "options": { + // "recursive": false, + // "traverse": false + // } + // } + + const client = await this.getWebSocketClient(); + let method = "filesystem.chown"; + + return client.call(method, [data]); + } + + async call(method, params) { + const client = await this.getWebSocketClient(); + return client.call(method, params); + } +} + +module.exports.Api = Api; +module.exports.ERROR_DATASET_DOES_NOT_EXIST_REGEX = + ERROR_DATASET_DOES_NOT_EXIST_REGEX; +module.exports.ERROR_SNAPSHOT_DOES_NOT_EXIST_REGEX = + ERROR_SNAPSHOT_DOES_NOT_EXIST_REGEX; diff --git a/src/driver/freenas/websocket/index.js b/src/driver/freenas/websocket/index.js new file mode 100644 index 0000000..a28de6c --- /dev/null +++ b/src/driver/freenas/websocket/index.js @@ -0,0 +1,393 @@ +/** + * + * https://www.truenas.com/docs/api/scale_websocket_api.html + * https://github.com/truenas/api_client + * + * base websocket client functionality here + */ + +const _ = require("lodash"); +const EventEmitter = require("node:events"); +const WebSocket = require("ws"); +const ReconnectingWebSocket = require("reconnecting-websocket"); +const uuidv4 = require("uuid").v4; + +const GeneralUtils = require("../../../utils/general"); + +class TrueNASError extends Error { + constructor(rpcError, options = {}) { + super(rpcError.reason ?? "TrueNAS RPC error", { + cause: options.cause, + }); + + this.name = "TrueNASError"; + + // Node.js-style error properties + this.code = rpcError.errname; // "ENOENT" + this.errno = rpcError.error; // 2 + + // TrueNAS-specific properties + this.type = rpcError.type; // "VALIDATION" + this.reason = rpcError.reason; + + // TrueNAS Python exception information + this.remoteClass = rpcError.trace?.class; + this.remoteTrace = rpcError.trace?.formatted; + this.remoteRepr = rpcError.trace?.repr; + + // Additional TrueNAS error information + this.extra = rpcError.extra; + + // Preserve the complete response error + this.rpcError = rpcError; + } +} + +class TimeoutError extends Error { + constructor(message = "Operation timed out", options = {}) { + super(message, options); + + this.name = "TimeoutError"; + this.code = "ETIMEDOUT"; + } +} + +class Client extends EventEmitter { + constructor(options = {}, logger = null) { + super(); + + const client = this; + this.options = JSON.parse(JSON.stringify(options)); + this.logger = logger; + if (!this.logger) { + this.logger = console; + } + + // rewrite protocol + switch (String(this.options.protocol).toLowerCase()) { + case "http": + this.options.protocol = "ws"; + break; + case "https": + this.options.protocol = "wss"; + break; + } + + // legacy endpoint + // this.url = `${this.options.protocol}://${this.options.host}:${this.options.port}/websocket`; + + // JSON-RPC 2.0 endpoint + this.url = `${this.options.protocol}://${this.options.host}:${this.options.port}/api/current`; + this.logger.info(`TrueNAS WebSocket connecting to: ${this.url}`); + this.wss = new ReconnectingWebSocket(this.url, [], { + WebSocket: class extends WebSocket { + constructor(url, protocols) { + // https://github.com/websockets/ws/blob/master/doc/ws.md#class-websocket + super(url, protocols, { + rejectUnauthorized: !!!client.options.allowInsecure, + }); + } + send(data) { + client.logger.debug( + "TrueNAS WebSocket sending message: " + + GeneralUtils.stringify(client.log_cleanse(JSON.parse(data))), + ); + super.send(data); + } + }, + //connectionTimeout: 1000, + startClosed: true, + //debug: true, + }); + this.session; + this.authenticated = false; + this._systemInfo = {}; + this._systemVersion = null; + + this.wss.addEventListener("open", async () => { + this.logger.info("TrueNAS WebSocket opened!"); + await this.authenticate(); + }); + + this.wss.addEventListener("close", () => { + this.logger.warn("TrueNAS WebSocket closed!"); + this.session = null; + this.authenticated = false; + this._systemInfo = {}; + this._systemVersion = null; + }); + + this.wss.addEventListener("message", (msg) => { + const data = JSON.parse(msg.data); + client.logger.debug( + "TrueNAS WebSocket received message: " + + GeneralUtils.stringify(client.log_cleanse(data)), + ); + + // event pushed from server, not a response to an rpc call + if (!data.id) { + switch (data.method) { + case "collection_update": + // data.params.msg = added, changed, or removed + this.emit( + `event:${data.method}:${data.params.collection}:${data.params.msg}`, + data, + ); + this.emit(`event:${data.method}:${data.params.collection}`, data); + this.emit(`event:${data.method}`, data); + break; + + case "notify_unsubscribed": + this.emit(`event:${data.method}:${data.params.collection}`, data); + this.emit(`event:${data.method}`, data); + break; + + default: + this.logger.log(`unhandled event method: ${data.method}`); + break; + } + } + }); + + this.wss.addEventListener("error", (err) => { + this.logger.error("TrueNAS WebSocket received error!", err); + }); + + this.on("authenticated", () => { + // TODO: make this return when all initialization is complete and set a 'ready' parameter + let promises = []; + this.call("system.info").then((result) => { + this._systemInfo = result; + }); + + this.call("system.version").then((result) => { + this._systemVersion = result; + }); + + //this.call("core.subscribe", ["*"]); + //this.call("core.set_options", [{ legacy_jobs: false }]); + }); + } + + log_cleanse(data) { + let log = JSON.parse(JSON.stringify(data)); + + let method = log?.method || ""; + + if (String(method).startsWith("auth.")) { + if (log.params && Array.isArray(log.params) && log.params.length > 0) { + log.params = log.params.map(() => { + return "redacted"; + }); + } + } + + return log; + } + + async systemInfo() { + if (!Object.keys(this._systemInfo).length > 0) { + this._systemInfo = await this.call("system.info"); + } + + return this._systemInfo; + } + + async systemVersion() { + if (!this._systemVersion) { + this._systemVersion = await this.call("system.version"); + } + return this._systemVersion; + } + + async connect() { + await new Promise((resolve, reject) => { + this.wss.addEventListener( + "open", + () => { + resolve(); + }, + { once: true }, + ); + + this.wss.addEventListener( + "close", + () => { + reject(); + }, + { once: true }, + ); + + this.wss.addEventListener( + "error", + () => { + reject(); + }, + { once: true }, + ); + this.wss.reconnect(); + }); + + // this keeps the connection alive, otherwise TN will drop it after a timeout on their end + if (!this.pingInterval) { + this.pingInterval = setInterval(() => { + if (this.wss.readyState == WebSocket.OPEN) { + this.wss._ws.ping(); + } + }, 10 * 1000); + } + } + + async authenticate() { + let method = "auth.login"; + let params = [this.options.username, this.options.password]; + + if (this.options.apiKey && this.options.protocol == "ws") { + throw new Error("TrueNAS WebSocket unable to use apiKey over clear text"); + } + + if (this.options.apiKey) { + method = "auth.login_with_api_key"; + params = [this.options.apiKey]; + } + + return this.call(method, params).then((result) => { + this.authenticated = result; + if (result) { + this.emit("authenticated"); + } else { + this.wss.close(); + } + }); + } + + async waitauthenticated(timeout = 3 * 1000) { + let timeoutId; + return new Promise((resolve, reject) => { + if (this.authenticated) { + resolve(); + } + + if (timeout) { + timeoutId = setTimeout(() => { + reject( + new TimeoutError( + `TrueNAS timeout waiting for authentication to complete`, + ), + ); + }, timeout); + } + + setInterval(() => { + if (this.authenticated) { + resolve(); + } + }, 100); + }); + } + + async subscribe(events, options = {}) { + if (!Array.isArray(events)) { + events = [events]; + } + return this.call("core.subscribe", events, options); + } + + async unsubscribe(id, options = {}) { + return this.call("core.unsubscribe", [id], options); + } + + async call(method, params = [], options = {}) { + const controller = new AbortController(); + let timeoutId; + let timeout = options.timeout; + let is_job_method = false; + let wait_for_job = true; + let job_id; + + const uuid = uuidv4(); + const req = {}; + req.jsonrpc = "2.0"; + req.id = uuid; + req.msg = "method"; + req.method = method; + req.params = params; + + switch (method) { + case "filesystem.chown": + case "filesystem.get": + case "pool.scrub.scrub": + is_job_method = true; + break; + } + + return new Promise((resolve, reject) => { + if (timeout) { + timeoutId = setTimeout(() => { + reject( + new TimeoutError( + `TrueNAS timeout waiting for call response: type=${req.method}, id=${ + req.id + }, params=[${params.join(" ")}]`, + ), + ); + }, timeout); + } + + if (is_job_method && wait_for_job) { + this.on( + "event:core.get_jobs:changed", + (event) => { + if (event.id == job_id) { + switch (event.fields.state) { + case "FAILED": + reject(new TrueNASError(event.fields.error)); + break; + case "SUCCESS": + case "ABORTED": + resolve(event.fields.result); + break; + case "WAITING": + case "RUNNING": + default: + // ignore + break; + } + } + }, + { + signal: controller.signal, + }, + ); + } + + this.wss.addEventListener( + "message", + (msg) => { + const data = JSON.parse(msg.data); + if (data.id == uuid) { + if (data.error) { + reject(new TrueNASError(data.error.data)); + } else { + if (is_job_method && wait_for_job) { + job_id = data.result; + } else { + resolve(data.result); + } + } + } + }, + { + signal: controller.signal, + }, + ); + + this.wss.send(JSON.stringify(req)); + }).finally(() => { + controller.abort(); + clearTimeout(timeoutId); + }); + } +} + +module.exports.Client = Client; diff --git a/src/driver/freenas/websocket/share.js b/src/driver/freenas/websocket/share.js new file mode 100644 index 0000000..ce8184b --- /dev/null +++ b/src/driver/freenas/websocket/share.js @@ -0,0 +1,1448 @@ +const _ = require("lodash"); +const Handlebars = require("handlebars"); + +const { GrpcError, grpc } = require("../../../utils/grpc"); +const GeneralUtils = require("../../../utils/general"); + +const { ERROR_DATASET_DOES_NOT_EXIST_REGEX } = require("./api"); + +// freenas properties +const FREENAS_NFS_SHARE_PROPERTY_NAME = "democratic-csi:freenas_nfs_share_id"; +const FREENAS_SMB_SHARE_PROPERTY_NAME = "democratic-csi:freenas_smb_share_id"; + +// iscsi +const FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME = + "democratic-csi:freenas_iscsi_target_id"; +const FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME = + "democratic-csi:freenas_iscsi_extent_id"; +const FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME = + "democratic-csi:freenas_iscsi_targettoextent_id"; +const FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME = + "democratic-csi:freenas_iscsi_assets_name"; + +// nvmeof +const FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME = + "democratic-csi:freenas_nvmeof_subsystem_id"; +const FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME = + "democratic-csi:freenas_nvmeof_namespace_id"; +const FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME = + "democratic-csi:freenas_nvmeof_assets_name"; + +/** + * Helper class to keep common api bits across ssh and api drivers + */ +class FreeNASApiShareHelper { + /** + * driver must implement the following methods: + * - driver.getDriverShareType(); + * - await driver.getTrueNASWebSocketApiClient(); + * - await driver.getZetabyte(); + * - await driver.getMaxZvolNameLength() + */ + constructor(driver) { + this.driver = driver; + } + + /** + * Check if an error response indicates a target already exists. + * This method handles variations in TrueNAS API error messages across different API versions. + * + * @param {string|Object} responseBody - The HTTP response body (string or object) + * @returns {boolean} - true if the error indicates target already exists + */ + isTargetAlreadyExistsError(responseBody) { + // Extract error message more efficiently + let errorString = ""; + + if (typeof responseBody === "string") { + errorString = responseBody; + } else if (responseBody && typeof responseBody === "object") { + // Try common error message fields first to avoid full JSON.stringify + errorString = + responseBody.message || + responseBody.error || + responseBody.detail || + JSON.stringify(responseBody); + } else { + return false; + } + + // Handle multiple variations of the target already exists error message + const targetExistsPatterns = [ + "Target name already exists", // Original pattern in code (API v1) + "Target with this name already exists", // Actual TrueNAS error message (API v2) + "Target\\b.*\\balready\\b.*\\bexists", // Flexible pattern with word boundaries + ]; + + return targetExistsPatterns.some((pattern) => { + if (pattern.includes("\\")) { + // Use regex for flexible patterns with word boundaries + const regex = new RegExp(pattern, "i"); + return regex.test(errorString); + } else { + // Use case-insensitive simple string matching for exact patterns + return errorString.toLowerCase().includes(pattern.toLowerCase()); + } + }); + } + + /** + * should create any necessary share resources + * should set the SHARE_VOLUME_CONTEXT_PROPERTY_NAME propery + * + * @param {*} datasetName + */ + async createShare(call, datasetName) { + const driver = this.driver; + const driverShareType = driver.getDriverShareType(); + const apiClient = await driver.getTrueNASWebSocketApiClient(); + const zb = await driver.getZetabyte(); + + let volume_context; + let properties; + let endpoint; + let response; + let share = {}; + + switch (driverShareType) { + case "nfs": + { + properties = await apiClient.DatasetGet(datasetName, [ + "mountpoint", + FREENAS_NFS_SHARE_PROPERTY_NAME, + ]); + driver.ctx.logger.debug("zfs props data: %j", properties); + + // create nfs share + if ( + !zb.helpers.isPropertyValueSet( + properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value, + ) + ) { + let nfsShareComment; + if (driver.options.nfs.shareCommentTemplate) { + nfsShareComment = Handlebars.compile( + driver.options.nfs.shareCommentTemplate, + )({ + name: call.request.name, + parameters: call.request.parameters, + csi: { + name: driver.ctx.args.csiName, + version: driver.ctx.args.csiVersion, + }, + zfs: { + datasetName: datasetName, + }, + }); + } else { + nfsShareComment = `democratic-csi (${driver.ctx.args.csiName}): ${datasetName}`; + } + + try { + share = { + path: properties.mountpoint.value, + comment: nfsShareComment || "", + networks: driver.options.nfs.shareAllowedNetworks, + hosts: driver.options.nfs.shareAllowedHosts, + ro: false, + maproot_user: driver.options.nfs.shareMaprootUser, + maproot_group: driver.options.nfs.shareMaprootGroup, + mapall_user: driver.options.nfs.shareMapallUser, + mapall_group: driver.options.nfs.shareMapallGroup, + security: [], + }; + response = await GeneralUtils.retry( + 3, + 1000, + async () => { + return await apiClient.NfsShareCreate(share); + }, + { + retryCondition: (err) => { + // TODO: change the logic here + return false; + }, + }, + ); + + if (response.path != properties.mountpoint.value) { + throw new GrpcError( + grpc.status.UNKNOWN, + `FreeNAS responded with incorrect share data: body: ${JSON.stringify(response)}`, + ); + } + + //set zfs property + await apiClient.DatasetSet(datasetName, { + [FREENAS_NFS_SHARE_PROPERTY_NAME]: response.id, + }); + } catch (err) { + if ( + err.message.includes( + "You can't share same filesystem with all hosts twice.", + ) || + err.message.includes( + "Another NFS share already exports this dataset for some network", + ) + ) { + let lookupShare = await apiClient.findResourceByProperties( + "/sharing/nfs", + (item) => { + if (item.path && item.path == properties.mountpoint.value) { + return true; + } + return false; + }, + ); + + if (!lookupShare) { + throw new GrpcError( + grpc.status.UNKNOWN, + `TrueNAS failed to find matching share`, + ); + } + + //set zfs property + await apiClient.DatasetSet(datasetName, { + [FREENAS_NFS_SHARE_PROPERTY_NAME]: lookupShare.id, + }); + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error creating nfs share - code: ${ + err.code + } body: ${err.message}`, + ); + } + } + } + + volume_context = { + node_attach_driver: "nfs", + server: driver.options.nfs.shareHost, + share: properties.mountpoint.value, + }; + return volume_context; + } + break; + /** + * TODO: smb need to be more defensive like iscsi and nfs + * ensuring the path is valid and the shareName + */ + case "smb": + { + properties = await apiClient.DatasetGet(datasetName, [ + "mountpoint", + FREENAS_SMB_SHARE_PROPERTY_NAME, + ]); + driver.ctx.logger.debug("zfs props data: %j", properties); + + let smbName; + + if (driver.options.smb.nameTemplate) { + smbName = Handlebars.compile(driver.options.smb.nameTemplate)({ + name: call.request.name, + parameters: call.request.parameters, + }); + } else { + smbName = zb.helpers.extractLeafName(datasetName); + } + + if (driver.options.smb.namePrefix) { + smbName = driver.options.smb.namePrefix + smbName; + } + + if (driver.options.smb.nameSuffix) { + smbName += driver.options.smb.nameSuffix; + } + + smbName = smbName.toLowerCase(); + + driver.ctx.logger.info( + "FreeNAS creating smb share with name: " + smbName, + ); + + // create smb share + if ( + !zb.helpers.isPropertyValueSet( + properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value, + ) + ) { + /** + * The only required parameters are: + * - path + * - name + * + * Note that over time it appears the list of available parameters has increased + * so in an effort to best support old versions of FreeNAS we should check the + * presense of each parameter in the config and set the corresponding parameter in + * the API request *only* if present in the config. + */ + + try { + share = { + name: smbName, + path: properties.mountpoint.value, + }; + + let propertyMapping = { + shareAuxiliaryConfigurationTemplate: "auxsmbconf", + shareHome: "home", + shareAllowedHosts: "hostsallow", + shareDeniedHosts: "hostsdeny", + shareDefaultPermissions: "default_permissions", + shareGuestOk: "guestok", + shareGuestOnly: "guestonly", + shareShowHiddenFiles: "showhiddenfiles", + shareRecycleBin: "recyclebin", + shareBrowsable: "browsable", + shareAccessBasedEnumeration: "abe", + shareTimeMachine: "timemachine", + shareStorageTask: "storage_task", + }; + + for (const key in propertyMapping) { + if (driver.options.smb.hasOwnProperty(key)) { + let value; + switch (key) { + case "shareAuxiliaryConfigurationTemplate": + value = Handlebars.compile( + driver.options.smb.shareAuxiliaryConfigurationTemplate, + )({ + name: call.request.name, + parameters: call.request.parameters, + }); + break; + default: + value = driver.options.smb[key]; + break; + } + share[propertyMapping[key]] = value; + } + } + + let topLevelProperties = [ + "purpose", + "name", + "path", + "enabled", + "comment", + "readonly", + "browsable", + "access_based_share_enumeration", + "audit", + ]; + let disallowedOptions = ["abe"]; + share.purpose = "LEGACY_SHARE"; + share.options = { + purpose: "LEGACY_SHARE", + }; + for (const key in share) { + switch (key) { + case "options": + // ignore + break; + default: + if (!topLevelProperties.includes(key)) { + if (!disallowedOptions.includes(key)) { + share.options[key] = share[key]; + } + delete share[key]; + } + break; + } + } + + response = await GeneralUtils.retry( + 3, + 1000, + async () => { + return await apiClient.SmbShareCreate(share); + }, + { + retryCondition: (err) => { + if (err.toString().includes("must be unique")) return false; + if (err.toString().includes("already exists")) return false; + return true; + }, + }, + ); + + let sharePath = response.path; + let shareName = response.name; + + if (shareName != smbName) { + throw new GrpcError( + grpc.status.UNKNOWN, + `FreeNAS responded with incorrect share data: body: ${JSON.stringify(response)}`, + ); + } + + if (sharePath != properties.mountpoint.value) { + throw new GrpcError( + grpc.status.UNKNOWN, + `FreeNAS responded with incorrect share data: body: ${JSON.stringify(response)}`, + ); + } + + //set zfs property + await apiClient.DatasetSet(datasetName, { + [FREENAS_SMB_SHARE_PROPERTY_NAME]: response.id, + }); + } catch (err) { + // [EINVAL] sharingsmb_create.name: Share names are case-insensitive and must be unique + if ( + err.message.includes("A share with this name already exists") || + err.message.includes("must be unique") + ) { + let lookupShare = await apiClient.findResourceByProperties( + "/sharing/smb", + (item) => { + if ( + item?.path == properties.mountpoint.value && + item?.name == smbName + ) { + return true; + } + return false; + }, + ); + + if (!lookupShare) { + throw new GrpcError( + grpc.status.UNKNOWN, + `FreeNAS failed to find matching share`, + ); + } + + //set zfs property + await apiClient.DatasetSet(datasetName, { + [FREENAS_SMB_SHARE_PROPERTY_NAME]: lookupShare.id, + }); + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error creating smb share - code: ${ + err.code + } body: ${err.message}`, + ); + } + } + } + + volume_context = { + node_attach_driver: "smb", + server: driver.options.smb.shareHost, + share: smbName, + }; + return volume_context; + } + break; + case "iscsi": + { + properties = await apiClient.DatasetGet(datasetName, [ + FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, + FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, + FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, + ]); + driver.ctx.logger.debug("zfs props data: %j", properties); + + let basename; + let iscsiName; + + if (driver.options.iscsi.nameTemplate) { + iscsiName = Handlebars.compile(driver.options.iscsi.nameTemplate)({ + name: call.request.name, + parameters: call.request.parameters, + }); + } else { + iscsiName = zb.helpers.extractLeafName(datasetName); + } + + if (driver.options.iscsi.namePrefix) { + iscsiName = driver.options.iscsi.namePrefix + iscsiName; + } + + if (driver.options.iscsi.nameSuffix) { + iscsiName += driver.options.iscsi.nameSuffix; + } + + // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' + // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name + // https://tools.ietf.org/html/rfc3720 + // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 + iscsiName = iscsiName.toLowerCase(); + + let extentDiskName = "zvol/" + datasetName; + let maxZvolNameLength = await driver.getMaxZvolNameLength(); + driver.ctx.logger.debug( + "max zvol name length: %s", + maxZvolNameLength, + ); + + /** + * limit is a FreeBSD limitation + * https://www.ixsystems.com/documentation/freenas/11.2-U5/storage.html#zfs-zvol-config-opts-tab + */ + if (extentDiskName.length > maxZvolNameLength) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `extent disk name cannot exceed ${maxZvolNameLength} characters: ${extentDiskName}`, + ); + } + + // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 + if (iscsiName.length > 64) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `extent name cannot exceed 64 characters: ${iscsiName}`, + ); + } + + driver.ctx.logger.info( + "FreeNAS creating iscsi assets with name: " + iscsiName, + ); + + let extentComment; + if (driver.options.iscsi.extentCommentTemplate) { + extentComment = Handlebars.compile( + driver.options.iscsi.extentCommentTemplate, + )({ + name: call.request.name, + parameters: call.request.parameters, + csi: { + name: driver.ctx.args.csiName, + version: driver.ctx.args.csiVersion, + }, + zfs: { + datasetName: datasetName, + }, + }); + } else { + extentComment = ""; + } + + const extentInsecureTpc = driver.options.iscsi.hasOwnProperty( + "extentInsecureTpc", + ) + ? driver.options.iscsi.extentInsecureTpc + : true; + + const extentXenCompat = driver.options.iscsi.hasOwnProperty( + "extentXenCompat", + ) + ? driver.options.iscsi.extentXenCompat + : false; + + const extentBlocksize = driver.options.iscsi.hasOwnProperty( + "extentBlocksize", + ) + ? driver.options.iscsi.extentBlocksize + : 512; + + const extentDisablePhysicalBlocksize = + driver.options.iscsi.hasOwnProperty( + "extentDisablePhysicalBlocksize", + ) + ? driver.options.iscsi.extentDisablePhysicalBlocksize + : true; + + const extentRpm = driver.options.iscsi.hasOwnProperty("extentRpm") + ? driver.options.iscsi.extentRpm + : "SSD"; + + let extentAvailThreshold = driver.options.iscsi.hasOwnProperty( + "extentAvailThreshold", + ) + ? Number(driver.options.iscsi.extentAvailThreshold) + : null; + + if (!(extentAvailThreshold > 0 && extentAvailThreshold <= 100)) { + extentAvailThreshold = null; + } + + try { + response = await apiClient.IscsiGlobalConfigGet(); + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `error getting iscsi configuration - code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + + basename = response.basename; + driver.ctx.logger.verbose("FreeNAS ISCSI BASENAME: " + basename); + + // if we got all the way to the TARGETTOEXTENT then we fully finished + // otherwise we must do all assets every time due to the interdependence of IDs etc + if ( + !zb.helpers.isPropertyValueSet( + properties[FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME].value, + ) + ) { + // create target and targetgroup + //let targetId; + let targetGroups = []; + for (let targetGroupConfig of driver.options.iscsi.targetGroups) { + targetGroups.push({ + portal: targetGroupConfig.targetGroupPortalGroup, + initiator: targetGroupConfig.targetGroupInitiatorGroup, + auth: + targetGroupConfig.targetGroupAuthGroup > 0 + ? targetGroupConfig.targetGroupAuthGroup + : null, + authmethod: + targetGroupConfig.targetGroupAuthType.length > 0 + ? targetGroupConfig.targetGroupAuthType + .toUpperCase() + .replace(" ", "_") + : "NONE", + }); + } + let target = { + name: iscsiName, + alias: null, // cannot send "" error: handler error - driver: FreeNASDriver method: CreateVolume error: {"name":"GrpcError","code":2,"message":"received error creating iscsi target - code: 422 body: {\"iscsi_target_create.alias\":[{\"message\":\"Alias already exists\",\"errno\":22}]}"} + mode: "ISCSI", // ISCSI, FC, BOTH + groups: targetGroups, + }; + + try { + response = await apiClient.IscsiTargetCreate(target); + target = response; + } catch (err) { + target = null; + if (this.isTargetAlreadyExistsError(err.message)) { + driver.ctx.logger.debug( + "iSCSI target already exists, attempting to find existing target with name: %s", + iscsiName, + ); + target = await apiClient.findResourceByProperties( + "/iscsi/target", + { + name: iscsiName, + }, + ); + if (target) { + driver.ctx.logger.debug( + "Found existing iSCSI target with ID: %s, name: %s", + target.id, + target.name, + ); + } + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error creating iscsi target - code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + } + + if (!target) { + throw new GrpcError( + grpc.status.UNKNOWN, + `unknown error creating iscsi target`, + ); + } + + if (target.name != iscsiName) { + throw new GrpcError( + grpc.status.UNKNOWN, + `mismatch name error creating iscsi target`, + ); + } + + // handle situations/race conditions where groups failed to be added/created on the target + // groups":[{"portal":1,"initiator":1,"auth":null,"authmethod":"NONE"},{"portal":2,"initiator":1,"auth":null,"authmethod":"NONE"}] + // TODO: this logic could be more intelligent but this should do for now as it appears in the failure scenario no groups are added + // in other words, I have never seen them invalid, only omitted so this should be enough + if (target.groups.length != targetGroups.length) { + try { + response = await apiClient.IscsiTargetUpdate( + `/iscsi/target/id/${target.id}`, + { + groups: targetGroups, + }, + ); + + target = response; + + // re-run sanity checks + if (!target) { + throw new GrpcError( + grpc.status.UNKNOWN, + `unknown error creating iscsi target`, + ); + } + + if (target.name != iscsiName) { + throw new GrpcError( + grpc.status.UNKNOWN, + `mismatch name error creating iscsi target`, + ); + } + + if (target.groups.length != targetGroups.length) { + throw new GrpcError( + grpc.status.UNKNOWN, + `failed setting target groups`, + ); + } + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `failed setting target groups`, + ); + } + } + + driver.ctx.logger.verbose("FreeNAS ISCSI TARGET: %j", target); + + // set target.id on zvol + await apiClient.DatasetSet(datasetName, { + [FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME]: target.id, + }); + + let extent = { + comment: extentComment, + type: "DISK", // Disk/File, after save Disk becomes "ZVOL" + name: iscsiName, + //iscsi_target_extent_naa: "0x3822690834aae6c5", + disk: extentDiskName, + insecure_tpc: extentInsecureTpc, + xen: extentXenCompat, + avail_threshold: extentAvailThreshold, + blocksize: Number(extentBlocksize), + pblocksize: extentDisablePhysicalBlocksize, + rpm: "" + extentRpm, // should be a string + ro: false, + }; + + try { + response = await apiClient.IscsiExtentCreate(extent); + extent = response; + } catch (err) { + extent = null; + if (String(err.message).includes("Extent name must be unique")) { + extent = await apiClient.findResourceByProperties( + "/iscsi/extent", + { + name: iscsiName, + }, + ); + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error creating iscsi extent - code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + } + + if (!extent) { + throw new GrpcError( + grpc.status.UNKNOWN, + `unknown error creating iscsi extent`, + ); + } + + if (extent.name != iscsiName) { + throw new GrpcError( + grpc.status.UNKNOWN, + `mismatch name error creating iscsi extent`, + ); + } + + driver.ctx.logger.verbose("FreeNAS ISCSI EXTENT: %j", extent); + + await apiClient.DatasetSet(datasetName, { + [FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME]: extent.id, + }); + + // create targettoextent + let targetToExtent = { + target: target.id, + extent: extent.id, + lunid: 0, + }; + + try { + response = + await apiClient.IscsiTargetExtentCreate(targetToExtent); + targetToExtent = response; + } catch (err) { + // fill in + targetToExtent = null; + + // LUN ID is already being used for this target. + // Extent is already in this target. + if ( + String(err.message).includes( + "Extent is already in this target.", + ) || + String(err.message).includes( + "LUN ID is already being used for this target.", + ) + ) { + targetToExtent = await apiClient.findResourceByProperties( + "/iscsi/targetextent", + { + target: target.id, + extent: extent.id, + lunid: 0, + }, + ); + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error creating iscsi targetextent - code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + } + + if (!targetToExtent) { + throw new GrpcError( + grpc.status.UNKNOWN, + `unknown error creating iscsi targetextent`, + ); + } + driver.ctx.logger.verbose( + "FreeNAS ISCSI TARGET_TO_EXTENT: %j", + targetToExtent, + ); + + await apiClient.DatasetSet(datasetName, { + [FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME]: + targetToExtent.id, + }); + } + + // iqn = target + let iqn = basename + ":" + iscsiName; + driver.ctx.logger.info("FreeNAS iqn: " + iqn); + + // store this off to make delete process more bullet proof + await apiClient.DatasetSet(datasetName, { + [FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME]: iscsiName, + }); + + volume_context = { + node_attach_driver: "iscsi", + portal: driver.options.iscsi.targetPortal || "", + portals: driver.options.iscsi.targetPortals + ? driver.options.iscsi.targetPortals.join(",") + : "", + interface: driver.options.iscsi.interface || "", + iqn: iqn, + lun: 0, + }; + return volume_context; + } + break; + + case "nvmeof": + { + properties = await apiClient.DatasetGet(datasetName, [ + FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, + FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, + FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, + ]); + driver.ctx.logger.debug("zfs props data: %j", properties); + + let nvmeofName; + + if (driver.options.nvmeof.nameTemplate) { + nvmeofName = Handlebars.compile(driver.options.nvmeof.nameTemplate)( + { + name: call.request.name, + parameters: call.request.parameters, + }, + ); + } else { + nvmeofName = zb.helpers.extractLeafName(datasetName); + } + + if (driver.options.nvmeof.namePrefix) { + nvmeofName = driver.options.nvmeof.namePrefix + nvmeofName; + } + + if (driver.options.nvmeof.nameSuffix) { + nvmeofName += driver.options.nvmeof.nameSuffix; + } + + // According to RFC3270, 'Each iSCSI node, whether an initiator or target, MUST have an iSCSI name. Initiators and targets MUST support the receipt of iSCSI names of up to the maximum length of 223 bytes.' + // https://kb.netapp.com/Advice_and_Troubleshooting/Miscellaneous/What_is_the_maximum_length_of_a_iSCSI_iqn_name + // https://tools.ietf.org/html/rfc3720 + // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 + nvmeofName = nvmeofName.toLowerCase(); + + let namespaceDiskName = "zvol/" + datasetName; + let maxZvolNameLength = await driver.getMaxZvolNameLength(); + driver.ctx.logger.debug( + "max zvol name length: %s", + maxZvolNameLength, + ); + + if (namespaceDiskName.length > maxZvolNameLength) { + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `namespace disk name cannot exceed ${maxZvolNameLength} characters: ${namespaceDiskName}`, + ); + } + + // TODO: get basenqn from global config, add nvemofName to it and ensure full nqn is <= 223 + // // https://github.com/SCST-project/scst/blob/master/scst/src/dev_handlers/scst_vdisk.c#L203 + // if (isScale && nvmeofName.length > 64) { + // throw new GrpcError( + // grpc.status.FAILED_PRECONDITION, + // `extent name cannot exceed 64 characters: ${nvmeofName}` + // ); + // } + + driver.ctx.logger.info( + "FreeNAS creating nvmeof assets with name: " + nvmeofName, + ); + + // http:///api/docs/current/api_methods_nvmet.subsys.create.html + let subsystemTemplate = _.get( + driver.options, + "nvmeof.subsystemTemplate", + {}, + ); + subsystemTemplate = subsystemTemplate || {}; + + // http:///api/docs/current/api_methods_nvmet.namespace.create.html + let namespaceTemplate = _.get( + driver.options, + "nvmeof.namespaceTemplate", + {}, + ); + namespaceTemplate = namespaceTemplate || {}; + + // create subsystem + let subsystem; + try { + subsystem = await apiClient.NvmetSubsysCreate({ + ...subsystemTemplate, + name: nvmeofName, + }); + } catch (err) { + if (String(err.message).includes("already exists")) { + subsystem = await apiClient.findResourceByProperties( + "nvmet.subsys", + { name: nvmeofName }, + ); + } + } + + if (!subsystem) { + throw new GrpcError( + grpc.status.NOT_FOUND, + `unable to find nvmeof subsystem: ${nvmeofName}`, + ); + } + driver.ctx.logger.verbose("FreeNAS NVMEOF SUBSYSTEM: %j", subsystem); + await apiClient.DatasetSet(datasetName, { + [FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME]: subsystem.id, + }); + + // create subsystem + let namespace; + try { + namespace = await apiClient.NvmetNamespaceCreate({ + ...namespaceTemplate, + device_type: "ZVOL", + device_path: + apiClient.NvmetNamespaceZvolPathNormalized(namespaceDiskName), + subsys_id: subsystem.id, + }); + } catch (err) { + if (String(err.message).includes("already used by subsystem")) { + namespace = await apiClient.findResourceByProperties( + "nvmet.namespace", + (item) => { + if ( + item.device_type == "ZVOL" && + item.device_path == + apiClient.NvmetNamespaceZvolPathNormalized( + namespaceDiskName, + ) && + item.subsys.id == subsystem.id + ) { + return true; + } + return false; + }, + ); + } + } + + if (!namespace) { + throw new GrpcError( + grpc.status.NOT_FOUND, + `unable to find nvmeof namespace: ${namespaceDiskName}`, + ); + } + driver.ctx.logger.verbose("FreeNAS NVMEOF NAMESPACE: %j", namespace); + await apiClient.DatasetSet(datasetName, { + [FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME]: namespace.id, + }); + + // assign ports to subsystem + let ports = _.get(driver.options, "nvmeof.ports", []); + for (const port_i of ports) { + const port = await apiClient.NvmetPortSubsysCreate( + port_i, + subsystem.id, + ); + driver.ctx.logger.verbose("FreeNAS NVMEOF PORT: %j", port); + } + + // TODO: assign hosts + + // store this off to make delete process more bullet proof + await apiClient.DatasetSet(datasetName, { + [FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME]: nvmeofName, + }); + + volume_context = { + node_attach_driver: "nvmeof", + transport: driver.options.nvmeof.transport || "", + transports: driver.options.nvmeof.transports + ? driver.options.nvmeof.transports.join(",") + : "", + nqn: subsystem.subnqn, + nsid: namespace.nsid, + }; + return volume_context; + } + break; + + default: + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `invalid configuration: unknown driverShareType ${driverShareType}`, + ); + } + } + + async deleteShare(call, datasetName) { + const driver = this.driver; + const driverShareType = driver.getDriverShareType(); + const apiClient = await driver.getTrueNASWebSocketApiClient(); + const zb = await driver.getZetabyte(); + + let properties; + let response; + let endpoint; + let shareId; + let deleteAsset; + + switch (driverShareType) { + case "nfs": + { + try { + properties = await apiClient.DatasetGet(datasetName, [ + "mountpoint", + FREENAS_NFS_SHARE_PROPERTY_NAME, + ]); + } catch (err) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { + return; + } + throw err; + } + driver.ctx.logger.debug("zfs props data: %j", properties); + + shareId = properties[FREENAS_NFS_SHARE_PROPERTY_NAME].value; + + // only remove if the process has not succeeded already + if (zb.helpers.isPropertyValueSet(shareId)) { + // remove nfs share + + let share = await apiClient.findResourceByProperties( + "/sharing/nfs", + (item) => { + if (item.id == shareId) { + return true; + } + return false; + }, + ); + + if (share) { + deleteAsset = share.path == properties.mountpoint.value; + if (deleteAsset) { + try { + await GeneralUtils.retry( + 3, + 1000, + async () => { + return await apiClient.NfsShareDelete(shareId); + }, + { + retryCondition: (err) => { + // TODO: fix this logic + return false; + }, + }, + ); + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting nfs share - share: ${shareId} code: ${ + err.code + } body: ${err.message}`, + ); + } + + // remove property to prevent delete race conditions + // due to id re-use by FreeNAS/TrueNAS + await apiClient.DatasetInherit( + datasetName, + FREENAS_NFS_SHARE_PROPERTY_NAME, + ); + } + } else { + // assume share is gone for now + } + } + } + break; + case "smb": + { + try { + properties = await apiClient.DatasetGet(datasetName, [ + "mountpoint", + FREENAS_SMB_SHARE_PROPERTY_NAME, + ]); + } catch (err) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { + return; + } + throw err; + } + driver.ctx.logger.debug("zfs props data: %j", properties); + + shareId = properties[FREENAS_SMB_SHARE_PROPERTY_NAME].value; + + // only remove if the process has not succeeded already + if (zb.helpers.isPropertyValueSet(shareId)) { + // remove smb share + let share = await apiClient.findResourceByProperties( + "/sharing/smb", + (item) => { + if (item.id == shareId) { + return true; + } + return false; + }, + ); + + if (share) { + deleteAsset = share.path == properties.mountpoint.value; + if (deleteAsset) { + try { + response = await GeneralUtils.retry( + 3, + 1000, + async () => { + return await apiClient.SmbShareDelete(shareId); + }, + { + retryCondition: (err) => { + // TODO: fix this logic + return false; + }, + }, + ); + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting smb share - share: ${shareId} code: ${ + err.code + } body: ${err.message}`, + ); + } + + // remove property to prevent delete race conditions + // due to id re-use by FreeNAS/TrueNAS + await apiClient.DatasetInherit( + datasetName, + FREENAS_SMB_SHARE_PROPERTY_NAME, + ); + } + } else { + // assume share is gone for now + } + } + } + break; + case "iscsi": + { + // Delete target + // NOTE: deleting a target inherently deletes associated targetgroup(s) and targettoextent(s) + + // Delete extent + try { + properties = await apiClient.DatasetGet(datasetName, [ + FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, + FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, + FREENAS_ISCSI_TARGETTOEXTENT_ID_PROPERTY_NAME, + FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME, + ]); + } catch (err) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { + return; + } + throw err; + } + + driver.ctx.logger.debug("zfs props data: %j", properties); + + let targetId = + properties[FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME].value; + let extentId = + properties[FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME].value; + let iscsiName = + properties[FREENAS_ISCSI_ASSETS_NAME_PROPERTY_NAME].value; + let assetName; + let message; + + // only remove if the process has not succeeded already + if (zb.helpers.isPropertyValueSet(targetId)) { + response = null; + deleteAsset = false; + assetName = null; + message = ""; + + try { + response = await apiClient.ResourceGet("iscsi.target", targetId); + deleteAsset = true; + } catch (err) { + if (String(err.message).includes("does not exist")) { + // assume is gone for now + message = `not deleting iscsitarget asset as it appears ID ${targetId} has already been deleted: zfs name - ${iscsiName}`; + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting iscsi target - target: ${targetId} code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + } + + // checking if set for backwards compatibility + if (zb.helpers.isPropertyValueSet(iscsiName)) { + assetName = response.name; + if (assetName != iscsiName) { + deleteAsset = false; + message = `not deleting iscsitarget asset as it appears ID ${targetId} has been re-used: zfs name - ${iscsiName}, iscsitarget name - ${assetName}`; + } + } + + if (deleteAsset) { + try { + await GeneralUtils.retry( + 5, + 1000, + async () => { + return await apiClient.IscsiTargetDelete(targetId); + }, + { + retryCondition: (err) => { + // TODO: errno == 14? + if ( + String(err.message).includes("is in use") && + String(err.message).includes("Target") + ) + return true; + return false; + }, + }, + ); + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting iscsi target - target: ${targetId} code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + + // remove property to prevent delete race conditions + // due to id re-use by FreeNAS/TrueNAS + await apiClient.DatasetInherit( + datasetName, + FREENAS_ISCSI_TARGET_ID_PROPERTY_NAME, + ); + } else { + driver.ctx.logger.debug(message); + } + } + + // only remove if the process has not succeeded already + if (zb.helpers.isPropertyValueSet(extentId)) { + response = null; + deleteAsset = false; + assetName = null; + message = ""; + + try { + response = await apiClient.ResourceGet("iscsi.extent", extentId); + deleteAsset = true; + } catch (err) { + if (String(err.message).includes("does not exist")) { + // assume is gone for now + message = `not deleting iscsiextent asset as it appears ID ${extentId} has already been deleted: zfs name - ${iscsiName}`; + } else { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting iscsi extent - extent: ${extentId} code: ${ + err.scode + } body: ${JSON.stringify(err.message)}`, + ); + } + } + + // checking if set for backwards compatibility + if (zb.helpers.isPropertyValueSet(iscsiName)) { + assetName = response.name; + if (assetName != iscsiName) { + deleteAsset = false; + message = `not deleting iscsiextent asset as it appears ID ${extentId} has been re-used: zfs name - ${iscsiName}, iscsitarget name - ${assetName}`; + } + } + + if (deleteAsset) { + try { + await GeneralUtils.retry( + 5, + 1000, + async () => { + return await apiClient.IscsiExtentDelete(extentId); + }, + { + retryCondition: (err) => { + // TODO: better logic here + return false; + }, + }, + ); + } catch (err) { + throw new GrpcError( + grpc.status.UNKNOWN, + `received error deleting iscsi extent - extent: ${extentId} code: ${ + err.code + } body: ${JSON.stringify(err.message)}`, + ); + } + + // remove property to prevent delete race conditions + // due to id re-use by FreeNAS/TrueNAS + await apiClient.DatasetInherit( + datasetName, + FREENAS_ISCSI_EXTENT_ID_PROPERTY_NAME, + ); + } else { + driver.ctx.logger.debug(message); + } + } + } + break; + + case "nvmeof": + { + try { + properties = await apiClient.DatasetGet(datasetName, [ + FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, + FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, + FREENAS_NVMEOF_ASSETS_NAME_PROPERTY_NAME, + ]); + } catch (err) { + if (ERROR_DATASET_DOES_NOT_EXIST_REGEX.test(err.toString())) { + return; + } + throw err; + } + driver.ctx.logger.debug("zfs props data: %j", properties); + + let subsystemId = + properties[FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME].value; + let namespaceId = + properties[FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME].value; + + // remove namespace + if (zb.helpers.isPropertyValueSet(namespaceId)) { + await GeneralUtils.retry( + 15, + 2000, + async () => { + await apiClient.NvmetNamespaceDelete(namespaceId); + }, + { + retryCondition: (err) => { + return true; + }, + }, + ); + + await apiClient.DatasetInherit( + datasetName, + FREENAS_NVMEOF_NAMESPACE_ID_PROPERTY_NAME, + ); + } + + // remove subsystem + if (zb.helpers.isPropertyValueSet(subsystemId)) { + await GeneralUtils.retry( + 15, + 2000, + async () => { + await apiClient.NvmetSubsysDelete(subsystemId, { + force: true, + }); + }, + { + retryCondition: (err) => { + return true; + }, + }, + ); + + await apiClient.DatasetInherit( + datasetName, + FREENAS_NVMEOF_SUBSYSTEM_ID_PROPERTY_NAME, + ); + } + } + break; + + default: + throw new GrpcError( + grpc.status.FAILED_PRECONDITION, + `invalid configuration: unknown driverShareType ${driverShareType}`, + ); + } + } + + /** + * Hypothetically this isn't needed. The middleware is supposed to reload stuff as appropriate. + * + * @param {*} call + * @param {*} datasetName + * @returns + */ + async expandVolume(call, datasetName) { + return; + } +} + +module.exports.FreeNASApiShareHelper = FreeNASApiShareHelper; diff --git a/src/utils/general.js b/src/utils/general.js index 1657a67..21b917b 100644 --- a/src/utils/general.js +++ b/src/utils/general.js @@ -11,7 +11,7 @@ axios.interceptors.request.use( }, function (error) { return Promise.reject(error); - } + }, ); axios.interceptors.response.use( @@ -26,7 +26,7 @@ axios.interceptors.response.use( error.duration = error.config.metadata.endTime - error.config.metadata.startTime; return Promise.reject(error); - } + }, ); function sleep(ms) { @@ -285,6 +285,19 @@ function expandenv(string, env) { }); } +function IsJsonString(str) { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +} + +function is_object(v) { + return typeof v === "object" && !Array.isArray(v) && v !== null; +} + module.exports.sleep = sleep; module.exports.md5 = md5; module.exports.crc32 = crc32; @@ -306,3 +319,5 @@ module.exports.retry = retry; module.exports.trimchar = trimchar; module.exports.hostname_lookup = hostname_lookup; module.exports.expandenv = expandenv; +module.exports.IsJsonString = IsJsonString; +module.exports.is_object = is_object; diff --git a/src/utils/nvmeof.js b/src/utils/nvmeof.js index 82c7b63..4240720 100644 --- a/src/utils/nvmeof.js +++ b/src/utils/nvmeof.js @@ -2,6 +2,7 @@ const cp = require("child_process"); const { hostname_lookup, trimchar } = require("./general"); const URI = require("uri-js"); const querystring = require("querystring"); +const semver = require("semver"); const DEFAULT_TIMEOUT = process.env.NVMEOF_DEFAULT_TIMEOUT || 30000; @@ -35,6 +36,16 @@ class NVMEoF { } } + async version() { + const nvmeof = this; + let args = []; + args.unshift("version"); + let result = await nvmeof.exec(nvmeof.options.paths.nvme, args); + const match = result.stdout.match(/^nvme version\s+([0-9]+(?:\.[0-9]+)*)/m); + const version = match?.[1]; + return version; + } + /** * List all NVMe devices and namespaces on machine * @@ -119,6 +130,12 @@ class NVMEoF { args.unshift("connect", "--nqn", nqn, ...transport_args); + // version 3.0 added a new flag and changed the stderr output + let version = await nvmeof.version(); + if (semver.satisfies(semver.coerce(version), ">=3.0")) { + args.push("--idempotent"); + } + try { await nvmeof.exec(nvmeof.options.paths.nvme, args); } catch (err) { @@ -233,7 +250,7 @@ class NVMEoF { */ controllerAddress = controllerAddress.replace( new RegExp(/ ([a-z_]*=)/, "g"), - ",$1" + ",$1", ); let parts = controllerAddress.split(","); @@ -483,9 +500,8 @@ class NVMEoF { continue; } - let controller_transport = await nvmeof.parseTransportFromPath( - controller - ); + let controller_transport = + await nvmeof.parseTransportFromPath(controller); if (controller_transport.address != transport.address) { continue; @@ -504,8 +520,8 @@ class NVMEoF { nvmeof.logger.warn( `failed to find controller for transport: ${JSON.stringify( - transport - )}, nqn: ${nqn}` + transport, + )}, nqn: ${nqn}`, ); } @@ -601,7 +617,7 @@ class NVMEoF { nvmeof.logger.verbose( "executing nvmeof command: %s %s", command, - args.join(" ") + args.join(" "), ); return new Promise((resolve, reject) => {