From cf33566b8505b4cafa22653ed97db3be82587ff3 Mon Sep 17 00:00:00 2001 From: dhslove <44049806+dhslove@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:58:49 +0900 Subject: [PATCH 1/5] feat: support static IPv4 for L2 SharedFS --- .../storage/sharedfs/CreateSharedFSCmd.java | 52 ++++++ .../api/response/SharedFSResponse.java | 56 ++++++ .../cloudstack/storage/sharedfs/SharedFS.java | 28 +++ .../diplo-sharedfs-l2-static-network.md | 88 +++++++++ .../storage/sharedfs/SharedFSVO.java | 79 ++++++++ .../META-INF/db/schema-41910to42000.sql | 8 +- .../META-INF/db/schema-Diplo-After.sql | 7 + .../db/views/cloud.shared_filesystem_view.sql | 6 + ...bvirtStorageServiceHostCommandWrapper.java | 84 +++++++++ ...tStorageServiceHostCommandWrapperTest.java | 53 ++++++ .../lifecycle/StorageVmSharedFSLifeCycle.java | 16 +- .../storage/sharedfs/SharedFSServiceImpl.java | 151 ++++++++++++++- .../query/dao/SharedFSJoinDaoImpl.java | 10 + .../sharedfs/query/vo/SharedFSJoinVO.java | 43 +++++ .../sharedfs/SharedFSServiceImplTest.java | 47 ++++- ui/public/locales/en.json | 12 ++ ui/public/locales/ko_KR.json | 12 ++ ui/src/views/storage/CreateSharedFS.vue | 176 ++++++++++++++++++ .../unit/views/storage/CreateSharedFS.spec.js | 45 +++++ 19 files changed, 958 insertions(+), 15 deletions(-) create mode 100644 docs/design/diplo-sharedfs-l2-static-network.md create mode 100644 plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapperTest.java diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/storage/sharedfs/CreateSharedFSCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/storage/sharedfs/CreateSharedFSCmd.java index 21eeb173d7bd..4650fc74c985 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/storage/sharedfs/CreateSharedFSCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/storage/sharedfs/CreateSharedFSCmd.java @@ -158,6 +158,31 @@ public class CreateSharedFSCmd extends BaseAsyncCreateCmd implements UserCmd { description = "network to attach the shared filesystem to") private Long networkId; + @Parameter(name = "networkmode", + type = CommandType.STRING, + description = "network addressing mode for an L2 SharedFS network: DHCP or STATIC") + private String networkMode; + + @Parameter(name = "ipcidr", + type = CommandType.STRING, + description = "static IPv4 address and prefix for the SharedFS VM, for example 10.10.1.211/24") + private String ipCidr; + + @Parameter(name = ApiConstants.GATEWAY, + type = CommandType.STRING, + description = "optional static IPv4 default gateway") + private String gateway; + + @Parameter(name = ApiConstants.DNS1, + type = CommandType.STRING, + description = "optional primary IPv4 DNS server") + private String dns1; + + @Parameter(name = ApiConstants.DNS2, + type = CommandType.STRING, + description = "optional secondary IPv4 DNS server") + private String dns2; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -218,6 +243,33 @@ public Long getNetworkId() { return networkId; } + public SharedFS.NetworkMode getNetworkMode() { + if (networkMode == null) { + return SharedFS.NetworkMode.DHCP; + } + try { + return SharedFS.NetworkMode.valueOf(networkMode.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Invalid network mode. Supported values are DHCP and STATIC."); + } + } + + public String getIpCidr() { + return ipCidr; + } + + public String getGateway() { + return gateway; + } + + public String getDns1() { + return dns1; + } + + public String getDns2() { + return dns2; + } + public String getSharedFSProviderName() { if (sharedFSProviderName != null) { return sharedFSProviderName; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/SharedFSResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/SharedFSResponse.java index bac348fe36e4..689231cd03c1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/SharedFSResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/SharedFSResponse.java @@ -116,6 +116,34 @@ public class SharedFSResponse extends BaseResponseWithTagInformation implements @Param(description = "Network name of the shared filesystem") private String networkName; + @SerializedName("networkmode") + @Param(description = "SharedFS VM network addressing mode") + private String networkMode; + + @SerializedName("ipcidr") + @Param(description = "requested static IPv4 address and prefix") + private String ipCidr; + + @SerializedName(ApiConstants.IP_ADDRESS) + @Param(description = "requested static IPv4 address") + private String ipAddress; + + @SerializedName(ApiConstants.CIDR) + @Param(description = "requested static IPv4 CIDR") + private String cidr; + + @SerializedName(ApiConstants.GATEWAY) + @Param(description = "requested static IPv4 gateway") + private String gateway; + + @SerializedName(ApiConstants.DNS1) + @Param(description = "requested primary DNS server") + private String dns1; + + @SerializedName(ApiConstants.DNS2) + @Param(description = "requested secondary DNS server") + private String dns2; + @SerializedName(ApiConstants.NIC) @Param(description = "the list of nics associated with the shared filesystem", responseObject = NicResponse.class) private List nics; @@ -258,6 +286,34 @@ public void setNetworkName(String networkName) { this.networkName = networkName; } + public void setNetworkMode(String networkMode) { + this.networkMode = networkMode; + } + + public void setIpCidr(String ipCidr) { + this.ipCidr = ipCidr; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + public void setCidr(String cidr) { + this.cidr = cidr; + } + + public void setGateway(String gateway) { + this.gateway = gateway; + } + + public void setDns1(String dns1) { + this.dns1 = dns1; + } + + public void setDns2(String dns2) { + this.dns2 = dns2; + } + public List getNics() { return nics; } diff --git a/api/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFS.java b/api/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFS.java index bcba425abbff..b39c92887c20 100644 --- a/api/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFS.java +++ b/api/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFS.java @@ -75,6 +75,10 @@ enum Protocol { NFS } + enum NetworkMode { + DHCP, STATIC + } + enum State { Allocated(false, "The shared filesystem is allocated in db but hasn't been created or started yet."), Ready(false, "The shared filesystem is ready to use."), @@ -181,6 +185,30 @@ static String getSharedFSPath() { void setServiceOfferingId(Long serviceOfferingId); + NetworkMode getNetworkMode(); + + void setNetworkMode(NetworkMode networkMode); + + String getIpAddress(); + + void setIpAddress(String ipAddress); + + String getCidr(); + + void setCidr(String cidr); + + String getGateway(); + + void setGateway(String gateway); + + String getDns1(); + + void setDns1(String dns1); + + String getDns2(); + + void setDns2(String dns2); + Date getUpdated(); public long getUpdatedCount(); diff --git a/docs/design/diplo-sharedfs-l2-static-network.md b/docs/design/diplo-sharedfs-l2-static-network.md new file mode 100644 index 000000000000..0c2227d4b843 --- /dev/null +++ b/docs/design/diplo-sharedfs-l2-static-network.md @@ -0,0 +1,88 @@ +# SharedFS L2 static network without ConfigDrive + +## Goal + +Allow a Storage Service SharedFS VM attached to an L2 guest network to use an +operator-supplied IPv4 address even when the network has neither DHCP nor +ConfigDrive. The change is scoped to SharedFS VMs and must not alter the boot or +network behavior of routers, consoles, secondary-storage VMs, or ordinary user +VMs. + +## Behavioral contract + +| Area | DHCP mode | L2 static mode | +|---|---|---| +| Existing behavior | Unchanged | New opt-in path | +| UserData requirement | Preserved | Not required | +| Address source | Existing network services | Required `ipcidr` in IPv4/prefix form; optional `gateway`, `dns1`, and `dns2` | +| Cloud DB NIC address | Existing allocation | Requested address is reserved at VM deployment | +| Guest configuration | Existing template path | Fixed QGA operation after VM start | +| Reboot persistence | Existing template path | SharedFS-only oneshot systemd unit inside that VM | +| SystemVM template | Unchanged | Unchanged | + +## API and persistence + +`createSharedFileSystem` gains optional `networkmode`, `ipcidr`, +`gateway`, `dns1`, and `dns2` parameters. Gateway and DNS values are optional. +`ipcidr` uses host-address/prefix notation such as `10.10.1.211/24`. +`networkmode` is `DHCP` by default or +`STATIC` for the new path. The management server calculates the network address +from any valid prefix from `/0` through `/32`, then persists the host address and +the calculated network CIDR on `shared_filesystem`, +exposed by `listSharedFileSystems`, and included in +`shared_filesystem_view`. Persisting desired state before deployment lets +start, restart, redeploy, and diagnostics use the same values without changing +the `SharedFSLifeCycle` interface signature. + +## Server validation + +Static mode is accepted only when the selected network has guest type `L2`. +The server validates all supplied IPv4 values, requires the address and any +supplied gateway to be in the CIDR, rejects network/broadcast addresses, and rejects an address +already assigned to a NIC in the same network. DHCP mode keeps the existing +UserData/ConfigDrive capability check. + +The lifecycle receives the requested IPv4 through the persisted SharedFS +entity and passes it to `createAdvancedVirtualMachine`. This reserves the +address and records it on the VM NIC while keeping the existing lifecycle +method signature intact. + +## QGA application path + +The host agent recognizes one exact operation, +`configure-sharedfs-static-network`. Other Storage Service operations continue +to invoke `/usr/local/bin/ablestack-storagectl` unchanged. + +The fixed operation accepts only JSON data produced by the management server. +It installs a SharedFS-specific network state file, helper, and oneshot unit in +the guest. The helper resolves the interface by its VM NIC MAC address, applies +the IPv4 address, conditionally applies a default route and DNS resolver entries, +and verifies the resulting configured state. The unit runs on +boot only when the SharedFS state file exists. No arbitrary command or script +is accepted from the API. + +The management server retries dispatch while QGA becomes ready. Failure to +apply or verify the requested network fails the SharedFS deployment instead of +reporting a ready but unreachable service. + +## UI + +The create dialog identifies L2 networks from the `listNetworks.type` field. +For L2 networks with UserData, DHCP is the default and static remains selectable. +For L2 networks without UserData/ConfigDrive, static mode is selected automatically +and DHCP is disabled. Static mode reveals one required IPv4/prefix field plus optional +gateway, DNS 1, and DNS 2 fields using existing theme tokens. Changing networks +clears stale static values and selects the valid default mode for the new network. + +## Verification gates + +1. API/server unit tests cover validation, persistence, DHCP compatibility, + and QGA dispatch. +2. KVM wrapper tests prove that only the exact static-network operation bypasses + `ablestack-storagectl` and that generic operations remain unchanged. +3. UI tests prove conditional request construction. +4. The management backend, UI, and KVM agent plugin are deployed together. +5. Test deployment verifies management/UI health and deployed asset hashes. +6. End-to-end acceptance creates an L2 SharedFS in a no-DHCP network, confirms + the requested address through QGA, validates connectivity, reboots the VM, + and confirms that the same address and any configured route and DNS return. diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSVO.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSVO.java index 8870bf6d4d89..f4f80e1cded3 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSVO.java @@ -87,6 +87,25 @@ public class SharedFSVO implements SharedFS { @Column(name = "service_offering_id") private Long serviceOfferingId; + @Column(name = "network_mode") + @Enumerated(value = EnumType.STRING) + private NetworkMode networkMode = NetworkMode.DHCP; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "cidr") + private String cidr; + + @Column(name = "gateway") + private String gateway; + + @Column(name = "dns1") + private String dns1; + + @Column(name = "dns2") + private String dns2; + @Column(name = "updated") @Temporal(value = TemporalType.TIMESTAMP) Date updated; @@ -228,6 +247,66 @@ public void setServiceOfferingId(Long serviceOfferingId) { this.serviceOfferingId = serviceOfferingId; } + @Override + public NetworkMode getNetworkMode() { + return networkMode == null ? NetworkMode.DHCP : networkMode; + } + + @Override + public void setNetworkMode(NetworkMode networkMode) { + this.networkMode = networkMode; + } + + @Override + public String getIpAddress() { + return ipAddress; + } + + @Override + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + @Override + public String getCidr() { + return cidr; + } + + @Override + public void setCidr(String cidr) { + this.cidr = cidr; + } + + @Override + public String getGateway() { + return gateway; + } + + @Override + public void setGateway(String gateway) { + this.gateway = gateway; + } + + @Override + public String getDns1() { + return dns1; + } + + @Override + public void setDns1(String dns1) { + this.dns1 = dns1; + } + + @Override + public String getDns2() { + return dns2; + } + + @Override + public void setDns2(String dns2) { + this.dns2 = dns2; + } + @Override public Date getUpdated() { return updated; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql b/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql index 6bc855db542e..71c58eeb0e6c 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-41910to42000.sql @@ -294,6 +294,12 @@ CREATE TABLE IF NOT EXISTS `cloud`.`shared_filesystem`( `vm_id` bigint unsigned COMMENT 'vm on which the shared filesystem is hosted', `fs_type` varchar(10) NOT NULL COMMENT 'The filesystem format to be used for the shared filesystem', `service_offering_id` bigint unsigned COMMENT 'Service offering for the vm', + `network_mode` varchar(16) NOT NULL DEFAULT 'DHCP' COMMENT 'SharedFS VM network addressing mode', + `ip_address` varchar(45) DEFAULT NULL COMMENT 'Requested static IPv4 address', + `cidr` varchar(45) DEFAULT NULL COMMENT 'Requested static IPv4 CIDR', + `gateway` varchar(45) DEFAULT NULL COMMENT 'Requested static IPv4 gateway', + `dns1` varchar(45) DEFAULT NULL COMMENT 'Requested primary DNS server', + `dns2` varchar(45) DEFAULT NULL COMMENT 'Requested secondary DNS server', `update_count` bigint unsigned COMMENT 'Update count for state change', `updated` datetime COMMENT 'date updated', `created` datetime NOT NULL COMMENT 'date created', @@ -420,4 +426,4 @@ INSERT IGNORE INTO `cloud`.`hypervisor_capabilities` (uuid, hypervisor_type, hyp INSERT IGNORE INTO `cloud`.`guest_os_hypervisor` (uuid, hypervisor_type, hypervisor_version, guest_os_name, guest_os_id, created, is_user_defined) SELECT UUID(),'VMware', '8.0.3', guest_os_name, guest_os_id, utc_timestamp(), 0 FROM `cloud`.`guest_os_hypervisor` WHERE hypervisor_type='VMware' AND hypervisor_version='8.0'; CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vm_instance', 'delete_protection', 'boolean DEFAULT FALSE COMMENT "delete protection for vm" '); -CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'delete_protection', 'boolean DEFAULT FALSE COMMENT "delete protection for volumes" '); \ No newline at end of file +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'delete_protection', 'boolean DEFAULT FALSE COMMENT "delete protection for volumes" '); diff --git a/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql b/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql index c5160c505ae4..130de537a928 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql @@ -19,6 +19,13 @@ -- Schema upgrade from ablestack-cerato to ablestack-diplo --; +CALL `cloud`.`ADD_COL`('shared_filesystem', 'network_mode', 'varchar(16) NOT NULL DEFAULT ''DHCP'' COMMENT ''SharedFS VM network addressing mode'''); +CALL `cloud`.`ADD_COL`('shared_filesystem', 'ip_address', 'varchar(45) DEFAULT NULL COMMENT ''Requested static IPv4 address'''); +CALL `cloud`.`ADD_COL`('shared_filesystem', 'cidr', 'varchar(45) DEFAULT NULL COMMENT ''Requested static IPv4 CIDR'''); +CALL `cloud`.`ADD_COL`('shared_filesystem', 'gateway', 'varchar(45) DEFAULT NULL COMMENT ''Requested static IPv4 gateway'''); +CALL `cloud`.`ADD_COL`('shared_filesystem', 'dns1', 'varchar(45) DEFAULT NULL COMMENT ''Requested primary DNS server'''); +CALL `cloud`.`ADD_COL`('shared_filesystem', 'dns2', 'varchar(45) DEFAULT NULL COMMENT ''Requested secondary DNS server'''); + -- BEGIN TABLE vbmc_port CREATE TABLE IF NOT EXISTS `vbmc_port` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.shared_filesystem_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.shared_filesystem_view.sql index 1f72babd1cec..7a4103c1c9be 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.shared_filesystem_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.shared_filesystem_view.sql @@ -28,6 +28,12 @@ SELECT `shared_filesystem`.`state` AS `state`, `shared_filesystem`.`fs_provider_name` AS `provider`, `shared_filesystem`.`fs_type` AS `fs_type`, + `shared_filesystem`.`network_mode` AS `network_mode`, + `shared_filesystem`.`ip_address` AS `ip_address`, + `shared_filesystem`.`cidr` AS `cidr`, + `shared_filesystem`.`gateway` AS `gateway`, + `shared_filesystem`.`dns1` AS `dns1`, + `shared_filesystem`.`dns2` AS `dns2`, `shared_filesystem`.`volume_id` AS `volume_id`, `shared_filesystem`.`account_id` AS `account_id`, `shared_filesystem`.`data_center_id` AS `zone_id`, diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapper.java index e5e552a38801..e41b81e62a52 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapper.java @@ -41,6 +41,69 @@ @ResourceWrapper(handles = StorageServiceHostCommand.class) public final class LibvirtStorageServiceHostCommandWrapper extends CommandWrapper { private static final int QGA_POLL_INTERVAL_MILLIS = 1000; + private static final String CONFIGURE_SHAREDFS_STATIC_NETWORK = "configure-sharedfs-static-network"; + private static final String SHAREDFS_NETWORK_STATE = "/etc/ablestack-storage/sharedfs-network.json"; + private static final String SHAREDFS_NETWORK_HELPER = "/usr/local/sbin/ablestack-sharedfs-network"; + private static final String SHAREDFS_NETWORK_UNIT = "/etc/systemd/system/ablestack-sharedfs-network.service"; + private static final String SHAREDFS_NETWORK_HELPER_CONTENT = String.join("\n", + "#!/usr/bin/env python3", + "import ipaddress", + "import json", + "import subprocess", + "import sys", + "from pathlib import Path", + "", + "def run(*args):", + " return subprocess.run(args, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout", + "", + "state_path = Path(sys.argv[1])", + "state = json.loads(state_path.read_text(encoding='utf-8'))", + "network = ipaddress.ip_network(state['cidr'], strict=False)", + "address = ipaddress.ip_address(state['ipAddress'])", + "gateway = ipaddress.ip_address(state['gateway']) if state.get('gateway') else None", + "dns = [ipaddress.ip_address(state[key]) for key in ('dns1', 'dns2') if state.get(key)]", + "if network.version != 4 or address.version != 4 or address not in network or (gateway and (gateway.version != 4 or gateway not in network)):", + " raise ValueError('invalid SharedFS static IPv4 configuration')", + "if address in (network.network_address, network.broadcast_address):", + " raise ValueError('SharedFS static IP cannot be the network or broadcast address')", + "mac = state['macAddress'].lower()", + "interfaces = [path for path in Path('/sys/class/net').iterdir() if path.name != 'lo']", + "interface = next((path.name for path in interfaces if (path / 'address').read_text().strip().lower() == mac), None)", + "if not interface:", + " raise RuntimeError('unable to find SharedFS NIC by MAC address ' + mac)", + "subprocess.run(['systemctl', 'disable', '--now', 'cloud-dhclient@' + interface + '.service'], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)", + "run('ip', 'link', 'set', 'dev', interface, 'up')", + "run('ip', '-4', 'addr', 'flush', 'dev', interface, 'scope', 'global')", + "run('ip', 'addr', 'replace', str(address) + '/' + str(network.prefixlen), 'dev', interface)", + "subprocess.run(['ip', 'route', 'del', 'default', 'dev', interface], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)", + "if gateway:", + " run('ip', 'route', 'replace', 'default', 'via', str(gateway), 'dev', interface)", + "if dns:", + " Path('/etc/resolv.conf').write_text(''.join('nameserver ' + str(server) + '\\n' for server in dns), encoding='utf-8')", + "addresses = json.loads(run('ip', '-j', '-4', 'addr', 'show', 'dev', interface))", + "routes = json.loads(run('ip', '-j', '-4', 'route', 'show', 'default'))", + "configured = any(info.get('local') == str(address) and info.get('prefixlen') == network.prefixlen for item in addresses for info in item.get('addr_info', []))", + "routed = gateway is None or any(route.get('gateway') == str(gateway) and route.get('dev') == interface for route in routes)", + "if not configured or not routed:", + " raise RuntimeError('SharedFS static network verification failed')", + "print(json.dumps({'interface': interface, 'ipAddress': str(address), 'cidr': str(network), 'gateway': str(gateway) if gateway else None, 'dns': [str(server) for server in dns]}, separators=(',', ':')))", + ""); + private static final String SHAREDFS_NETWORK_UNIT_CONTENT = String.join("\n", + "[Unit]", + "Description=ABLESTACK SharedFS static network restore", + "After=local-fs.target", + "Before=network-pre.target network.target", + "Wants=network-pre.target", + "ConditionPathExists=" + SHAREDFS_NETWORK_STATE, + "", + "[Service]", + "Type=oneshot", + "ExecStart=" + SHAREDFS_NETWORK_HELPER + " " + SHAREDFS_NETWORK_STATE, + "RemainAfterExit=yes", + "", + "[Install]", + "WantedBy=multi-user.target", + ""); @Override public Answer execute(final StorageServiceHostCommand command, final LibvirtComputingResource libvirtComputingResource) { @@ -126,6 +189,9 @@ protected String buildGuestExecCommand(final StorageServiceHostCommand command) } protected String buildStorageCtlShell(final StorageServiceHostCommand command) { + if (CONFIGURE_SHAREDFS_STATIC_NETWORK.equals(command.getOperation())) { + return buildSharedFsStaticNetworkShell(command); + } final String payload = command.getPayload() == null ? "" : command.getPayload(); final String encodedPayload = Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8)); return "payload=$(mktemp /tmp/ablestack-storage-XXXXXX.json); " + @@ -134,6 +200,24 @@ protected String buildStorageCtlShell(final StorageServiceHostCommand command) { "rc=$?; rm -f \"$payload\"; exit $rc"; } + protected String buildSharedFsStaticNetworkShell(final StorageServiceHostCommand command) { + final String payload = command.getPayload() == null ? "" : command.getPayload(); + final String encodedPayload = Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + final String encodedHelper = Base64.getEncoder().encodeToString(SHAREDFS_NETWORK_HELPER_CONTENT.getBytes(StandardCharsets.UTF_8)); + final String encodedUnit = Base64.getEncoder().encodeToString(SHAREDFS_NETWORK_UNIT_CONTENT.getBytes(StandardCharsets.UTF_8)); + return "set -e; install -d -m 0755 /etc/ablestack-storage; " + + "printf '%s' '" + encodedPayload + "' | base64 -d > " + SHAREDFS_NETWORK_STATE + ".tmp; " + + "python3 -m json.tool " + SHAREDFS_NETWORK_STATE + ".tmp >/dev/null; " + + "mv " + SHAREDFS_NETWORK_STATE + ".tmp " + SHAREDFS_NETWORK_STATE + "; chmod 0600 " + SHAREDFS_NETWORK_STATE + "; " + + "printf '%s' '" + encodedHelper + "' | base64 -d > " + SHAREDFS_NETWORK_HELPER + ".tmp; " + + "mv " + SHAREDFS_NETWORK_HELPER + ".tmp " + SHAREDFS_NETWORK_HELPER + "; chmod 0755 " + SHAREDFS_NETWORK_HELPER + "; " + + "printf '%s' '" + encodedUnit + "' | base64 -d > " + SHAREDFS_NETWORK_UNIT + ".tmp; " + + "mv " + SHAREDFS_NETWORK_UNIT + ".tmp " + SHAREDFS_NETWORK_UNIT + "; chmod 0644 " + SHAREDFS_NETWORK_UNIT + "; " + + "systemctl daemon-reload; systemctl enable ablestack-sharedfs-network.service >/dev/null; " + + "systemctl restart ablestack-sharedfs-network.service; systemctl --no-pager --full status ablestack-sharedfs-network.service >/dev/null; " + + SHAREDFS_NETWORK_HELPER + " " + SHAREDFS_NETWORK_STATE; + } + protected String decodeGuestData(final JsonObject response, final String field) { if (!response.has(field) || response.get(field).isJsonNull()) { return null; diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapperTest.java new file mode 100644 index 000000000000..1b83a51e71a0 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStorageServiceHostCommandWrapperTest.java @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.util.Collections; + +import org.junit.Assert; +import org.junit.Test; + +import com.cloud.agent.api.StorageServiceHostCommand; + +public class LibvirtStorageServiceHostCommandWrapperTest { + + private final LibvirtStorageServiceHostCommandWrapper wrapper = new LibvirtStorageServiceHostCommandWrapper(); + + @Test + public void testStaticSharedFSNetworkUsesDedicatedGuestHelper() { + StorageServiceHostCommand command = new StorageServiceHostCommand("sharedfs-test", + "configure-sharedfs-static-network", "{\"ipAddress\":\"10.10.1.201\"}", 60, Collections.emptySet()); + + String shell = wrapper.buildStorageCtlShell(command); + + Assert.assertTrue(shell.contains("ablestack-sharedfs-network.service")); + Assert.assertTrue(shell.contains("/usr/local/sbin/ablestack-sharedfs-network")); + Assert.assertFalse(shell.contains("/usr/local/bin/ablestack-storagectl")); + } + + @Test + public void testGenericStorageOperationStillUsesStorageCtl() { + StorageServiceHostCommand command = new StorageServiceHostCommand("sharedfs-test", + "apply-nfs-desired-state", "{}", 60, Collections.emptySet()); + + String shell = wrapper.buildStorageCtlShell(command); + + Assert.assertTrue(shell.contains("/usr/local/bin/ablestack-storagectl")); + Assert.assertFalse(shell.contains("ablestack-sharedfs-network.service")); + } +} diff --git a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java index b1af8029c0e6..6bcc524ec900 100644 --- a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java +++ b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java @@ -152,7 +152,9 @@ private String getStorageVmName(String fileShareName) { return (String.format("%s-%s", prefix, suffix)); } - private UserVm deploySharedFSVM(Long zoneId, Account owner, List networkIds, String name, Long serviceOfferingId, Long diskOfferingId, SharedFS.FileSystemType fileSystem, Long size, Long minIops, Long maxIops) throws OperationTimedoutException, ResourceUnavailableException, InsufficientCapacityException, ResourceAllocationException { + private UserVm deploySharedFSVM(Long zoneId, Account owner, List networkIds, String name, Long serviceOfferingId, Long diskOfferingId, + SharedFS.FileSystemType fileSystem, Long size, Long minIops, Long maxIops, SharedFS.NetworkMode networkMode, String requestedIp) throws OperationTimedoutException, + ResourceUnavailableException, InsufficientCapacityException, ResourceAllocationException { ServiceOffering serviceOffering = serviceOfferingDao.findById(serviceOfferingId); DataCenter zone = dataCenterDao.findById(zoneId); @@ -164,7 +166,7 @@ private UserVm deploySharedFSVM(Long zoneId, Account owner, List networkId } String hostName = getStorageVmName(name); - Network.IpAddresses addrs = new Network.IpAddresses(null, null); + Network.IpAddresses addrs = new Network.IpAddresses(networkMode == SharedFS.NetworkMode.STATIC ? requestedIp : null, null); Map customParameterMap = new HashMap(); if (minIops != null) { customParameterMap.put("minIopsDo", minIops.toString()); @@ -186,8 +188,11 @@ private UserVm deploySharedFSVM(Long zoneId, Account owner, List networkId } UserVm vm = null; - String fsVmConfig = getStorageVmConfig(); - String base64UserData = Base64.encodeBase64String(fsVmConfig.getBytes(com.cloud.utils.StringUtils.getPreferredCharset())); + String base64UserData = null; + if (networkMode == SharedFS.NetworkMode.DHCP) { + String fsVmConfig = getStorageVmConfig(); + base64UserData = Base64.encodeBase64String(fsVmConfig.getBytes(com.cloud.utils.StringUtils.getPreferredCharset())); + } CallContext vmContext = CallContext.register(CallContext.current(), ApiCommandResourceType.VirtualMachine); try { vm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, template, networkIds, owner, hostName, hostName, @@ -240,7 +245,8 @@ public void checkPrerequisites(DataCenter zone, Long serviceOfferingId) { @Override public Pair deploySharedFS(SharedFS sharedFS, Long networkId, Long diskOfferingId, Long storageId, Long size, Long minIops, Long maxIops) throws ResourceUnavailableException, InsufficientCapacityException, ResourceAllocationException, OperationTimedoutException { Account owner = accountMgr.getActiveAccountById(sharedFS.getAccountId()); - UserVm vm = deploySharedFSVM(sharedFS.getDataCenterId(), owner, List.of(networkId), sharedFS.getName(), sharedFS.getServiceOfferingId(), diskOfferingId, sharedFS.getFsType(), size, minIops, maxIops); + UserVm vm = deploySharedFSVM(sharedFS.getDataCenterId(), owner, List.of(networkId), sharedFS.getName(), sharedFS.getServiceOfferingId(), diskOfferingId, + sharedFS.getFsType(), size, minIops, maxIops, sharedFS.getNetworkMode(), sharedFS.getIpAddress()); List volumes = volumeDao.findByInstance(vm.getId()); VolumeVO dataVol = null; diff --git a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImpl.java b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImpl.java index dfa86ce123bd..e6e3a98f6e64 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImpl.java +++ b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImpl.java @@ -70,6 +70,7 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.fsm.NoTransitionException; import com.cloud.utils.fsm.StateMachine2; +import com.cloud.utils.net.NetUtils; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.api.ResponseObject; @@ -95,6 +96,9 @@ import org.apache.cloudstack.storage.dataservice.StorageFileShareVO; import org.apache.cloudstack.storage.dataservice.StorageServiceInstance; import org.apache.cloudstack.storage.dataservice.StorageServiceInstanceVO; +import org.apache.cloudstack.storage.dataservice.StorageServiceGuestCommand; +import org.apache.cloudstack.storage.dataservice.StorageServiceGuestCommandDispatcher; +import org.apache.cloudstack.storage.dataservice.StorageServiceGuestCommandResult; import org.apache.cloudstack.storage.dataservice.StorageServiceProtocolVO; import org.apache.cloudstack.storage.dataservice.dao.StorageAccessRuleDao; import org.apache.cloudstack.storage.dataservice.dao.StorageFileShareDao; @@ -110,6 +114,8 @@ import org.apache.cloudstack.storage.sharedfs.query.vo.SharedFSJoinVO; import org.apache.commons.lang3.StringUtils; +import com.google.gson.JsonObject; + import com.cloud.event.ActionEvent; import com.cloud.event.EventTypes; import com.cloud.user.Account; @@ -121,6 +127,19 @@ public class SharedFSServiceImpl extends ManagerBase implements SharedFSService, Configurable, PluggableService { private static final String SHAREDFS_COMPAT_PROVIDER = "SHAREDFS_COMPATIBILITY"; + private static final String CONFIGURE_SHAREDFS_STATIC_NETWORK = "configure-sharedfs-static-network"; + private static final int STATIC_NETWORK_QGA_ATTEMPTS = 30; + private static final int STATIC_NETWORK_QGA_RETRY_MILLIS = 2000; + + protected static class StaticNetworkConfiguration { + final String ipAddress; + final String networkCidr; + + StaticNetworkConfiguration(String ipAddress, String networkCidr) { + this.ipAddress = ipAddress; + this.networkCidr = networkCidr; + } + } @Inject private AccountManager accountMgr; @@ -173,6 +192,9 @@ public class SharedFSServiceImpl extends ManagerBase implements SharedFSService, @Inject StorageAccessRuleDao storageAccessRuleDao; + @Inject + StorageServiceGuestCommandDispatcher guestCommandDispatcher; + protected List sharedFSProviders; private Map sharedFSProviderMap = new HashMap<>(); @@ -311,6 +333,109 @@ private void validateInitialBackingStorage(Long diskOfferingId, Long storageId, } } + protected StaticNetworkConfiguration validateStaticNetworkConfiguration(CreateSharedFSCmd cmd, NetworkVO network) { + if (cmd.getNetworkMode() == SharedFS.NetworkMode.DHCP) { + if (!networkModel.areServicesSupportedInNetwork(network.getId(), Network.Service.UserData)) { + throw new InvalidParameterValueException(String.format("Network %s does not support UserData or ConfigDrive. Select STATIC network mode and provide ipcidr for this L2 SharedFS network.", + network.getUuid())); + } + return null; + } + if (network.getGuestType() != Network.GuestType.L2) { + throw new InvalidParameterValueException("Static SharedFS network configuration is supported only for L2 networks"); + } + StaticNetworkConfiguration configuration = parseStaticIpCidr(cmd.getIpCidr()); + if ((StringUtils.isNotBlank(cmd.getGateway()) && !NetUtils.isValidIp4(cmd.getGateway())) || + (StringUtils.isNotBlank(cmd.getDns1()) && !NetUtils.isValidIp4(cmd.getDns1())) || + (StringUtils.isNotBlank(cmd.getDns2()) && !NetUtils.isValidIp4(cmd.getDns2()))) { + throw new InvalidParameterValueException("Static SharedFS network configuration must contain valid IPv4 values"); + } + final String[] cidrParts = configuration.networkCidr.split("/"); + final int prefix = Integer.parseInt(cidrParts[1]); + if (StringUtils.isNotBlank(cmd.getGateway()) && !NetUtils.sameSubnetCIDR(cmd.getGateway(), cidrParts[0], prefix)) { + throw new InvalidParameterValueException("Static SharedFS IP address and gateway must belong to the selected CIDR"); + } + final long address = NetUtils.ip2Long(configuration.ipAddress); + final long mask = prefix == 0 ? 0L : (0xffffffffL << (32 - prefix)) & 0xffffffffL; + final long networkAddress = NetUtils.ip2Long(cidrParts[0]); + final long broadcastAddress = networkAddress | (~mask & 0xffffffffL); + if (prefix <= 30 && (address == networkAddress || address == broadcastAddress)) { + throw new InvalidParameterValueException("Static SharedFS IP address cannot be the network or broadcast address"); + } + NicVO existingNic = nicDao.findByIp4AddressAndNetworkId(configuration.ipAddress, network.getId()); + if (existingNic != null) { + throw new InvalidParameterValueException("Static SharedFS IP address is already allocated on the selected network"); + } + return configuration; + } + + protected StaticNetworkConfiguration parseStaticIpCidr(String ipCidr) { + if (StringUtils.isBlank(ipCidr)) { + throw new InvalidParameterValueException("ipcidr is required for static SharedFS network configuration"); + } + final String[] parts = ipCidr.trim().split("/", -1); + if (parts.length != 2 || !NetUtils.isValidIp4(parts[0])) { + throw new InvalidParameterValueException("ipcidr must use IPv4/prefix format, for example 10.10.1.211/24"); + } + final int prefix; + try { + prefix = Integer.parseInt(parts[1]); + } catch (NumberFormatException e) { + throw new InvalidParameterValueException("ipcidr prefix must be a number between 0 and 32"); + } + if (prefix < 0 || prefix > 32) { + throw new InvalidParameterValueException("ipcidr prefix must be a number between 0 and 32"); + } + final long mask = prefix == 0 ? 0L : (0xffffffffL << (32 - prefix)) & 0xffffffffL; + final String networkCidr = String.format("%s/%d", NetUtils.long2Ip(NetUtils.ip2Long(parts[0]) & mask), prefix); + return new StaticNetworkConfiguration(parts[0], networkCidr); + } + + protected void configureStaticNetwork(SharedFS sharedFS) { + if (sharedFS.getNetworkMode() != SharedFS.NetworkMode.STATIC) { + return; + } + if (sharedFS.getVmId() == null) { + throw new CloudRuntimeException("Unable to configure static SharedFS network before the Storage Service VM is deployed"); + } + List nics = nicDao.listByVmId(sharedFS.getVmId()); + if (nics.size() != 1) { + throw new CloudRuntimeException("Static SharedFS network configuration requires exactly one Storage Service VM NIC"); + } + JsonObject payload = new JsonObject(); + payload.addProperty("macAddress", nics.get(0).getMacAddress()); + payload.addProperty("ipAddress", sharedFS.getIpAddress()); + payload.addProperty("cidr", sharedFS.getCidr()); + if (StringUtils.isNotBlank(sharedFS.getGateway())) { + payload.addProperty("gateway", sharedFS.getGateway()); + } + if (StringUtils.isNotBlank(sharedFS.getDns1())) { + payload.addProperty("dns1", sharedFS.getDns1()); + } + if (StringUtils.isNotBlank(sharedFS.getDns2())) { + payload.addProperty("dns2", sharedFS.getDns2()); + } + + String lastError = null; + for (int attempt = 1; attempt <= STATIC_NETWORK_QGA_ATTEMPTS; attempt++) { + StorageServiceGuestCommandResult result = guestCommandDispatcher.dispatch(new StorageServiceGuestCommand( + sharedFS.getVmId(), CONFIGURE_SHAREDFS_STATIC_NETWORK, payload.toString(), 60, Set.of())); + if (result.isSuccess()) { + return; + } + lastError = result.getDetails(); + if (attempt < STATIC_NETWORK_QGA_ATTEMPTS) { + try { + Thread.sleep(STATIC_NETWORK_QGA_RETRY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted while waiting for SharedFS QGA static network configuration", e); + } + } + } + throw new CloudRuntimeException("Failed to configure static SharedFS network through QGA: " + lastError); + } + @Override @ActionEvent(eventType = EventTypes.EVENT_SHAREDFS_CREATE, eventDescription = "Allocating Shared FileSystem", create = true) public SharedFS allocSharedFS(CreateSharedFSCmd cmd) { @@ -336,10 +461,7 @@ public SharedFS allocSharedFS(CreateSharedFSCmd cmd) { if (networkVO == null) { throw new InvalidParameterValueException("Unable to find a network with Network ID " + cmd.getNetworkId()); } - if (!networkModel.areServicesSupportedInNetwork(networkVO.getId(), Network.Service.UserData)) { - throw new InvalidParameterValueException(String.format("Network %s does not support UserData service. Shared FileSystem Storage VM initialization requires a network offering with UserData or ConfigDrive support.", - networkVO.getUuid())); - } + StaticNetworkConfiguration staticNetwork = validateStaticNetworkConfiguration(cmd, networkVO); if (networkVO.getGuestType() == Network.GuestType.Shared) { if ((networkVO.getAclType() != ControlledEntity.ACLType.Account) || (cmd.getDomainId() != null && (networkVO.getDomainId() != cmd.getDomainId())) || @@ -362,6 +484,14 @@ public SharedFS allocSharedFS(CreateSharedFSCmd cmd) { SharedFSVO sharedFS = new SharedFSVO(cmd.getName(), cmd.getDescription(), owner.getDomainId(), ownerId, cmd.getZoneId(), cmd.getSharedFSProviderName(), SharedFS.Protocol.NFS, fsType, cmd.getServiceOfferingId()); + sharedFS.setNetworkMode(cmd.getNetworkMode()); + if (cmd.getNetworkMode() == SharedFS.NetworkMode.STATIC) { + sharedFS.setIpAddress(staticNetwork.ipAddress); + sharedFS.setCidr(staticNetwork.networkCidr); + sharedFS.setGateway(cmd.getGateway()); + sharedFS.setDns1(cmd.getDns1()); + sharedFS.setDns2(cmd.getDns2()); + } return sharedFSDao.persist(sharedFS); } @@ -376,16 +506,17 @@ public SharedFS deploySharedFS(CreateSharedFSCmd cmd) throws ResourceUnavailable Long maxIops = cmd.getMaxIops(); SharedFSProvider provider = getSharedFSProvider(cmd.getSharedFSProviderName()); SharedFSLifeCycle lifeCycle = provider.getSharedFSLifeCycle(); - Pair result = null; + Pair result; try { result = lifeCycle.deploySharedFS(sharedFS, cmd.getNetworkId(), diskOfferingId, cmd.getStorageId(), size, minIops, maxIops); + sharedFS.setVolumeId(result.first()); + sharedFS.setVmId(result.second()); + sharedFSDao.update(sharedFS.getId(), sharedFS); + configureStaticNetwork(sharedFSDao.findById(sharedFS.getId())); } catch (Exception ex) { stateTransitTo(sharedFS, Event.OperationFailed); throw ex; } - sharedFS.setVolumeId(result.first()); - sharedFS.setVmId(result.second()); - sharedFSDao.update(sharedFS.getId(), sharedFS); stateTransitTo(sharedFS, Event.OperationSucceeded); syncSharedFSToStorageService(sharedFSDao.findById(sharedFS.getId())); return sharedFS; @@ -398,6 +529,7 @@ private SharedFS startSharedFS(SharedFS sharedFS) throws OperationTimedoutExcept try { stateTransitTo(sharedFS, Event.StartRequested); lifeCycle.startSharedFS(sharedFS); + configureStaticNetwork(sharedFS); } catch (Exception ex) { stateTransitTo(sharedFS, Event.OperationFailed); throw ex; @@ -451,6 +583,9 @@ private SharedFSVO reDeploySharedFS(SharedFSVO sharedFS) throws OperationTimedou SharedFSProvider provider = getSharedFSProvider(sharedFS.getFsProviderName()); SharedFSLifeCycle lifeCycle = provider.getSharedFSLifeCycle(); boolean result = lifeCycle.reDeploySharedFS(sharedFS); + if (result) { + configureStaticNetwork(sharedFSDao.findById(sharedFS.getId())); + } return (result ? sharedFS : null); } diff --git a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/dao/SharedFSJoinDaoImpl.java b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/dao/SharedFSJoinDaoImpl.java index 025e50214e13..610b0049985a 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/dao/SharedFSJoinDaoImpl.java +++ b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/dao/SharedFSJoinDaoImpl.java @@ -24,6 +24,7 @@ import javax.inject.Inject; +import org.apache.commons.lang3.StringUtils; import org.apache.cloudstack.api.ResponseObject; import org.apache.cloudstack.api.response.SharedFSResponse; import org.apache.cloudstack.api.response.NicResponse; @@ -85,6 +86,15 @@ public SharedFSResponse newSharedFSResponse(ResponseObject.ResponseView view, Sh response.setState(sharedFS.getState().toString()); response.setProvider(sharedFS.getProvider()); response.setFilesystem(sharedFS.getFsType().toString()); + response.setNetworkMode(sharedFS.getNetworkMode().toString()); + response.setIpAddress(sharedFS.getIpAddress()); + if (StringUtils.isNotBlank(sharedFS.getIpAddress()) && StringUtils.contains(sharedFS.getCidr(), "/")) { + response.setIpCidr(sharedFS.getIpAddress() + sharedFS.getCidr().substring(sharedFS.getCidr().indexOf('/'))); + } + response.setCidr(sharedFS.getCidr()); + response.setGateway(sharedFS.getGateway()); + response.setDns1(sharedFS.getDns1()); + response.setDns2(sharedFS.getDns2()); response.setPath(SharedFS.getSharedFSPath()); response.setObjectName(SharedFS.class.getSimpleName().toLowerCase()); response.setZoneId(sharedFS.getZoneUuid()); diff --git a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/vo/SharedFSJoinVO.java b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/vo/SharedFSJoinVO.java index 681cc042c956..025ce7a3cc72 100644 --- a/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/vo/SharedFSJoinVO.java +++ b/server/src/main/java/org/apache/cloudstack/storage/sharedfs/query/vo/SharedFSJoinVO.java @@ -65,6 +65,25 @@ public class SharedFSJoinVO extends BaseViewVO implements InternalIdentity, Iden @Enumerated(EnumType.STRING) SharedFS.FileSystemType fsType; + @Column(name = "network_mode") + @Enumerated(EnumType.STRING) + private SharedFS.NetworkMode networkMode; + + @Column(name = "ip_address") + private String ipAddress; + + @Column(name = "cidr") + private String cidr; + + @Column(name = "gateway") + private String gateway; + + @Column(name = "dns1") + private String dns1; + + @Column(name = "dns2") + private String dns2; + @Column(name = "size") private Long size; @@ -194,6 +213,30 @@ public SharedFS.FileSystemType getFsType() { return fsType; } + public SharedFS.NetworkMode getNetworkMode() { + return networkMode == null ? SharedFS.NetworkMode.DHCP : networkMode; + } + + public String getIpAddress() { + return ipAddress; + } + + public String getCidr() { + return cidr; + } + + public String getGateway() { + return gateway; + } + + public String getDns1() { + return dns1; + } + + public String getDns2() { + return dns2; + } + public Long getSize() { return size; } diff --git a/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java b/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java index 3621f530b384..2f92b12e90ca 100644 --- a/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/storage/sharedfs/SharedFSServiceImplTest.java @@ -43,6 +43,7 @@ import org.apache.cloudstack.storage.sharedfs.dao.SharedFSDao; import org.apache.cloudstack.storage.sharedfs.query.dao.SharedFSJoinDao; import org.apache.cloudstack.storage.sharedfs.query.vo.SharedFSJoinVO; +import org.apache.cloudstack.storage.dataservice.StorageServiceGuestCommandDispatcher; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -137,6 +138,9 @@ public class SharedFSServiceImplTest { @Mock private SharedFSLifeCycle lifeCycle; + @Mock + private StorageServiceGuestCommandDispatcher guestCommandDispatcher; + @Spy @InjectMocks private SharedFSServiceImpl sharedFSServiceImpl; @@ -204,6 +208,7 @@ private CreateSharedFSCmd getMockCreateSharedFSCmd() { when(cmd.getServiceOfferingId()).thenReturn(s_serviceOfferingId); when(cmd.getNetworkId()).thenReturn(s_networkId); when(cmd.getFsFormat()).thenReturn(s_fsFormat); + when(cmd.getNetworkMode()).thenReturn(SharedFS.NetworkMode.DHCP); return cmd; } @@ -423,10 +428,50 @@ public void testAllocSharedFSNetworkMustSupportUserData() { when(networkModel.areServicesSupportedInNetwork(s_networkId, Network.Service.UserData)).thenReturn(false); InvalidParameterValueException exception = Assert.assertThrows(InvalidParameterValueException.class, () -> sharedFSServiceImpl.allocSharedFS(cmd)); - Assert.assertEquals("Network network-without-userdata does not support UserData service. Shared FileSystem Storage VM initialization requires a network offering with UserData or ConfigDrive support.", + Assert.assertEquals("Network network-without-userdata does not support UserData or ConfigDrive. Select STATIC network mode and provide ipcidr for this L2 SharedFS network.", exception.getMessage()); } + @Test + public void testStaticNetworkAllowsOptionalGatewayAndDns() { + CreateSharedFSCmd cmd = getMockCreateSharedFSCmd(); + when(cmd.getNetworkMode()).thenReturn(SharedFS.NetworkMode.STATIC); + when(cmd.getIpCidr()).thenReturn("10.10.1.201/24"); + when(cmd.getGateway()).thenReturn(null); + when(cmd.getDns1()).thenReturn(null); + when(cmd.getDns2()).thenReturn(null); + + NetworkVO network = mock(NetworkVO.class); + when(network.getId()).thenReturn(s_networkId); + when(network.getGuestType()).thenReturn(Network.GuestType.L2); + + SharedFSServiceImpl.StaticNetworkConfiguration configuration = sharedFSServiceImpl.validateStaticNetworkConfiguration(cmd, network); + Assert.assertEquals("10.10.1.201", configuration.ipAddress); + Assert.assertEquals("10.10.1.0/24", configuration.networkCidr); + } + + @Test + public void testStaticNetworkNormalizesHostPrefixToNetworkCidr() { + SharedFSServiceImpl.StaticNetworkConfiguration configuration = sharedFSServiceImpl.parseStaticIpCidr("10.10.15.211/16"); + + Assert.assertEquals("10.10.15.211", configuration.ipAddress); + Assert.assertEquals("10.10.0.0/16", configuration.networkCidr); + } + + @Test + public void testStaticNetworkAcceptsHostPrefix() { + SharedFSServiceImpl.StaticNetworkConfiguration configuration = sharedFSServiceImpl.parseStaticIpCidr("10.10.1.211/32"); + + Assert.assertEquals("10.10.1.211", configuration.ipAddress); + Assert.assertEquals("10.10.1.211/32", configuration.networkCidr); + } + + @Test + public void testStaticNetworkRejectsInvalidIpPrefix() { + Assert.assertThrows(InvalidParameterValueException.class, + () -> sharedFSServiceImpl.parseStaticIpCidr("10.10.1.211/33")); + } + @Test public void testStartSharedFS() throws ResourceUnavailableException, InsufficientCapacityException, ResourceAllocationException, OperationTimedoutException, NoTransitionException { SharedFSVO sharedFS = getMockSharedFS(); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index c7ec3614331d..1e4e62f23516 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1,4 +1,16 @@ { + "label.sharedfs.network.mode": "Network configuration", + "label.sharedfs.ip.cidr": "IP address / prefix", + "label.static.ip": "Static IP", + "message.sharedfs.network.mode": "Choose DHCP or a SharedFS-specific static network configuration for an L2 network.", + "message.sharedfs.static.required.no.userdata": "The selected L2 network does not provide UserData or ConfigDrive. Enter a static IPv4 configuration for the Storage Service VM.", + "message.sharedfs.static.ip": "An available IPv4 address to apply to the Storage Service VM.", + "message.sharedfs.static.cidr": "Enter the IPv4 network address and prefix in CIDR notation.", + "message.sharedfs.static.ip.cidr": "Enter the Storage Service VM IPv4 address and prefix in IP/mask-bit form, for example 10.10.1.211/24.", + "message.sharedfs.static.ip.cidr.invalid": "Enter a valid IPv4 address and prefix in IP/mask-bit form, for example 10.10.1.211/24.", + "message.sharedfs.static.gateway": "Optional default gateway IPv4 address within the selected CIDR.", + "message.sharedfs.static.dns": "Optional primary DNS server IPv4 address for the Storage Service VM.", + "message.sharedfs.static.dns.optional": "An optional secondary DNS server IPv4 address.", "alert.service.domainrouter": "Domain router", "changed.item.properties": "Changed item properties", "confirm.enable.s3": "Please fill in the following information to enable support for S3-backed Secondary Storage", diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index ceddb85aa9dd..c0294fffbc9f 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -1,4 +1,16 @@ { + "label.sharedfs.network.mode": "네트워크 설정 방식", + "label.sharedfs.ip.cidr": "IP 주소 / 프리픽스", + "label.static.ip": "고정 IP", + "message.sharedfs.network.mode": "L2 네트워크에서 DHCP 또는 SharedFS 전용 고정 IP 설정 방식을 선택합니다.", + "message.sharedfs.static.required.no.userdata": "선택한 L2 네트워크는 UserData 또는 ConfigDrive를 제공하지 않습니다. Storage Service VM에 적용할 고정 IPv4 정보를 입력하세요.", + "message.sharedfs.static.ip": "Storage Service VM에 적용할 사용 가능한 IPv4 주소입니다.", + "message.sharedfs.static.cidr": "IPv4 네트워크 주소와 프리픽스를 CIDR 형식으로 입력합니다.", + "message.sharedfs.static.ip.cidr": "Storage Service VM에 적용할 IPv4 주소와 프리픽스를 IP/maskbit 형식으로 입력합니다. 예: 10.10.1.211/24", + "message.sharedfs.static.ip.cidr.invalid": "IPv4 주소와 프리픽스를 올바른 IP/maskbit 형식으로 입력하세요. 예: 10.10.1.211/24", + "message.sharedfs.static.gateway": "선택 사항인 기본 게이트웨이 IPv4 주소입니다. 입력하는 경우 선택한 CIDR에 속해야 합니다.", + "message.sharedfs.static.dns": "선택 사항인 Storage Service VM 기본 DNS 서버 IPv4 주소입니다.", + "message.sharedfs.static.dns.optional": "선택 사항인 보조 DNS 서버 IPv4 주소입니다.", "alert.service.domainrouter": "\ub3c4\uba54\uc778.\ub77c\uc6b0\ud130", "changed.item.properties": "\ubcc0\uacbd\ub41c \ud56d\ubaa9 \uc18d\uc131", "confirm.enable.s3": "S3 \uc9c0\uc6d0 \ubcf4\uc870 \uc800\uc7a5\uc18c\uc5d0 \ub300\ud55c \uc9c0\uc6d0\uc744 \ud65c\uc131\ud654\ud558\ub824\uba74 \ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud558\uc2ed\uc2dc\uc624.", diff --git a/ui/src/views/storage/CreateSharedFS.vue b/ui/src/views/storage/CreateSharedFS.vue index c93974262fbd..76e9e2e7e2ff 100644 --- a/ui/src/views/storage/CreateSharedFS.vue +++ b/ui/src/views/storage/CreateSharedFS.vue @@ -229,6 +229,54 @@ + +
+ + + + + + {{ $t('label.dhcp') }} + {{ $t('label.static.ip') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+