diff --git a/src/driver/controller-synology/http/index.js b/src/driver/controller-synology/http/index.js new file mode 100644 index 0000000..9a2d306 --- /dev/null +++ b/src/driver/controller-synology/http/index.js @@ -0,0 +1,302 @@ +const request = require("request"); + +const USER_AGENT = "democratic-csi"; + +class SynologyHttpClient { + constructor(options = {}) { + this.options = JSON.parse(JSON.stringify(options)); + this.logger = console; + + setInterval(() => { + console.log("WIPING OUT SYNOLOGY SID"); + this.sid = null; + }, 60 * 1000); + } + + async login() { + if (!this.sid) { + const data = { + api: "SYNO.API.Auth", + version: "2", + method: "login", + account: this.options.username, + passwd: this.options.password, + session: this.options.session, + format: "sid", + }; + + this.authenticating = true; + let response = await this.do_request("GET", "auth.cgi", data); + this.sid = response.body.data.sid; + this.authenticating = false; + } + } + + log_response(error, response, body, options) { + this.logger.debug("SYNOLOGY HTTP REQUEST: " + JSON.stringify(options)); + this.logger.debug("SYNOLOGY HTTP ERROR: " + error); + this.logger.debug("SYNOLOGY HTTP STATUS: " + response.statusCode); + this.logger.debug( + "SYNOLOGY HTTP HEADERS: " + JSON.stringify(response.headers) + ); + this.logger.debug("SYNOLOGY HTTP BODY: " + JSON.stringify(body)); + } + + async do_request(method, path, data = {}) { + const client = this; + if (!this.authenticating) { + await this.login(); + } + + return new Promise((resolve, reject) => { + if (data.api != "SYNO.API.Auth") { + data._sid = this.sid; + } + + const options = { + method: method, + url: `${this.options.protocol}://${this.options.host}:${this.options.port}/webapi/${path}`, + headers: { + Accept: "application/json", + "User-Agent": USER_AGENT, + "Content-Type": "application/json", + }, + json: true, + agentOptions: { + rejectUnauthorized: !!!client.options.allowInsecure, + }, + }; + + switch (method) { + case "GET": + options.qs = data; + break; + default: + options.body = data; + break; + } + + request(options, function (error, response, body) { + client.log_response(...arguments, options); + + if (error) { + reject(error); + } + + if (response.statusCode > 299 || response.statusCode < 200) { + reject(response); + } + + if (response.body.success === false) { + reject(response); + } + + resolve(response); + }); + }); + } + + async GetLunUUIDByName(name) { + const lun_list = { + api: "SYNO.Core.ISCSI.LUN", + version: "1", + method: "list", + }; + + let response = await this.do_request("GET", "entry.cgi", lun_list); + let lun = response.body.data.luns.find((i) => { + return i.name == name; + }); + + if (lun) { + return lun.uuid; + } + } + + async GetTargetByTargetID(target_id) { + let targets = await this.ListTargets(); + let target = targets.find((i) => { + return i.target_id == target_id; + }); + + return target; + } + + async ListTargets() { + const iscsi_target_list = { + api: "SYNO.Core.ISCSI.Target", + version: "1", + path: "entry.cgi", + method: "list", + additional: '["mapped_lun", "status", "acls", "connected_sessions"]', + }; + let response = await this.do_request("GET", "entry.cgi", iscsi_target_list); + return response.body.data.targets; + } + + async CreateLun(data = {}) { + let response; + let iscsi_lun_create = Object.assign(data, { + api: "SYNO.Core.ISCSI.LUN", + version: "1", + method: "create", + }); + + const lun_list = { + api: "SYNO.Core.ISCSI.LUN", + version: "1", + method: "list", + }; + + try { + response = await this.do_request("GET", "entry.cgi", iscsi_lun_create); + return response.body.data.uuid; + } catch (err) { + if ([18990538].includes(err.body.error.code)) { + response = await this.do_request("GET", "entry.cgi", lun_list); + let lun = response.body.data.luns.find((i) => { + return i.name == iscsi_lun_create.name; + }); + return lun.uuid; + } else { + throw err; + } + } + } + + async MapLun(data = {}) { + // this is mapping from the perspective of the lun + let iscsi_target_map = Object.assign(data, { + api: "SYNO.Core.ISCSI.LUN", + method: "map_target", + version: "1", + }); + iscsi_target_map.target_ids = JSON.stringify(iscsi_target_map.target_ids); + + // this is mapping from the perspective of the target + /* + iscsi_target_map = Object.assign(data, { + api: "SYNO.Core.ISCSI.Target", + method: "map_lun", + version: "1", + }); + iscsi_target_map.lun_uuids = JSON.stringify(iscsi_target_map.lun_uuids); + */ + + await this.do_request("GET", "entry.cgi", iscsi_target_map); + } + + async DeleteLun(uuid) { + let iscsi_lun_delete = { + api: "SYNO.Core.ISCSI.LUN", + method: "delete", + version: 1, + uuid: uuid || "", + }; + try { + await this.do_request("GET", "entry.cgi", iscsi_lun_delete); + } catch (err) { + if (![18990505].includes(err.body.error.code)) { + throw err; + } + } + } + + async GetTargetIDByIQN(iqn) { + const iscsi_target_list = { + api: "SYNO.Core.ISCSI.Target", + version: "1", + path: "entry.cgi", + method: "list", + additional: '["mapped_lun", "status", "acls", "connected_sessions"]', + }; + + let response = await this.do_request("GET", "entry.cgi", iscsi_target_list); + let target = response.body.data.targets.find((i) => { + return i.iqn == iqn; + }); + + if (target) { + return target.target_id; + } + } + + async CreateTarget(data = {}) { + let iscsi_target_create = Object.assign(data, { + api: "SYNO.Core.ISCSI.Target", + version: "1", + method: "create", + }); + + let response; + + try { + response = await this.do_request("GET", "entry.cgi", iscsi_target_create); + + return response.body.data.target_id; + } catch (err) { + if ([18990744].includes(err.body.error.code)) { + //do lookup + const iscsi_target_list = { + api: "SYNO.Core.ISCSI.Target", + version: "1", + path: "entry.cgi", + method: "list", + additional: '["mapped_lun", "status", "acls", "connected_sessions"]', + }; + + response = await this.do_request("GET", "entry.cgi", iscsi_target_list); + let target = response.body.data.targets.find((i) => { + return i.iqn == iscsi_target_create.iqn; + }); + + let target_id = target.target_id; + return target_id; + } else { + throw err; + } + } + } + + async DeleteTarget(target_id) { + const iscsi_target_delete = { + api: "SYNO.Core.ISCSI.Target", + method: "delete", + version: "1", + path: "entry.cgi", + }; + + try { + await this.do_request( + "GET", + "entry.cgi", + Object.assign(iscsi_target_delete, { + target_id: JSON.stringify(String(target_id || "")), + }) + ); + } catch (err) { + /** + * 18990710 = non-existant + */ + if (![18990710].includes(err.body.error.code)) { + throw err; + } + } + } + + async ExpandISCSILun(uuid, size) { + const iscsi_lun_extend = { + api: "SYNO.Core.ISCSI.LUN", + method: "set", + version: 1, + }; + + await this.do_request( + "GET", + "entry.cgi", + Object.assign(iscsi_lun_extend, { uuid: uuid, new_size: size }) + ); + } +} + +module.exports.SynologyHttpClient = SynologyHttpClient; diff --git a/src/driver/controller-synology/index.js b/src/driver/controller-synology/index.js index 143dd4e..b01e280 100644 --- a/src/driver/controller-synology/index.js +++ b/src/driver/controller-synology/index.js @@ -1,5 +1,6 @@ const { CsiBaseDriver } = require("../index"); const { GrpcError, grpc } = require("../../utils/grpc"); +const SynologyHttpClient = require("./http").SynologyHttpClient; /** * @@ -57,7 +58,7 @@ class ControllerSynologyDriver extends CsiBaseDriver { //"LIST_SNAPSHOTS", //"CLONE_VOLUME", //"PUBLISH_READONLY", - //"EXPAND_VOLUME", + "EXPAND_VOLUME", ]; } @@ -73,6 +74,13 @@ class ControllerSynologyDriver extends CsiBaseDriver { } } + async getHttpClient() { + if (!this.httpClient) { + this.httpClient = new SynologyHttpClient(this.options.httpConnection); + } + return this.httpClient; + } + getDriverResourceType() { switch (this.options.driver) { case "synology-nfs": @@ -98,6 +106,19 @@ class ControllerSynologyDriver extends CsiBaseDriver { } } + buildIscsiName(name) { + let iscsiName = name; + if (this.options.iscsi.namePrefix) { + iscsiName = this.options.iscsi.namePrefix + iscsiName; + } + + if (this.options.iscsi.nameSuffix) { + iscsiName += this.options.iscsi.nameSuffix; + } + + return iscsiName.toLowerCase(); + } + assertCapabilities(capabilities) { const driverResourceType = this.getDriverResourceType(); this.ctx.logger.verbose("validating capabilities: %j", capabilities); @@ -176,6 +197,7 @@ class ControllerSynologyDriver extends CsiBaseDriver { */ async CreateVolume(call) { const driver = this; + const httpClient = await driver.getHttpClient(); let name = call.request.name; let volume_content_source = call.request.volume_content_source; @@ -230,23 +252,84 @@ class ControllerSynologyDriver extends CsiBaseDriver { ); } + let volume_context = {}; switch (driver.getDriverShareType()) { case "nfs": // TODO: create volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; case "smb": // TODO: create volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; case "iscsi": - // TODO: create volume here + let iscsiName = driver.buildIscsiName(name); + let data; + let iqn = driver.options.iscsi.baseiqn + iscsiName; + data = Object.assign(driver.options.iscsi.targetAttributes, { + name: iscsiName, + iqn, + }); + + let target_id = await httpClient.CreateTarget(data); + data = Object.assign(driver.options.iscsi.lunAttributes, { + name: iscsiName, + location: driver.options.synology.location, + size: capacity_bytes, + }); + let lun_uuid = await httpClient.CreateLun(data); + let target = await httpClient.GetTargetByTargetID(target_id); + + if (!target) { + throw new GrpcError( + grpc.status.UNKNOWN, + `failed to lookup target: ${target_id}` + ); + } + + if ( + !target.mapped_luns.some((lun) => { + return lun.lun_uuid == lun_uuid; + }) + ) { + data = { + uuid: lun_uuid, + target_ids: [target_id], + }; + /* + data = { + lun_uuids: [lun_uuid], + target_id: target_id, + }; + */ + await httpClient.MapLun(data); + } + + 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, + lun: 0, + }; break; default: - // throw an error + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; } - let volume_context = driver.getVolumeContext(name); - volume_context["provisioner_driver"] = driver.options.driver; if (driver.options.instance_id) { volume_context["provisioner_driver_instance_id"] = @@ -256,8 +339,7 @@ class ControllerSynologyDriver extends CsiBaseDriver { const res = { volume: { volume_id: name, - //capacity_bytes: capacity_bytes, // kubernetes currently pukes if capacity is returned as 0 - capacity_bytes: 0, + capacity_bytes, // kubernetes currently pukes if capacity is returned as 0 content_source: volume_content_source, volume_context, }, @@ -273,6 +355,7 @@ class ControllerSynologyDriver extends CsiBaseDriver { */ async DeleteVolume(call) { const driver = this; + const httpClient = await driver.getHttpClient(); let name = call.request.volume_id; @@ -283,18 +366,38 @@ class ControllerSynologyDriver extends CsiBaseDriver { ); } + let response; + switch (driver.getDriverShareType()) { case "nfs": // TODO: delete volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; case "smb": // TODO: delete volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; case "iscsi": - // TODO: delete volume here + let iscsiName = driver.buildIscsiName(name); + let iqn = driver.options.iscsi.baseiqn + iscsiName; + + response = await httpClient.GetLunUUIDByName(iscsiName); + await httpClient.DeleteLun(response); + + response = await httpClient.GetTargetIDByIQN(iqn); + await httpClient.DeleteTarget(response); break; default: - // throw an error + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); break; } @@ -306,10 +409,90 @@ class ControllerSynologyDriver extends CsiBaseDriver { * @param {*} call */ async ControllerExpandVolume(call) { - throw new GrpcError( - grpc.status.UNIMPLEMENTED, - `operation not supported by driver` - ); + const driver = this; + const httpClient = await driver.getHttpClient(); + + let name = call.request.volume_id; + + if (!name) { + throw new GrpcError( + grpc.status.INVALID_ARGUMENT, + `volume_id is required` + ); + } + + let capacity_bytes = + call.request.capacity_range.required_bytes || + call.request.capacity_range.limit_bytes; + if (!capacity_bytes) { + //should never happen, value must be set + throw new GrpcError( + grpc.status.INVALID_ARGUMENT, + `volume capacity is required (either required_bytes or limit_bytes)` + ); + } + + if ( + call.request.capacity_range.required_bytes > 0 && + call.request.capacity_range.limit_bytes > 0 && + call.request.capacity_range.required_bytes > + call.request.capacity_range.limit_bytes + ) { + throw new GrpcError( + grpc.status.INVALID_ARGUMENT, + `required_bytes is greather than limit_bytes` + ); + } + + // ensure *actual* capacity is not greater than limit + if ( + call.request.capacity_range.limit_bytes && + call.request.capacity_range.limit_bytes > 0 && + capacity_bytes > call.request.capacity_range.limit_bytes + ) { + throw new GrpcError( + grpc.status.OUT_OF_RANGE, + `required volume capacity is greater than limit` + ); + } + + let node_expansion_required = false; + let response; + + switch (driver.getDriverShareType()) { + case "nfs": + // TODO: expand volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); + break; + case "smb": + // TODO: expand volume here + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); + break; + case "iscsi": + node_expansion_required = true; + let iscsiName = driver.buildIscsiName(name); + + response = await httpClient.GetLunUUIDByName(iscsiName); + await httpClient.ExpandISCSILun(response, capacity_bytes); + break; + default: + throw new GrpcError( + grpc.status.UNIMPLEMENTED, + `operation not supported by driver` + ); + break; + } + + return { + capacity_bytes, + node_expansion_required, + }; } /**