diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 689275cff115..375912667d63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,7 +17,7 @@ /plugins/storage/volume/linstor @rp- /plugins/storage/volume/storpool @slavkap -/plugins/storage/volume/ontap @rajiv1 @sandeeplocharla @piyush5 @suryag +/plugins/storage/volume/ontap @rajiv-jain-netapp @sandeeplocharla @piyush5netapp @suryag1201 .pre-commit-config.yaml @jbampton /.github/linters/ @jbampton diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000000..4469efa2f494 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ + + +# Agent Guide for Apache CloudStack + +This file is read by automated agents (security scanners, code +analyzers, AI assistants) operating on this repository. + +## Security + +Security model: [SECURITY.md](./SECURITY.md) + +Agents that scan this repository should consult `SECURITY.md` and the +threat model it links before reporting issues. + +The project-wide security threat model is linked from `SECURITY.md`. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000000..018f2fa8cb86 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ + + +# Security Policy + +## Reporting a Vulnerability + +`apache/cloudstack` follows the [Apache Software Foundation security process](https://www.apache.org/security/). Please report suspected +vulnerabilities privately to `security@apache.org`; do not open public GitHub issues or pull requests for security reports. + +For more details, see https://cloudstack.apache.org/security.html. + +## Threat Model + +What the project treats as in scope and out of scope, the security +properties it provides and disclaims, the adversary model, and how +findings are triaged are documented in the project-wide threat model: +[THREAT_MODEL.md](THREAT_MODEL.md). diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 000000000000..4659b937dedb --- /dev/null +++ b/THREAT_MODEL.md @@ -0,0 +1,1148 @@ + + +# Apache CloudStack Security Threat Model (draft) + +> **Document scope and PMC structural decision.** The CloudStack PMC owns +> five repositories: `apache/cloudstack` (the management server, agent, and +> systemvm), plus four satellite clients — `apache/cloudstack-cloudmonkey` +> (CLI), `apache/cloudstack-go` (Go SDK), `apache/cloudstack-terraform-provider`, +> `apache/cloudstack-kubernetes-provider`. This document models +> `apache/cloudstack` as the canonical threat model; the four satellite +> models are short *deltas* that inherit §3 / §4 / §7 from this +> document and add only what each satellite uniquely introduces (`§4 B1` +> reachability, the credential file shape, the wrapper-of-SDK contract, +> etc.). The deltas live at `/tmp/claude/cloudstack--threat-model-draft.md`. +> The satellite clients' interfaces point **inward** at the management-server +> API; some satellites additionally expose **outward** interfaces that are +> designed to be safe to expose *(maintainer: DaanHoogland)*. +> An umbrella model was rejected because the satellites are uniformly thin +> "HMAC-SHA1-signing HTTP client" wrappers — a single document either +> drowns them in CloudStack-server content or, worse, drowns the +> CloudStack-server content in satellite caveats. Each satellite is small +> enough that a 1–2 page delta works. + +## §1 Header + +- **Project:** Apache CloudStack (`apache/cloudstack`) — IaaS orchestration + platform. This document does **not** cover the four satellite repos, which + carry their own delta models. +- **Commit:** `7308dad1` (HEAD of `main` at draft time). +- **Date:** 2026-05-29. +- **Authors:** ASF Security team draft, awaiting CloudStack PMC review. +- **Status:** Draft — under maintainer review. +- **Version binding:** This document describes the model as of the commit + above. A vulnerability report against CloudStack release *N* (currently + the 4.20.x line) should be triaged against the model as it stood at *N*'s + release tag, not against HEAD. +- **Reporting:** vulnerabilities that fall under §8 (claimed properties) + should be reported per the project's published policy + (`security@apache.org` per `README.md` and + `https://cloudstack.apache.org/security.html`); reports that fall under + §3 (out of scope), §9 (properties not provided), or §11a (known + non-findings) will be closed by CloudStack triagers citing this document. +- **Provenance legend** — + *(documented)* = paraphrased from an in-repo source or the project website + with citation; *(maintainer)* = stated by a CloudStack PMC member in + response to this draft; *(inferred)* = synthesized by the producer from + code structure or domain knowledge, awaiting PMC ratification (every + *(inferred)* tag has a matching §14 question). +- **Draft confidence (provenance-tag tally):** 51 *(documented)* / 42 + *(maintainer)* / 38 *(inferred)*. Eleven formerly-open questions (Q1, + Q2, Q4, Q5, Q12 — including the highest-leverage Root-CA strictness + default — plus Q8, Q9, Q10, Q17, Q18, Q19 from the 2026-06-08 review) + were resolved by the CloudStack PMC review (DaanHoogland, vishesh92) and + their tags promoted from *(inferred)* to *(maintainer)*. + +**About the project.** Apache CloudStack is an open-source Infrastructure-as-a- +Service (IaaS) orchestration platform *(documented: `README.md`, +`https://cloudstack.apache.org/`)*. It deploys and manages large fleets of +virtual machines across multiple hypervisors (KVM, VMware, XenServer/XCP-ng, +Hyper-V, baremetal-bridge, OVM) and over object/block/file storage +(NFS, Ceph/RBD, iSCSI, SMB, primary-storage plugins, S3-compatible secondary +storage). A central **management server** (Java/Tomcat-style servlets, +backed by MariaDB/MySQL) exposes a signed REST/JSON API to admins, end +users, and integrations; runs system VMs (Secondary Storage VM, Console +Proxy VM, virtual router); and orchestrates a fleet of **agents** running +on each hypervisor host. Authorization is RBAC + multi-tenant +domain/account/project hierarchy. The deployment shape is "operator-run +private/public cloud control plane", not a hosted-as-a-service appliance. + +## §2 Scope and intended use + +### Intended use + +- A multi-tenant IaaS control plane deployed by an operator inside a + controlled datacenter or cloud, exposing compute / storage / network + orchestration to authenticated end users via APIs which return responses in JSON or XML and a Vue.js Web + UI, with separately authenticated administrators *(documented: `README.md`, + `INSTALL.md`)*. +- Used both for service-provider public clouds and for on-premises private + clouds; the trust model is the same in both *(documented: `README.md`)*. + +### Deployment shape + +CloudStack is **not** an in-process library, **not** a single-binary +appliance, and **not** a hosted SaaS. It is a distributed control plane: +one or more management-server instances — **a single management-server +instance for smaller clouds, or a cluster behind a load balancer for +larger deployments** *(maintainer: DaanHoogland)* — a MariaDB/MySQL +database, one usage server, an optional +SecondaryStorageVM/ConsoleProxyVM/VirtualRouter set of system VMs, and a +per-hypervisor-host `cloudstack-agent` (for KVM/baremetal) or +out-of-process resource bridges (for VMware / XenServer / XCP-ng / Hyper-V). +The operator owns the surrounding L2/L3 network (the **management network**, +the **public network**, the **guest network**, the **storage network**) +and the physical hosts. The threat model is therefore that of a +distributed service (single-instance or clustered), not a library +*(maintainer: DaanHoogland — confirms the distributed control-plane shape; +single-instance is also a supported topology)*. + +### Caller roles + +| Role | Trust level | Notes | +| --- | --- | --- | +| **End-user API client / Web UI user** | untrusted but authenticated | Identity verified via Apache CloudStack-native (password + HMAC-SHA1 signed request), LDAP, SAML2, OAuth2, or pluggable `APIAuthenticator` *(documented: `plugins/user-authenticators/{ldap,saml2,oauth2,...}`, `server/src/main/java/com/cloud/api/ApiServer.java` `verifyRequest`)*. | +| **Domain / Project admin** | partial trust within their domain | Bounded by RBAC (`plugins/acl/{static,dynamic,project}-role-based`) and the domain hierarchy; can manage users / VMs / networks within a domain. | +| **Root admin** | trusted control plane | Global RBAC role; can change global configuration, upload templates/ISOs, run privileged orchestration. | +| **Operator / cluster admin** | trusted | OS-level access to management-server hosts, the MariaDB database, the keystore, and the agent hosts. Sets `agent.properties`, manages `cloudstack-agent` packages, manages the JCEKS keystore used by the agent for TLS *(documented: `agent/conf/agent.properties`, `framework/security/.../KeystoreManager.java`)*. | +| **Hypervisor agent (cloudstack-agent on KVM/baremetal)** | trusted-once-enrolled peer | Mutually authenticated via X.509 client cert signed by the management server's Root CA *(documented: `framework/ca/`, `plugins/ca/root-ca/`, `agent/src/main/java/com/cloud/agent/Agent.java` `setupAgentKeystore`)*. | +| **System VM (SSVM / CPVM / VR)** | trusted-once-enrolled peer | Same X.509 enrolment shape as the agent; carries the agent binary inside *(maintainer: confirmed — same trust tier as agents, not a separate tier)*. | +| **Hypervisor host (the underlying KVM/VMware/etc.)** | trusted by virtue of operator-controlled provisioning | CloudStack expects to drive the hypervisor via libvirt / VMware vSphere SDK / XenAPI as a privileged user *(documented: `plugins/hypervisors/kvm/`, `plugins/hypervisors/vmware/`, `plugins/hypervisors/xenserver/`)*. | +| **Hypervisor-managed guest VM (end-user workload)** | **untrusted** | A guest VM is an attacker's workload; the model defends against it. | +| **Reverse proxy / load balancer in front of management server** | trusted *(if `proxy.header.verify=true`)* | When the operator enables forward-header processing, only requests whose `Remote_Addr` ∈ `proxy.cidr` have their `proxy.header.names` header honoured *(documented: `server/src/main/java/com/cloud/api/ApiServlet.java` `getClientAddress`; setting names maintainer: vishesh92)*. | +| **Underlying storage (primary / secondary)** | trusted by virtue of operator-granted credentials | CloudStack reads/writes via NFS / RBD / iSCSI / S3 with operator-supplied credentials *(documented: primary/secondary storage plugins under `plugins/storage/`)*. | +| **External integrations (Tungsten, NSX, Netscaler, Palo Alto, …)** | trusted control-plane peers | Operator-configured; CloudStack assumes truthful responses *(inferred — §14 Q3)*. | + +### Component-family table + +| Management server JSON and XML APIs | `server/src/main/java/com/cloud/api/ApiServlet.java`, HTTP on `:8080` (API + UI), optional HTTPS on `:8443` when `https.enable=true`; user API path `:8080/client/api` *(documented: `server/src/main/java/com/cloud/api/ApiServlet.java`, `client/conf/server.properties.in`)* | network (TCP, optionally TLS) | **yes** | +| Management server cluster RPC (peer-to-peer) | NIO + TLS between management-server replicas, `:9090` *(documented: `framework/cluster/`, `utils/.../nio/`)* | network | **yes** (peer auth via Root CA) | +| Management server → agent RPC | NIO + TLS on `:8250` (default `agent.properties`) *(documented: `agent/conf/agent.properties` line 47, `utils/.../nio/NioServer.java`)* | network | **yes** (mutually authenticated via Root CA) | +| `cloudstack-agent` (KVM/baremetal) | reverse-connects to management server, runs commands via libvirt / hypervisor SDK *(documented: `agent/`, `plugins/hypervisors/kvm/`)* | network + hypervisor + OS | **yes** | +| System VMs — SecondaryStorageVM, ConsoleProxyVM, Virtual Router | shipped images under `systemvm/`; agent binaries inside them *(documented: `systemvm/`)* | network (storage / public / guest) | **yes** | +| Console proxy data path | browser ↔ ConsoleProxyVM ↔ hypervisor VNC/SPICE socket; signed token issued by management server *(documented: `server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java`, `server/src/main/java/com/cloud/servlet/ConsoleProxyPasswordBasedEncryptor.java`)* | network | **yes** | +| Secondary-storage HTTP (templates, ISO downloads, snapshot copies) | download links are UUID-named symlinks served by an Apache httpd, with **no auth on the link**; the UUID format prevents enumeration and the symlinks are removed after a period *(maintainer: vishesh92, DaanHoogland)* | network | **yes** | +| Hypervisor plugins (`plugins/hypervisors/{kvm,vmware,xenserver,hyperv,ovm,ovm3,baremetal,ucs,simulator}`) | invoked by agent or by management server *(documented: `plugins/hypervisors/`)* | hypervisor APIs | **yes** for the call shape; **out-of-model** for the upstream hypervisor's own bugs | +| Network plugins (`plugins/network-elements/{netscaler,nsx,palo-alto,tungsten,nicira-nvp,...}`) | management server outbound | external SDN/firewall APIs | **yes** for credential handling and request construction; **out-of-model** for the external endpoint | +| Storage plugins (`plugins/storage/{volume,image,object}`) | management server / agent | NFS, RBD, iSCSI, S3 endpoints | **yes** for credential handling; **out-of-model** for the storage endpoint | +| User authenticator plugins (`plugins/user-authenticators/{md5,sha256salted,pbkdf2,plain-text,ldap,saml2,oauth2}`) | management server | LDAP / SAML2 IdP / OAuth2 IdP | **yes** for the local code; **out-of-model** for the IdP | +| RootCA provider (`plugins/ca/root-ca/`) | self-signed CA generated by management server at first boot, issues certs to agents *(documented: `plugins/ca/root-ca/.../RootCAProvider.java`)* | none directly | **yes** | +| Two-factor authenticators (`plugins/user-two-factor-authenticators/{static-pin,totp}`) | management server | none | **yes** | +| Backup providers (`plugins/backup/`) | management server outbound | external backup endpoints | **yes** for credential handling | +| Quota / metrics / DRS / HA planners | internal | none | **yes** as orchestration only; not a security boundary | +| Database layer (MariaDB/MySQL, Jasypt-encrypted secrets) | management server | network to DB | **yes** for credential handling; DB itself is trusted *(documented: `README.md` "Notice of Cryptographic Software" — JaSypt, native DB encryption)* | +| `cloud-cli`, `tools/marvin`, `test/`, `developer/`, `quickcloud/` | integration / test tooling | varies | **out of model** *(§3)* | +| `systemvm/agent/noVNC` (a vendored fork of `github.com/novnc/novnc` with CloudStack-specific changes on top *(maintainer: vishesh92)*), `…/vendor/pako`, other vendored JS / shell scripts | vendored upstream | n/a | in-model only at the wrapper boundary; upstream bugs go upstream. No automated vendored-dependency update procedure exists today (dependabot does not produce viable PRs); the PMC would prefer to have one *(maintainer: DaanHoogland)* | + +## §3 Out of scope (explicit non-goals) + +CloudStack is not, and does not aim to be, the following — reports +requiring any of these will be closed with the cited disposition: + +1. **A defender against the operator.** Anyone with `root` on a + management-server host, `root` on a hypervisor host, raw MariaDB + credentials, the JCEKS keystore + `security.encryption.key` / + `security.encryption.iv` *(documented: + `framework/security/.../KeysManager.java`)*, or the Root CA private key + already has unbounded power. "The operator misconfigured X" is not a + vulnerability *(inferred — §14 Q6)*. → `OUT-OF-MODEL: + adversary-not-in-scope`. +2. **A defender against a malicious external service the operator + configured.** A hostile LDAP server, SAML IdP, OAuth IdP, Tungsten / + NSX / Netscaler controller, S3 endpoint, Ceph cluster, or backup + provider is treated as a trusted control-plane peer. If the report + requires that peer to be hostile, it is out of model *(inferred — + §14 Q3)*. → `OUT-OF-MODEL: trusted-input`. +3. **A defender against the hypervisor.** CloudStack drives KVM / VMware / + XenServer / XCP-ng / Hyper-V via their own admin APIs. A hypervisor + bug that allows guest escape, a vSphere SDK vulnerability, a libvirt + privilege escalation — all are upstream to the hypervisor project, not + to CloudStack *(inferred — §14 Q7)*. → `OUT-OF-MODEL: + unsupported-component` (upstream pointer). +4. **An isolation boundary between an authorized administrator's API + call and the management server process.** Root admin can change global + configuration, upload templates and scripts to system VMs, register + arbitrary network/storage plugins, and run `runCustomAction`-style + commands. A new way for a root admin to do something they are already + authorized to do is not a vulnerability *(maintainer: vishesh92 — §14 Q8)*. → + `OUT-OF-MODEL: equivalent-harm`. +5. **A defender against a guest VM doing things the hypervisor allows it + to do.** A guest VM consuming CPU, memory, or disk up to its allocated + limit, sending arbitrary IP traffic within its assigned VLAN / VXLAN / + security group, or exploiting another VM via the hypervisor's own + shared surfaces (sidechannel, RowHammer, GPU passthrough leak) is out + of model. CloudStack is responsible only for the orchestration that + *places* the guest, not for hypervisor-level isolation *(maintainer: + vishesh92 — §14 Q9; the in-model case is CloudStack applying + wrong/insecure hypervisor settings — Daan to confirm boundary)*. → `OUT-OF-MODEL: adversary-not-in-scope` for the + side-channel case, `BY-DESIGN: property-disclaimed` for the + resource-limit case. +6. **A sandbox for templates, ISO images, or user-data scripts.** A + user-uploaded template (via `registerTemplate`) is run by the + hypervisor with the privileges the system grants. cloud-init / + user-data / metadata is passed through to the guest; CloudStack does + not parse or sanitize its semantics *(documented: kubernetes-service + plugin `userdata` references; maintainer: vishesh92 — §14 Q10, end-user guest customization)*. → + `BY-DESIGN: property-disclaimed`. +7. **Code that ships but is not part of the supported product:** + `tools/marvin/`, `test/`, `developer/`, `quickcloud/`, `cloud-cli/`, + `tools/devcloud4/`, `tools/devcloud-kvm/`, `tools/appliance/`, + `tools/checkstyle/`, `tools/transifex/`, `services/`-side simulators, + `simulator` hypervisor plugin, and IDE / build helpers under `tools/`. + *(inferred — §14 Q11)*. → `OUT-OF-MODEL: unsupported-component`. +8. **Bundled / vendored upstream libraries** — JaSypt, Bouncy Castle, + JSch, OpenSwan, noVNC + `pako`, MariaDB Connector/J, Spring, + Apache Commons, log4j, etc. *(documented: `README.md` Cryptographic + Software notice)*. `systemvm/agent/noVNC` is specifically a **vendored + fork of `github.com/novnc/novnc`** carrying CloudStack-specific changes + *(maintainer: vishesh92)*. Where CloudStack vendors source, the vendored + code is modeled at the wrapper boundary; vulnerabilities intrinsic to the + upstream project should be reported upstream. There is currently **no + automated procedure** to pull upstream fixes into the vendored copies + (dependabot has not produced viable PRs); the PMC would prefer to + establish one *(maintainer: DaanHoogland)*. + → `OUT-OF-MODEL: unsupported-component` (with an upstream pointer). +9. **The four satellite repos** (`apache/cloudstack-cloudmonkey`, + `apache/cloudstack-go`, `apache/cloudstack-terraform-provider`, + `apache/cloudstack-kubernetes-provider`) — covered by their own delta + threat models which inherit §3 / §4 / §7 from this document. +10. **The CloudStack documentation site, Confluence wiki, downloads + mirrors, Docker Hub images outside `apache/cloudstack-*`, gem / + npm / PyPI packages with similar names, and other non-product + surfaces.** Out of scope. + +## §4 Trust boundaries and data flow + +CloudStack has at least nine distinct trust transitions; a finding is +in-model only when it cleanly maps to one of them. + +| # | Transition | Authentication | Authorization | +| --- | --- | --- | --- | +| B1 | API client → management server JSON API (`:8080`/`:8443`) | per-user API key + HMAC-SHA1 signature over query string, or session login + 2FA *(documented: `server/src/main/java/com/cloud/api/ApiServer.java` `verifyRequest`)*; signature version 3 has expiration enforcement *(documented: same file line ~1053)* | RBAC (dynamic-role-based / static-role-based / project-role-based) on the called API command name + domain/account ownership of named resources | +| B2 | Web UI → management server (`:8080`) | same as B1 plus session cookie | same as B1 | +| B3 | Browser → ConsoleProxyVM → hypervisor VNC socket | signed token issued by management server, embedded in URL; encrypted with `ConsoleProxyPasswordBasedEncryptor` *(documented: `server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java`, `ConsoleProxyPasswordBasedEncryptor.java`)* | implicit (signed-token possession) | +| B4 | Management server ↔ management server (cluster peers) | NIO + TLS, Root CA-issued certs *(documented: `framework/cluster/`, `framework/ca/`)* | peer-trust by valid cert | +| B5 | Management server → `cloudstack-agent` (KVM/baremetal) | NIO + TLS on `:8250`; agent uses X.509 client cert issued by Root CA on first connect; cert provisioning is the `SetupKeyStoreCommand` shape *(documented: `agent/src/main/java/com/cloud/agent/Agent.java` `setupAgentKeystore`, `framework/ca/.../CAService.java`, `plugins/ca/root-ca/.../RootCAProvider.java`)*; trust strictness governed by `ca.plugin.root.auth.strictness` (**default `true` for new setups; `false` only on upgrade from pre-Aug-2017 versions** — see §5a) and `ca.plugin.root.allow.expired.cert` | peer-trust by valid cert | +| B6 | Management server → external services (LDAP / SAML2 / OAuth2 IdP, NSX, Netscaler, Tungsten, S3, backup providers) | per-provider (service account, OAuth token, etc.) | external-service-side | +| B7 | Agent → hypervisor (libvirt / vSphere SDK / XenAPI) | local Unix socket (libvirt) or operator-supplied SDK credentials | hypervisor-side | +| B8 | Management server / agent → primary/secondary storage (NFS, RBD, iSCSI, S3) | OS-level (NFS), Ceph cephx, iSCSI CHAP, IAM key / static credential (S3) | storage-side | +| B9 | Operator → management server config (`db.properties`, `server.properties`, JCEKS keystore, global config table) | filesystem permissions on the host + DB access | OS-level + DB-level | + +### Reachability preconditions per family + +For each family in §2, a finding is in-model only if it is reachable as +follows: + +- **Management server JSON API**: reachable from an *unauthenticated* network + peer who can reach `:8080` / `:8443`. Findings that require an + authenticated peer collapse to "authenticated user with RBAC privilege + X", and must additionally either clear RBAC for the harmful command or + bypass it. +- **Web UI**: same shape as the JSON API; the Vue.js SPA is a presentation + layer over the API. +- **Cluster RPC (B4)**: reachable from a peer that has cleared the Root CA + trust check. A flat "cluster RPC has no auth" finding is `OUT-OF-MODEL: + adversary-not-in-scope` because the model *requires* the Root CA to be + enrolled across peers; a *cleartext*/un-certed cluster RPC finding is + gated by `ca.plugin.root.auth.strictness`, which defaults to `true` on + new setups (see §5a). +- **Management ↔ agent (B5)**: reachable from a peer that presents a + Root-CA-signed certificate the management server accepts. By default on + new setups `ca.plugin.root.auth.strictness = true`, so the management + server **does require** a client certificate from the connecting agent + *(maintainer: vishesh92 — + `https://github.com/apache/cloudstack/pull/2239`)*. The value remains + `false` only when upgrading from versions released before Aug 2017 that + predate the setting; that upgrade case is documented in the upgrade + instructions and is therefore not a concern *(maintainer: DaanHoogland)* + *(documented: `plugins/ca/root-ca/.../RootCAProvider.java`, + `RootCACustomTrustManager.java`)*. +- **Console proxy (B3)**: reachable by anyone who holds a valid signed + token. The token is the entire authorization gate. +- **Agent → hypervisor (B7)**: reachable only on the agent host, by code + the agent runs. +- **External integrations (B6)**: reachable from the management server's + outbound posture; a hostile external service is `OUT-OF-MODEL: + trusted-input` (§3 item 2). + +## §5 Assumptions about the environment + +- **Operating system (management server / usage server)**: RHEL 8/9/10, + CentOS 8/9, Rocky 9/10, Ubuntu 22.04/24.04, SUSE 15, openSUSE Leap 15; + Java 17 (`README.md`, `INSTALL.md`, `packaging/{el8,el9,el10,debian,suse15}`). +- **Operating system (agent)**: same family on KVM/baremetal hosts; + agent ships as `cloudstack-agent` package *(documented: `debian/`, + `packaging/`)*. +- **Database**: MariaDB or MySQL-compatible, accessible from each + management-server instance; CloudStack uses native DB encryption + + JaSypt for application-level secrets *(documented: `README.md` + "Notice of Cryptographic Software")*. +- **Cryptography**: JaSypt (application-secret encryption), Bouncy Castle + (general-purpose crypto, X.509 issuance in the Root CA provider), JSch + (SSH client to system VMs), OpenSwan (optional VPN endpoint termination) + *(documented: `README.md` Cryptographic Software notice)*. +- **Network**: operator-controlled L2/L3 with at least the management + network, public network, guest network, and storage network as logical + fabrics *(documented: CloudStack admin documentation; inferred — + §14 Q13)*. The management network is the trusted control-plane + network; the guest network carries untrusted guest VM traffic. +- **Time**: signature version 3 enforces an `expires` parameter on signed + API requests *(documented: `ApiServer.java` line ~1054)*; this assumes + loosely-synchronized clocks between client and management server + *(inferred — §14 Q14)*. +- **Filesystem**: the JCEKS keystore, `db.properties`, `server.properties`, + and Root CA private key are stored under `/etc/cloudstack/management/` + with OS-level permissions restricted to the `cloudstack` user + *(inferred — §14 Q15)*. +- **Hypervisor**: each supported hypervisor is assumed to provide its own + guest isolation (memory, vCPU, disk, network) and to expose a stable + admin API (libvirt for KVM, vSphere SDK for VMware, XenAPI for + XenServer/XCP-ng, WinRM/Hyper-V API for Hyper-V). +- **What CloudStack does to its host** (negative claims, awaiting + maintainer ratification): + - **does** open listening sockets on documented ports + (`:8080`/`:8443`/`:8250`/`:8096`/`:9090`/console-proxy ports) *(documented)*; + - **does** maintain MariaDB connections from the management server; + - **does** issue X.509 certificates from its self-signed Root CA *(documented: + `plugins/ca/root-ca/.../RootCAProvider.java`)*; + - **does** spawn child processes from the agent (`Script` invocations + against `/usr/share/cloudstack-common/scripts/`) *(documented: + `agent/src/main/java/com/cloud/agent/Agent.java` `keystoreSetupSetupPath`, + `keystoreCertImportScriptPath`)*; + - **does** write logs under operator-configured locations; + - **does** read a documented set of environment variables and the + `db.properties` file at startup *(inferred — §14 Q16)*; + - **does** install signal handlers / shutdown hooks only as + Tomcat/Jetty servlet container default *(inferred — §14 Q16)*. + +## §5a Build-time and configuration variants + +CloudStack ships as a family of packages: 'cloudstack-agent', 'cloudstack-baremetal-agent', 'cloudstack-common', 'cloudstack-integration-tests', 'cloudstack-management', 'cloudstack-marvin', 'cloudstack-mysql-ha', 'cloudstack-ui', 'cloudstack-usage' +*(documented: `debian/`, `packaging/`)*. A sizable number of runtime +configuration knobs materially change the security envelope. The +security-relevant subset: + +| Knob | Default | Maintainer stance | Effect | +| --- | --- | --- | --- | +| `ca.plugin.root.auth.strictness` | **`true` for new setups; `false` only on upgrade from pre-Aug-2017 versions** *(maintainer: vishesh92 — `https://github.com/apache/cloudstack/pull/2239`)* | New setups are strict by default; the `false`-on-upgrade case is called out in the upgrade instructions and is therefore not a concern *(maintainer: DaanHoogland)* | When `false`, the management server's `RootCACustomTrustManager` does **not** require a client certificate from a peer attempting to connect on `:8250` (agent port) or cluster ports. A peer without a cert is allowed in. | +| `ca.plugin.root.allow.expired.cert` | **`true`** *(documented: `RootCAProvider.java`)* | operational default to survive cert-rotation lag *(maintainer: paired with the strictness ruling above)* | When `true`, an expired client cert is accepted during SSL handshake. | +| `ca.plugin.root.issuer.dn` | `CN=ca.cloudstack.apache.org` *(documented: same file)* | configured at first management-server boot | Subject DN of the auto-generated self-signed Root CA. | +| `proxy.header.verify` | `false` by default *(maintainer: vishesh92 — §14 Q17)* | When on, the operator must restrict `proxy.cidr` to the trusted reverse-proxy CIDR | When set, `ApiServlet.getClientAddress` honours proxy-set forward headers *only* for source IPs in `proxy.cidr` *(documented: `server/src/main/java/com/cloud/api/ApiServlet.java` `getClientAddress`; setting name maintainer: vishesh92)*. | +| `proxy.header.names` | list of header names; semantics: names to check for allowed IP addresses from a proxy-set header *(maintainer: vishesh92)* | list of header names to consult for the allowed client address when set by a proxy | Names the request header(s) carrying the proxy-set client IP. | +| `proxy.cidr` | unset *(maintainer: vishesh92 — §14 Q17; headers honoured only when `Remote_Addr` ∈ this list)* | required when `proxy.header.verify` is on | List of CIDRs for which `proxy.header.names` headers are honoured when the connecting `Remote_Addr` is in this list *(semantics maintainer: vishesh92)*. | +| `enable.user.2fa` / `mandate.user.2fa` | both default `false`; domain-configurable *(maintainer: vishesh92 — §14 Q18)* | `enable.user.2fa` turns 2FA on; `mandate.user.2fa` makes it mandatory (only when `enable.user.2fa` is true) — a deployment choice, not a §10 violation when off | When on, users must complete static-pin or TOTP 2FA after login. | +| `security.encryption.key`, `security.encryption.iv` | auto-generated at first boot *(documented: `framework/security/.../KeysManager.java`)* | trusted secret | Base64-encoded JaSypt master key + IV used to encrypt application-level secrets in the DB. | +| `user.password.encoders.order` | `PBKDF2,SHA256SALT,MD5,LDAP,SAML2,PLAINTEXT` *(maintainer: vishesh92)* | first encoder in the order is used to hash new passwords; the list also defines the verification fall-through order | Governs how user passwords are stored and which encoders are accepted on verify. | +| `user.password.encoders.exclude` | `MD5,LDAP,PLAINTEXT` *(maintainer: vishesh92)* | excluded encoders are not used to (re)hash passwords | Excludes weak/legacy encoders from being chosen, even though they remain in the order list for verifying already-stored hashes. | +| `enforce.post.requests.and.timestamps` | per `isPostRequestsAndTimestampsEnforced` *(documented: `ApiServer.java`; setting name maintainer: vishesh92)* | bounds `expires` to a maximum future offset | Prevents an attacker who steals a signed URL with a 10-year expiration from using it forever. | +| `integration.api.port` (`:8096`) | typically disabled *(inferred — §14 Q20)* | When non-zero, exposes an *unauthenticated* admin API for integration testing | An open integration port is a complete RBAC bypass on the management server. | +| Hypervisor enablement (which `plugins/hypervisors/*` are installed and configured) | per zone | operator-driven | An unused hypervisor plugin still ships but is not connected to any host. | +| Hostname / SAN of management-server cert (`ca.framework.cert.management.custom.san`) | unset *(maintainer: vishesh92)* | when set, included in the auto-generated cert SAN | governs which hostnames clients can use to reach the management server. | +| SAML2 / OAuth2 enablement (`plugins/user-authenticators/{saml2,oauth2}`) | off *(inferred — §14 Q19)* | turning on adds an external IdP trust dependency | adds B6 transitions. | +| LDAP enablement (`plugins/user-authenticators/ldap`) | off *(inferred — §14 Q19)* | turning on adds an external LDAP trust dependency | adds B6 transitions. | + +**The Root-CA strictness default (resolved).** Earlier drafts treated +`ca.plugin.root.auth.strictness = false` as the shipped default and the +single highest-leverage open question. The PMC has clarified that **new +setups default to `true`** — the management server *does* require a +Root-CA-signed client cert on `:8250` and the cluster ports — and the +value is `false` **only** when upgrading from versions released before +Aug 2017 that predate the setting *(maintainer: vishesh92 — +`https://github.com/apache/cloudstack/pull/2239`)*. That upgrade case is +documented in the upgrade instructions, so a leftover `false` after such +an upgrade is an operator-hardening/upgrade-hygiene item, not a shipped +insecure default *(maintainer: DaanHoogland)*. A report against an open +`:8250` accepting an un-certed peer on a **new** install is therefore +`MODEL-GAP`/`VALID` (strictness should be on), whereas the same on an +**upgraded** pre-2017 install is `OUT-OF-MODEL: non-default-build` +(documented upgrade step not applied). `ca.plugin.root.allow.expired.cert` +remains `true` as an operational concession to cert-rotation lag. + +## §6 Assumptions about inputs + +### Per-endpoint trust table (network surfaces) + +| Surface / route | Parameter | Attacker-controllable? | Caller must enforce | +| --- | --- | --- | --- | +| Management server `:8080`/`:8443` JSON API | command name + params | **yes** | nothing — CloudStack parses, authenticates (B1), applies RBAC, dispatches | +| Management server `:8080`/`:8443` JSON API | `signature` parameter | **yes** | HMAC-SHA1 verified *constant-time* against expected signature *(documented: `ApiServer.java` line 1137 `ConstantTimeComparator.compareStrings`)* | +| Management server `:8080`/`:8443` JSON API | `expires` parameter (sig v3) | **yes** | rejected if past, or beyond the `enforce.post.requests.and.timestamps` ceiling *(documented: same file; setting name maintainer: vishesh92)* | +| Management server `:8080`/`:8443` JSON API | proxy-set forward headers (`proxy.header.names`) | **yes** if `proxy.header.verify=true` | honoured **only** if the connecting `Remote_Addr` ∈ `proxy.cidr` *(documented: `ApiServlet.java` `getClientAddress`; setting names maintainer: vishesh92)* | +| Management server `:8080`/`:8443` Web UI | session cookie | **yes** | session-fixation / invalidation handled via `invalidateHttpSession` on auth failure *(documented: `ApiServlet.java` line 418)* | +| Integration API `:8096` (if enabled) | command name + params | **yes** | **no signature check** — integration port is unauthenticated by design | +| Management ↔ agent `:8250` | NIO Thrift-like payload | **only by a peer that has cleared B5 trust** | client cert via `RootCACustomTrustManager` | +| Management ↔ cluster peer | NIO payload | **only by a peer that has cleared B4 trust** | client cert via `RootCACustomTrustManager` | +| Console proxy URL | encrypted token (containing VM identity + endpoint + duration) | **yes** | token MUST decrypt + verify with `ConsoleProxyPasswordBasedEncryptor` keys *(documented: `ConsoleProxyPasswordBasedEncryptor.java`)* | +| Secondary-storage HTTP download URL | UUID-named symlink path | **yes** | **no auth on the download link**; the UUID format is the anti-enumeration control and the symlink is removed after a period — timed availability of the download token is the mitigation *(maintainer: vishesh92, DaanHoogland)* | +| Template / ISO upload | URL of remote source | **yes** within RBAC | upload-gated by `registerTemplate` RBAC; bytes are then served to hypervisors as image data | +| User-data / metadata service (`169.254.169.254` from inside guests) | guest-controlled bytes (the request) | **yes from the guest**, but the service is reached *from the guest* and serves only that guest's data | guest-VM-side isolation by virtual router | +| Hypervisor agent log / event stream | bytes from hypervisor | trusted operator surface | none — assumed truthful | +| LDAP / SAML / OAuth response (B6) | bytes from IdP | trusted | LDAP queries treat returned attributes as authoritative | +| Storage response (B8) | bytes / metadata from storage | trusted | bytes are object content; envelope is control-plane | + +### Size / shape / rate + +- CloudStack does not document a maximum signed-API request size; assumed + to be servlet-container default (Jetty / Tomcat) *(inferred — §14 Q21)*. +- API rate limiting is per-account via the global config knobs `api.throttling.*` + *(inferred — §14 Q22)*; an attacker with a valid API key can be rate- + limited at the application layer. +- Template / ISO upload size is bounded by storage capacity and per-account + resource limits *(inferred — §14 Q22)*; pathological compressed-image + inputs (e.g. extremely compressible QCOW2 with sparse holes that expand + to TB on extraction) are robustness concerns *(inferred — §14 Q23)*. +- Cluster-peer and agent RPC payload sizes: no documented application-layer + cap; NIO framing applies *(inferred — §14 Q21)*. + +## §7 Adversary model + +### Actors + +| Actor | In scope? | Capabilities granted | +| --- | --- | --- | +| Unauthenticated network peer reaching `:8080`/`:8443` | **yes** | TCP to the listening ports; may attempt authentication; may attempt to violate the protocol pre-auth | +| Unauthenticated peer reaching `:8250` (agent port) | **only if** `ca.plugin.root.auth.strictness = false`, which on new setups it is **not** (default `true`); `false` arises only on un-remediated pre-Aug-2017 upgrades (§5a) | TCP to the listening port; may attempt to connect as a peer without presenting a cert | +| Unauthenticated peer reaching `:8096` (integration port) | **yes** *if* the port is open (typically not in production) | full unauthenticated admin API | +| Authenticated end user with limited RBAC role | **yes** | call APIs their role permits; manage VMs/networks/storage in their domain/account/project | +| Authenticated end user with broad RBAC role | partial | only RBAC-envelope escapes are in scope | +| Authenticated domain admin | **yes** | full management within their domain; cross-domain leakage is in scope | +| Authenticated root admin | **out of scope** — see §3 item 4 | unbounded by design | +| Co-tenant (different account in same domain or different domain on same CloudStack) | **yes** | cross-tenant leakage (VM ID guessing, network bleed, storage bleed, template visibility) is in scope | +| Guest VM workload | **partial** | hypervisor-mediated; out-of-scope for hypervisor isolation bugs (§3 item 5), in-scope for the orchestration that placed the VM (security-group rule application, VLAN tagging, public IP routing) | +| Browser holding a valid console-proxy URL | **yes** | the URL is a bearer credential; scope of harm is one VM's console for the URL's lifetime | +| Operator | **out of scope** | see §3 item 1 | +| Hostile hypervisor | **out of scope** | see §3 item 3 | +| Hostile LDAP / SAML / OAuth IdP, hostile NSX/Netscaler/Tungsten, hostile S3 endpoint | **out of scope** | see §3 item 2 | +| Reverse proxy that should be trusted but is not in `proxy.cidr` | **out of scope** | its forward headers are not honoured | +| Local process on the management-server host running as a different UID | **partial** *(inferred — §14 Q24)* | same-host attackers with non-cloudstack UID can reach `:8080` unless host firewalling forbids; CloudStack does not defend against same-host `root` | +| Side-channel observer (cache timing, network timing, hypervisor side channels) | **out of scope** *(inferred — §14 Q25)* | n/a | +| Quantum adversary | **out of scope** | n/a | + +### Authenticated-but-Byzantine peer (distributed-systems threshold) + +CloudStack is **not** a Byzantine-fault-tolerant system. A compromised +management-server cluster peer with a valid Root-CA-issued cert can +schedule arbitrary work onto the agent fleet, read any guest's data, and +hand out console-proxy tokens. The cluster trusts its own membership +*(inferred — §14 Q26)*. Likewise, a compromised agent host can serve +malicious data on the management network and produce wrong status. → +reports requiring a Byzantine internal peer are `OUT-OF-MODEL: +adversary-not-in-scope`. + +## §8 Security properties the project provides + +For each property: condition, violation symptom, severity tier, provenance. + +### P1 — Authentication of API clients via signed request + +- **Condition**: a request carries `apiKey` + `signature` (and, for + signature version 3, an `expires` parameter not in the past) + *(documented: `ApiServer.java` `verifyRequest`)*; the signature is + HMAC-SHA1 of the canonical parameter string under the per-user + secret key, base64-encoded, lowercased, URL-decoded, and compared + to the computed value using `ConstantTimeComparator.compareStrings` + *(documented: same file line 1137)*. +- **Violation symptom**: a request executes API commands without a + valid `apiKey`+`signature` pair (and without a valid session + cookie / SAML / OAuth / LDAP login). +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P2 — Session authentication via password + optional 2FA + +- **Condition**: user logs in via the `login` API; 2FA is verified after + password if enabled for the user / domain *(documented: `ApiServlet.java` + lines 360–582)*. +- **Violation symptom**: a session is created without a valid password, or + 2FA enforcement is bypassed for a user where it is mandated. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P3 — Constant-time signature comparison + +- **Condition**: applies to the API signature check. +- **Violation symptom**: timing-side-channel measurement of signature + comparison reveals the expected signature byte-by-byte. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented: `ApiServer.java` line 1137)* + +### P4 — Authorization via RBAC + domain/account/project hierarchy + +- **Condition**: the authenticated principal calls an API command, and the + command name is permitted for their role *(documented: `plugins/acl/{static,dynamic,project}-role-based`)*; + resources named in the request belong to the principal's domain/account/project + or to a child within the principal's scope. +- **Violation symptom**: a non-root principal successfully executes an + API command not licensed for their role, or operates on a resource + outside their domain/account/project scope. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P5 — Mutual TLS on management ↔ agent, management ↔ cluster peer, *when configured* + +- **Condition**: `ca.plugin.root.auth.strictness = true` — **the default + on new setups** *(maintainer: vishesh92 — + `https://github.com/apache/cloudstack/pull/2239`)*. Pre-Aug-2017 + upgrades may leave it `false` until the documented upgrade step is + applied *(maintainer: DaanHoogland)*. `ca.plugin.root.allow.expired.cert` + remains `true` (cert-rotation concession), so the property covers + *peer-cert presence and Root-CA chain*, not cert freshness. +- **Violation symptom**: a peer without a Root-CA-issued cert successfully + completes a session on `:8250` or the cluster port on a setup where + strictness is on. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented; default resolved by maintainer.)* + +### P6 — Reverse-proxy IP-trust gating for forward headers + +- **Condition**: `proxy.header.verify` on (default `false`) *(maintainer: + vishesh92 — §14 Q17)*; + only requests whose `Remote_Addr` falls in `proxy.cidr` have their + `proxy.header.names` forward header(s) consulted *(documented: + `ApiServlet.java` `getClientAddress` `NetUtils.isIpInCidrList`; setting + names maintainer: vishesh92)*. +- **Violation symptom**: a request from a source IP **outside** + `proxy.cidr` succeeds with an attacker-supplied forward header + taking effect. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P7 — Console-proxy token confidentiality and integrity + +- **Condition**: tokens are encrypted under the + `ConsoleProxyPasswordBasedEncryptor` keys *(documented: + `ConsoleProxyPasswordBasedEncryptor.java`)*; a token includes the VM + identity, the hypervisor endpoint, and a duration / expiry. +- **Violation symptom**: a third party with no console-access RBAC + privilege forges or decrypts a token to gain console access; or a token + remains valid past its declared expiry. +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P8 — Application-secret encryption at rest in the DB via JaSypt + +- **Condition**: `security.encryption.key` + `security.encryption.iv` are + initialised at first boot and kept under filesystem ACLs + *(documented: `framework/security/.../KeysManager.java`, + `README.md` Cryptographic Software notice)*. +- **Violation symptom**: an attacker with read access to the DB but not + to the encryption key file recovers plaintext for secrets the model + claims are encrypted (typically: external service passwords, account + API secret keys when stored encrypted). +- **Severity**: **security-critical**, `VALID` per §13. +- *(documented)* + +### P9 — Memory safety on well-formed inputs across documented surfaces (JVM-bounded) + +- **Condition**: input matches the documented protocol on B1–B5; the JVM + is conformant; native code is invoked only via documented hypervisor + SDKs (libvirt / vSphere / XenAPI). CloudStack presumes **no limitation + on implementation language** — ocaml, python and bash run on hypervisors + and go is used on the management server (the set may grow); the + memory-safety claims here hold for the **JVM components**, to which the + JVM-conformance condition applies *(maintainer: DaanHoogland — §14 Q27)*. +- **Violation symptom**: heap corruption, OOM-via-input-size attack on a + surface where the input source is `:8080` / `:8443` / B5; JVM-side + crashes from a request a normally-RBAC'd user could send. +- **Severity**: **security-critical** when reachable from network input; + **`VALID-HARDENING`** when reachable only by a writer who already + controls the bytes (§3 item 5). +- *(maintainer: DaanHoogland — §14 Q27)* + +### P10 — Bounded RBAC scope of cross-domain visibility (`SHOW`-equivalent listing) + +- **Condition**: `list*` API commands filter responses to the principal's + domain/account/project scope per `plugins/acl/` policy. +- **Violation symptom**: a `list*` response leaks resource IDs / names / + metadata for resources outside the principal's RBAC scope. +- **Severity**: **security-critical** for resources whose existence is + itself confidential (typically: customer VM names, custom template + names); `VALID` per §13. +- *(inferred — §14 Q28)* + +## §9 Security properties the project does *not* provide + +State each plainly so a triager can route an inbound report to the matching +disclaimer. + +- **No defence against the operator.** Anyone with root on a + management-server host, the JCEKS keystore + `security.encryption.key`, + the Root CA private key, or the MariaDB credentials wins. See §3 item 1 + *(inferred — §14 Q6)*. +- **No defence against a malicious external service the operator + configured.** A hostile LDAP/SAML/OAuth IdP, NSX controller, Tungsten, + Netscaler, S3 endpoint, or backup provider is trusted. See §3 item 2. +- **No defence against the hypervisor.** Guest VM escape via libvirt, + vSphere, XenAPI, Hyper-V is upstream. See §3 item 3. +- **No isolation between a root admin's API call and the management-server + process.** Root admin can register arbitrary plugins, upload arbitrary + templates, run `runCustomAction`. See §3 item 4 *(inferred — §14 Q8)*. +- **No sandbox for guest VM workloads beyond what the hypervisor provides.** + Side-channel leaks between co-tenant VMs (cache, branch, memory bus, + shared GPU) are out of scope. See §3 item 5 *(inferred — §14 Q9)*. +- **No sandbox for user-data / templates / ISOs.** Templates run as their + own OS image with their own cloud-init; CloudStack does not parse or + reject user-data semantics. See §3 item 6 *(inferred — §14 Q10)*. +- **No defence against decompression / decoding bombs in uploaded + templates / ISOs.** A pathological QCOW2 / RAW image can consume + arbitrary CPU / disk on extraction; per-account resource limits are the + bound *(inferred — §14 Q23)*. +- **No defence against intra-cluster Byzantine failure.** A compromised + cluster peer with a valid Root-CA-issued cert can read any data the + cluster can read; see §7 *(inferred — §14 Q26)*. Likewise a compromised + agent host. +- **No data-at-rest encryption beyond JaSypt for selected DB columns + + whatever storage layers provide.** Guest volumes are encrypted only if + the primary-storage plugin supports it (Ceph RBD encryption, LUKS at + hypervisor layer) and the operator has configured it *(inferred — + §14 Q29)*. +- **No defence against side-channel observation** of API request timing, + agent RPC timing, or memory access patterns *(inferred — §14 Q25)*. +- **No application-layer constant-time comparison of anything other than + the API signature.** Login password comparison, session cookie + comparison, console-token comparison — not documented constant-time + *(inferred — §14 Q30)*. +- **No defender stance against an attacker on the same Linux host running + as a non-`cloudstack` UID** — CloudStack defends only across the + network surface; same-host attackers with shell access on the + management-server host already have many paths to win *(inferred — + §14 Q24)*. +- **No supported posture for the integration API port (`:8096`).** When + open, it is an unauthenticated admin surface; closing it is the + operator's job *(inferred — §14 Q20)*. + +### False-friend properties (call out separately) + +- **The Root CA is self-signed and auto-generated** — it is *not* a + publicly-trusted CA. Browsers and external clients require manual trust + bootstrap. The Root CA private key resides on the management server; a + compromised management server compromises the entire agent fleet's + trust. +- **`ca.plugin.root.auth.strictness = false` is not "TLS off" — it is + "client cert not required"** *(documented: `RootCAProvider.java`)*. TLS + on the wire is still there; what is missing is the peer-cert check. + Note the value is `true` on new setups *(maintainer: vishesh92)*; a + scanner that flags "client cert not requested" is only correct on an + un-remediated pre-Aug-2017 upgrade, and even then it identifies a + documented upgrade step, not a transport-encryption bug. +- **`ca.plugin.root.allow.expired.cert = true` is the operational default + to survive cert-rotation lag** but is not a security boundary. +- **The HMAC-SHA1 signature is request-integrity over the URL, not + request encryption.** Transport encryption is TLS; if the operator + serves the API over `http://`, the signature still validates but the + whole request (including the secret-derived signature) is visible to + the network. +- **The console-proxy URL is a bearer credential.** Anyone who sees the + URL (in logs, in a proxy, in a shoulder-surf) holds the console for + the URL's lifetime. +- **`list*` filtering is a per-call authorization view, not an + information-flow channel.** Existence of a resource that the principal + cannot see may leak through error messages, async-job status, event + logs, or by-ID lookup probing *(inferred — §14 Q28)*. +- **The integration API port is not a "trusted" port in the sense of + Kerberos `auth-int` — it is *no authentication at all***. The name + invites confusion. +- **JaSypt-encrypted DB columns are *(documented)* protected against a + DB-only read.** They are *not* protected against an attacker who + obtains both the DB and the encryption-key file. + +### Well-known attack classes the project does not defend against + +- **Cross-tenant VM-ID guessing / template-name enumeration**: §10 misuse, + not engine breakage. +- **Decompression / decoding bombs in uploaded templates and ISOs**. +- **Hypervisor side-channel attacks between co-tenant VMs**. +- **Confused-deputy between RBAC role and resource ownership** — e.g. a + domain admin's role permits a command, but the resource named is in a + child domain they should not touch *(inferred — §14 Q28)*. +- **Time-of-check-to-time-of-use** between RBAC check at API entry and + the actual orchestration on the agent fleet — policy revocations + mid-job are not retroactively enforced *(inferred — §14 Q31)*. + +## §10 Downstream responsibilities + +The operator deploying CloudStack in production **must**: + +1. Keep `ca.plugin.root.auth.strictness = true` (the default on new + setups). When **upgrading from a pre-Aug-2017 version**, follow the + documented upgrade step to turn strictness on — otherwise agent and + cluster-peer ports accept peers without a cert *(maintainer: vishesh92, + DaanHoogland — `https://github.com/apache/cloudstack/pull/2239`)*. + Consider tightening `ca.plugin.root.allow.expired.cert` (default `true`) + once cert rotation is reliable. +2. Restrict the management network at L2/L3 so that `:8250` (agent), + `:9090` (cluster), and the MariaDB port are reachable only from the + intended peers *(inferred — §14 Q13)*. +3. Restrict the integration API port `:8096` — either disable it entirely + or limit it to a localhost/management subnet *(inferred — §14 Q20)*. +4. Terminate TLS for the JSON API and Web UI on `:8443` (not `:8080`); if + `:8080` is exposed at all, only behind a TLS-terminating reverse + proxy *(inferred — §14 Q32)*. +5. When using a reverse proxy, set `proxy.header.verify = true`, + `proxy.header.names` to the forward header(s) the proxy sets, *and* + `proxy.cidr` to the proxy's CIDR — leaving `proxy.cidr` unset/empty + means the header is ignored (safe-default per P6), but a misconfigured + wide CIDR is a trust-bypass *(setting names maintainer: vishesh92)*. +6. Protect the `security.encryption.key` / `security.encryption.iv` + files, the JaSypt-encrypted DB, the Root CA private key, and the + `cloudstack-management` Unix user's home directory at OS level. +7. Keep the password-encoder configuration at safe defaults: + `user.password.encoders.order` defaults to + `PBKDF2,SHA256SALT,MD5,LDAP,SAML2,PLAINTEXT` (so PBKDF2 is used to hash + new passwords) and `user.password.encoders.exclude` defaults to + `MD5,LDAP,PLAINTEXT` (so the weak encoders are not chosen for hashing, + only retained for verifying already-stored hashes) *(maintainer: + vishesh92)*. Do not remove `MD5`/`PLAINTEXT` from the exclude list in + production — the supported greenfield encoder set is + `PBKDF2,SHA256SALT,SAML2` *(maintainer: vishesh92 — §14 Q19)*. +8. Enable 2FA (`totp` or `static-pin`) for administrators and ideally for + all users — 2FA on/off is a deployment choice via `enable.user.2fa` + and `mandate.user.2fa` (both default `false`) *(maintainer: vishesh92 — + §14 Q18)*. +9. Rotate per-user API secret keys on personnel change and on suspected + compromise. +10. Treat user-uploaded templates and ISOs as crossing a trust boundary — + scan / quarantine before allowing into the supported-template set. +11. Apply per-account resource limits (vCPU / RAM / volume size / image + size) to bound decompression-bomb and orchestration-DoS attacks. +12. Configure storage-layer encryption (Ceph RBD encryption, LUKS at KVM, + vSphere VM Encryption, etc.) if data-at-rest encryption is required. +13. Secure each `cloudstack-agent` host: `cloudstack` Unix user, agent + keystore under `/etc/cloudstack/agent/`, root account, libvirt / + vSphere admin credentials. +14. Restrict console-proxy URLs: do not log them, do not embed them in + public responses, set a short token lifetime. +15. Audit API call logs (via the event-bus plugin) for anomalous patterns. + +## §11 Known misuse patterns + +- **Leaving `:8250` open to the world with `ca.plugin.root.auth.strictness=false` + on an upgraded pre-Aug-2017 cluster.** New setups default to `true`; + the `false` value only survives an upgrade where the documented step + was skipped *(maintainer: vishesh92, DaanHoogland)*. In that state any + peer can connect as an agent — an upgrade-hygiene gap, dispositioned + `OUT-OF-MODEL: non-default-build` (documented upgrade step not applied). +- **Exposing `:8096` (integration API) publicly.** Anyone reaching the + port executes admin API commands without auth. +- **Exposing `:8080` (HTTP JSON API) publicly without a TLS-terminating + reverse proxy.** Signed-request integrity holds, but the API secret- + key-derived signature is visible to any wire observer; replay within + the `expires` window is trivial. +- **Setting `proxy.header.verify=true` with `proxy.cidr` wider than + the actual reverse-proxy CIDR.** An attacker outside the proxy can + spoof a `proxy.header.names` header and claim any IP address for audit + logs and authentication-IP checks *(setting names maintainer: vishesh92)*. +- **Removing `MD5`/`PLAINTEXT` from `user.password.encoders.exclude` (or + reordering them to the front of `user.password.encoders.order`) in + production.** The encoders ship for verifying legacy hashes; promoting + them to hash new passwords stores weakly-protected credentials + *(maintainer: vishesh92 — §14 Q19; the supported greenfield encoder set is `PBKDF2,SHA256SALT,SAML2`)*. +- **Granting domain admin to too many users.** A domain admin can manage + all accounts within the domain — including reading guest console URLs. +- **Embedding console-proxy URLs in screenshots, ticketing systems, or + chat.** Tokens are bearer credentials. +- **Reusing `security.encryption.key` across environments of different + trust levels.** A staging-env leak becomes a production-env decrypt + primitive *(inferred — §14 Q33)*. +- **Leaving `ca.plugin.root.auth.strictness=false` after a pre-Aug-2017 + upgrade in a multi-management-server deployment.** A peer can join the + cluster without a cert until the documented upgrade step flips it to the + new-setup default of `true` *(maintainer: vishesh92, DaanHoogland)*. +- **Uploading large or pathological templates and relying on hypervisor + to enforce size.** Per-account resource limits, not the engine, are the + enforcement. + +## §11a Known non-findings (recurring false positives) + +This section is the highest-leverage input for automated agentic security +scans. Each entry: tool symptom, why it is safe under the model, the § +that licenses the call. + +- **"Management ↔ agent port `:8250` accepts no client cert" reported + against a setup with `ca.plugin.root.auth.strictness=false`.** New setups + default to `true` and **do** require a Root-CA-signed client cert + *(maintainer: vishesh92 — `https://github.com/apache/cloudstack/pull/2239`)*. + The value is `false` only on an upgrade from a pre-Aug-2017 version that + predates the setting, and the upgrade instructions document turning it on + *(maintainer: DaanHoogland)*. → On a new install: `KNOWN-NON-FINDING` + (strictness is on). On an upgraded install with the step skipped: + `OUT-OF-MODEL: non-default-build` (documented upgrade step not applied). +- **"Integration port `:8096` is unauthenticated."** The port is + unauthenticated by design; operator responsibility per §10 to close / + bind to localhost. → `OUT-OF-MODEL: non-default-build` once the PMC + confirms. +- **"HMAC-SHA1 signature uses SHA1."** SHA1-HMAC is **not** broken for + HMAC use; collision attacks on SHA1 do not extend to HMAC-SHA1 + *(documented: cryptographic literature; CloudStack uses + `Mac.getInstance("HmacSHA1")` — `ApiServer.java` line 1130)*. → `KNOWN-NON-FINDING`. +- **"Constant-time string compare for the signature."** Already done — + `ConstantTimeComparator.compareStrings` per `ApiServer.java` line 1137. + → `KNOWN-NON-FINDING` (a finding flagging this is wrong). +- **"Root CA private key is on the management server."** By design — the + management server *is* the CA. → `BY-DESIGN: property-disclaimed`. +- **"Self-signed Root CA cert."** By design — the CA is generated at + first boot per `RootCAProvider.java`. Browsers will warn until the + operator bootstraps trust. → `BY-DESIGN: property-disclaimed`. +- **"Expired agent cert is accepted (`ca.plugin.root.allow.expired.cert=true`)."** + Documented default — an operational concession to cert-rotation lag, paired + with the strictness default *(maintainer: vishesh92, DaanHoogland)*. → + `VALID-HARDENING` at most; tightening it is an operator choice per §10. +- **"Hardcoded password / keytab in `tools/marvin/`, `test/`, `developer/`, + `quickcloud/`."** These directories are unsupported components per §3 + item 7. → `OUT-OF-MODEL: unsupported-component`. +- **"User-data / template contents execute arbitrary code in the guest + VM."** Templates are run as their own OS by the hypervisor; cloud-init + / user-data is intentionally a code-execution channel into the guest. + → `BY-DESIGN: property-disclaimed` per §9. +- **"Root admin can change global config / register plugins / upload + arbitrary templates."** Documented and intentional. → `BY-DESIGN: + property-disclaimed` per §9 / §3 item 4. +- **"DoS via expensive list call on a large CloudStack."** Pagination is + present; further bounds are admission-control / quota. → `BY-DESIGN: + property-disclaimed` per §9. +- **"Decompression bomb in an uploaded QCOW2 / template."** Per-account + resource limits are the bound. → `VALID-HARDENING` at most, unless the + decompression reaches §8 P9 memory-safety violations. +- **"Vendored Bouncy Castle / JaSypt / noVNC / `pako` has CVE-X."** Report + upstream; `systemvm/agent/noVNC` is a vendored fork of + `github.com/novnc/novnc` with CloudStack changes, and there is no + automated sync procedure today *(maintainer: vishesh92, DaanHoogland)*. → + `OUT-OF-MODEL: unsupported-component` (upstream pointer); a + CloudStack-introduced change *to* the fork is in-model. +- **"Secondary-storage download URL has no authentication / can be replayed."** + By design: download links are UUID-named symlinks served by an Apache + httpd with no auth on the link; the UUID format defeats enumeration and + the symlink is removed after a period, so timed availability is the + mitigation *(maintainer: vishesh92, DaanHoogland)*. → `BY-DESIGN: + property-disclaimed` for the no-auth aspect; a link that is *not* removed + after its window, or a guessable (non-UUID) name, is `VALID-HARDENING`. +- **"A proxy-set forward header is honoured without authentication."** + Honoured only if (a) `proxy.header.verify=true`, (b) the header is one of + `proxy.header.names`, *and* (c) the connecting `Remote_Addr` ∈ + `proxy.cidr` *(setting names maintainer: vishesh92)*. → `KNOWN-NON-FINDING`. +- **"Session-fixation: a session ID is reusable after failed login."** + `invalidateHttpSession` is called on each auth failure path per + `ApiServlet.java`. → `KNOWN-NON-FINDING` (verify the symptom; if + reproducible, escalate to `MODEL-GAP`). + +## §12 Conditions that would change this model + +Revise this document when any of the following lands: + +- A new authentication mechanism on a client-facing surface (e.g. + mTLS-as-API-auth on the JSON API, WebAuthn, OIDC). +- A new RBAC backend beyond the three included ACL plugins (e.g. OPA + integration, policy-engine integration). +- A new data-at-rest encryption story at the CloudStack layer (currently + delegated; see §9). +- A change in the default of any §5a flag, *especially* + `ca.plugin.root.auth.strictness` and `ca.plugin.root.allow.expired.cert`. +- Removal or change of the legacy `md5` / `plain-text` user-authenticator + plugins. +- A change in the signing algorithm or signature scheme on the JSON API + (e.g. SHA1 → SHA256 by default). +- A new hypervisor or system VM that adds a new trust boundary. +- A change in the extension mechanisms implemented by CloudStack + *(maintainer: DaanHoogland — §14 Q36)*. +- A new external-data surface (a new SDN controller integration, a new + storage provider, a new backup provider). +- A vulnerability report that cannot be cleanly routed to one of the §13 + dispositions: that is evidence the model is incomplete. + +## §13 Triage dispositions + +A report against `apache/cloudstack` receives exactly one of the +following: + +| Disposition | Meaning | Licensed by | +| --- | --- | --- | +| `VALID` | Violates a §8 property via an in-scope §7 adversary using an in-scope §6 input. | §8, §6, §7 | +| `VALID-HARDENING` | No §8 property violated, but a §11 misuse pattern can be made harder to fall into by code change. Fixed at maintainer discretion, typically no CVE. | §11 | +| `OUT-OF-MODEL: trusted-input` | Requires attacker control of a §6 parameter the model marks trusted (e.g. operator-supplied config flag, hostile LDAP/SAML/NSX/etc.). | §6 | +| `OUT-OF-MODEL: adversary-not-in-scope` | Requires a §7 actor the model excludes (operator, hostile hypervisor, hostile external IdP / SDN, Byzantine peer, side-channel observer, same-host non-`cloudstack` `root`). | §7 | +| `OUT-OF-MODEL: unsupported-component` | Lands in `tools/marvin/`, `test/`, `developer/`, `quickcloud/`, vendored upstream code, `simulator` hypervisor, etc. | §3 items 7–8 | +| `OUT-OF-MODEL: non-default-build` | Only manifests under a §5a flag that is not the new-setup default (e.g. `ca.plugin.root.auth.strictness=false` surviving an un-remediated pre-Aug-2017 upgrade, integration port `:8096` open). | §5a | +| `OUT-OF-MODEL: equivalent-harm` | An actor already-authorized under the model can cause the same harm via a documented path (root admin doing root-admin things, RBAC-licensed user using their RBAC-licensed commands). | §3 items 4, 5 | +| `BY-DESIGN: property-disclaimed` | Concerns a §9 property the project explicitly does not provide (template sandboxing, side-channel resistance, hypervisor isolation, etc.). | §9 | +| `KNOWN-NON-FINDING` | Matches a §11a recurring false positive. | §11a | +| `MODEL-GAP` | Cannot be cleanly routed to any of the above — triggers §12 model revision. | §12 | + +## §14 Open questions for the maintainers + +Every *(inferred)* tag in the body maps to one of these. Proposed answers +are inline; please confirm, correct, or strike. + +### Wave 1 — scope, intended use, the two big insecure defaults + +**Q1.** ~~The model assumes CloudStack is "a clustered distributed +control plane deployed inside an operator-controlled datacenter +network", not a single-host appliance or a hosted SaaS. Confirm?~~ +**RESOLVED** *(maintainer: DaanHoogland)* — distributed control plane; +**both** a single management-server instance (smaller clouds) and a +clustered deployment are supported topologies. Folded into §2. + +**Q2.** ~~Are the SecondaryStorageVM, ConsoleProxyVM, and Virtual Router +treated as trusted-once-enrolled peers, or do they get their own trust +tier?~~ **RESOLVED** *(maintainer)* — **yes**, same trust tier as agents, +not a separate tier. Folded into §2 caller-roles. + +**Q3.** ~~Are external integrations (LDAP, SAML2 IdP, OAuth2 IdP, NSX +controller, Netscaler, Tungsten, S3-compatible storage, backup +providers) modeled as trusted control-plane peers?~~ **RESOLVED** +*(maintainer: DaanHoogland — yes)* — trusted control-plane peers; this +licenses §3 item 2 and §11a trusted-input dispositions. *(maps to §2, §3, §11a)* + +**Q4.** ~~SecondaryStorageVM HTTP download surface — is the URL token +per-template ACL-checked, or is the SSVM URL itself a bearer credential?~~ +**RESOLVED** *(maintainer: vishesh92, DaanHoogland)* — download links are +UUID-named symlinks served by an Apache httpd with **no auth on the link**; +the UUID format defeats enumeration and the symlink is removed after a +period (timed availability is the mitigation). The PMC noted this should +be re-tested/confirmed in code. Folded into §6, §11a. *(Daan also asked +why static code analysis did not surface this — a note for the scan +agent, not a model gap.)* + +**Q5.** ~~Vendored upstream code under `systemvm/agent/noVNC` and bundled +JaSypt / Bouncy Castle / JSch — is the policy "report upstream; we pick up +fixes on next sync"?~~ **RESOLVED** *(maintainer: vishesh92, DaanHoogland)* +— `systemvm/agent/noVNC` is a **vendored fork of `github.com/novnc/novnc`** +with CloudStack changes; vendored bugs go upstream. There is **no automated +update procedure today** (dependabot has not produced viable PRs); the PMC +would prefer to establish one. Folded into §3 item 8, §11a. + +**Q6.** ~~Is "an operator with `root` on a management-server host, the +JCEKS keystore + encryption keys, the Root CA private key, or MariaDB +credentials" out of scope?~~ **RESOLVED** *(maintainer: DaanHoogland — yes)* +— `OUT-OF-MODEL: adversary-not-in-scope`. *(maps to §3 item 1, §9)* + +**Q7.** ~~Hypervisor bugs (libvirt / vSphere SDK / XenAPI / Hyper-V API / +KVM/QEMU itself) — out of scope, report upstream?~~ **RESOLVED** +*(maintainer: DaanHoogland — yes, out of scope; report upstream)*. *(maps to §3 item 3)* + +### Wave 2 — the two big insecure defaults + +**Q12.** ~~**Highest-leverage question in the model.** Are +`ca.plugin.root.auth.strictness` and `ca.plugin.root.allow.expired.cert` +shipped insecure-by-default?~~ **RESOLVED** *(maintainer: vishesh92, +DaanHoogland — `https://github.com/apache/cloudstack/pull/2239`)*: + +- `ca.plugin.root.auth.strictness` defaults to **`true` on new setups** — + the management server **does** require a Root-CA-signed client cert on + `:8250` and the cluster ports. It is `false` **only** after upgrading + from a version released before Aug 2017 that predates the setting; the + upgrade instructions document turning it on, so a leftover `false` is an + upgrade-hygiene gap, not a shipped insecure default. +- `ca.plugin.root.allow.expired.cert` defaults to `true` as an operational + concession to cert-rotation lag. + +This resolution reshaped §3 item 1, §5a, §7 (the un-certed peer row), +§8 P5, §9 false-friends, §10, §11, §11a, and §13. The earlier +"assumes operator must flip per §10" framing is withdrawn. + +### Wave 3 — adjacent insecure defaults and admin-only surfaces + +**Q8.** ~~Is "a root admin with full RBAC role causes harm Y via a +documented path Z" out of scope (proposed: **yes**, `OUT-OF-MODEL: +equivalent-harm`)? In particular: `runCustomAction`, template upload, +plugin registration, global config change, system-VM patching, system-VM +console access.~~ **RESOLVED** *(maintainer: vishesh92)* — yes; a root +admin generally has direct access to most of these resources anyway → +`OUT-OF-MODEL: equivalent-harm`. *(maps to §3 item 4, §9)* + +**Q9.** ~~Guest VM workloads — confirm that hypervisor-mediated side +channels and resource-exhaustion-within-allocation are out of scope, and +that the in-scope orchestration concerns are limited to "did CloudStack +place the VM in the right VLAN / apply the right security group / route +the right IP" (proposed)?~~ **RESOLVED** *(maintainer: vishesh92)* — yes; +side channels + resource-exhaustion-within-allocation are out of scope. +The one in-model case: CloudStack applying a wrong/insecure setting while +launching or managing the guest (CloudStack must use correct/secure +hypervisor settings). *(DaanHoogland to confirm the boundary.)* *(maps to +§3 item 5, §7, §9)* + +**Q10.** ~~Templates / ISOs / user-data — confirm that there is no +sandboxing of user-supplied OS images, and that user-data is intentionally +a code-execution channel into the guest (proposed)?~~ **RESOLVED** +*(maintainer: vishesh92)* — yes; userdata is the end user customizing +their own guest OS (tenant-controlled data inside their own boundary), not +a CloudStack-side injection surface. *(maps to §3 item 6, §9)* + +**Q11.** Confirm the unsupported-component list: `tools/marvin/`, +`test/`, `developer/`, `quickcloud/`, `cloud-cli/`, +`tools/{devcloud4,devcloud-kvm,appliance,checkstyle,transifex,bugs-wiki,...}`, +`simulator` hypervisor plugin. Anything to add or remove? **RESOLVED** *(maintainer: DaanHoogland)* — exclude `simulator` and `tools/appliance` explicitly (out of scope for now; a future security-purpose tooling effort may revisit). *(maps to §3 item 7)* + +**Q17.** Forward-header gating — the **setting names are confirmed** +*(maintainer: vishesh92)*: `proxy.header.verify` (the on/off gate), +`proxy.header.names` (header names to consult), and `proxy.cidr` (CIDRs of +the `Remote_Addr` values for which those headers are honoured). **RESOLVED** *(maintainer: vishesh92)* — `proxy.header.verify` is +**`false` by default**; only when the connecting `Remote_Addr` ∈ +`proxy.cidr` does CloudStack read the client IP from `proxy.header.names`. +*(maps to §5a, §6, §10)* + +**Q18.** ~~2FA — proposed: off by default, operator turns it on per +domain / per user via `enable.2fa.*`. Confirm; and is "2FA disabled in +production" a §10 violation or a deployment choice?~~ **RESOLVED** +*(maintainer: vishesh92)* — deployment choice, not a §10 violation. Two +domain-configurable global settings: `enable.user.2fa` (default `false`; +whether 2FA is enabled) and `mandate.user.2fa` (default `false`; whether +2FA is mandatory — applies only when `enable.user.2fa` is true). *(maps to +§5a, §10)* + +**Q19.** User-authenticator plugins — encoder selection is governed by +`user.password.encoders.order` (default +`PBKDF2,SHA256SALT,MD5,LDAP,SAML2,PLAINTEXT`) and +`user.password.encoders.exclude` (default `MD5,LDAP,PLAINTEXT`), so PBKDF2 +is the effective hashing default and `MD5`/`PLAINTEXT` are retained only +for verifying legacy hashes *(maintainer: vishesh92)*. **RESOLVED** *(maintainer: vishesh92)* — a +report against `md5`/`plain-text` hashing *new* passwords in a greenfield +install is `OUT-OF-MODEL: non-default-build`: the default +`user.password.encoders.exclude` (`MD5,LDAP,PLAINTEXT`) removes them from +the effective set, so the supported greenfield encoders are +`PBKDF2,SHA256SALT,SAML2`. *(maps to §5a, §10, §11)* + +**Q20.** Integration API port `:8096` — proposed: closed (port-zero) by +default in production packaging, open only when explicitly configured; +when open, it is unauthenticated by design. A report of "integration +port allows admin commands without auth" is `OUT-OF-MODEL: +non-default-build` *if* the operator opened it, else `VALID`. Confirm the default. **RESOLVED** *(maintainer: DaanHoogland)* — default is `0` (disabled); `8096` is set only in test configurations. *(maps to §5a, §10, §11a)* + +### Wave 4 — environment, distributed model, false-friends + +**Q13.** Network-fabric assumptions — proposed: at least four logical +networks (management, public, guest, storage), with the management +network as the trusted control plane. Is that the canonical model, or +do you support more compressed topologies (single-fabric) in production? **RESOLVED** *(maintainer: DaanHoogland)* — there are four logical networks (management, public, guest, storage); each may have multiple instances across topologies (e.g. multiple zones) and may be combined within physical networks, but all four logical types must be present for a functional system. *(maps to §5, §10)* + +**Q14.** Clock-skew assumption for signature v3 `expires` enforcement — +proposed: operator's responsibility to keep client + management-server clocks roughly in sync. Confirm. **RESOLVED** *(maintainer: DaanHoogland)* — confirmed; operator responsibility (PMC to add to the security model page). *(maps to §5)* + +**Q15.** Confirm the filesystem-permissions inventory for sensitive +files: JCEKS keystore, Root CA private key, JaSypt key + IV, +`db.properties`. Who owns them, what mode? **RESOLVED** *(maintainer: vishesh92)* — not a CSV inventory of every file in a running system; only the four sensitive artifacts named here (JCEKS keystore, Root CA private key, JaSypt key + IV, `db.properties`). Ownership is `root` (user) / `cloud` (group), mode read+write for the owner and read for the `cloud` group (i.e. `0640`, `root:cloud`). *(maps to §5, §10)* + +**Q16.** Confirm the "what CloudStack does not do to its host" inventory +in §5: no child processes besides agent `Script` invocations / system +VM provisioning; signal-handlers via servlet container default; +environment-variable consumption confined to documented set. Anything to add? **RESOLVED** *(maintainer: DaanHoogland)* — confirmed; nothing to add. *(maps to §5)* + +**Q21.** API request size cap and cluster/agent RPC payload size cap — +are these explicitly bounded, or "whatever Jetty / NIO defaults give"? **RESOLVED** *(maintainer: DaanHoogland)* — the UI server sets an explicit cap, `org.apache.cloudstack.ServerDaemon.DEFAULT_REQUEST_CONTENT_SIZE = 1048576` (1 MiB); for other components the sizes are capped by the upstream components used. *(maps to §6, §9)* + +**Q22.** `api.throttling.*` and per-account resource limits — proposed: +these are the entire DoS-protection surface, with no engine-level guard. Confirm. **RESOLVED** *(maintainer: DaanHoogland)* — confirmed; enforced at the API access check, and `api.throttling.enabled` is **`false` by default**. *(maps to §6, §9, §10)* + +**Q23.** Decompression behaviour on uploaded QCOW2 / RAW / OVA — proposed: +no engine-side cap; per-account storage limits + hypervisor limits are the bound. Confirm. **RESOLVED** *(maintainer: DaanHoogland)* — correct. *(maps to §6, §9)* + +**Q24.** Same-host non-`cloudstack` UID — proposed: game-over, no defence +claimed. Confirm. **RESOLVED** *(maintainer: DaanHoogland, vishesh92)* — same-host non-`cloudstack` UID is game-over (no defence claimed; an operator at that level already controls the host, §3). On the related host-registration question: re-adding a host with the same IP **updates the existing host record** rather than creating a spoofed peer, and is gated by root-admin/operator access plus the keys/certs required to connect to the management server — adding a host directly without those fails (refinement tracked in apache/cloudstack#13182). So this is not an unauthenticated identity-spoof path. *(maps to §7, §9)* + +**Q25.** Side-channel observers (CPU cache timing, branch-predictor / speculative-execution channels e.g. Spectre-class, hypervisor-shared microarchitectural channels) — out of scope (proposed). **RESOLVED** *(maintainer: DaanHoogland)* — agreed, out of scope. *("branch" = branch-predictor / speculative-execution side channels — clarified by producer.)* *(maps to §7, §9)* + +**Q26.** Byzantine-internal-peer threshold — confirm CloudStack makes no +BFT claim, so any compromised cluster peer or agent with a valid +Root-CA-issued cert is unbounded (proposed). **RESOLVED** *(maintainer: DaanHoogland)* — agreed; no BFT claim. (A quorum-style mitigation would only be meaningful in larger clusters, not single/dual-node — possible future feature proposal.) *(maps to §7, §9)* + +**Q27.** §8 P9 memory-safety — JVM-bounded; is the reachability +boundary correctly "in-model for the JSON API + B5 input; out-of-model +for native hypervisor SDK bugs that surface as `Throwable`"? **RESOLVED** *(maintainer: DaanHoogland)* — the reachability boundary is right, but **§8 P9 must not imply CloudStack is Java-only** — no implementation-language limitation is presumed (ocaml, python, bash run on hypervisors; go is used on the management server; the set may grow). The memory-safety claims hold for the JVM components only. *(reflected in §8 P9.)* *(maps to §8 P9, §9)* + +**Q28.** §8 P10 listing-scope — confirm the §10 invariant "`list*` +responses are scoped to the principal's domain/account/project". And: +is information leak via error messages / async-job status / event log an in-model concern, or accepted? **RESOLVED** *(maintainer: DaanHoogland)* — in-model: regular system logs (e.g. log4j) are exempt, but other than those, information leaks (via error messages, async-job status, event log) are a concern. *(maps to §8 P10, §9, §11)* + +**Q29.** Data-at-rest encryption — confirm CloudStack delegates entirely +to storage layer / hypervisor (LUKS, Ceph encryption, vSphere VM +Encryption); no CloudStack-layer encryption of guest volumes. **RESOLVED** *(maintainer: DaanHoogland, vishesh92)* — correct; delegated entirely to the storage layer / hypervisor. *(maps to §9)* + +**Q30.** Constant-time comparison — confirm that *only* the API +signature path uses `ConstantTimeComparator`. Login password compare, +session cookie compare, console-token compare — none documented +constant-time. Is that intentional? **RESOLVED** *(maintainer: DaanHoogland)* — not intentional — the absence of constant-time comparison on the login-password / session-cookie / console-token paths is a lack of feature (hardening opportunity), not a by-design decision. *(maps to §8, §9)* + +**Q31.** Time-of-check-to-time-of-use between RBAC check at API entry +and orchestration on agent fleet — confirm mid-job RBAC revocation is +**not** retroactively enforced (proposed). **RESOLVED** *(maintainer: DaanHoogland)* — agreed/confirmed. *(maps to §9)* + +**Q32.** TLS posture on `:8080` vs `:8443` — confirm production deploys +behind TLS on `:8443` or behind a TLS-terminating reverse proxy; a bare +`:8080` HTTP API is dev-only. **RESOLVED** *(maintainer: DaanHoogland)* — confirmed. *(maps to §5a, §10)* + +**Q33.** `security.encryption.key` reuse across environments — confirm +that reusing the JaSypt key + IV across staging and production is a +documented misuse. **RESOLVED** *(maintainer: DaanHoogland)* — indeed — confirmed misuse. *(maps to §11)* + +### Wave 5 — meta + +**Q34.** Should this document live at `docs/threat-model.md` in +`apache/cloudstack`, or as a page on `cloudstack.apache.org/security/`? +Or both, with one canonical and the other linked? **RESOLVED** *(maintainer: DaanHoogland, vishesh92)* — both: this document is the source of truth, and `cloudstack.apache.org/security` carries an excerpt plus a link to it. *(meta)* + +**Q35.** Is there an existing CloudStack threat-model document +(Confluence, internal, or a `[SECURITY]`-tagged dev@ thread) that this +should reconcile against rather than supersede? **RESOLVED** *(maintainer: DaanHoogland)* — `cloudstack.apache.org/security/` is the only existing security model today; this document becomes its source of truth, with the page linking to it. *(meta — §3.1a of the rubric)* + +**Q36.** What kind of change should trigger a revision (proposed list in +§12 — confirm or correct)? **RESOLVED** *(maintainer: DaanHoogland)* — confirmed, plus add: a change in the extension mechanisms implemented by CloudStack (now reflected in §12). *(meta, §12)* + +**Q37.** §11a is the highest-leverage section for the scan agent's +suppression list. The current draft has 15 patterns; could the PMC +populate §11a from recurring "not a vuln" closures on the +`security@apache.org` ↔ CloudStack triage queue and on +`https://cloudstack.apache.org/security.html`? Concrete asks: 3–5 +patterns the PMC sees recur in inbound reports (e.g. "SSL bare on +`:8080` in a dev cluster", "agent port open without strictness flipped", +"`md5` authenticator left enabled after upgrade", "console URL appears +in support ticket"). *(meta — §11a)* + +**Q38.** Confirm the structural decision to keep the four satellite repos +as separate delta models (`cloudstack-go-threat-model-draft.md`, +`cloudstack-cloudmonkey-threat-model-draft.md`, +`cloudstack-terraform-provider-threat-model-draft.md`, +`cloudstack-kubernetes-provider-threat-model-draft.md`) inheriting §3 +/ §4 / §7 from this document. **RESOLVED** *(maintainer: DaanHoogland)* — confirmed; the satellites are not the system core (the core runs without them, they cannot run without the core), and there is an added hierarchy — `cloudstack-go` is a dependency of the other three. *(meta, §3 item 9)* + +--- + +## Appendix: SECURITY.md → §x back-map + +CloudStack does not currently ship an in-repo `SECURITY.md`; the `README.md` +section "Reporting Security Vulnerabilities" points to +`https://cloudstack.apache.org/security.html` as the canonical disclosure +landing page. The de facto security-policy artifacts are scattered: + +| Source | Claim | Lands in | +| --- | --- | --- | +| `README.md` "Reporting Security Vulnerabilities" | report to `security@apache.org`; canonical page at `cloudstack.apache.org/security.html` | §1 reporting cross-reference | +| `README.md` "Notice of Cryptographic Software" | JaSypt, Bouncy Castle, JSch, OpenSwan, MySQL native encryption | §5 cryptography assumption, §8 P8 | +| `agent/conf/agent.properties` (`host`, `port`, `ssl.handshake.timeout`, …) | agent ↔ management server transport on `:8250` | §2 component table, §4 B5 | +| `server/src/main/java/com/cloud/api/ApiServer.java` `verifyRequest` (lines ~980–1156) | HMAC-SHA1 signature + `expires` enforcement (`enforce.post.requests.and.timestamps`) + constant-time compare | §8 P1, §8 P3, §5a "enforce.post.requests.and.timestamps" row, §11a "SHA1 / constant-time" entries | +| `server/src/main/java/com/cloud/api/ApiServlet.java` `getClientAddress` (lines 700–725) | forward-header gating by `proxy.cidr` / `proxy.header.names` when `proxy.header.verify=true` | §8 P6, §5a "proxy.header.verify" row | +| `server/src/main/java/com/cloud/api/ApiServlet.java` 2FA path (lines 360–582) | password + 2FA flow | §8 P2 | +| `framework/ca/.../CAService.java`, `plugins/ca/root-ca/.../RootCAProvider.java` | Root CA generated at first boot; agent enrolment via `SetupKeyStoreCommand` | §4 B5, §8 P5, §5a strictness/allow-expired rows | +| `plugins/ca/root-ca/.../RootCACustomTrustManager.java` | `authStrictness` and `allowExpiredCertificate` semantics | §5a, §8 P5 | +| `plugins/acl/{static,dynamic,project}-role-based` | RBAC backends | §8 P4 | +| `plugins/user-authenticators/{md5,sha256salted,pbkdf2,plain-text,ldap,saml2,oauth2}` | pluggable user auth; selection via `user.password.encoders.order` / `user.password.encoders.exclude` | §2 caller-roles row, §5a "user.password.encoders.*" rows, §10 item 7 | +| `plugins/user-two-factor-authenticators/{static-pin,totp}` | 2FA backends | §5a "enable.user.2fa / mandate.user.2fa", §10 item 8 | +| `framework/security/.../KeysManager.java`, `KeystoreManager.java` | `security.encryption.key`, `security.encryption.iv` (Hidden), application-secret JaSypt encryption | §8 P8, §5a, §10 item 6 | +| `agent/src/main/java/com/cloud/agent/Agent.java` `setupAgentKeystore` (lines ~793–916) | agent receives Root CA-signed cert via `SetupKeyStoreCommand` and imports it | §4 B5, §8 P5 | +| `server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java`, `ConsoleProxyPasswordBasedEncryptor.java` | signed encrypted console-proxy URL token | §4 B3, §8 P7 | +| `https://cloudstack.apache.org/security.html` (website) | canonical disclosure landing page | §1 reporting cross-reference (note: not accessible from the producer's network at draft time; verify content with PMC) | diff --git a/agent/conf/agent.properties b/agent/conf/agent.properties index b48848a43fca..4e36eff4d75f 100644 --- a/agent/conf/agent.properties +++ b/agent/conf/agent.properties @@ -316,7 +316,7 @@ iscsi.session.cleanup.enabled=false #vm.migrate.domain.retrieve.timeout=10 # This parameter specifies if the host must be rebooted when something goes wrong with the heartbeat. -#reboot.host.and.alert.management.on.heartbeat.timeout=true +#reboot.host.and.alert.management.on.heartbeat.timeout=false # Enables manually setting CPU's topology on KVM's VM. #enable.manually.setting.cpu.topology.on.kvm.vm=true @@ -488,3 +488,15 @@ iscsi.session.cleanup.enabled=false # Optional vCenter SHA1 thumbprint for VMware to KVM conversion via VDDK, passed as # -io vddk-thumbprint=. If unset, CloudStack computes it on the KVM host via openssl. #vddk.thumbprint= + +# Timeout (in seconds) for QCOW2 delta merge operations, mainly used for classic volume snapshots, disk-only VM snapshots on file-based storage, and the KBOSS plugin. +# If a value of 0 or less is informed, the default will be used. +# qcow2.delta.merge.timeout=259200 + +# Maximum number of backup validation jobs that can be executed at the same time. Values lower than 0 remove the limit, meaning that as many validations as possible will be done at +# the same time. +# backup.validation.max.concurrent.operations.per.host= + +# Maximum number of backup compression jobs that can be executed at the same time. Values lower than 0 remove the limit, meaning that as many compressions as possible will be +# done at the same time. +# backup.compression.max.concurrent.operations.per.host= diff --git a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java index 9156af1c7d48..e4775188d0ca 100644 --- a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java +++ b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java @@ -170,7 +170,8 @@ public class AgentProperties{ public static final Property CMDS_TIMEOUT = new Property<>("cmds.timeout", 7200); /** - * The timeout (in seconds) for the snapshot merge operation, mainly used for classic volume snapshots and disk-only VM snapshots on file-based storage.
+ * The timeout (in seconds) for QCOW2 delta merge operations, mainly used for classic volume snapshots, disk-only VM snapshots on file-based storage, and the KBOSS plugin. + * If a value of 0 or less is informed, the default will be used.
* This configuration is only considered if libvirt.events.enabled is also true.
* Data type: Integer.
* Default value: 259200 @@ -616,10 +617,10 @@ public class AgentProperties{ /** * This parameter specifies if the host must be rebooted when something goes wrong with the heartbeat.
* Data type: Boolean.
- * Default value: true + * Default value: false */ public static final Property REBOOT_HOST_AND_ALERT_MANAGEMENT_ON_HEARTBEAT_TIMEOUT - = new Property<>("reboot.host.and.alert.management.on.heartbeat.timeout", true); + = new Property<>("reboot.host.and.alert.management.on.heartbeat.timeout", false); /** * Enables manually setting CPU's topology on KVM's VM.
@@ -937,6 +938,33 @@ public Property getWorkers() { * */ public static final Property INCREMENTAL_SNAPSHOT_RETRY_REBASE_WAIT = new Property<>("incremental.snapshot.retry.rebase.wait", 60); + /** + * When set to true, executes modifymacip.sh (resolved via the + * network scripts directory) on VM NIC plug (VM start) and unplug (VM stop) to manage static + * ARP/NDP entries and host routes for VM interfaces.
+ * The script is invoked with:
+ *   add: -o add -b <bridge> -m <mac> [-4 <ipv4>] [-6 <ipv6>]
+ *   delete: -o delete -b <bridge> -m <mac>
+ * A bundled reference implementation is available at + * scripts/vm/network/vnet/modifymacip.sh.
+ * Set to false or leave unset to disable this feature.
+ * Data type: Boolean.
+ * Default value: false + */ + public static final Property VM_NETWORK_MACIP_STATIC = new Property<>("vm.network.macip.static", false, Boolean.class); + + + /** + * Maximum number of backup validation jobs that can be executed at the same time. Values lower than 0 remove the limit, meaning that as many validations as possible will be done at + * the same time. + */ + public static final Property BACKUP_VALIDATION_MAX_CONCURRENT_OPERATIONS_PER_HOST = new Property<>("backup.validation.max.concurrent.operations.per.host", null, Integer.class); + + /** + * Maximum number of backup compression jobs that can be executed at the same time. Values lower than 0 remove the limit, meaning that as many compressions as possible will be + * done at the same time. + */ + public static final Property BACKUP_COMPRESSION_MAX_CONCURRENT_OPERATIONS_PER_HOST = new Property<>("backup.compression.max.concurrent.operations.per.host", null, Integer.class); public static class Property { private String name; diff --git a/api/src/main/java/com/cloud/agent/api/to/DataObjectType.java b/api/src/main/java/com/cloud/agent/api/to/DataObjectType.java index 26294cfbb223..76a75e03ba55 100644 --- a/api/src/main/java/com/cloud/agent/api/to/DataObjectType.java +++ b/api/src/main/java/com/cloud/agent/api/to/DataObjectType.java @@ -19,5 +19,5 @@ package com.cloud.agent.api.to; public enum DataObjectType { - VOLUME, SNAPSHOT, TEMPLATE, ARCHIVE + VOLUME, SNAPSHOT, TEMPLATE, ARCHIVE, BACKUP } diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 23e79173bdfe..f7d13343d469 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -30,6 +30,9 @@ import org.apache.cloudstack.backup.BackupRepositoryService; import org.apache.cloudstack.config.Configuration; import org.apache.cloudstack.datacenter.DataCenterIpv4GuestSubnet; +import org.apache.cloudstack.dns.DnsRecord; +import org.apache.cloudstack.dns.DnsServer; +import org.apache.cloudstack.dns.DnsZone; import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionCustomAction; import org.apache.cloudstack.gpu.GpuCard; @@ -665,6 +668,7 @@ public class EventTypes { public static final String EVENT_VM_BACKUP_USAGE_METRIC = "BACKUP.USAGE.METRIC"; public static final String EVENT_VM_BACKUP_EDIT = "BACKUP.OFFERING.EDIT"; public static final String EVENT_VM_CREATE_FROM_BACKUP = "VM.CREATE.FROM.BACKUP"; + public static final String EVENT_SCREENSHOT_DOWNLOAD = "BACKUP.VALIDATION.SCREENSHOT.DOWNLOAD"; // external network device events public static final String EVENT_EXTERNAL_NVP_CONTROLLER_ADD = "PHYSICAL.NVPCONTROLLER.ADD"; @@ -872,6 +876,7 @@ public class EventTypes { public static final String EVENT_EXTENSION_DELETE = "EXTENSION.DELETE"; public static final String EVENT_EXTENSION_RESOURCE_REGISTER = "EXTENSION.RESOURCE.REGISTER"; public static final String EVENT_EXTENSION_RESOURCE_UNREGISTER = "EXTENSION.RESOURCE.UNREGISTER"; + public static final String EVENT_EXTENSION_RESOURCE_UPDATE = "EXTENSION.RESOURCE.UPDATE"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_ADD = "EXTENSION.CUSTOM.ACTION.ADD"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_UPDATE = "EXTENSION.CUSTOM.ACTION.UPDATE"; public static final String EVENT_EXTENSION_CUSTOM_ACTION_DELETE = "EXTENSION.CUSTOM.ACTION.DELETE"; @@ -883,6 +888,17 @@ public class EventTypes { public static final String EVENT_BACKUP_REPOSITORY_ADD = "BACKUP.REPOSITORY.ADD"; public static final String EVENT_BACKUP_REPOSITORY_UPDATE = "BACKUP.REPOSITORY.UPDATE"; + // DNS Framework Events + public static final String EVENT_DNS_SERVER_ADD = "DNS.SERVER.ADD"; + public static final String EVENT_DNS_SERVER_UPDATE = "DNS.SERVER.UPDATE"; + public static final String EVENT_DNS_SERVER_DELETE = "DNS.SERVER.DELETE"; + public static final String EVENT_DNS_ZONE_CREATE = "DNS.ZONE.CREATE"; + public static final String EVENT_DNS_ZONE_UPDATE = "DNS.ZONE.UPDATE"; + public static final String EVENT_DNS_ZONE_DELETE = "DNS.ZONE.DELETE"; + public static final String EVENT_DNS_RECORD_CREATE = "DNS.RECORD.CREATE"; + public static final String EVENT_DNS_RECORD_DELETE = "DNS.RECORD.DELETE"; + public static final String EVENT_DNS_NAME_COLLISION = "DNS.NAME.COLLISION"; + static { // TODO: need a way to force author adding event types to declare the entity details as well, with out braking @@ -1441,6 +1457,17 @@ public class EventTypes { // Backup Repository entityEventDetails.put(EVENT_BACKUP_REPOSITORY_ADD, BackupRepositoryService.class); entityEventDetails.put(EVENT_BACKUP_REPOSITORY_UPDATE, BackupRepositoryService.class); + + // DNS Framework Events + entityEventDetails.put(EVENT_DNS_SERVER_ADD, DnsServer.class); + entityEventDetails.put(EVENT_DNS_SERVER_UPDATE, DnsServer.class); + entityEventDetails.put(EVENT_DNS_SERVER_DELETE, DnsServer.class); + entityEventDetails.put(EVENT_DNS_ZONE_CREATE, DnsZone.class); + entityEventDetails.put(EVENT_DNS_ZONE_UPDATE, DnsZone.class); + entityEventDetails.put(EVENT_DNS_ZONE_DELETE, DnsZone.class); + entityEventDetails.put(EVENT_DNS_RECORD_CREATE, DnsRecord.class); + entityEventDetails.put(EVENT_DNS_RECORD_DELETE, DnsRecord.class); + } public static boolean isNetworkEvent(String eventType) { diff --git a/api/src/main/java/com/cloud/hypervisor/HypervisorGuru.java b/api/src/main/java/com/cloud/hypervisor/HypervisorGuru.java index 0c821b4e36c0..67db19b7cc54 100644 --- a/api/src/main/java/com/cloud/hypervisor/HypervisorGuru.java +++ b/api/src/main/java/com/cloud/hypervisor/HypervisorGuru.java @@ -20,6 +20,7 @@ import java.util.Map; import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.BackupProvider; import org.apache.cloudstack.framework.config.ConfigKey; import com.cloud.agent.api.Command; @@ -94,10 +95,10 @@ public interface HypervisorGuru extends Adapter { Map getClusterSettings(long vmId); VirtualMachine importVirtualMachineFromBackup(long zoneId, long domainId, long accountId, long userId, - String vmInternalName, Backup backup) throws Exception; + String vmInternalName, Backup backup, BackupProvider backupProvider) throws Exception; boolean attachRestoredVolumeToVirtualMachine(long zoneId, String location, Backup.VolumeInfo volumeInfo, - VirtualMachine vm, long poolId, Backup backup) throws Exception; + VirtualMachine vm, long poolId, Backup backup, BackupProvider backupProvider) throws Exception; /** * Will generate commands to migrate a vm to a pool. For now this will only work for stopped VMs on Vmware. * diff --git a/api/src/main/java/com/cloud/network/Network.java b/api/src/main/java/com/cloud/network/Network.java index e41eb880ffd5..2f0bcdd5ef9a 100644 --- a/api/src/main/java/com/cloud/network/Network.java +++ b/api/src/main/java/com/cloud/network/Network.java @@ -116,6 +116,7 @@ class Service { public static final Service NetworkACL = new Service("NetworkACL", Capability.SupportedProtocols); public static final Service Connectivity = new Service("Connectivity", Capability.DistributedRouter, Capability.RegionLevelVpc, Capability.StretchedL2Subnet, Capability.NoVlan, Capability.PublicAccess); + public static final Service CustomAction = new Service("CustomAction"); private final String name; private final Capability[] caps; @@ -207,6 +208,7 @@ public static class Provider { public static final Provider Nsx = new Provider("Nsx", false); public static final Provider Netris = new Provider("Netris", false); + public static final Provider NetworkExtension = new Provider("NetworkExtension", false, true); private final String name; private final boolean isExternal; @@ -250,11 +252,47 @@ public static Provider getProvider(String providerName) { return null; } + /** Private constructor for transient (non-registered) providers. */ + private Provider(String name) { + this.name = name; + this.isExternal = false; + this.needCleanupOnShutdown = true; + // intentionally NOT added to supportedProviders + } + + /** + * Creates a transient (non-registered) {@link Provider} with the given name. + * + *

The new instance is not added to {@code supportedProviders}, so it + * will never be returned by {@link #getProvider(String)} and will not pollute the + * global provider registry. Use this for dynamic / extension-backed providers + * whose names are only known at runtime (e.g. NetworkOrchestrator extensions).

+ * + * @param name the provider name (typically the extension name) + * @return a transient {@link Provider} instance with the given name + */ + public static Provider createTransientProvider(String name) { + return new Provider(name); + } + @Override public String toString() { return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) .append("name", name) .toString(); } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Provider)) return false; + Provider provider = (Provider) obj; + return this.name.equals(provider.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } } public static class Capability { diff --git a/api/src/main/java/com/cloud/network/NetworkModel.java b/api/src/main/java/com/cloud/network/NetworkModel.java index c212e6319eb4..7e1a07ebeb69 100644 --- a/api/src/main/java/com/cloud/network/NetworkModel.java +++ b/api/src/main/java/com/cloud/network/NetworkModel.java @@ -187,6 +187,8 @@ public interface NetworkModel { boolean canElementEnableIndividualServices(Provider provider); + boolean canElementEnableIndividualServicesByName(String providerName); + boolean areServicesSupportedInNetwork(long networkId, Service... services); boolean isNetworkSystem(Network network); @@ -237,6 +239,18 @@ public interface NetworkModel { String getDefaultGuestTrafficLabel(long dcId, HypervisorType vmware); + /** + * Resolves a provider name to a {@link Provider} instance. + * For known static providers, delegates to {@link Provider#getProvider(String)}. + * For dynamically-registered NetworkOrchestrator extension providers whose names + * are not in the static registry, returns a transient {@link Provider} with the + * given name so callers can still dispatch correctly. + * + * @param providerName the provider name from {@code ntwk_service_map} or similar + * @return a {@link Provider} instance, or {@code null} if not resolvable + */ + Provider resolveProvider(String providerName); + /** * @param providerName * @return diff --git a/api/src/main/java/com/cloud/network/NetworkRuleApplier.java b/api/src/main/java/com/cloud/network/NetworkRuleApplier.java index b9942e71eb26..69b712bc6ca2 100644 --- a/api/src/main/java/com/cloud/network/NetworkRuleApplier.java +++ b/api/src/main/java/com/cloud/network/NetworkRuleApplier.java @@ -21,8 +21,13 @@ import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.rules.FirewallRule; +import com.cloud.network.vpc.Vpc; public interface NetworkRuleApplier { - public boolean applyRules(Network network, FirewallRule.Purpose purpose, List rules) throws ResourceUnavailableException; + default boolean applyRules(Network network, FirewallRule.Purpose purpose, List rules) throws ResourceUnavailableException { + return applyRules(network, null, purpose, rules); + } + + boolean applyRules(Network network, Vpc vpc, FirewallRule.Purpose purpose, List rules) throws ResourceUnavailableException; } diff --git a/api/src/main/java/com/cloud/network/Networks.java b/api/src/main/java/com/cloud/network/Networks.java index 5f767686dc97..61a1c820723f 100644 --- a/api/src/main/java/com/cloud/network/Networks.java +++ b/api/src/main/java/com/cloud/network/Networks.java @@ -81,7 +81,11 @@ public String getValueFrom(URI uri) { return uri == null ? null : uri.getAuthority(); } }, - Vswitch("vs", String.class), LinkLocal(null, null), Vnet("vnet", Long.class), Storage("storage", Integer.class), Lswitch("lswitch", String.class) { + Vswitch("vs", String.class), + LinkLocal(null, null), + Vnet("vnet", Long.class), + Storage("storage", Integer.class), + Lswitch("lswitch", String.class) { @Override public URI toUri(T value) { try { @@ -99,7 +103,8 @@ public String getValueFrom(URI uri) { return uri == null ? null : uri.getSchemeSpecificPart(); } }, - Mido("mido", String.class), Pvlan("pvlan", String.class), + Mido("mido", String.class), + Pvlan("pvlan", String.class), Vxlan("vxlan", Long.class) { @Override public URI toUri(T value) { diff --git a/api/src/main/java/com/cloud/network/element/DnsServiceProvider.java b/api/src/main/java/com/cloud/network/element/DnsServiceProvider.java index 7abce537221c..2942e76965a7 100644 --- a/api/src/main/java/com/cloud/network/element/DnsServiceProvider.java +++ b/api/src/main/java/com/cloud/network/element/DnsServiceProvider.java @@ -33,4 +33,6 @@ boolean configDnsSupportForSubnet(Network network, NicProfile nic, VirtualMachin throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException; boolean removeDnsSupportForSubnet(Network network) throws ResourceUnavailableException; + + default boolean removeDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) throws ResourceUnavailableException { return true; } } diff --git a/api/src/main/java/com/cloud/network/element/FirewallServiceProvider.java b/api/src/main/java/com/cloud/network/element/FirewallServiceProvider.java index c091142d9353..6b0f932e8c22 100644 --- a/api/src/main/java/com/cloud/network/element/FirewallServiceProvider.java +++ b/api/src/main/java/com/cloud/network/element/FirewallServiceProvider.java @@ -21,14 +21,20 @@ import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Network; import com.cloud.network.rules.FirewallRule; +import com.cloud.network.vpc.Vpc; public interface FirewallServiceProvider extends NetworkElement { /** - * Apply rules - * @param network - * @param rules - * @return - * @throws ResourceUnavailableException + * Apply firewall rules in a network context. */ - boolean applyFWRules(Network network, List rules) throws ResourceUnavailableException; + default boolean applyFWRules(Network network, List rules) throws ResourceUnavailableException { + return false; + } + + /** + * Apply firewall rules in a VPC context. + */ + default boolean applyFWRulesInVPC(Vpc vpc, List rules) throws ResourceUnavailableException { + return false; + } } diff --git a/api/src/main/java/com/cloud/network/element/NetworkElement.java b/api/src/main/java/com/cloud/network/element/NetworkElement.java index cb0fc2fca981..67be7b9ba2e2 100644 --- a/api/src/main/java/com/cloud/network/element/NetworkElement.java +++ b/api/src/main/java/com/cloud/network/element/NetworkElement.java @@ -146,4 +146,8 @@ boolean shutdownProviderInstances(PhysicalNetworkServiceProvider provider, Reser * @return true/false */ boolean verifyServicesCombination(Set services); + + default boolean rollingRestartSupported() { + return true; + } } diff --git a/api/src/main/java/com/cloud/network/rules/FirewallRule.java b/api/src/main/java/com/cloud/network/rules/FirewallRule.java index 369c6aa57eb8..38ba009163ce 100644 --- a/api/src/main/java/com/cloud/network/rules/FirewallRule.java +++ b/api/src/main/java/com/cloud/network/rules/FirewallRule.java @@ -69,7 +69,9 @@ enum TrafficType { State getState(); - long getNetworkId(); + Long getNetworkId(); + + Long getVpcId(); Long getSourceIpAddressId(); diff --git a/api/src/main/java/com/cloud/storage/Storage.java b/api/src/main/java/com/cloud/storage/Storage.java index ddf5978497ba..3511b4e88cb9 100644 --- a/api/src/main/java/com/cloud/storage/Storage.java +++ b/api/src/main/java/com/cloud/storage/Storage.java @@ -35,7 +35,8 @@ public static enum ImageFormat { VDI(true, true, false, "vdi"), TAR(false, false, false, "tar"), ZIP(false, false, false, "zip"), - DIR(false, false, false, "dir"); + DIR(false, false, false, "dir"), + PNG(false, false, false, "png"); private final boolean supportThinProvisioning; private final boolean supportSparse; diff --git a/api/src/main/java/com/cloud/storage/Volume.java b/api/src/main/java/com/cloud/storage/Volume.java index c7a13d5780d0..89298e04587f 100644 --- a/api/src/main/java/com/cloud/storage/Volume.java +++ b/api/src/main/java/com/cloud/storage/Volume.java @@ -60,7 +60,9 @@ enum State { UploadError(false, "Volume upload encountered some error"), UploadAbandoned(false, "Volume upload is abandoned since the upload was never initiated within a specified time"), Attaching(true, "The volume is attaching to a VM from Ready state."), - Restoring(true, "The volume is being restored from backup."); + Restoring(true, "The volume is being restored from backup."), + Consolidating(true, "The volume is being flattened."), + RestoreError(false, "The volume restore encountered an error."); boolean _transitional; @@ -153,6 +155,10 @@ public String getDescription() { s_fsm.addTransition(new StateMachine2.Transition(Destroy, Event.RestoreRequested, Restoring, null)); s_fsm.addTransition(new StateMachine2.Transition(Restoring, Event.RestoreSucceeded, Ready, null)); s_fsm.addTransition(new StateMachine2.Transition(Restoring, Event.RestoreFailed, Ready, null)); + s_fsm.addTransition(new StateMachine2.Transition<>(Ready, Event.ConsolidationRequested, Consolidating, null)); + s_fsm.addTransition(new StateMachine2.Transition<>(Consolidating, Event.OperationSucceeded, Ready, null)); + s_fsm.addTransition(new StateMachine2.Transition<>(Consolidating, Event.OperationFailed, RestoreError, null)); + s_fsm.addTransition(new StateMachine2.Transition<>(RestoreError, Event.RestoreFailed, RestoreError, null)); } } @@ -179,7 +185,8 @@ enum Event { OperationTimeout, RestoreRequested, RestoreSucceeded, - RestoreFailed; + RestoreFailed, + ConsolidationRequested } /** diff --git a/api/src/main/java/com/cloud/storage/VolumeApiService.java b/api/src/main/java/com/cloud/storage/VolumeApiService.java index d287cc335eed..372eb0385618 100644 --- a/api/src/main/java/com/cloud/storage/VolumeApiService.java +++ b/api/src/main/java/com/cloud/storage/VolumeApiService.java @@ -114,7 +114,7 @@ Volume allocVolume(long ownerId, Long zoneId, Long diskOfferingId, Long vmId, Lo Volume attachVolumeToVM(AttachVolumeCmd command); - Volume attachVolumeToVM(Long vmId, Long volumeId, Long deviceId, Boolean allowAttachForSharedFS); + Volume attachVolumeToVM(Long vmId, Long volumeId, Long deviceId, Boolean allowAttachForSharedFS, boolean allowAttachOnRestoring); Volume detachVolumeViaDestroyVM(long vmId, long volumeId); @@ -189,7 +189,7 @@ Volume updateVolume(long volumeId, String path, String state, Long storageId, boolean validateConditionsToReplaceDiskOfferingOfVolume(Volume volume, DiskOffering newDiskOffering, StoragePool destPool); - Volume destroyVolume(long volumeId, Account caller, boolean expunge, boolean forceExpunge); + Volume destroyVolume(long volumeId, Account caller, boolean expunge, boolean forceExpunge, Boolean countDisplayFalseInResourceCount); void destroyVolume(long volumeId); diff --git a/api/src/main/java/com/cloud/user/ResourceLimitService.java b/api/src/main/java/com/cloud/user/ResourceLimitService.java index 9c493fb383c9..89128f87829e 100644 --- a/api/src/main/java/com/cloud/user/ResourceLimitService.java +++ b/api/src/main/java/com/cloud/user/ResourceLimitService.java @@ -254,14 +254,14 @@ public interface ResourceLimitService { void updateTaggedResourceLimitsAndCountsForAccounts(List responses, String tag); void updateTaggedResourceLimitsAndCountsForDomains(List responses, String tag); void checkVolumeResourceLimit(Account owner, Boolean display, Long size, DiskOffering diskOffering, List reservations) throws ResourceAllocationException; - List getResourceLimitStorageTagsForResourceCountOperation(Boolean display, DiskOffering diskOffering); + List getResourceLimitStorageTagsForResourceCountOperation(Boolean display, DiskOffering diskOffering, Boolean enforceResourceLimitOnDisplayFalse); void checkVolumeResourceLimitForDiskOfferingChange(Account owner, Boolean display, Long currentSize, Long newSize, DiskOffering currentOffering, DiskOffering newOffering, List reservations) throws ResourceAllocationException; void checkPrimaryStorageResourceLimit(Account owner, Boolean display, Long size, DiskOffering diskOffering, List reservations) throws ResourceAllocationException; void incrementVolumeResourceCount(long accountId, Boolean display, Long size, DiskOffering diskOffering); - void decrementVolumeResourceCount(long accountId, Boolean display, Long size, DiskOffering diskOffering); + void decrementVolumeResourceCount(long accountId, Boolean display, Long size, DiskOffering diskOffering, Boolean countDisplayFalseInResourceCount); void updateVmResourceCountForTemplateChange(long accountId, Boolean display, ServiceOffering offering, VirtualMachineTemplate currentTemplate, VirtualMachineTemplate newTemplate); @@ -276,8 +276,8 @@ void updateVolumeResourceCountForDiskOfferingChange(long accountId, Boolean disp void incrementVolumePrimaryStorageResourceCount(long accountId, Boolean display, Long size, DiskOffering diskOffering); void decrementVolumePrimaryStorageResourceCount(long accountId, Boolean display, Long size, DiskOffering diskOffering); void checkVmResourceLimit(Account owner, Boolean display, ServiceOffering serviceOffering, VirtualMachineTemplate template, List reservations) throws ResourceAllocationException; - void incrementVmResourceCount(long accountId, Boolean display, ServiceOffering serviceOffering, VirtualMachineTemplate template); - void decrementVmResourceCount(long accountId, Boolean display, ServiceOffering serviceOffering, VirtualMachineTemplate template); + void incrementVmResourceCount(long accountId, Boolean display, ServiceOffering serviceOffering, VirtualMachineTemplate template, Boolean countDisplayFalseInResourceLimit); + void decrementVmResourceCount(long accountId, Boolean display, ServiceOffering serviceOffering, VirtualMachineTemplate template, Boolean countDisplayFalseInResourceCount); void checkVmResourceLimitsForServiceOfferingChange(Account owner, Boolean display, Long currentCpu, Long newCpu, Long currentMemory, Long newMemory, ServiceOffering currentOffering, ServiceOffering newOffering, VirtualMachineTemplate template, List reservations) throws ResourceAllocationException; diff --git a/api/src/main/java/com/cloud/vm/Nic.java b/api/src/main/java/com/cloud/vm/Nic.java index cc0b294205ca..3722e5769c92 100644 --- a/api/src/main/java/com/cloud/vm/Nic.java +++ b/api/src/main/java/com/cloud/vm/Nic.java @@ -33,6 +33,11 @@ * Nic represents one nic on the VM. */ public interface Nic extends Identity, InternalIdentity { + + interface Topics { + String NIC_LIFECYCLE = "nic.lifecycle"; + } + enum Event { ReservationRequested, ReleaseRequested, CancelRequested, OperationCompleted, OperationFailed, } diff --git a/api/src/main/java/com/cloud/vm/NicProfile.java b/api/src/main/java/com/cloud/vm/NicProfile.java index d3ed7b4b87d2..54a32bbcb181 100644 --- a/api/src/main/java/com/cloud/vm/NicProfile.java +++ b/api/src/main/java/com/cloud/vm/NicProfile.java @@ -463,6 +463,6 @@ public String toString() { return String.format("NicProfile %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( this, "id", "uuid", "vmId", "deviceId", - "broadcastUri", "reservationId", "iPv4Address")); + "broadcastType", "broadcastUri", "reservationId", "iPv4Address")); } } diff --git a/api/src/main/java/com/cloud/vm/UserVmService.java b/api/src/main/java/com/cloud/vm/UserVmService.java index ffa00734d575..5864e91cd7f4 100644 --- a/api/src/main/java/com/cloud/vm/UserVmService.java +++ b/api/src/main/java/com/cloud/vm/UserVmService.java @@ -75,10 +75,11 @@ public interface UserVmService { * Destroys one virtual machine * * @param cmd the API Command Object containg the parameters to use for this service action + * @param checkExpunge * @throws ConcurrentOperationException * @throws ResourceUnavailableException */ - UserVm destroyVm(DestroyVMCmd cmd) throws ResourceUnavailableException, ConcurrentOperationException; + UserVm destroyVm(DestroyVMCmd cmd, boolean checkExpunge) throws ResourceUnavailableException, ConcurrentOperationException; /** * Destroys one virtual machine diff --git a/api/src/main/java/com/cloud/vm/VirtualMachine.java b/api/src/main/java/com/cloud/vm/VirtualMachine.java index 41c9a864c9d0..3adcc85d28a1 100644 --- a/api/src/main/java/com/cloud/vm/VirtualMachine.java +++ b/api/src/main/java/com/cloud/vm/VirtualMachine.java @@ -58,7 +58,10 @@ public enum State { Error(false, "VM is in error"), Unknown(false, "VM state is unknown."), Shutdown(false, "VM state is shutdown from inside"), - Restoring(true, "VM is being restored from backup"); + Restoring(true, "VM is being restored from backup"), + BackingUp(true, "VM is being backed up"), + BackupError(false, "VM backup is in an inconsistent state. Operator should analyse the logs and restore the VM"), + RestoreError(false, "VM restore left the VM in an inconsistent state. Operator should analyse the logs and restore the VM"); private final boolean _transitional; String _description; @@ -134,6 +137,14 @@ public static StateMachine2 getStat s_fsm.addTransition(new Transition(State.Destroyed, Event.RestoringRequested, State.Restoring, null)); s_fsm.addTransition(new Transition(State.Restoring, Event.RestoringSuccess, State.Stopped, null)); s_fsm.addTransition(new Transition(State.Restoring, Event.RestoringFailed, State.Stopped, null)); + s_fsm.addTransition(new Transition<>(State.Running, Event.BackupRequested, State.BackingUp, null)); + s_fsm.addTransition(new Transition<>(State.Stopped, Event.BackupRequested, State.BackingUp, null)); + s_fsm.addTransition(new Transition<>(State.BackingUp, Event.BackupSucceededRunning, State.Running, null)); + s_fsm.addTransition(new Transition<>(State.BackingUp, Event.BackupSucceededStopped, State.Stopped, null)); + s_fsm.addTransition(new Transition<>(State.BackingUp, Event.OperationFailedToError, State.BackupError, null)); + s_fsm.addTransition(new Transition<>(State.BackingUp, Event.OperationFailedToRunning, State.Running, null)); + s_fsm.addTransition(new Transition<>(State.BackingUp, Event.OperationFailedToStopped, State.Stopped, null)); + s_fsm.addTransition(new Transition(State.RestoreError, Event.RestoringFailed, State.RestoreError, null)); s_fsm.addTransition(new Transition(State.Starting, VirtualMachine.Event.FollowAgentPowerOnReport, State.Running, Arrays.asList(new Impact[]{Impact.USAGE}))); s_fsm.addTransition(new Transition(State.Stopping, VirtualMachine.Event.FollowAgentPowerOnReport, State.Running, null)); @@ -212,6 +223,8 @@ public enum Event { ExpungeOperation, OperationSucceeded, OperationFailed, + OperationFailedToRunning, + OperationFailedToStopped, OperationFailedToError, OperationRetry, AgentReportShutdowned, @@ -221,6 +234,10 @@ public enum Event { RestoringRequested, RestoringFailed, RestoringSuccess, + BackupRequested, + BackupSucceededStopped, + BackupSucceededRunning, + FinalizedBackupChain, // added for new VMSync logic FollowAgentPowerOnReport, diff --git a/api/src/main/java/com/cloud/vm/VmDetailConstants.java b/api/src/main/java/com/cloud/vm/VmDetailConstants.java index 33cc6da70812..877df55c6d67 100644 --- a/api/src/main/java/com/cloud/vm/VmDetailConstants.java +++ b/api/src/main/java/com/cloud/vm/VmDetailConstants.java @@ -136,4 +136,14 @@ public interface VmDetailConstants { String ACTIVE_CHECKPOINT_CREATE_TIME = "active.checkpoint.create.time"; String LAST_CHECKPOINT_ID = "last.checkpoint.id"; String LAST_CHECKPOINT_CREATE_TIME = "last.checkpoint.create.time"; + + // KBOSS specific + String LINKED_VOLUMES_SECONDARY_STORAGE_UUIDS = "linkedVolumesSecondaryStorageUuids"; + String VALIDATION_COMMAND = "backupValidationCommand"; + String VALIDATION_COMMAND_ARGUMENTS = "backupValidationCommandArguments"; + String VALIDATION_COMMAND_EXPECTED_RESULT = "backupValidationCommandExpectedResult"; + String VALIDATION_COMMAND_TIMEOUT = "backupValidationCommandTimeout"; + String VALIDATION_SCREENSHOT_WAIT = "backupValidationScreenshotWait"; + String VALIDATION_BOOT_TIMEOUT = "backupValidationBootTimeout"; + String LAST_KNOWN_STATE = "last_known_state"; } diff --git a/api/src/main/java/org/apache/cloudstack/alert/AlertService.java b/api/src/main/java/org/apache/cloudstack/alert/AlertService.java index fcc87908bd5d..a9c2abc11ce7 100644 --- a/api/src/main/java/org/apache/cloudstack/alert/AlertService.java +++ b/api/src/main/java/org/apache/cloudstack/alert/AlertService.java @@ -83,6 +83,9 @@ private AlertType(short type, String name, boolean isDefault) { public static final AlertType ALERT_TYPE_VPN_GATEWAY_OBSOLETE_PARAMETERS = new AlertType((short)34, "ALERT.S2S.VPN.GATEWAY.OBSOLETE.PARAMETERS", true, true); public static final AlertType ALERT_TYPE_BACKUP_STORAGE = new AlertType(Capacity.CAPACITY_TYPE_BACKUP_STORAGE, "ALERT.STORAGE.BACKUP", true); public static final AlertType ALERT_TYPE_OBJECT_STORAGE = new AlertType(Capacity.CAPACITY_TYPE_OBJECT_STORAGE, "ALERT.STORAGE.OBJECT", true); + public static final AlertType ALERT_TYPE_BACKUP_VALIDATION_FAILED = new AlertType((short)35, "ALERT.BACKUP.VALIDATION.FAILED", true, true); + public static final AlertType ALERT_TYPE_BACKUP_VALIDATION_UNABLE_TO_VALIDATE = new AlertType((short)36, "ALERT.BACKUP.VALIDATION.UNABLE.TO.VALIDATE", true, true); + public static final AlertType ALERT_TYPE_BACKUP_VALIDATION_CLEANUP_FAILED = new AlertType((short)37, "ALERT.BACKUP.VALIDATION.CLEANUP_FAILED", true, true); public short getType() { return type; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 15025621a33c..ac6acdf42516 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -63,6 +63,7 @@ public class ApiConstants { public static final String BACKUP_LIMIT = "backuplimit"; public static final String BACKUP_OFFERING_NAME = "backupofferingname"; public static final String BACKUP_OFFERING_ID = "backupofferingid"; + public static final String BACKUP_OFFERING_DETAILS = "backupofferingdetails"; public static final String BACKUP_STORAGE_AVAILABLE = "backupstorageavailable"; public static final String BACKUP_STORAGE_LIMIT = "backupstoragelimit"; public static final String BACKUP_STORAGE_TOTAL = "backupstoragetotal"; @@ -286,6 +287,7 @@ public class ApiConstants { public static final String HEALTH = "health"; public static final String HEADERS = "headers"; public static final String HIDE_IP_ADDRESS_USAGE = "hideipaddressusage"; + public static final String HISTORY = "history"; public static final String HOST_ID = "hostid"; public static final String HOST_IDS = "hostids"; public static final String HOST_IP = "hostip"; @@ -533,6 +535,7 @@ public class ApiConstants { public static final String QUALIFIERS = "qualifiers"; public static final String QUERY_FILTER = "queryfilter"; public static final String QUIESCE_VM = "quiescevm"; + public static final String QUICK_RESTORE = "quickrestore"; public static final String SCHEDULE = "schedule"; public static final String SCHEDULE_ID = "scheduleid"; public static final String SCOPE = "scope"; @@ -583,6 +586,8 @@ public class ApiConstants { public static final String STATE = "state"; public static final String STATS = "stats"; public static final String STATUS = "status"; + public static final String COMPRESSION_STATUS = "compressionstatus"; + public static final String VALIDATION_STATUS = "validationstatus"; public static final String STORAGE_TYPE = "storagetype"; public static final String STORAGE_POLICY = "storagepolicy"; public static final String STORAGE_MOTION_ENABLED = "storagemotionenabled"; @@ -681,6 +686,7 @@ public class ApiConstants { public static final String ETCD_SERVICE_OFFERING_NAME = "etcdofferingname"; public static final String REMOVE_VLAN = "removevlan"; public static final String VLAN_ID = "vlanid"; + public static final String ISOLATED = "isolated"; public static final String ISOLATED_PVLAN = "isolatedpvlan"; public static final String ISOLATED_PVLAN_TYPE = "isolatedpvlantype"; public static final String ISOLATION_URI = "isolationuri"; @@ -1205,6 +1211,7 @@ public class ApiConstants { public static final String CLEAN_UP_EXTRA_CONFIG = "cleanupextraconfig"; public static final String CLEAN_UP_PARAMETERS = "cleanupparameters"; public static final String VIRTUAL_SIZE = "virtualsize"; + public static final String UNCOMPRESSED_SIZE = "uncompressedsize"; public static final String NETSCALER_CONTROLCENTER_ID = "netscalercontrolcenterid"; public static final String NETSCALER_SERVICEPACKAGE_ID = "netscalerservicepackageid"; public static final String FETCH_ROUTER_HEALTH_CHECK_RESULTS = "fetchhealthcheckresults"; @@ -1341,7 +1348,7 @@ public class ApiConstants { public static final String IMPORT_SOURCE = "importsource"; public static final String TEMP_PATH = "temppath"; public static final String HEURISTIC_RULE = "heuristicrule"; - public static final String HEURISTIC_TYPE_VALID_OPTIONS = "Valid options are: ISO, SNAPSHOT, TEMPLATE and VOLUME."; + public static final String HEURISTIC_TYPE_VALID_OPTIONS = "Valid options are: ISO, SNAPSHOT, BACKUP, TEMPLATE and VOLUME."; public static final String MANAGEMENT = "management"; public static final String IS_VNF = "isvnf"; public static final String VNF_NICS = "vnfnics"; @@ -1386,6 +1393,38 @@ public class ApiConstants { " a VR, CloudStack will use the same MAC address for the public NIC of all VRs. Otherwise, if \"false\", new public NICs will always have " + " a new MAC address."; + // DNS provider related + public static final String NAME_SERVERS = "nameservers"; + public static final String DNS_USER_NAME = "dnsusername"; + public static final String DNS_API_KEY = "dnsapikey"; + public static final String DNS_ZONE_ID = "dnszoneid"; + public static final String DNS_ZONE = "dnszone"; + public static final String DNS_RECORD = "dnsrecord"; + public static final String DNS_SUB_DOMAIN = "dnssubdomain"; + public static final String DNS_SERVER_ID = "dnsserverid"; + public static final String CONTENT = "content"; + public static final String CONTENTS = "contents"; + public static final String PUBLIC_DOMAIN_SUFFIX = "publicdomainsuffix"; + public static final String AUTHORITATIVE = "authoritative"; + public static final String KIND = "kind"; + public static final String DNS_SEC = "dnssec"; + public static final String TTL = "ttl"; + public static final String CHANGE_TYPE = "changetype"; + public static final String RECORDS = "records"; + public static final String RR_SETS = "rrsets"; + public static final String X_API_KEY = "X-API-Key"; + public static final String DISABLED = "disabled"; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String NATIVE_ZONE = "Native"; + public static final String NIC_DNS_NAME = "nicdnsname"; + public static final String TIME_STAMP = "timestamp"; + public static final String INSTANCE_ID = "instanceId"; + public static final String OLD_STATE = "oldState"; + public static final String NEW_STATE = "newState"; + public static final String OLD_HOST_NAME = "oldHostName"; + public static final String EXISTING = "existing"; + public static final String UNMANAGE = "unmanage"; + public static final String PARAMETER_DESCRIPTION_ACTIVATION_RULE = "Quota tariff's activation rule. It can receive a JS script that results in either " + "a boolean or a numeric value: if it results in a boolean value, the tariff value will be applied according to the result; if it results in a numeric value, the " + "numeric value will be applied; if the result is neither a boolean nor a numeric value, the tariff will not be applied. If the rule is not informed, the tariff " + @@ -1405,6 +1444,10 @@ public class ApiConstants { public static final String VMWARE_DC = "vmwaredc"; + public static final String PARAMETER_DESCRIPTION_ISOLATED_BACKUPS = "Whether the backup will be isolated, defaults to false. " + + "Isolated backups are always created as full backups in independent chains. Therefore, they will never depend on any existing backup chain " + + "and no backup chain will depend on them. Currently only supported for the KBOSS provider."; + public static final String CSS = "css"; public static final String JSON_CONFIGURATION = "jsonconfiguration"; @@ -1426,6 +1469,27 @@ public class ApiConstants { public static final String OBSOLETE_PARAMETERS = "obsoleteparameters"; public static final String EXCLUDED_PARAMETERS = "excludedparameters"; + public static final String COMPRESS = "compress"; + + public static final String VALIDATE = "validate"; + + public static final String VALIDATION_STEPS = "validationsteps"; + + public static final String ALLOW_QUICK_RESTORE = "allowquickrestore"; + + public static final String ALLOW_EXTRACT_FILE = "allowextractfile"; + + public static final String BACKUP_CHAIN_SIZE = "backupchainsize"; + + public static final String COMPRESSION_LIBRARY = "compressionlibrary"; + public static final String ATTEMPTS = "attempts"; + + public static final String EXECUTING = "executing"; + + public static final String SCHEDULED = "scheduled"; + public static final String SCHEDULED_DATE = "scheduleddate"; + public static final String BACKUP_PROVIDER = "backupprovider"; + /** * This enum specifies IO Drivers, each option controls specific policies on I/O. * Qemu guests support "threads" and "native" options Since 0.8.8 ; "io_uring" is supported Since 6.3.0 (QEMU 5.0). @@ -1467,7 +1531,7 @@ public String toString() { } public enum HostDetails { - all, capacity, events, stats, min; + all, capacity, core, events, stats, min; } public enum VMDetails { diff --git a/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java b/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java index 00b1bc310d5a..483fa83630ad 100644 --- a/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java @@ -40,6 +40,7 @@ import org.apache.cloudstack.alert.AlertService; import org.apache.cloudstack.annotation.AnnotationService; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsProviderManager; import org.apache.cloudstack.gpu.GpuService; import org.apache.cloudstack.network.RoutedIpv4Manager; import org.apache.cloudstack.network.lb.ApplicationLoadBalancerService; @@ -232,6 +233,9 @@ public static enum CommandType { @Inject public RoutedIpv4Manager routedIpv4Manager; + @Inject + public DnsProviderManager dnsProviderManager; + public abstract void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java index 1c832b7217ef..9b42be137314 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java @@ -216,6 +216,8 @@ public abstract class NetworkOfferingBaseCmd extends BaseCmd { description = "the routing mode for the network offering. Supported types are: Static or Dynamic.") private String routingMode; + private Map sourceDetailsMap; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -430,9 +432,8 @@ public Map getServiceCapabilities(Network.Service se public Map getDetails() { if (details == null || details.isEmpty()) { - return null; + return sourceDetailsMap; } - Collection paramsCollection = details.values(); Object objlist[] = paramsCollection.toArray(); Map params = (Map) (objlist[0]); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CreateVMFromBackupCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CreateVMFromBackupCmdByAdmin.java index d95f17ef304c..280e4a8bab77 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CreateVMFromBackupCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/CreateVMFromBackupCmdByAdmin.java @@ -45,6 +45,8 @@ public class CreateVMFromBackupCmdByAdmin extends CreateVMFromBackupCmd implemen @Parameter(name = ApiConstants.CLUSTER_ID, type = CommandType.UUID, entityType = ClusterResponse.class, description = "destination Cluster ID to deploy the VM to - parameter available for root admin only", since = "4.21") private Long clusterId; + private String instanceType; + public Long getPodId() { return podId; } @@ -52,4 +54,17 @@ public Long getPodId() { public Long getClusterId() { return clusterId; } + + @Override + public String getInstanceType() { + return instanceType; + } + + public CreateVMFromBackupCmdByAdmin(){} + + public CreateVMFromBackupCmdByAdmin(String hypervisor, String instanceType) { + this.displayVm = false; + this.hypervisor = hypervisor; + this.instanceType = instanceType; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java index 0840b4ce6f99..de90fee102de 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/volume/DestroyVolumeCmdByAdmin.java @@ -40,7 +40,7 @@ public class DestroyVolumeCmdByAdmin extends DestroyVolumeCmd implements AdminCm @Override public void execute() { CallContext.current().setEventDetails("Volume Id: " + getId()); - Volume result = _volumeService.destroyVolume(getId(), CallContext.current().getCallingAccount(), getExpunge(), false); + Volume result = _volumeService.destroyVolume(getId(), CallContext.current().getCallingAccount(), getExpunge(), false, null); if (result != null) { VolumeResponse response = _responseGenerator.createVolumeResponse(ResponseView.Full, result); response.setResponseName(getCommandName()); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupCmd.java index 8a4053aa15da..5b7bd518df0e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupCmd.java @@ -75,12 +75,18 @@ public class CreateBackupCmd extends BaseAsyncCreateCmd { @Parameter(name = ApiConstants.QUIESCE_VM, type = CommandType.BOOLEAN, required = false, - description = "Quiesce the instance before checkpointing the disks for backup. Applicable only to NAS backup provider. " + + description = "Quiesce the instance before checkpointing the disks for backup. Applicable only to NAS and KBOSS backup providers. " + "The filesystem is frozen before the backup starts and thawed immediately after. " + "Requires the instance to have the QEMU Guest Agent installed and running.", since = "4.21.0") private Boolean quiesceVM; + @Parameter(name = ApiConstants.ISOLATED, + type = CommandType.BOOLEAN, + description = ApiConstants.PARAMETER_DESCRIPTION_ISOLATED_BACKUPS, + since = "4.23.0") + private boolean isolated; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -101,6 +107,10 @@ public Boolean getQuiesceVM() { return quiesceVM; } + public boolean isIsolated() { + return isolated; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupOfferingCmd.java new file mode 100644 index 000000000000..c5d29b615439 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupOfferingCmd.java @@ -0,0 +1,185 @@ +// 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 org.apache.cloudstack.api.command.user.backup; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.BackupOfferingResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.BackupManager; +import org.apache.cloudstack.backup.BackupOffering; + + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = "createBackupOffering", description = "Creates a backup offering", responseObject = BackupOfferingResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.23.0") +public class CreateBackupOfferingCmd extends BaseCmd { + + @Inject + protected BackupManager backupManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "Backup offering name.", required = true) + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, required = true, + description = "The description of the backup offering") + private String description; + + @Parameter(name = ApiConstants.COMPRESS, type = CommandType.BOOLEAN, description = "Whether the backups should be compressed or not.") + private Boolean compress; + + @Parameter(name = ApiConstants.VALIDATE, type = CommandType.BOOLEAN, description = "Whether the backups should be validated or not.") + private Boolean validate; + + @Parameter(name = ApiConstants.VALIDATION_STEPS, type = CommandType.STRING, description = "Which validation steps should be performed. Accepts a comma-separated list of " + + "steps. Accepted values are: wait_for_boot, screenshot and execute_command.") + private String validationSteps; + + @Parameter(name = ApiConstants.ALLOW_QUICK_RESTORE, type = CommandType.BOOLEAN, description = "Whether quick restore is enabled for the backups or not.") + private Boolean allowQuickRestore; + + @Parameter(name = ApiConstants.ALLOW_EXTRACT_FILE, type = CommandType.BOOLEAN, description = "Whether files may be extracted from backups or not.") + private Boolean allowExtractFile; + + @Parameter(name = ApiConstants.BACKUP_CHAIN_SIZE, type = CommandType.INTEGER, description = "Backup chain size for backups created with this offering.") + private Integer backupChainSize; + + @Parameter(name = ApiConstants.COMPRESSION_LIBRARY, type = CommandType.STRING, description = "Compression library, for offerings that support compression. Accepted values " + + "are zstd and zlib. By default, zstd is used for images that support it. If the image only supports zlib, it will be used regardless of this parameter.") + private String compressionLibrary; + + @Parameter(name = ApiConstants.ZONE_ID, type = BaseCmd.CommandType.UUID, entityType = ZoneResponse.class, + description = "Restrict the backup offering to the Zone identified by this ID.", required = true) + private Long zoneId; + + @Parameter(name = ApiConstants.ALLOW_USER_DRIVEN_BACKUPS, type = CommandType.BOOLEAN, + description = "Whether users are allowed to create ad-hoc backups and backup schedules when using this offering.", required = true) + private Boolean userDrivenBackups; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.LIST, collectionType = CommandType.UUID, entityType = DomainResponse.class, + description = "Restrict the backup offering to the Domains identified by these IDs.") + private List domainIds; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + public boolean isCompress() { + return Boolean.TRUE.equals(compress); + } + + public boolean isValidate() { + return Boolean.TRUE.equals(validate); + } + + public boolean isAllowQuickRestore() { + return Boolean.TRUE.equals(allowQuickRestore); + } + + public boolean isAllowExtractFile() { + return Boolean.TRUE.equals(allowExtractFile); + } + + public Integer getBackupChainSize() { + return backupChainSize; + } + + public Backup.CompressionLibrary getCompressionLibrary() { + if (compressionLibrary == null) { + return null; + } + try { + return Backup.CompressionLibrary.valueOf(compressionLibrary); + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException(String.format("Invalid compression library, accepted values are zstd and zlib, received [%s].", compressionLibrary)); + } + } + + public String getValidationSteps() { + if (validationSteps == null) { + return Backup.ValidationSteps.screenshot.name(); + } + StringBuilder sb = new StringBuilder(); + for (String step : validationSteps.strip().split(",")) { + try { + Backup.ValidationSteps enumStep = Backup.ValidationSteps.valueOf(step); + sb.append(enumStep.name()); + sb.append(","); + } catch (IllegalArgumentException ex) { + logger.error("Invalid validation step informed [{}].", step, ex); + throw new InvalidParameterValueException(String.format("Invalid validation step [%s] informed. Accepted values are: wait_for_boot, screenshot and execute_command.", step)); + } + } + sb.deleteCharAt(sb.lastIndexOf(",")); + return sb.toString(); + } + + public String getDescription() { + return description; + } + + public Long getZoneId() { + return zoneId; + } + + public List getDomainIds() { + return domainIds; + } + + public Boolean getUserDrivenBackups() { + return userDrivenBackups; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, + NetworkRuleConflictException { + BackupOffering offering = backupManager.createBackupOffering(this); + BackupOfferingResponse response = _responseGenerator.createBackupOfferingResponse(offering); + response.setResponseName(getCommandName()); + this.setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return 0; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupScheduleCmd.java index f6e17a2b3908..812d78425e89 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupScheduleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/CreateBackupScheduleCmd.java @@ -82,12 +82,18 @@ public class CreateBackupScheduleCmd extends BaseCmd { @Parameter(name = ApiConstants.QUIESCE_VM, type = CommandType.BOOLEAN, required = false, - description = "Quiesce the Instance before checkpointing the disks for backup. Applicable only to NAS backup provider. " + + description = "Quiesce the Instance before checkpointing the disks for backup. Applicable only to NAS and KBOSS backup providers. " + "The filesystem is frozen before the backup starts and thawed immediately after. " + "Requires the instance to have the QEMU Guest Agent installed and running.", since = "4.21.0") private Boolean quiesceVM; + @Parameter(name = ApiConstants.ISOLATED, + type = CommandType.BOOLEAN, + description = ApiConstants.PARAMETER_DESCRIPTION_ISOLATED_BACKUPS, + since = "4.23.0") + private boolean isolated; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -116,6 +122,10 @@ public Boolean getQuiesceVM() { return quiesceVM; } + public boolean isIsolated() { + return isolated; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/DownloadValidationScreenshotCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/DownloadValidationScreenshotCmd.java new file mode 100644 index 000000000000..997d0d24c6f0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/DownloadValidationScreenshotCmd.java @@ -0,0 +1,94 @@ +// 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 org.apache.cloudstack.api.command.user.backup; + +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.Account; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.BackupResponse; +import org.apache.cloudstack.api.response.ExtractResponse; +import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.InternalBackupService; + +import javax.inject.Inject; + +@APICommand(name = "downloadValidationScreenshot", description = "Download validation screenshot of given backup.", + responseObject = ExtractResponse.class, since = "4.23.0", requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class DownloadValidationScreenshotCmd extends BaseAsyncCmd { + + @Inject + private InternalBackupService internalBackupService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL + @Parameter(name = ApiConstants.BACKUP_ID, type = CommandType.UUID, entityType = BackupResponse.class, required = true, + description = "ID of the backup.") + private Long backupId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getBackupId() { + return backupId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getEventType() { + return EventTypes.EVENT_SCREENSHOT_DOWNLOAD; + } + + @Override + public String getEventDescription() { + Backup backup = _entityMgr.findById(Backup.class, getBackupId()); + if (backup == null) { + throw new InvalidParameterValueException(String.format("Unable to find backup with ID [%s].", getBackupId())); + } + return "Downloading validation screenshot of backup " + backup.getUuid(); + } + + @Override + public void execute() { + ExtractResponse response = internalBackupService.downloadScreenshot(getBackupId()); + response.setResponseName(getCommandName()); + response.setObjectName(getCommandName()); + this.setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + Backup backup = _entityMgr.findById(Backup.class, getBackupId()); + if (backup != null) { + return backup.getAccountId(); + } + + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/FinishBackupChainCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/FinishBackupChainCmd.java new file mode 100644 index 000000000000..575df7ae0831 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/FinishBackupChainCmd.java @@ -0,0 +1,86 @@ +// 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 org.apache.cloudstack.api.command.user.backup; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.user.Account; +import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.api.response.VirtualMachineResponse; +import org.apache.cloudstack.backup.InternalBackupService; + +import javax.inject.Inject; + +@APICommand(name = "finishBackupChain", description = "Finish the backup chain of VM. Currently only has effect on VMs with KBOSS backup offerings.", + responseObject = SuccessResponse.class, since = "4.23.0", requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class FinishBackupChainCmd extends BaseCmd { + @Inject + private InternalBackupService internalBackupService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, entityType = VirtualMachineResponse.class, required = true, + description = "ID of the VM to finish the chain.") + private Long vmId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getVmId() { + return vmId; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, + NetworkRuleConflictException { + boolean result = internalBackupService.finishBackupChain(getVmId()); + SuccessResponse response = new SuccessResponse(); + response.setSuccess(result); + response.setResponseName(getCommandName()); + response.setObjectName(getCommandName()); + this.setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + VirtualMachine vm = _entityMgr.findById(VirtualMachine.class, getVmId()); + if (vm != null) { + return vm.getAccountId(); + } + + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/ListBackupServiceJobsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/ListBackupServiceJobsCmd.java new file mode 100644 index 000000000000..39ca444ae2ee --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/ListBackupServiceJobsCmd.java @@ -0,0 +1,105 @@ +// 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 org.apache.cloudstack.api.command.user.backup; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.BackupServiceJobResponse; +import org.apache.cloudstack.api.response.BackupResponse; +import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ZoneResponse; + +@APICommand(name = "listBackupServiceJobs", description = "List backup service jobs", responseObject = BackupServiceJobResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.23.0") +public class ListBackupServiceJobsCmd extends BaseListCmd { + + @Parameter(name = ApiConstants.ID, type = CommandType.LONG, entityType = BackupServiceJobResponse.class, description = "List only job with given ID.") + private Long id; + + @Parameter(name = ApiConstants.BACKUP_ID, type = CommandType.UUID, entityType = BackupResponse.class, description = "List jobs for the given backup.") + private Long backupId; + + @Parameter(name = ApiConstants.HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, description = "List jobs in the given host. When passing this parameter, only jobs that are currently executing will be returned.") + private Long hostId; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, description = "List jobs in the given zone.") + private Long zoneId; + + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, description = "List jobs with the given type. Accepted values are StartCompression, FinalizeCompression and " + + "BackupValidation.") + private String type; + + @Parameter(name = ApiConstants.EXECUTING, type = CommandType.BOOLEAN, description = "List executing jobs.") + private Boolean executing; + + @Parameter(name = ApiConstants.SCHEDULED, type = CommandType.BOOLEAN, description = "List scheduled jobs.") + private Boolean scheduled; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public Long getBackupId() { + return backupId; + } + + public Long getHostId() { + return hostId; + } + + public Long getZoneId() { + return zoneId; + } + + public String getType() { + return type; + } + + public boolean getExecuting() { + return Boolean.TRUE.equals(executing); + } + + public boolean getScheduled() { + return Boolean.TRUE.equals(scheduled); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, + NetworkRuleConflictException { + ListResponse response = _queryService.listBackupServiceJobs(this); + response.setResponseName(getCommandName()); + this.setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreBackupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreBackupCmd.java index c29d117161f2..9c67aa6a9f69 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreBackupCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreBackupCmd.java @@ -26,6 +26,7 @@ import org.apache.cloudstack.api.BaseAsyncCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.HostResponse; import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.api.response.BackupResponse; import org.apache.cloudstack.backup.BackupManager; @@ -38,6 +39,7 @@ import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.BooleanUtils; @APICommand(name = "restoreBackup", description = "Restores an existing stopped or deleted Instance using an Instance backup", @@ -59,6 +61,14 @@ public class RestoreBackupCmd extends BaseAsyncCmd { description = "ID of the backup") private Long backupId; + @Parameter(name = ApiConstants.QUICK_RESTORE, type = CommandType.BOOLEAN, entityType = BackupResponse.class, description = "Whether to use the quick restore process or not. " + + "Currently this parameter is only supported by the KBOSS provider.", since = "4.23.0") + private Boolean quickRestore; + + @Parameter(name = ApiConstants.HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, description = "If quickrestore is true, which host to start the VM on;" + + " otherwise, ignored. Currently this parameter is only supported by the KBOSS provider.", since = "4.23.0", authorized = {RoleType.Admin}) + private Long hostId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -67,6 +77,14 @@ public Long getBackupId() { return backupId; } + public boolean isQuickRestore() { + return BooleanUtils.isTrue(quickRestore); + } + + public Long getHostId() { + return hostId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -74,7 +92,7 @@ public Long getBackupId() { @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { - boolean result = backupManager.restoreBackup(backupId); + boolean result = backupManager.restoreBackup(backupId, isQuickRestore(), getHostId()); if (result) { SuccessResponse response = new SuccessResponse(getCommandName()); response.setResponseName(getCommandName()); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreVolumeFromBackupAndAttachToVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreVolumeFromBackupAndAttachToVMCmd.java index c15e6f8de684..e05845866c2b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreVolumeFromBackupAndAttachToVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/backup/RestoreVolumeFromBackupAndAttachToVMCmd.java @@ -28,6 +28,7 @@ import org.apache.cloudstack.api.BaseAsyncCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.HostResponse; import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.api.response.UserVmResponse; import org.apache.cloudstack.api.response.BackupResponse; @@ -78,6 +79,14 @@ public class RestoreVolumeFromBackupAndAttachToVMCmd extends BaseAsyncCmd { description = "ID of the Instance where to attach the restored volume") private Long vmId; + @Parameter(name = ApiConstants.QUICK_RESTORE, type = CommandType.BOOLEAN, description = "Whether to use the quick restore process or not. " + + "Currently this parameter is only supported by the KBOSS provider.", since = "4.23.0") + private Boolean quickRestore; + + @Parameter(name = ApiConstants.HOST_ID, type = CommandType.UUID, entityType = HostResponse.class, description = "If quickrestore is true, which host to start the VM on;" + + " otherwise, ignored. Currently this parameter is only supported by the KBOSS provider.", since = "4.23.0", authorized = {RoleType.Admin}) + private Long hostId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -94,6 +103,14 @@ public Long getBackupId() { return backupId; } + public boolean isQuickRestore() { + return org.apache.commons.lang3.BooleanUtils.isTrue(quickRestore); + } + + public Long getHostId() { + return hostId; + } + @Override public long getEntityOwnerId() { return CallContext.current().getCallingAccount().getId(); @@ -106,7 +123,7 @@ public long getEntityOwnerId() { @Override public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { try { - boolean result = backupManager.restoreBackupVolumeAndAttachToVM(volumeUuid, backupId, vmId); + boolean result = backupManager.restoreBackupVolumeAndAttachToVM(volumeUuid, backupId, vmId, isQuickRestore(), getHostId()); if (result) { SuccessResponse response = new SuccessResponse(getCommandName()); response.setResponseName(getCommandName()); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java new file mode 100644 index 000000000000..298ddd64a31c --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmd.java @@ -0,0 +1,169 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsProviderType; +import org.apache.cloudstack.dns.DnsServer; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.BooleanUtils; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "addDnsServer", + description = "Adds a new external DNS server", + responseObject = DnsServerResponse.class, + entityType = {DnsServer.class}, + requestHasSensitiveInfo = true, + responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class AddDnsServerCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + /// + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "Name of the DNS server") + private String name; + + @Parameter(name = ApiConstants.URL, type = CommandType.STRING, required = true, description = "API URL of the provider") + private String url; + + @Parameter(name = ApiConstants.PROVIDER, type = CommandType.STRING, required = true, description = "Provider type (e.g., PowerDNS)") + private String provider; + + @Parameter(name = ApiConstants.DNS_USER_NAME, type = CommandType.STRING, + description = "Username or email associated with the DNS provider account (used for authentication)") + private String dnsUserName; + + @Parameter(name = ApiConstants.DNS_API_KEY, required = true, type = CommandType.STRING, description = "API key or token for the DNS provider") + private String dnsApiKey; + + @Parameter(name = ApiConstants.PORT, type = CommandType.INTEGER, description = "Port number of the external DNS server") + private Integer port; + + @Parameter(name = ApiConstants.IS_PUBLIC, type = CommandType.BOOLEAN, + description = "Whether this DNS server can be used by accounts other than the owner to create and manage DNS zones") + private Boolean isPublic; + + @Parameter(name = ApiConstants.PUBLIC_DOMAIN_SUFFIX, type = CommandType.STRING, + description = "Domain suffix that restricts DNS zones created by non-owner accounts to subdomains of this " + + "suffix (for example, sub.example.com under example.com)") + private String publicDomainSuffix; + + @Parameter(name = ApiConstants.NAME_SERVERS, type = CommandType.LIST, collectionType = CommandType.STRING, + required = true, + description = "Comma separated list of name servers; used to create NS records for the DNS Zone (for example, ns1.example.com, ns2.example.com)") + private List nameServers; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Details in key/value pairs using " + + "format details[i].keyname=keyvalue. Example: details[0].pdnsServerId=localhost") + protected Map details; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { return name; } + + public String getUrl() { return url; } + + public String getDnsApiKey() { + return dnsApiKey; + } + + public Integer getPort() { + return port; + } + + public Boolean isPublic() { + return BooleanUtils.isTrue(isPublic); + } + + public String getPublicDomainSuffix() { + return publicDomainSuffix; + } + + public List getNameServers() { + return nameServers; + } + + public DnsProviderType getProvider() { + DnsProviderType dnsProviderType = EnumUtils.getEnumIgnoreCase(DnsProviderType.class, provider, DnsProviderType.PowerDNS); + if (dnsProviderType == null) { + throw new InvalidParameterValueException(String.format("Invalid value passed for provider type, valid values are: %s", + EnumUtils.listValues(DnsProviderType.values()))); + } + return dnsProviderType; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public void execute() { + try { + DnsServer server = dnsProviderManager.addDnsServer(this); + if (server != null) { + DnsServerResponse response = dnsProviderManager.createDnsServerResponse(server); + response.setResponseName(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to add DNS server"); + } + } catch (Exception ex) { + logger.error("Failed to add DNS server", ex); + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); + } + } + + public String getDnsUserName() { + return dnsUserName; + } + + public Map getDetails() { + Map detailsMap = new HashMap<>(); + if (MapUtils.isNotEmpty(details)) { + Collection props = details.values(); + for (Object prop : props) { + HashMap detail = (HashMap) prop; + for (Map.Entry entry: detail.entrySet()) { + detailsMap.put(entry.getKey(),entry.getValue()); + } + } + } + return detailsMap; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmd.java new file mode 100644 index 000000000000..5ffffc7c304d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmd.java @@ -0,0 +1,94 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneNetworkMapResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.NetworkResponse; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.user.Account; + +@APICommand(name = "associateDnsZoneToNetwork", + description = "Associates a DNS Zone with a Network for VM auto-registration", + responseObject = DnsZoneNetworkMapResponse.class, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class AssociateDnsZoneToNetworkCmd extends BaseCmd { + + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, + required = true, description = "The ID of the DNS zone") + private Long dnsZoneId; + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.NETWORK_ID, type = CommandType.UUID, entityType = NetworkResponse.class, + required = true, description = "The ID of the network") + private Long networkId; + + @Parameter(name = "subdomain", type = CommandType.STRING, + description = "Optional subdomain to append (e.g., 'dev' creates vm1.dev.example.com)") + private String subDomain; + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + try { + DnsZoneNetworkMapResponse response = dnsProviderManager.associateZoneToNetwork(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + Network network = _entityMgr.findById(Network.class, networkId); + if (network != null) { + return network.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + public Long getDnsZoneId() { + return dnsZoneId; + } + + public Long getNetworkId() { + return networkId; + } + + public String getSubDomain() { + return subDomain; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java new file mode 100644 index 000000000000..d2fe3a5024a7 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmd.java @@ -0,0 +1,100 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import java.util.List; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsRecordResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsRecord; + +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "createDnsRecord", + description = "Creates a DNS record directly on the provider", + responseObject = DnsRecordResponse.class, + entityType = {DnsRecord.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateDnsRecordCmd extends BaseAsyncCmd { + + @ACL + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, + description = "ID of the DNS zone") + private Long dnsZoneId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "DNS record name") + private String name; + + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true, description = "DNS record type (e.g., A, AAAA, CNAME, MX, TXT, etc.)") + private String type; + + @Parameter(name = ApiConstants.CONTENTS, type = CommandType.LIST, collectionType = CommandType.STRING, required = true, + description = "The content of the record (IP address for A/AAAA, FQDN for CNAME/NS, quoted string for TXT, etc.)") + private List contents; + + @Parameter(name = "ttl", type = CommandType.INTEGER, description = "Time to live") + private Integer ttl; + + // Getters + public Long getDnsZoneId() { return dnsZoneId; } + public String getName() { return name; } + + public List getContents() { return contents; } + public Integer getTtl() { return (ttl == null) ? 3600 : ttl; } + + public DnsRecord.RecordType getType() { + DnsRecord.RecordType dnsRecordType = EnumUtils.getEnumIgnoreCase(DnsRecord.RecordType.class, type); + if (dnsRecordType == null) { + throw new InvalidParameterValueException("Invalid value passed for record type, valid values are: " + EnumUtils.listValues(DnsRecord.RecordType.values())); + } + return dnsRecordType; + } + + @Override + public void execute() { + try { + DnsRecordResponse response = dnsProviderManager.createDnsRecord(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { return CallContext.current().getCallingAccount().getId(); } + + @Override + public String getEventType() { return EventTypes.EVENT_DNS_RECORD_CREATE; } + + @Override + public String getEventDescription() { return "Creating DNS Record: " + getName(); } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java new file mode 100644 index 000000000000..10a706e3a815 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmd.java @@ -0,0 +1,154 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import java.util.Arrays; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCreateCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsZone; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "createDnsZone", + description = "Creates a new DNS Zone on a specific server", + responseObject = DnsZoneResponse.class, + entityType = {DnsZone.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateDnsZoneCmd extends BaseAsyncCreateCmd { + + ///////////////////////////////////////////////////// + //////////////// API Parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, + description = "The name of the DNS zone (e.g. example.com)") + private String name; + + @ACL + @Parameter(name = ApiConstants.DNS_SERVER_ID, type = CommandType.UUID, entityType = DnsServerResponse.class, + required = true, description = "The ID of the DNS server to host this zone") + private Long dnsServerId; + + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, + description = "The type of zone (Public, Private). Defaults to Public.") + private String type; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, description = "The description of the DNS zone") + private String description; + + @Parameter(name = ApiConstants.EXISTING, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class, + description = "If true, imports an existing DNS zone from the DNS provider into CloudStack. " + + "If false, creates the zone in the DNS provider and registers it in CloudStack. Default is false") + private Boolean existing = false; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + public Long getDnsServerId() { + return dnsServerId; + } + + public DnsZone.ZoneType getType() { + if (StringUtils.isBlank(type)) { + return DnsZone.ZoneType.Public; + } + DnsZone.ZoneType zoneType = EnumUtils.getEnumIgnoreCase(DnsZone.ZoneType.class, type); + if (zoneType == null) { + throw new IllegalArgumentException("Invalid type value, supported values are: " + Arrays.toString(DnsZone.ZoneType.values())); + } + return zoneType; + } + + public String getDescription() { + return description; + } + + ///////////////////////////////////////////////////// + /////////////// Implementation ////////////////////// + ///////////////////////////////////////////////////// + + @Override + public void create() throws ResourceAllocationException { + try { + DnsZone zone = dnsProviderManager.allocateDnsZone(this); + if (zone != null) { + setEntityId(zone.getId()); + setEntityUuid(zone.getUuid()); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create DNS Zone entity"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to allocate DNS Zone: " + e.getMessage()); + } + } + + @Override + public void execute() { + try { + DnsZone result = dnsProviderManager.provisionDnsZone(getEntityId(), isExistingZone()); + if (result != null) { + DnsZoneResponse response = dnsProviderManager.createDnsZoneResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to provision DNS Zone on external provider"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to provision DNS Zone: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_DNS_ZONE_CREATE; + } + + @Override + public String getEventDescription() { + return "creating DNS zone: " + getName(); + } + + public Boolean isExistingZone() { + return Boolean.TRUE.equals(existing); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java new file mode 100644 index 000000000000..1d7914c346e9 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmd.java @@ -0,0 +1,92 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsRecord; + +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "deleteDnsRecord", + description = "Deletes a DNS record from the external provider", + responseObject = SuccessResponse.class, + entityType = {DnsRecord.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteDnsRecordCmd extends BaseAsyncCmd { + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, + required = true, description = "The ID of the DNS zone") + private Long dnsZoneId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true) + private String name; + + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true) + private String type; + + // Getters + public DnsRecord.RecordType getType() { + DnsRecord.RecordType dnsRecordType = EnumUtils.getEnumIgnoreCase(DnsRecord.RecordType.class, type); + if (dnsRecordType == null) { + throw new InvalidParameterValueException("Invalid value passed for record type, valid values are: " + EnumUtils.listValues(DnsRecord.RecordType.values())); + } + return dnsRecordType; + } + public Long getDnsZoneId() { return dnsZoneId; } + public String getName() { return name; } + + @Override + public void execute() { + try { + boolean result = dnsProviderManager.deleteDnsRecord(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS Record"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Error deleting DNS Record: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { return CallContext.current().getCallingAccount().getId(); } + + @Override + public String getEventType() { return EventTypes.EVENT_DNS_RECORD_DELETE; } + + @Override + public String getEventDescription() { return "Deleting DNS Record: " + getName(); } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmd.java new file mode 100644 index 000000000000..099fc62f354c --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmd.java @@ -0,0 +1,115 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.dns.DnsServer; + +import com.cloud.event.EventTypes; +import com.cloud.user.Account; + +@APICommand(name = "deleteDnsServer", + description = "Removes a DNS server integration", + responseObject = SuccessResponse.class, + entityType = {DnsServer.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteDnsServerCmd extends BaseAsyncCmd { + + ///////////////////////////////////////////////////// + //////////////// API Parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsServerResponse.class, + required = true, description = "the ID of the DNS server") + private Long id; + + @Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN, + entityType = DnsZoneResponse.class, description = "If true, all associated DNS zones will be cleaned up " + + "when the server is removed. Default: true") + private Boolean cleanup = true; + + @Parameter(name = ApiConstants.UNMANAGE, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class, + description = "If true, the DNS zone is only removed from CloudStack (unmanaged); if false, it is removed " + + "from both CloudStack and the DNS provider. Default: false") + private Boolean unmanage = false; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// Implementation ////////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + boolean result = dnsProviderManager.deleteDnsServer(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS server"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS server: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + DnsServer server = _entityMgr.findById(DnsServer.class, id); + if (server != null) { + return server.getAccountId(); + } + // If server not found, return System to fail safely (or let manager handle 404) + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { return EventTypes.EVENT_DNS_SERVER_DELETE; } + + @Override + public String getEventDescription() { return "Deleting DNS server ID: " + getId(); } + + public Boolean getCleanup() { + return Boolean.TRUE.equals(cleanup); + } + + public Boolean isUnmanage() { + return Boolean.TRUE.equals(unmanage); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmd.java new file mode 100644 index 000000000000..a6d5a710ae56 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmd.java @@ -0,0 +1,109 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.dns.DnsZone; + +import com.cloud.event.EventTypes; +import com.cloud.user.Account; + +@APICommand(name = "deleteDnsZone", + description = "Removes a DNS Zone from CloudStack and the external provider", + responseObject = SuccessResponse.class, + entityType = {DnsZone.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteDnsZoneCmd extends BaseAsyncCmd { + + ///////////////////////////////////////////////////// + //////////////// API Parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, + description = "The ID of the DNS zone") + private Long id; + + @Parameter(name = ApiConstants.UNMANAGE, type = CommandType.BOOLEAN, entityType = DnsZoneResponse.class, + description = "If true, removes the DNS zone only from CloudStack; if false, removes it from " + + "both CloudStack and the DNS provider. Default: false") + private Boolean unmanage = false; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation ////////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + boolean result = dnsProviderManager.deleteDnsZone(getId(), isUnmanage()); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete DNS Zone"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + DnsZone zone = _entityMgr.findById(DnsZone.class, id); + if (zone != null) { + return zone.getAccountId(); + } + // Fallback or System if not found (likely to fail in execute() anyway) + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_DNS_ZONE_DELETE; + } + + @Override + public String getEventDescription() { + return "Deleting DNS Zone ID: " + getId(); + } + + public Boolean isUnmanage() { + return Boolean.TRUE.equals(unmanage); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmd.java new file mode 100644 index 000000000000..b0aa0902e9e6 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmd.java @@ -0,0 +1,80 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NetworkResponse; +import org.apache.cloudstack.api.response.SuccessResponse; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.Network; +import com.cloud.user.Account; + +@APICommand(name = "disassociateDnsZoneFromNetwork", + description = "Removes the association between a DNS Zone and a Network", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DisassociateDnsZoneFromNetworkCmd extends BaseCmd { + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.NETWORK_ID, type = CommandType.UUID, entityType = NetworkResponse.class, + required = true, description = "The ID of the Network") + private Long networkId; + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + try { + boolean result = dnsProviderManager.disassociateZoneFromNetwork(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to disassociate DNS zone from network."); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + Network network = _entityMgr.findById(Network.class, networkId); + if (network != null) { + return network.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } + + public Long getNetworkId() { + return networkId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmd.java new file mode 100644 index 000000000000..800f030754b9 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmd.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 org.apache.cloudstack.api.command.user.dns; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.response.DnsProviderResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.dns.DnsProvider; + +@APICommand(name = "listDnsProviders", + description = "Lists available DNS plugin providers", + responseObject = DnsProviderResponse.class, + entityType = {DnsProvider.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListDnsProvidersCmd extends BaseListCmd { + + @Override + public void execute() { + List providers = dnsProviderManager.listProviderNames(); + ListResponse response = new ListResponse<>(); + List responses = new ArrayList<>(); + for (String name : providers) { + DnsProviderResponse resp = new DnsProviderResponse(name); + resp.setName(name); + responses.add(resp); + } + response.setResponses(responses); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java new file mode 100644 index 000000000000..246c8bee9aab --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmd.java @@ -0,0 +1,54 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.DnsRecordResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.dns.DnsRecord; + +@APICommand(name = "listDnsRecords", + description = "Lists DNS records from the external provider", + responseObject = DnsRecordResponse.class, + entityType = {DnsRecord.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListDnsRecordsCmd extends BaseListCmd { + + @Parameter(name = ApiConstants.DNS_ZONE_ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, required = true, + description = "ID of the DNS zone to list records from") + private Long dnsZoneId; + + public Long getDnsZoneId() { + return dnsZoneId; + } + + @Override + public void execute() { + // The manager will fetch live data from the plugin + ListResponse response = dnsProviderManager.listDnsRecords(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmd.java new file mode 100644 index 000000000000..ca7ac1944a9d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmd.java @@ -0,0 +1,82 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListAccountResourcesCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.dns.DnsProviderType; +import org.apache.cloudstack.dns.DnsServer; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "listDnsServers", + description = "Lists DNS servers owned by the account.", + responseObject = DnsServerResponse.class, + entityType = {DnsServer.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListDnsServersCmd extends BaseListAccountResourcesCmd { + + ///////////////////////////////////////////////////// + //////////////// API Parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsServerResponse.class, + description = "the ID of the DNS server") + private Long id; + + @Parameter(name = ApiConstants.PROVIDER_TYPE, type = CommandType.STRING, + description = "filter by provider type (e.g. PowerDNS, Cloudflare)") + private String providerType; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public DnsProviderType getProviderType() { + DnsProviderType dnsProviderType = EnumUtils.getEnumIgnoreCase(DnsProviderType.class, providerType, DnsProviderType.PowerDNS); + if (dnsProviderType == null) { + throw new InvalidParameterValueException(String.format("Invalid value passed for provider type, valid values are: %s", + EnumUtils.listValues(DnsProviderType.values()))); + } + return dnsProviderType; + } + + ///////////////////////////////////////////////////// + /////////////// Implementation ////////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + ListResponse response = dnsProviderManager.listDnsServers(this); + response.setResponseName(getCommandName()); + response.setObjectName("dnsserver"); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmd.java new file mode 100644 index 000000000000..e71bdabaf617 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmd.java @@ -0,0 +1,63 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.dns.DnsZone; + +@APICommand(name = "listDnsZones", + description = "Lists DNS zones.", responseObject = DnsZoneResponse.class, + entityType = {DnsZone.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListDnsZonesCmd extends BaseListCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, + description = "List DNS zone by ID") + private Long id; + + @Parameter(name = "dnsserverid", type = CommandType.UUID, entityType = DnsServerResponse.class, + description = "List DNS zones belonging to a specific DNS server") + private Long dnsServerId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "List by zone name") + private String name; + + public Long getId() { return id; } + public Long getDnsServerId() { return dnsServerId; } + public String getName() { return name; } + + @Override + public void execute() { + ListResponse response = dnsProviderManager.listDnsZones(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java new file mode 100644 index 000000000000..6b790fa8ade8 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmd.java @@ -0,0 +1,150 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import java.util.List; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.dns.DnsServer; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.user.Account; +import com.cloud.utils.EnumUtils; + +@APICommand(name = "updateDnsServer", + description = "Update DNS server", + responseObject = DnsServerResponse.class, + entityType = {DnsServer.class}, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateDnsServerCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsServerResponse.class, + required = true, description = "The ID of the DNS server to update") + private Long id; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "Name of the DNS server") + private String name; + + @Parameter(name = ApiConstants.URL, type = CommandType.STRING, description = "API URL of the provider") + private String url; + + @Parameter(name = ApiConstants.DNS_API_KEY, type = CommandType.STRING, description = "API Key or Credentials for the external provider") + private String dnsApiKey; + + @Parameter(name = ApiConstants.PORT, type = CommandType.INTEGER, description = "Port number of the external DNS server") + private Integer port; + + @Parameter(name = ApiConstants.IS_PUBLIC, type = CommandType.BOOLEAN, + description = "Whether this DNS server can be used by accounts other than the owner to create and manage DNS zones") + private Boolean isPublic; + + @Parameter(name = ApiConstants.PUBLIC_DOMAIN_SUFFIX, type = CommandType.STRING, + description = "Domain suffix that restricts DNS zones created by non-owner accounts to subdomains of this " + + "suffix (for example, sub.example.com under example.com)") + private String publicDomainSuffix; + + @Parameter(name = ApiConstants.NAME_SERVERS, type = CommandType.LIST, collectionType = CommandType.STRING, + description = "Comma separated list of name servers; used to create NS records for the DNS Zone (for example, ns1.example.com, ns2.example.com)") + private List nameServers; + + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, description = "Update state for the DNS server (Enabled, Disabled)") + private String state; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { return id; } + public String getName() { return name; } + public String getUrl() { return url; } + public String getDnsApiKey() { + return dnsApiKey; + } + public Integer getPort() { + return port; + } + public Boolean isPublic() { + return isPublic; + } + public String getPublicDomainSuffix() { + return publicDomainSuffix; + } + public String getNameServers() { + if (nameServers == null) { + return null; + } + return StringUtils.join(nameServers.stream() + .filter(StringUtils::isNotBlank) + .map(StringUtils::trim) + .toArray(String[]::new), ","); + } + + @Override + public long getEntityOwnerId() { + DnsServer server = _entityMgr.findById(DnsServer.class, id); + if (server != null) { + return server.getAccountId(); + } + // If server not found, return System to fail safely (or let manager handle 404) + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public void execute() { + try { + DnsServer server = dnsProviderManager.updateDnsServer(this); + if (server != null) { + DnsServerResponse response = dnsProviderManager.createDnsServerResponse(server); + response.setResponseName(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update DNS server"); + } + } catch (Exception ex) { + logger.error("Failed to update DNS server", ex); + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); + } + } + + public DnsServer.State getState() { + if (StringUtils.isBlank(state)) { + return null; + } + DnsServer.State dnsState = EnumUtils.getEnumIgnoreCase(DnsServer.State.class, state); + if (dnsState == null) { + throw new IllegalArgumentException("Invalid state value: " + state); + } + return dnsState; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmd.java new file mode 100644 index 000000000000..5952b73ea6e4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmd.java @@ -0,0 +1,95 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.dns.DnsZone; + +import com.cloud.user.Account; + +@APICommand(name = "updateDnsZone", + description = "Updates a DNS Zone's metadata", + responseObject = DnsZoneResponse.class, + entityType = {DnsZone.class}, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateDnsZoneCmd extends BaseCmd { + + ///////////////////////////////////////////////////// + //////////////// API Parameters ///////////////////// + ///////////////////////////////////////////////////// + + @ACL(accessType = SecurityChecker.AccessType.OperateEntry) + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = DnsZoneResponse.class, + required = true, description = "The ID of the DNS zone") + private Long id; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, description = "The description of the DNS zone to be updated") + private String description; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getDescription() { + return description; + } + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// Implementation ////////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + try { + DnsZone result = dnsProviderManager.updateDnsZone(this); + if (result != null) { + DnsZoneResponse response = dnsProviderManager.createDnsZoneResponse(result); + response.setResponseName(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update DNS Zone on external provider"); + } + } catch (Exception e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update DNS Zone: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + DnsZone dnsZone = _entityMgr.findById(DnsZone.class, id); + if (dnsZone != null) { + return dnsZone.getAccountId(); + } + return Account.ACCOUNT_ID_SYSTEM; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateEgressFirewallRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateEgressFirewallRuleCmd.java index 3fd571b7a479..e2c84ac85c7a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateEgressFirewallRuleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateEgressFirewallRuleCmd.java @@ -212,7 +212,7 @@ public State getState() { } @Override - public long getNetworkId() { + public Long getNetworkId() { return networkId; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmd.java index bc65126f33bd..30dd1a2d015a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmd.java @@ -223,13 +223,9 @@ public State getState() { } @Override - public long getNetworkId() { - IpAddress ip = _entityMgr.findById(IpAddress.class, getIpAddressId()); - Long ntwkId = null; - - if (ip.getAssociatedWithNetworkId() != null) { - ntwkId = ip.getAssociatedWithNetworkId(); - } + public Long getNetworkId() { + IpAddress ip = getIp(); + Long ntwkId = isVpcIp(ip) ? getVpcNetworkIdForFirewallRule(ip) : getIsolatedNetworkIdForFirewallRule(ip); if (ntwkId == null) { throw new InvalidParameterValueException("Unable to create firewall rule for the IP address ID=" + ipAddressId + @@ -238,6 +234,12 @@ public long getNetworkId() { return ntwkId; } + @Override + public Long getVpcId() { + IpAddress ip = getIp(); + return isVpcIp(ip) ? ip.getVpcId() : null; + } + @Override public long getEntityOwnerId() { Account account = CallContext.current().getCallingAccount(); @@ -300,7 +302,21 @@ public String getSyncObjType() { @Override public Long getSyncObjId() { - return getIp().getAssociatedWithNetworkId(); + Long syncObjId = getIp().getAssociatedWithNetworkId(); + return syncObjId != null ? syncObjId : getNetworkId(); + } + + private boolean isVpcIp(IpAddress ip) { + return ip.getVpcId() != null; + } + + private Long getIsolatedNetworkIdForFirewallRule(IpAddress ip) { + return ip.getAssociatedWithNetworkId(); + } + + private Long getVpcNetworkIdForFirewallRule(IpAddress ip) { + // VPC flow is independent from tier association; manager resolves execution network. + return ip.getNetworkId(); } private IpAddress getIp() { @@ -311,6 +327,7 @@ private IpAddress getIp() { return ip; } + @Override public Integer getIcmpCode() { if (icmpCode != null) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java index 2bc5fc2ee68b..66fc118395ea 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java @@ -176,7 +176,7 @@ public Boolean getOpenFirewall() { } } - private Long getVpcId() { + public Long getVpcId() { if (ipAddressId != null) { IpAddress ipAddr = _networkService.getIp(ipAddressId); if (ipAddr == null || !ipAddr.readyToUse()) { @@ -275,7 +275,7 @@ public State getState() { } @Override - public long getNetworkId() { + public Long getNetworkId() { IpAddress ip = _entityMgr.findById(IpAddress.class, getIpAddressId()); Long ntwkId = _networkService.getPreferredNetworkIdForPublicIpRuleAssignment(ip, networkId); if (ntwkId == null) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/nat/CreateIpForwardingRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/nat/CreateIpForwardingRuleCmd.java index 7963dfe5c7d3..98487ddeb19a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/nat/CreateIpForwardingRuleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/nat/CreateIpForwardingRuleCmd.java @@ -229,8 +229,13 @@ public FirewallRule.State getState() { } @Override - public long getNetworkId() { - return -1; + public Long getNetworkId() { + return -1L; + } + + @Override + public Long getVpcId() { + return null; } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/network/CreateNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/network/CreateNetworkCmd.java index ee5b8568e835..79fb5f6d01cf 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/network/CreateNetworkCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/network/CreateNetworkCmd.java @@ -419,6 +419,27 @@ public Long getAsNumber() { ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// + + public CreateNetworkCmd() { + } + + public CreateNetworkCmd(long networkOfferingId, String name, String displayText, String gateway, String netmask, String startIp, String endIp, long domainId, + String accountName, long zoneId, String aclType, boolean subdomainAccess, boolean displayNetwork) { + this.networkOfferingId = networkOfferingId; + this.name = name; + this.displayText = displayText; + this.gateway = gateway; + this.netmask = netmask; + this.startIp = startIp; + this.endIp = endIp; + this.domainId = domainId; + this.accountName = accountName; + this.zoneId = zoneId; + this.aclType = aclType; + this.subdomainAccess = subdomainAccess; + this.displayNetwork = displayNetwork; + } + @Override public String getCommandName() { return s_name; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMFromBackupCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMFromBackupCmd.java index a719062e1bca..7743c031006b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMFromBackupCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMFromBackupCmd.java @@ -36,6 +36,7 @@ import com.cloud.exception.ResourceUnavailableException; import com.cloud.uservm.UserVm; import com.cloud.vm.VirtualMachine; +import org.apache.commons.lang3.ObjectUtils; @APICommand(name = "createVMFromBackup", description = "Creates and automatically starts a VM from a backup.", @@ -70,6 +71,10 @@ public class CreateVMFromBackupCmd extends BaseDeployVMCmd { @Parameter(name = ApiConstants.PRESERVE_IP, type = CommandType.BOOLEAN, description = "Use the same IP/MAC addresses as stored in the backup metadata. Works only if the original Instance is deleted and the IP/MAC address is available.") private Boolean preserveIp; + @Parameter(name = ApiConstants.QUICK_RESTORE, type = CommandType.BOOLEAN, entityType = BackupResponse.class, description = "Whether to use the quick restore process or not. " + + "Currently this parameter is only supported by the KBOSS provider.", since = "4.23.0") + private Boolean quickRestore; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -90,6 +95,10 @@ public boolean getPreserveIp() { return (preserveIp != null) ? preserveIp : false; } + public Boolean getQuickRestore() { + return ObjectUtils.defaultIfNull(this.quickRestore, false); + } + @Override public void create() { UserVm vm; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java index aec0688f1779..0a3e510d3d53 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DestroyVMCmd.java @@ -98,6 +98,14 @@ public boolean isForced() { /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// + public DestroyVMCmd() { + } + + public DestroyVMCmd(Long id, Boolean expunge) { + this.id = id; + this.expunge = expunge; + } + @Override public String getCommandName() { return s_name; @@ -136,7 +144,7 @@ public Long getApiResourceId() { @Override public void execute() throws ResourceUnavailableException, ConcurrentOperationException { CallContext.current().setEventDetails("Instance ID: " + getResourceUuid(ApiConstants.ID)); - UserVm result = _userVmService.destroyVm(this); + UserVm result = _userVmService.destroyVm(this, true); UserVmResponse response = new UserVmResponse(); if (result != null) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DeleteVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DeleteVolumeCmd.java index e102d51f0378..ab6cda651b06 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DeleteVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DeleteVolumeCmd.java @@ -85,7 +85,7 @@ public ApiCommandResourceType getApiResourceType() { @Override public void execute() throws ConcurrentOperationException { CallContext.current().setEventDetails("Volume ID: " + getResourceUuid(ApiConstants.ID)); - Volume result = _volumeService.destroyVolume(id, CallContext.current().getCallingAccount(), true, false); + Volume result = _volumeService.destroyVolume(id, CallContext.current().getCallingAccount(), true, false, null); if (result != null) { SuccessResponse response = new SuccessResponse(getCommandName()); setResponseObject(response); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java index 12a44f76ea15..e9e388436642 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/DestroyVolumeCmd.java @@ -116,7 +116,7 @@ public Long getApiResourceId() { @Override public void execute() { CallContext.current().setEventDetails("Volume ID: " + getResourceUuid(ApiConstants.ID)); - Volume result = _volumeService.destroyVolume(getId(), CallContext.current().getCallingAccount(), getExpunge(), false); + Volume result = _volumeService.destroyVolume(getId(), CallContext.current().getCallingAccount(), getExpunge(), false, null); if (result != null) { VolumeResponse response = _responseGenerator.createVolumeResponse(ResponseView.Restricted, result); response.setResponseName(getCommandName()); diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java index c4f3ee31dadc..69bee63e652f 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/BackupOfferingResponse.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.api.response; import java.util.Date; +import java.util.Map; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseResponse; @@ -79,6 +80,10 @@ public class BackupOfferingResponse extends BaseResponse { @Param(description = "The date this backup offering was created") private Date created; + @SerializedName(ApiConstants.BACKUP_OFFERING_DETAILS) + @Param(description = "Details for the backup offering", since = "4.23.0") + private Map details; + public void setId(String id) { this.id = id; } @@ -127,4 +132,7 @@ public void setDomain(String domain) { this.domain = domain; } + public void setDetails(Map details) { + this.details = details; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BackupResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BackupResponse.java index 51fcaa9836e9..70db01445edd 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/BackupResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/BackupResponse.java @@ -71,10 +71,22 @@ public class BackupResponse extends BaseResponse { @Param(description = "Backup protected (virtual) size in bytes") private Long protectedSize; + @SerializedName(ApiConstants.UNCOMPRESSED_SIZE) + @Param(description = "Backup uncompressed size in bytes. Only defined if backup is compressed.") + private Long uncompressedSize; + @SerializedName(ApiConstants.STATUS) @Param(description = "Backup status") private Backup.Status status; + @SerializedName(ApiConstants.COMPRESSION_STATUS) + @Param(description = "Backup compression status.") + private Backup.CompressionStatus compressionStatus; + + @SerializedName(ApiConstants.VALIDATION_STATUS) + @Param(description = "Backup validation status.") + private Backup.ValidationStatus validationStatus; + @SerializedName(ApiConstants.VOLUMES) @Param(description = "Backed up volumes") private String volumes; @@ -219,6 +231,14 @@ public void setProtectedSize(Long protectedSize) { this.protectedSize = protectedSize; } + public Long getUncompressedSize() { + return uncompressedSize; + } + + public void setUncompressedSize(Long uncompressedSize) { + this.uncompressedSize = uncompressedSize; + } + public Backup.Status getStatus() { return status; } @@ -227,6 +247,22 @@ public void setStatus(Backup.Status status) { this.status = status; } + public Backup.CompressionStatus getCompressionStatus() { + return compressionStatus; + } + + public void setCompressionStatus(Backup.CompressionStatus compressionStatus) { + this.compressionStatus = compressionStatus; + } + + public Backup.ValidationStatus getValidationStatus() { + return validationStatus; + } + + public void setValidationStatus(Backup.ValidationStatus validationStatus) { + this.validationStatus = validationStatus; + } + public String getVolumes() { return volumes; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BackupScheduleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BackupScheduleResponse.java index 13d0c5d8c562..5da07864603a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/BackupScheduleResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/BackupScheduleResponse.java @@ -56,6 +56,10 @@ public class BackupScheduleResponse extends BaseResponse { @Param(description = "maximum number of backups retained") private Integer maxBackups; + @SerializedName(ApiConstants.ISOLATED) + @Param(description = ApiConstants.PARAMETER_DESCRIPTION_ISOLATED_BACKUPS) + private boolean isolated; + public void setId(String id) { this.id = id; } @@ -111,4 +115,8 @@ public void setMaxBackups(Integer maxBackups) { public void setQuiesceVM(Boolean quiesceVM) { this.quiesceVM = quiesceVM; } + + public void setIsolated(boolean isolated) { + this.isolated = isolated; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/BackupServiceJobResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/BackupServiceJobResponse.java new file mode 100644 index 000000000000..0d4984fe34f5 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/BackupServiceJobResponse.java @@ -0,0 +1,79 @@ +// 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 org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import java.util.Date; + +public class BackupServiceJobResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "Compression job ID.") + private Long id; + + @SerializedName(ApiConstants.BACKUP_ID) + @Param(description = "Backup ID.") + private String backupId; + + @SerializedName(ApiConstants.HOST_ID) + @Param(description = "Host where the job is being executed.") + private String hostId; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "Zone where the job is being executed.") + private String zoneId; + + @SerializedName(ApiConstants.ATTEMPTS) + @Param(description = "Number of attempts already made to complete this job.") + private Integer attempts; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "Compression job type.") + private String type; + + @SerializedName(ApiConstants.START_DATE) + @Param(description = "Compression job start date.") + private Date startDate; + + @SerializedName(ApiConstants.SCHEDULED_DATE) + @Param(description = "Compression job scheduled start date.") + private Date scheduledDate; + + @SerializedName(ApiConstants.REMOVED) + @Param(description = "Compression job scheduled removed date.") + private Date removed; + + public BackupServiceJobResponse(Long id, String backupId, String zoneId, Integer attempts, String type, Date startDate, Date scheduledDate, Date removed) { + super("backupservicejob"); + this.id = id; + this.backupId = backupId; + this.zoneId = zoneId; + this.attempts = attempts; + this.type = type; + this.startDate = startDate; + this.scheduledDate = scheduledDate; + this.removed = removed; + } + + public void setHostId(String hostId) { + this.hostId = hostId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsProviderResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsProviderResponse.java new file mode 100644 index 000000000000..f3a571f40639 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsProviderResponse.java @@ -0,0 +1,45 @@ +// 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 org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class DnsProviderResponse extends BaseResponse { + + @SerializedName(ApiConstants.NAME) + @Param(description = "The name of the DNS provider (e.g. PowerDNS, Cloudflare)") + private String name; + + public DnsProviderResponse(String name) { + this.name = name; + setObjectName("dnsprovider"); // Sets the JSON wrapper name + } + + // Accessors + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java new file mode 100644 index 000000000000..6e2082485512 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsRecordResponse.java @@ -0,0 +1,57 @@ +// 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 org.apache.cloudstack.api.response; + +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.dns.DnsRecord; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class DnsRecordResponse extends BaseResponse { + + @SerializedName(ApiConstants.NAME) + @Param(description = "The record name (e.g., www.example.com.)") + private String name; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "The record type (e.g., A, CNAME, TXT)") + private DnsRecord.RecordType type; + + @SerializedName("contents") + @Param(description = "The contents of the record (IP address or target)") + private List contents; + + @SerializedName("ttl") + @Param(description = "Time to live (TTL) in seconds") + private Integer ttl; + + public DnsRecordResponse() { + super(); + setObjectName("dnsrecord"); + } + + // Setters + public void setName(String name) { this.name = name; } + public void setType(DnsRecord.RecordType type) { this.type = type; } + public void setContent(List contents) { this.contents = contents; } + public void setTtl(Integer ttl) { this.ttl = ttl; } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsServerResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsServerResponse.java new file mode 100644 index 000000000000..fecf49f0f984 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsServerResponse.java @@ -0,0 +1,133 @@ +// 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 org.apache.cloudstack.api.response; + +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.dns.DnsServer; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = DnsServer.class) +public class DnsServerResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the DNS server") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the DNS server") + private String name; + + @SerializedName(ApiConstants.URL) + @Param(description = "URL of the DNS server API") + private String url; + + @SerializedName(ApiConstants.PORT) + @Param(description = "The port of the DNS server") + private Integer port; + + @SerializedName(ApiConstants.PROVIDER) + @Param(description = "The provider type of the DNS server") + private String provider; + + @SerializedName(ApiConstants.IS_PUBLIC) + @Param(description = "Is the DNS server publicly available") + private Boolean isPublic; + + @SerializedName(ApiConstants.PUBLIC_DOMAIN_SUFFIX) + @Param(description = "The public domain suffix for the DNS server") + private String publicDomainSuffix; + + @SerializedName(ApiConstants.NAME_SERVERS) + @Param(description = "Name servers entries associated to DNS server") + private List nameServers; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account associated with the DNS server") + private String accountName; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "the ID of the domain associated with the DNS server") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "the name of the domain associated with the DNS server") + private String domainName; + + @SerializedName(ApiConstants.STATE) + @Param(description = "The state of the account") + private String state; + + public DnsServerResponse() { + super(); + + } + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setUrl(String url) { + this.url = url; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public void setPublic(Boolean value) { + isPublic = value; + } + + public void setPort(Integer port) { + this.port = port; + } + + public void setPublicDomainSuffix(String publicDomainSuffix) { + this.publicDomainSuffix = publicDomainSuffix; + } + + public void setNameServers(List nameServers) { + this.nameServers = nameServers; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneNetworkMapResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneNetworkMapResponse.java new file mode 100644 index 000000000000..84fe46ce5aca --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneNetworkMapResponse.java @@ -0,0 +1,64 @@ +// 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 org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class DnsZoneNetworkMapResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of the mapping") + private String id; + + @SerializedName(ApiConstants.DNS_ZONE_ID) + @Param(description = "The ID of the DNS zone") + private String dnsZoneId; + + @SerializedName(ApiConstants.NETWORK_ID) + @Param(description = "The ID of the Network") + private String networkId; + + @SerializedName("subdomain") + @Param(description = "The sub domain name of the auto-registered DNS record") + private String subDomain; + + public DnsZoneNetworkMapResponse() { + super(); + setObjectName("dnszonenetwork"); + } + + // Setters + public void setId(String id) { + this.id = id; + } + + public void setDnsZoneId(String dnsZoneId) { + this.dnsZoneId = dnsZoneId; + } + + public void setNetworkId(String networkId) { + this.networkId = networkId; + } + + public void setSubDomain(String subDomain) { + this.subDomain = subDomain; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneResponse.java new file mode 100644 index 000000000000..e179d2fd4b13 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/DnsZoneResponse.java @@ -0,0 +1,139 @@ +// 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 org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.dns.DnsZone; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = DnsZone.class) +public class DnsZoneResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the DNS zone") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the DNS zone") + private String name; + + @SerializedName("dnsserverid") + @Param(description = "ID of the DNS server this zone belongs to") + private String dnsServerId; + + @SerializedName("dnsservername") + @Param(description = "the name of the DNS server hosting this zone") + private String dnsServerName; + + @SerializedName("dnsserveraccount") + @Param(description = "the account name of the DNS server owner") + private String dnsServerAccountName; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account associated with the DNS zone") + private String accountName; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "the name of the domain associated with the DNS zone") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "the ID of the domain associated with the DNS server") + private String domainId; + + @SerializedName(ApiConstants.NETWORK_ID) + @Param(description = "ID of the network this zone is associated with") + private String networkId; + + @SerializedName(ApiConstants.NETWORK_NAME) + @Param(description = "Name of the network this zone is associated with") + private String networkName; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "The type of the zone (Public/Private)") + private DnsZone.ZoneType type; + + @SerializedName(ApiConstants.STATE) + @Param(description = "The state of the zone (Active/Inactive)") + private DnsZone.State state; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "Description for the DNS zone") + private String description; + + public DnsZoneResponse() { + super(); + setObjectName("dnszone"); + } + + public void setName(String name) { + this.name = name; + } + + public void setDnsServerId(String dnsServerId) { + this.dnsServerId = dnsServerId; + } + + public void setNetworkId(String networkId) { + this.networkId = networkId; + } + + public void setNetworkName(String networkName) { + this.networkName = networkName; + } + + public void setType(DnsZone.ZoneType type) { + this.type = type; + } + + public void setState(DnsZone.State state) { + this.state = state; + } + + public void setId(String id) { + this.id = id; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setDnsServerName(String dnsServerName) { + this.dnsServerName = dnsServerName; + } + + public void setDnsServerAccountName(String dnsServerAccountName) { + this.dnsServerAccountName = dnsServerAccountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public void setDomainId(String domainId) { + this.domainId = domainId; + } + +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/FirewallResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/FirewallResponse.java index 5986c16dc8c0..f6cc9e5d9499 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/FirewallResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/FirewallResponse.java @@ -51,6 +51,10 @@ public class FirewallResponse extends BaseResponse { @Param(description = "The Network ID of the firewall rule") private String networkId; + @SerializedName(ApiConstants.VPC_ID) + @Param(description = "The VPC ID of the firewall rule") + private String vpcId; + @SerializedName(ApiConstants.IP_ADDRESS) @Param(description = "The public IP address for the firewall rule") private String publicIpAddress; @@ -115,6 +119,10 @@ public void setNetworkId(String networkId) { this.networkId = networkId; } + public void setVpcId(String vpcId) { + this.vpcId = vpcId; + } + public void setState(String state) { this.state = state; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java index f8e1af8d5b76..10bd62804fb2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java @@ -201,6 +201,10 @@ public class HostResponse extends BaseResponseWithAnnotations { @Param(description = "the virtual machine id for host type ConsoleProxy and SecondaryStorageVM", since = "4.21.0") private String virtualMachineId; + @SerializedName("msid") + @Param(description = "(only for details=core) the msid of the host's management server") + private Long msId; + @SerializedName(ApiConstants.MANAGEMENT_SERVER_ID) @Param(description = "The management server ID of the host") private String managementServerId; @@ -492,6 +496,14 @@ public void setVirtualMachineId(String virtualMachineId) { this.virtualMachineId = virtualMachineId; } + public Long getMsId() { + return msId; + } + + public void setMsId(Long msId) { + this.msId = msId; + } + public void setManagementServerId(String managementServerId) { this.managementServerId = managementServerId; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java index 3a3663af2551..aeaf333540c7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java @@ -335,6 +335,14 @@ public class NetworkResponse extends BaseResponseWithAssociatedNetwork implement @Param(description = ApiConstants.PARAMETER_DESCRIPTION_KEEP_MAC_ADDRESS_ON_PUBLIC_NIC, since = "4.23.0") private Boolean keepMacAddressOnPublicNic; + @SerializedName(ApiConstants.DNS_ZONE) + @Param(description = "DNS zone associated to the network", since = "4.23.0") + private String dnsZone; + + @SerializedName(ApiConstants.DNS_SUB_DOMAIN) + @Param(description = "DNS subdomain associated to the network", since = "4.23.0") + private String dnsSubdomain; + public NetworkResponse() {} public Boolean getDisplayNetwork() { @@ -710,4 +718,12 @@ public void setIpv6Dns2(String ipv6Dns2) { public void setKeepMacAddressOnPublicNic(Boolean keepMacAddressOnPublicNic) { this.keepMacAddressOnPublicNic = keepMacAddressOnPublicNic; } + + public void setDnsZone(String dnsZone) { + this.dnsZone = dnsZone; + } + + public void setDnsSubdomain(String dnsSubdomain) { + this.dnsSubdomain = dnsSubdomain; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/NicResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/NicResponse.java index 92f25e370fb4..5ad41ad62244 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/NicResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/NicResponse.java @@ -150,6 +150,10 @@ public class NicResponse extends BaseResponse { @Param(description = "whether the NIC is enabled or not") private Boolean isEnabled; + @SerializedName(ApiConstants.NIC_DNS_NAME) + @Param(description = "DNS name associated with this NIC's IP address") + private String nicDnsName; + public void setVmId(String vmId) { this.vmId = vmId; } @@ -428,4 +432,8 @@ public Boolean getEnabled() { public void setEnabled(Boolean enabled) { isEnabled = enabled; } + + public void setNicDnsName(String nicDnsName) { + this.nicDnsName = nicDnsName; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java index 4d6eae2fad23..e08c6019f150 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java @@ -234,6 +234,10 @@ public class UserVmResponse extends BaseResponseWithTagInformation implements Co @Param(description = "The name of the backup offering of the Instance", since = "4.14") private String backupOfferingName; + @SerializedName(ApiConstants.BACKUP_PROVIDER) + @Param(description = "The name of the backup provider of the offering attached to the Instance", since = "4.23.0") + private String backupProvider; + @SerializedName("forvirtualnetwork") @Param(description = "The virtual Network for the service offering") private Boolean forVirtualNetwork; @@ -1362,4 +1366,11 @@ public void setLeaseExpiryDate(Date leaseExpiryDate) { this.leaseExpiryDate = leaseExpiryDate; } + public String getBackupProvider() { + return backupProvider; + } + + public void setBackupProvider(String backupProvider) { + this.backupProvider = backupProvider; + } } diff --git a/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java b/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java index ee3b98b8a4b6..cccc1fff9823 100644 --- a/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java +++ b/api/src/main/java/org/apache/cloudstack/auth/UserOAuth2Authenticator.java @@ -42,8 +42,19 @@ public interface UserOAuth2Authenticator extends Adapter { * Verifies the code provided by provider and fetches email * @return returns email */ - String verifyCodeAndFetchEmail(String secretCode); + String verifySecretCodeAndFetchEmail(String secretCode); + /** + * Verifies if the logged in user is valid for a specific domain + * @return true if it's a valid user, otherwise false + */ + boolean verifyUser(String email, String secretCode, Long domainId); + + /** + * Verifies the secret code provided by provider and fetches email for a specific domain + * @return email for the specified domain + */ + String verifySecretCodeAndFetchEmail(String secretCode, Long domainId); /** * Fetches email using the accessToken diff --git a/api/src/main/java/org/apache/cloudstack/backup/Backup.java b/api/src/main/java/org/apache/cloudstack/backup/Backup.java index 2d68f18b953f..2124423da108 100644 --- a/api/src/main/java/org/apache/cloudstack/backup/Backup.java +++ b/api/src/main/java/org/apache/cloudstack/backup/Backup.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.backup; +import java.io.Serializable; import java.util.Date; import java.util.List; import java.util.Map; @@ -39,7 +40,27 @@ public interface Backup extends ControlledEntity, InternalIdentity, Identity { Long getHostId(); enum Status { - Allocated, Queued, BackingUp, ReadyForImageTransfer, FinalizingImageTransfer, BackedUp, Error, Failed, Restoring, Removed, Expunged + Allocated, Queued, BackingUp, ReadyForImageTransfer, FinalizingImageTransfer, BackedUp, Error, Failed, Restoring, Removed, Expunged, + // Hidden: a chain backup kept as a tombstone after the user deleted it while it still has + // live descendants (incremental chains). Excluded from listBackups and from all backup + // operations (which require BackedUp); swept from the DB once its last descendant is gone. + Hidden + } + + enum CompressionStatus { + Uncompressed, Compressing, FinalizingCompression, Compressed, CompressionError + } + + enum ValidationStatus { + NotValidated, Validating, Valid, UnableToValidate, NotValid + } + + enum ValidationSteps { + wait_for_boot, screenshot, execute_command + } + + enum CompressionLibrary { + zstd, zlib } class Metric { @@ -128,7 +149,7 @@ public void setDataSize(Long dataSize) { } } - class VolumeInfo { + class VolumeInfo implements Serializable { private String uuid; private Volume.Type type; private Long size; @@ -197,11 +218,14 @@ public String toString() { String getType(); Date getDate(); Backup.Status getStatus(); + Backup.CompressionStatus getCompressionStatus(); + Backup.ValidationStatus getValidationStatus(); Long getSize(); Long getProtectedSize(); void setName(String name); String getDescription(); void setDescription(String description); + Long getUncompressedSize(); List getBackedUpVolumes(); long getZoneId(); Map getDetails(); diff --git a/api/src/main/java/org/apache/cloudstack/backup/BackupManager.java b/api/src/main/java/org/apache/cloudstack/backup/BackupManager.java index 0da11bbd651a..30fc2bbec40d 100644 --- a/api/src/main/java/org/apache/cloudstack/backup/BackupManager.java +++ b/api/src/main/java/org/apache/cloudstack/backup/BackupManager.java @@ -20,6 +20,8 @@ import java.util.List; import java.util.Map; +import com.cloud.storage.Volume; +import com.cloud.vm.VirtualMachine; import com.cloud.capacity.Capacity; import com.cloud.exception.ResourceAllocationException; import org.apache.cloudstack.api.command.admin.backup.CloneBackupOfferingCmd; @@ -31,18 +33,16 @@ import org.apache.cloudstack.api.command.user.backup.ListBackupOfferingsCmd; import org.apache.cloudstack.api.command.user.backup.ListBackupScheduleCmd; import org.apache.cloudstack.api.command.user.backup.ListBackupsCmd; +import org.apache.cloudstack.api.command.user.backup.CreateBackupOfferingCmd; import org.apache.cloudstack.api.response.BackupResponse; import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.ValidatedConfigKey; import org.apache.cloudstack.framework.config.Configurable; import com.cloud.exception.ResourceUnavailableException; import com.cloud.network.Network; -import com.cloud.storage.Volume; import com.cloud.utils.Pair; import com.cloud.utils.component.Manager; import com.cloud.utils.component.PluggableService; -import com.cloud.vm.VirtualMachine; import com.cloud.vm.VmDiskInfo; /** @@ -55,11 +55,11 @@ public interface BackupManager extends BackupService, Configurable, PluggableSer "false", "Is backup and recovery framework enabled.", false, ConfigKey.Scope.Zone); - ConfigKey BackupProviderPlugin = new ValidatedConfigKey<>("Advanced", String.class, + ConfigKey BackupProviderPlugin = new ConfigKey<>("Advanced", String.class, "backup.framework.provider.plugin", "dummy", - "The backup and recovery provider plugin. Valid plugin values: dummy, veeam, networker and nas", - true, ConfigKey.Scope.Zone, BackupFrameworkEnabled.key(), value -> validateBackupProviderConfig((String)value)); + "The backup and recovery provider plugin.", + true, ConfigKey.Scope.Zone, BackupFrameworkEnabled.key()); ConfigKey BackupSyncPollingInterval = new ConfigKey<>("Advanced", Long.class, "backup.framework.sync.interval", @@ -139,6 +139,12 @@ public interface BackupManager extends BackupService, Configurable, PluggableSer */ BackupOffering importBackupOffering(final ImportBackupOfferingCmd cmd); + /** + * Create a new Backup and Recovery policy to CloudStack. Currently only supported for KBOSS. + * @param cmd create backup offering cmd + */ + BackupOffering createBackupOffering(final CreateBackupOfferingCmd cmd); + List getBackupOfferingDomains(final Long offeringId); /** @@ -211,7 +217,7 @@ public interface BackupManager extends BackupService, Configurable, PluggableSer /** * Restore a full VM from backup */ - boolean restoreBackup(final Long backupId); + boolean restoreBackup(final Long backupId, boolean quickRestore, Long hostId); Map getIpToNetworkMapFromBackup(Backup backup, boolean preserveIps, List networkIds); @@ -222,12 +228,12 @@ public interface BackupManager extends BackupService, Configurable, PluggableSer /** * Restore a backup to a new Instance */ - boolean restoreBackupToVM(Long backupId, Long vmId) throws ResourceUnavailableException; + boolean restoreBackupToVM(Long backupId, Long vmId, boolean quickrestore) throws ResourceUnavailableException; /** * Restore a backed up volume and attach it to a VM */ - boolean restoreBackupVolumeAndAttachToVM(final String backedUpVolumeUuid, final Long backupId, final Long vmId) throws Exception; + boolean restoreBackupVolumeAndAttachToVM(final String backedUpVolumeUuid, final Long backupId, final Long vmId, boolean isQuickRestore, Long hostId) throws Exception; /** * Deletes a backup @@ -258,14 +264,4 @@ public interface BackupManager extends BackupService, Configurable, PluggableSer Capacity getBackupStorageUsedStats(Long zoneId); void checkAndRemoveBackupOfferingBeforeExpunge(VirtualMachine vm); - - static void validateBackupProviderConfig(String value) { - if (value != null && (value.contains(",") || value.trim().contains(" "))) { - throw new IllegalArgumentException("Multiple backup provider plugins are not supported. Please provide a single plugin value."); - } - List validPlugins = List.of("dummy", "veeam", "networker", "nas"); - if (value != null && !validPlugins.contains(value)) { - throw new IllegalArgumentException("Invalid backup provider plugin: " + value + ". Valid plugin values are: " + String.join(", ", validPlugins)); - } - } } diff --git a/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java b/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java index 23b8092425d9..66e4501c460e 100644 --- a/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java +++ b/api/src/main/java/org/apache/cloudstack/backup/BackupProvider.java @@ -63,6 +63,7 @@ public interface BackupProvider { */ boolean removeVMFromBackupOffering(VirtualMachine vm); + /** * Whether the provider will delete backups on removal of VM from the offering * @return boolean result @@ -73,11 +74,14 @@ public interface BackupProvider { * Starts and creates an adhoc backup process * for a previously registered VM backup * - * @param vm the machine to make a backup of - * @param quiesceVM instance will be quiesced for checkpointing for backup. Applicable only to NAS plugin. + * @param vm + * the machine to make a backup of + * @param quiesceVM + * instance will be quiesced for checkpointing for backup. Applicable only to NAS plugin. + * @param isolated * @return the result and {code}Backup{code} {code}Object{code} */ - Pair takeBackup(VirtualMachine vm, Boolean quiesceVM); + Pair takeBackup(VirtualMachine vm, Boolean quiesceVM, boolean isolated); /** * Delete an existing backup @@ -87,17 +91,30 @@ public interface BackupProvider { */ boolean deleteBackup(Backup backup, boolean forced); - Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid); + /** + * Whether {@link #deleteBackup(Backup, boolean)} owns DB-row removal and resource-count / + * usage accounting for every backup it physically removes. Providers that manage incremental + * chains (e.g. NAS) delete several backups per call — the leaf plus swept delete-pending + * ancestors — and decrement once per removed backup themselves, so the manager must NOT + * decrement or remove the row again. Defaults to {@code false}: the manager does the + * single-backup accounting (the historical behaviour for non-chain providers). + */ + default boolean handlesChainDeleteResourceAccounting() { + return false; + } + + Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickrestore); /** * Restore VM from backup */ - boolean restoreVMFromBackup(VirtualMachine vm, Backup backup); + boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId); /** * Restore a volume from a backup */ - Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, Pair vmNameAndState); + Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore); /** * Syncs backup metrics (backup size, protected size) from the plugin and stores it within the provider @@ -140,5 +157,4 @@ default boolean supportsMemoryVmSnapshot() { * @param zoneId the zone for which to return metrics */ void syncBackupStorageStats(Long zoneId); - } diff --git a/api/src/main/java/org/apache/cloudstack/backup/BackupSchedule.java b/api/src/main/java/org/apache/cloudstack/backup/BackupSchedule.java index 44fdf70c4c15..ddc823a14ff5 100644 --- a/api/src/main/java/org/apache/cloudstack/backup/BackupSchedule.java +++ b/api/src/main/java/org/apache/cloudstack/backup/BackupSchedule.java @@ -34,4 +34,5 @@ public interface BackupSchedule extends ControlledEntity, InternalIdentity { Boolean getQuiesceVM(); int getMaxBackups(); String getUuid(); + boolean isIsolated(); } diff --git a/api/src/main/java/org/apache/cloudstack/backup/InternalBackupProvider.java b/api/src/main/java/org/apache/cloudstack/backup/InternalBackupProvider.java new file mode 100644 index 000000000000..0543fb10af36 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/backup/InternalBackupProvider.java @@ -0,0 +1,142 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.storage.Volume; +import com.cloud.uservm.UserVm; +import com.cloud.utils.Pair; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.snapshot.VMSnapshot; +import org.apache.cloudstack.framework.config.ConfigKey; + +import java.util.Set; + +public interface InternalBackupProvider extends BackupProvider { + String VM_WORK_JOB_HANDLER = InternalBackupService.class.getSimpleName(); + + ConfigKey backupCompressionTimeout = new ConfigKey<>("Advanced", Integer.class, "backup.compression.timeout", "28800", "Backup compression timeout (in " + + "seconds). Will only start counting once the backup compression async job actually starts. This setting is currently only applicable to KBOSS.", true, + ConfigKey.Scope.Cluster); + + ConfigKey backupCompressionMinimumFreeStorage = new ConfigKey<>("Advanced", Double.class, "backup.compression.minimum.free.storage", "1", "The minimum " + + "amount of free storage that should be available to start the compression. This configuration uses a multiplier on the backup size, by default, it needs the same " + + "amount of free storage as the backup uses while uncompressed. This setting is currently only applicable to KBOSS.", true, ConfigKey.Scope.Zone); + + ConfigKey backupCompressionCoroutines = new ConfigKey<>("Advanced", Integer.class, "backup.compression.coroutines", "1", "Number of parallel coroutines " + + "for the compression process. This is translated to qemu-img '-m' parameter. This setting is currently only applicable to KBOSS.", true, ConfigKey.Scope.Cluster); + + ConfigKey backupCompressionRateLimit = new ConfigKey<>("Advanced", Integer.class, "backup.compression.rate.limit", "0", "Limit the compression rate to " + + "this configuration's value (in MB/s). Values lower than 1 disable the limit. This setting is currently only applicable to KBOSS.", true, ConfigKey.Scope.Cluster); + + ConfigKey backupValidationTimeout = new ConfigKey<>("Advanced", Integer.class, "backup.validation.timeout", "3600", "Backup validation job timeout (in " + + "seconds). Will only start counting once the backup validation async job actually starts. This setting is currently only applicable to KBOSS.", true, ConfigKey.Scope.Cluster); + + /** + * Actually execute the backup after being queued. + * */ + default Pair orchestrateTakeBackup(Backup backup, boolean quiesceVm, boolean isolated) { + return null; + } + + /** + * Actually delete the backup after being queued. + * */ + default Boolean orchestrateDeleteBackup(Backup backup, boolean forced) { + return null; + } + + /** + * Actually restore the backup after being queued. + * */ + default Boolean orchestrateRestoreVMFromBackup(Backup backup, VirtualMachine vm, boolean quickRestore, Long hostId, boolean sameVmAsBackup) { + return null; + } + + /** + * This method should be overwritten by any backup providers that want to schedule their backup restore jobs in the same queue as the VM jobs. + * Otherwise, just use the restoreBackedUpVolume method. + * */ + default Pair orchestrateRestoreBackedUpVolume(Backup backup, VirtualMachine vm, Backup.VolumeInfo backupVolumeInfo, String hostIp, boolean quickRestore) { + return null; + } + + /** + * This method should be overwritten by any native backup providers that want to allow backup compression through ACS.
+ * The compression is done in two steps:
+ * 1) Compress the backup to a different file;
+ * 2) Switch the old file for the newly compressed one.
+ *

+ * This method is supposed to execute step 1. + * + * @return + */ + default boolean startBackupCompression(long backupId, long hostId) { + return false; + } + + /** + * This method should be overwritten by any native backup providers that want to allow backup compression through ACS.
+ * The compression is done in two steps:
+ * 1) Compress the backup to a different file;
+ * 2) Switch the old file for the newly compressed one.
+ *

+ * This method is supposed to execute step 2. + * + * @return + */ + default boolean finalizeBackupCompression(long backupId, long hostId) { + return false; + } + + default boolean validateBackup(long backupId, long hostId) { + return false; + } + + /** + * This method should be overwritten by any native backup providers that allow volume detach but need to prepare it beforehand. + * */ + default void prepareVolumeForDetach(Volume volume, VirtualMachine virtualMachine) { + } + + /** + * This method should be overwritten by any native backup providers that allow volume migration but need to prepare it beforehand. + * */ + default void prepareVolumeForMigration(Volume volume, VirtualMachine virtualMachine) { + } + + /** + * This method should be overwritten by any native backup providers that must update metadata regarding a volume after certain operations (such as after a volume migration). + * */ + default void updateVolumeId(VirtualMachine virtualMachine, long oldVolumeId, long newVolumeId) { + } + + default Set getSecondaryStorageUrls(UserVm userVm) { + return Set.of(); + } + + /** + * This method should be overwritten by any native backup providers that are compatible with VM Snapshots but need to prepare the VM to be reverted. + * Currently, the only strategy that calls this method is the {@code KvmFileBasedStorageVmSnapshotStrategy}. + * */ + default void prepareVmForSnapshotRevert(VMSnapshot vmSnapshot, VirtualMachine virtualMachine) { + } + + default boolean finishBackupChain(VirtualMachine virtualMachine) { + return false; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/backup/InternalBackupService.java b/api/src/main/java/org/apache/cloudstack/backup/InternalBackupService.java new file mode 100644 index 000000000000..76c71f0eb4ce --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/backup/InternalBackupService.java @@ -0,0 +1,54 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import com.cloud.agent.api.to.DataTO; +import com.cloud.storage.Volume; +import com.cloud.uservm.UserVm; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.snapshot.VMSnapshot; +import org.apache.cloudstack.api.response.ExtractResponse; + +import java.util.Set; + +public interface InternalBackupService { + + void configureChainInfo(DataTO volumeTo, Command cmd); + + void cleanupBackupMetadata(long volumeId); + + void prepareVolumeForDetach(Volume volume, VirtualMachine virtualMachine); + + void prepareVolumeForMigration(Volume volume); + + void prepareVmForSnapshotRevert(VMSnapshot vmSnapshot); + + void updateVolumeId(long oldVolumeId, long newVolumeId); + + Set getSecondaryStorageUrls(UserVm userVm); + + boolean startBackupCompression(long backupId, long hostId, long zoneId); + + boolean finalizeBackupCompression(long backupId, long hostId, long zoneId); + + boolean validateBackup(long backupId, long hostId, long zoneId); + + ExtractResponse downloadScreenshot(long backupId); + + boolean finishBackupChain(long vmId); +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java b/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java new file mode 100644 index 000000000000..231583244c2a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProvider.java @@ -0,0 +1,45 @@ +// 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 org.apache.cloudstack.dns; + +import java.util.List; + +import org.apache.cloudstack.dns.exception.DnsProviderException; + +import com.cloud.utils.component.Adapter; + +public interface DnsProvider extends Adapter { + DnsProviderType getProviderType(); + + // Validates connectivity to the server + void validate(DnsServer server) throws Exception; + + String validateAndResolveServer(DnsServer server) throws Exception; + + // Zone Operations + String provisionZone(DnsServer server, DnsZone zone) throws DnsProviderException; + void deleteZone(DnsServer server, DnsZone zone) throws DnsProviderException; + void updateZone(DnsServer server, DnsZone zone) throws DnsProviderException; + + String addRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; + List listRecords(DnsServer server, DnsZone zone) throws DnsProviderException; + String updateRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; + String deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException; + boolean dnsRecordExists(DnsServer server, DnsZone zone, String recordName, String recordType) throws DnsProviderException; + boolean dnsZoneExists(DnsServer server, DnsZone zone); +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java new file mode 100644 index 000000000000..5430884a32d9 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderManager.java @@ -0,0 +1,78 @@ +// 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 org.apache.cloudstack.dns; + +import java.util.List; + +import org.apache.cloudstack.api.command.user.dns.AddDnsServerCmd; +import org.apache.cloudstack.api.command.user.dns.AssociateDnsZoneToNetworkCmd; +import org.apache.cloudstack.api.command.user.dns.CreateDnsRecordCmd; +import org.apache.cloudstack.api.command.user.dns.CreateDnsZoneCmd; +import org.apache.cloudstack.api.command.user.dns.DeleteDnsRecordCmd; +import org.apache.cloudstack.api.command.user.dns.DeleteDnsServerCmd; +import org.apache.cloudstack.api.command.user.dns.DisassociateDnsZoneFromNetworkCmd; +import org.apache.cloudstack.api.command.user.dns.ListDnsRecordsCmd; +import org.apache.cloudstack.api.command.user.dns.ListDnsServersCmd; +import org.apache.cloudstack.api.command.user.dns.ListDnsZonesCmd; +import org.apache.cloudstack.api.command.user.dns.UpdateDnsServerCmd; +import org.apache.cloudstack.api.command.user.dns.UpdateDnsZoneCmd; +import org.apache.cloudstack.api.response.DnsRecordResponse; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.DnsZoneNetworkMapResponse; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.ListResponse; + +import com.cloud.user.Account; +import com.cloud.utils.component.Manager; +import com.cloud.utils.component.PluggableService; + +public interface DnsProviderManager extends Manager, PluggableService { + + DnsServer addDnsServer(AddDnsServerCmd cmd); + ListResponse listDnsServers(ListDnsServersCmd cmd); + DnsServer updateDnsServer(UpdateDnsServerCmd cmd); + boolean deleteDnsServer(DeleteDnsServerCmd cmd); + DnsServerResponse createDnsServerResponse(DnsServer server); + + // Allocates the DB row (State: Inactive) + DnsZone allocateDnsZone(CreateDnsZoneCmd cmd); + // Calls the Plugin (State: Inactive -> Active) + DnsZone provisionDnsZone(long zoneId, boolean isImport); + + DnsZone updateDnsZone(UpdateDnsZoneCmd cmd); + boolean deleteDnsZone(Long id, boolean isUnmanage); + ListResponse listDnsZones(ListDnsZonesCmd cmd); + + DnsRecordResponse createDnsRecord(CreateDnsRecordCmd cmd); + boolean deleteDnsRecord(DeleteDnsRecordCmd cmd); + ListResponse listDnsRecords(ListDnsRecordsCmd cmd); + + List listProviderNames(); + + // Helper to create the response object + DnsZoneResponse createDnsZoneResponse(DnsZone zone); + DnsRecordResponse createDnsRecordResponse(DnsRecord record); + + DnsZoneNetworkMapResponse associateZoneToNetwork(AssociateDnsZoneToNetworkCmd cmd); + + boolean disassociateZoneFromNetwork(DisassociateDnsZoneFromNetworkCmd cmd); + + void checkDnsServerPermission(Account caller, DnsServer dnsServer); + + void checkDnsZonePermission(Account caller, DnsZone dnsZone); +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsProviderType.java b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderType.java new file mode 100644 index 000000000000..23c8e936613f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsProviderType.java @@ -0,0 +1,23 @@ +// 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 org.apache.cloudstack.dns; + +public enum DnsProviderType { + PowerDNS; +// Cloudflare +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java b/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java new file mode 100644 index 000000000000..ae62e4729cca --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsRecord.java @@ -0,0 +1,66 @@ +// 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 org.apache.cloudstack.dns; + +import java.util.List; + +import com.cloud.utils.exception.CloudRuntimeException; + +public class DnsRecord { + + public enum RecordType { + A, AAAA, CNAME, MX, TXT, SRV, PTR, NS; + + public static RecordType fromString(String type) { + if (type == null) return null; + try { + return RecordType.valueOf(type.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CloudRuntimeException("Invalid DNS Record Type: " + type + + ". Supported: " + java.util.Arrays.toString(values())); + } + } + } + + private String name; + private RecordType type; + private List contents; + private int ttl; + + public DnsRecord() {} + + public DnsRecord(String name, RecordType type, List contents, int ttl) { + this.name = name; + this.type = type; + this.contents = contents; + this.ttl = ttl; + } + + // Getters and Setters + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public RecordType getType() { return type; } + public void setType(RecordType type) { this.type = type; } + + public List getContents() { return contents; } + public void setContents(List contents) { this.contents = contents; } + + public int getTtl() { return ttl; } + public void setTtl(int ttl) { this.ttl = ttl; } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java b/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java new file mode 100644 index 000000000000..f34f94726d1b --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsServer.java @@ -0,0 +1,62 @@ +// 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 org.apache.cloudstack.dns; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface DnsServer extends InternalIdentity, Identity, ControlledEntity { + enum State { + Enabled, Disabled + }; + + String getName(); + + String getUrl(); + + DnsProviderType getProviderType(); + + List getNameServers(); + + String getDnsApiKey(); + + long getAccountId(); + + boolean getPublicServer(); + + Date getCreated(); + + Date getRemoved(); + + String getPublicDomainSuffix(); + + Integer getPort(); + + Map getDetails(); + + String getDetail(String name); + + void setDetails(Map details); + + void appendDetails(String name, String value); +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/DnsZone.java b/api/src/main/java/org/apache/cloudstack/dns/DnsZone.java new file mode 100644 index 000000000000..193185a6b0c6 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/DnsZone.java @@ -0,0 +1,47 @@ +// 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 org.apache.cloudstack.dns; + +import java.util.List; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface DnsZone extends InternalIdentity, Identity, ControlledEntity { + enum ZoneType { + Public, Private + } + enum State { + Active, Inactive + } + + String getName(); + + long getDnsServerId(); + + long getAccountId(); + + ZoneType getType(); + + String getDescription(); + + List getAssociatedNetworks(); + + State getState(); +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java new file mode 100644 index 000000000000..325cb78241ef --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsAuthenticationException.java @@ -0,0 +1,27 @@ +// 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 org.apache.cloudstack.dns.exception; + +/** + * Thrown when authentication to the DNS provider fails. + */ +public class DnsAuthenticationException extends DnsProviderException { + public DnsAuthenticationException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java new file mode 100644 index 000000000000..9a36bb87478a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsConflictException.java @@ -0,0 +1,27 @@ +// 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 org.apache.cloudstack.dns.exception; + +/** + * Thrown when attempting to create a zone or record that already exists. + */ +public class DnsConflictException extends DnsProviderException { + public DnsConflictException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java new file mode 100644 index 000000000000..aa88f308ce84 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsNotFoundException.java @@ -0,0 +1,27 @@ +// 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 org.apache.cloudstack.dns.exception; + +/** + * Thrown when the requested zone or record does not exist. + */ +public class DnsNotFoundException extends DnsProviderException { + public DnsNotFoundException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java new file mode 100644 index 000000000000..564acdc9a6f0 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsOperationException.java @@ -0,0 +1,27 @@ +// 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 org.apache.cloudstack.dns.exception; + +/** + * Thrown for unexpected or unknown errors returned by the DNS provider. + */ +public class DnsOperationException extends DnsProviderException { + public DnsOperationException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java new file mode 100644 index 000000000000..de307c9903e4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsProviderException.java @@ -0,0 +1,28 @@ +// 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 org.apache.cloudstack.dns.exception; + +public class DnsProviderException extends Exception { + public DnsProviderException(String message) { + super(message); + } + + public DnsProviderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java new file mode 100644 index 000000000000..50f04143c921 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/dns/exception/DnsTransportException.java @@ -0,0 +1,30 @@ +// 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 org.apache.cloudstack.dns.exception; + +import java.io.IOException; + +/** + * Thrown when HTTP or network errors occur communicating with the DNS provider. + */ +public class DnsTransportException extends DnsProviderException { + + public DnsTransportException(String message, IOException cause) { + super(message, cause); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java index 33ff70fcace5..558340694b8a 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java +++ b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java @@ -62,4 +62,12 @@ public Boolean getSuccess() { public void setResult(Map result) { this.result = result; } + + public Map getResult() { + return result; + } + + public boolean isSuccess() { + return Boolean.TRUE.equals(success); + } } diff --git a/api/src/main/java/org/apache/cloudstack/extension/Extension.java b/api/src/main/java/org/apache/cloudstack/extension/Extension.java index 3068612ed6fe..c1d905718b24 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/Extension.java +++ b/api/src/main/java/org/apache/cloudstack/extension/Extension.java @@ -24,7 +24,8 @@ public interface Extension extends InternalIdentity, Identity { enum Type { - Orchestrator + Orchestrator, + NetworkOrchestrator } enum State { Enabled, Disabled; diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java index 776b42f671b7..245faa762a2f 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java @@ -48,7 +48,9 @@ public interface ExtensionCustomAction extends InternalIdentity, Identity { enum ResourceType { - VirtualMachine(com.cloud.vm.VirtualMachine.class); + VirtualMachine(com.cloud.vm.VirtualMachine.class), + Network(com.cloud.network.Network.class), + Vpc(com.cloud.network.vpc.Vpc.class); private final Class clazz; diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java index a01131278a76..e3d84745f997 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java @@ -18,10 +18,125 @@ package org.apache.cloudstack.extension; import java.util.List; +import java.util.Map; + +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Service; public interface ExtensionHelper { Long getExtensionIdForCluster(long clusterId); Extension getExtension(long id); + Extension getExtensionByNameAndType(String name, Extension.Type type); Extension getExtensionForCluster(long clusterId); List getExtensionReservedResourceDetails(long extensionId); + + /** + * Network Guru of Network Extension + */ + String NETWORK_EXTENSION_GURU_NAME = "NetworkExtensionGuestNetworkGuru"; + + /** + * Extension resource-map detail key holding the isolation method for the physical + * network (e.g. {@code "NetworkExtension"}). + * If not specified, the isolation method of physical network will be used. + */ + String NETWORK_ISOLATION_METHOD_DETAIL_KEY = "network.isolation.method"; + + /** + * Value of {@link #NETWORK_ISOLATION_METHOD_DETAIL_KEY} that indicates + * the extension owns the network isolation. + */ + String NETWORK_EXTENSION_ISOLATION_METHOD = "NetworkExtension"; + + /** + * Detail key used to store the comma-separated list of network services provided + * by a NetworkOrchestrator extension (e.g. {@code "SourceNat,StaticNat,Firewall"}). + */ + String NETWORK_SERVICES_DETAIL_KEY = "network.services"; + + /** + * Detail key used to store a JSON object mapping each service name to its + * CloudStack {@link com.cloud.network.Network.Capability} key/value pairs. + * Example: {@code {"SourceNat":{"SupportedSourceNatTypes":"peraccount"}}}. + * Used together with {@link #NETWORK_SERVICES_DETAIL_KEY}. + */ + String NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY = "network.service.capabilities"; + + String getExtensionScriptPath(Extension extension); + + /** + * Finds the extension registered with the given physical network whose name + * matches the given provider name (case-insensitive). Returns {@code null} + * if no matching extension is found. + * + *

This is the preferred lookup when multiple extensions are registered on + * the same physical network: the provider name stored in + * {@code ntwk_service_map} is used to pinpoint the exact extension that + * handles a given network.

+ * + * @param physicalNetworkId the physical network ID + * @param providerName the provider name (must equal the extension name) + * @return the matching {@link Extension}, or {@code null} + */ + Extension getExtensionForPhysicalNetworkAndProvider(long physicalNetworkId, String providerName); + + /** + * Returns ALL {@code extension_resource_map_details} (including hidden) for + * the specific extension registered on the given physical network. Used by + * {@code NetworkExtensionElement} to inject device credentials into the script + * environment for the correct extension when multiple different extensions are + * registered on the same physical network. + * + * @param physicalNetworkId the physical network ID + * @param extensionId the extension ID + * @return all key/value details including non-display ones, or an empty map + */ + Map getAllResourceMapDetailsForExtensionOnPhysicalNetwork(long physicalNetworkId, long extensionId); + + /** + * Returns {@code true} if the given provider name is backed by a + * {@code NetworkOrchestrator} extension registered on any physical network. + * This is used by {@code NetworkModelImpl} to detect extension-backed providers + * that are not in the static {@code s_providerToNetworkElementMap}. + * + * @param providerName the provider / extension name + * @return true if the provider is a NetworkExtension provider + */ + boolean isNetworkExtensionProvider(String providerName); + + /** + * List all registered extensions filtered by extension {@link Extension.Type}. + * Useful for callers that need to discover available providers of a given + * type (e.g. Orchestrator, NetworkOrchestrator). + * + * @param type extension type to filter by + * @return list of matching {@link Extension} instances (empty list if none) + */ + List listExtensionsByType(Extension.Type type); + + /** + * Returns {@code true} when the extension registered for {@code providerName} on + * the given physical network has its {@link #NETWORK_ISOLATION_METHOD_DETAIL_KEY} + * resource-map detail set to {@link #NETWORK_EXTENSION_ISOLATION_METHOD}. + * + * @param providerName provider / extension name + * @return true if the extension uses NetworkExtension isolation + */ + boolean usesNetworkExtensionIsolation(String providerName); + + /** + * Returns the effective {@link Service} → ({@link Capability} → value) capabilities + * for the given external network provider, looking it up by name on the given + * physical network. + * + *

If {@code physicalNetworkId} is {@code null}, the method searches across all + * physical networks that have extensions registered and returns the capabilities for + * the first matching extension.

+ * + * @param physicalNetworkId physical network ID, or {@code null} for offering-level queries + * @param providerName provider / extension name + * @return capabilities map, or the default capabilities if no matching extension is found + */ + Map> getNetworkCapabilitiesForProvider(Long physicalNetworkId, String providerName); + } diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java index 40ebc19eb5e3..604e64a1f894 100644 --- a/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java @@ -24,7 +24,8 @@ public interface ExtensionResourceMap extends InternalIdentity, Identity { enum ResourceType { - Cluster + Cluster, + PhysicalNetwork } long getExtensionId(); diff --git a/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java new file mode 100644 index 000000000000..1c281c15f286 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/NetworkCustomActionProvider.java @@ -0,0 +1,74 @@ +// 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 org.apache.cloudstack.extension; + +import java.util.Map; + +import com.cloud.network.Network; +import com.cloud.network.vpc.Vpc; + +/** + * Implemented by network elements that support running custom actions on a + * managed network or VPC (e.g. NetworkExtensionElement). + * + *

This interface is looked up by {@code ExtensionsManagerImpl} to dispatch + * {@code runCustomAction} requests whose resource type is {@code Network} + * or {@code Vpc}.

+ */ +public interface NetworkCustomActionProvider { + + /** + * Returns {@code true} if this provider handles networks whose physical + * network has an ExternalNetwork service provider registered. + * + * @param network the target network + * @return {@code true} if this provider can handle the network + */ + boolean canHandleCustomAction(Network network); + + /** + * Returns {@code true} if this provider can handle custom actions for + * the given VPC. + * + * @param vpc the target VPC + * @return {@code true} if this provider can handle the VPC + */ + boolean canHandleVpcCustomAction(Vpc vpc); + + /** + * Runs a named custom action against the external network device that + * manages the given network. + * + * @param network the CloudStack network on which to run the action + * @param actionName the action name (e.g. {@code "reboot-device"}, {@code "dump-config"}) + * @param parameters optional parameters supplied by the caller + * @return output from the action script, or {@code null} on failure + */ + String runCustomAction(Network network, String actionName, Map parameters); + + /** + * Runs a named custom action against the external network device that + * manages the given VPC. + * + * @param vpc the CloudStack VPC on which to run the action + * @param actionName the action name + * @param parameters optional parameters supplied by the caller + * @return output from the action script, or {@code null} on failure + */ + String runCustomAction(Vpc vpc, String actionName, Map parameters); +} diff --git a/api/src/main/java/org/apache/cloudstack/query/QueryService.java b/api/src/main/java/org/apache/cloudstack/query/QueryService.java index 5b053aafd84b..b6362e9a9c9b 100644 --- a/api/src/main/java/org/apache/cloudstack/query/QueryService.java +++ b/api/src/main/java/org/apache/cloudstack/query/QueryService.java @@ -41,6 +41,7 @@ import org.apache.cloudstack.api.command.user.account.ListProjectAccountsCmd; import org.apache.cloudstack.api.command.user.address.ListQuarantinedIpsCmd; import org.apache.cloudstack.api.command.user.affinitygroup.ListAffinityGroupsCmd; +import org.apache.cloudstack.api.command.user.backup.ListBackupServiceJobsCmd; import org.apache.cloudstack.api.command.user.bucket.ListBucketsCmd; import org.apache.cloudstack.api.command.user.event.ListEventsCmd; import org.apache.cloudstack.api.command.user.iso.ListIsosCmd; @@ -63,6 +64,7 @@ import org.apache.cloudstack.api.command.user.zone.ListZonesCmd; import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.BackupServiceJobResponse; import org.apache.cloudstack.api.response.BucketResponse; import org.apache.cloudstack.api.response.DetailOptionsResponse; import org.apache.cloudstack.api.response.DiskOfferingResponse; @@ -119,7 +121,7 @@ public interface QueryService { "Determines whether users can view certain VM settings. When set to empty, default value used is: rootdisksize, cpuOvercommitRatio, memoryOvercommitRatio, Message.ReservedCapacityFreed.Flag.", true, ConfigKey.Scope.Global, null, null, null, null, null, ConfigKey.Kind.CSV, null); ConfigKey UserVMReadOnlyDetails = new ConfigKey<>(String.class, - "user.vm.readonly.details", "Advanced", "dataDiskController, rootDiskController", + "user.vm.readonly.details", "Advanced", "dataDiskController, rootDiskController, backupValidationCommandTimeout, backupValidationScreenshotWait, backupValidationBootTimeout", "List of read-only VM settings/details as comma separated string", true, ConfigKey.Scope.Global, null, null, null, null, null, ConfigKey.Kind.CSV, null, ""); ConfigKey SortKeyAscending = new ConfigKey<>("Advanced", Boolean.class, "sortkey.algorithm", "true", @@ -224,4 +226,6 @@ public interface QueryService { ListResponse searchForObjectStores(ListObjectStoragePoolsCmd listObjectStoragePoolsCmd); ListResponse searchForBuckets(ListBucketsCmd listBucketsCmd); + + ListResponse listBackupServiceJobs(ListBackupServiceJobsCmd cmd); } diff --git a/api/src/main/java/org/apache/cloudstack/secstorage/heuristics/HeuristicType.java b/api/src/main/java/org/apache/cloudstack/secstorage/heuristics/HeuristicType.java index f23e4b0b633b..489e9bf54f23 100644 --- a/api/src/main/java/org/apache/cloudstack/secstorage/heuristics/HeuristicType.java +++ b/api/src/main/java/org/apache/cloudstack/secstorage/heuristics/HeuristicType.java @@ -18,8 +18,8 @@ /** * The type of the heuristic used in the allocation process of secondary storage resources. - * Valid options are: {@link #ISO}, {@link #SNAPSHOT}, {@link #TEMPLATE} and {@link #VOLUME} + * Valid options are: {@link #ISO}, {@link #SNAPSHOT}, {@link #TEMPLATE}, {@link #VOLUME} and {@link #BACKUP} */ public enum HeuristicType { - ISO, SNAPSHOT, TEMPLATE, VOLUME + ISO, SNAPSHOT, TEMPLATE, VOLUME, BACKUP } diff --git a/api/src/test/java/com/cloud/network/NetworkTest.java b/api/src/test/java/com/cloud/network/NetworkTest.java new file mode 100644 index 000000000000..dba4d1fb7eb5 --- /dev/null +++ b/api/src/test/java/com/cloud/network/NetworkTest.java @@ -0,0 +1,73 @@ +// 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.network; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + + +public class NetworkTest { + + @Test + public void testProviderContains() { + List providers = new ArrayList<>(); + providers.add(Network.Provider.VirtualRouter); + + // direct instance present + assertTrue("List should contain VirtualRouter provider", providers.contains(Network.Provider.VirtualRouter)); + + // resolved provider by name (registered provider) + Network.Provider resolved = Network.Provider.getProvider("VirtualRouter"); + assertNotNull("Resolved provider should not be null", resolved); + assertTrue("List should contain resolved VirtualRouter provider", providers.contains(resolved)); + + // transient provider with same name should be considered equal (equals by name) + Network.Provider transientProvider = Network.Provider.createTransientProvider("NetworkExtension"); + assertFalse("List should not contain the transient provider", providers.contains(transientProvider)); + + providers.add(transientProvider); + assertTrue("List should contain the transient provider", providers.contains(transientProvider)); + + // another transient provider with same name should be considered equal + Network.Provider transientProviderNew = Network.Provider.createTransientProvider("NetworkExtension"); + assertTrue("List should contain the new transient provider with same name", providers.contains(transientProviderNew)); + } + + @Test + public void testCustomActionServiceLookup() { + Network.Service customAction = Network.Service.getService("CustomAction"); + assertNotNull("CustomAction service should be available", customAction); + assertTrue("CustomAction should be part of the supported services list", + Network.Service.listAllServices().contains(customAction)); + } + + @Test + public void testTransientProviderIsNotGloballyRegistered() { + Network.Provider transientProvider = Network.Provider.createTransientProvider("TransientOnly"); + assertNotNull(transientProvider); + assertNull("Transient provider should not be retrievable from the global registry", + Network.Provider.getProvider("TransientOnly")); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmdTest.java new file mode 100644 index 000000000000..2824a8dacb94 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AddDnsServerCmdTest.java @@ -0,0 +1,134 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.dns.DnsProviderType; +import org.apache.cloudstack.dns.DnsServer; +import org.junit.Test; + +public class AddDnsServerCmdTest extends BaseDnsCmdTest { + + private AddDnsServerCmd createCmd() throws Exception { + AddDnsServerCmd cmd = new AddDnsServerCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "name", "test-dns"); + setField(cmd, "url", "http://dns.example.com"); + setField(cmd, "provider", "PowerDNS"); + setField(cmd, "dnsApiKey", "api-key-123"); + setField(cmd, "port", 8081); + setField(cmd, "isPublic", true); + setField(cmd, "publicDomainSuffix", "public.example.com"); + setField(cmd, "nameServers", Arrays.asList("ns1.example.com", "ns2.example.com")); + setField(cmd, "dnsUserName", "admin@example.com"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + AddDnsServerCmd cmd = createCmd(); + + assertEquals("test-dns", cmd.getName()); + assertEquals("http://dns.example.com", cmd.getUrl()); + assertEquals("api-key-123", cmd.getDnsApiKey()); + assertEquals(Integer.valueOf(8081), cmd.getPort()); + assertTrue(cmd.isPublic()); + assertEquals("public.example.com", cmd.getPublicDomainSuffix()); + assertEquals(Arrays.asList("ns1.example.com", "ns2.example.com"), cmd.getNameServers()); + assertEquals(DnsProviderType.PowerDNS, cmd.getProvider()); + assertEquals("admin@example.com", cmd.getDnsUserName()); + } + + @Test + public void testIsPublicFalse() throws Exception { + AddDnsServerCmd cmd = createCmd(); + setField(cmd, "isPublic", false); + assertFalse(cmd.isPublic()); + } + + @Test + public void testIsPublicNull() throws Exception { + AddDnsServerCmd cmd = createCmd(); + setField(cmd, "isPublic", null); + assertFalse(cmd.isPublic()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + AddDnsServerCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetProviderDefault() throws Exception { + AddDnsServerCmd cmd = createCmd(); + setField(cmd, "provider", null); + assertEquals(DnsProviderType.PowerDNS, cmd.getProvider()); + } + + @Test + public void testGetProviderCaseInsensitive() throws Exception { + AddDnsServerCmd cmd = createCmd(); + setField(cmd, "provider", "powerdns"); + assertEquals(DnsProviderType.PowerDNS, cmd.getProvider()); + } + + @Test + public void testExecuteSuccess() throws Exception { + AddDnsServerCmd cmd = createCmd(); + + DnsServer mockServer = mock(DnsServer.class); + DnsServerResponse mockResponse = new DnsServerResponse(); + mockResponse.setName("test-dns"); + + when(dnsProviderManager.addDnsServer(cmd)).thenReturn(mockServer); + when(dnsProviderManager.createDnsServerResponse(mockServer)).thenReturn(mockResponse); + + cmd.execute(); + + DnsServerResponse response = (DnsServerResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("adddnsserverresponse", response.getResponseName()); + verify(dnsProviderManager).addDnsServer(cmd); + verify(dnsProviderManager).createDnsServerResponse(mockServer); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + AddDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.addDnsServer(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + AddDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.addDnsServer(cmd)).thenThrow(new RuntimeException("Connection refused")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmdTest.java new file mode 100644 index 000000000000..593a0a041cea --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/AssociateDnsZoneToNetworkCmdTest.java @@ -0,0 +1,97 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneNetworkMapResponse; +import org.junit.Test; + +import com.cloud.network.Network; +import com.cloud.user.Account; + +public class AssociateDnsZoneToNetworkCmdTest extends BaseDnsCmdTest { + + private static final long NETWORK_ID = 200L; + + private AssociateDnsZoneToNetworkCmd createCmd() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = new AssociateDnsZoneToNetworkCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "dnsZoneId", ENTITY_ID); + setField(cmd, "networkId", NETWORK_ID); + setField(cmd, "subDomain", "dev"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getDnsZoneId()); + assertEquals(Long.valueOf(NETWORK_ID), cmd.getNetworkId()); + assertEquals("dev", cmd.getSubDomain()); + } + + @Test + public void testGetEntityOwnerIdWithNetwork() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = createCmd(); + Network mockNetwork = mock(Network.class); + when(mockNetwork.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(Network.class, NETWORK_ID)) + .thenReturn(mockNetwork); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdNetworkNotFound() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = createCmd(); + when(entityManager.findById(Network.class, NETWORK_ID)) + .thenReturn(null); + + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = createCmd(); + DnsZoneNetworkMapResponse mockResponse = + new DnsZoneNetworkMapResponse(); + when(dnsProviderManager.associateZoneToNetwork(cmd)) + .thenReturn(mockResponse); + + cmd.execute(); + + DnsZoneNetworkMapResponse response = + (DnsZoneNetworkMapResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("associatednszonetonetworkresponse", + response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + AssociateDnsZoneToNetworkCmd cmd = createCmd(); + when(dnsProviderManager.associateZoneToNetwork(cmd)) + .thenThrow(new RuntimeException("Error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/BaseDnsCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/BaseDnsCmdTest.java new file mode 100644 index 000000000000..e8b611ca17ca --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/BaseDnsCmdTest.java @@ -0,0 +1,88 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; + +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.dns.DnsProviderManager; +import org.junit.After; +import org.junit.Before; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.cloud.user.Account; +import com.cloud.utils.db.EntityManager; + +/** + * Shared setup for all DNS command unit tests. + */ +public abstract class BaseDnsCmdTest { + + protected static final long ACCOUNT_ID = 42L; + protected static final long ENTITY_ID = 100L; + + protected DnsProviderManager dnsProviderManager; + protected EntityManager entityManager; + protected Account callingAccount; + + private MockedStatic callContextMock; + + @Before + public void setUp() { + dnsProviderManager = mock(DnsProviderManager.class); + entityManager = mock(EntityManager.class); + + callingAccount = mock(Account.class); + when(callingAccount.getId()).thenReturn(ACCOUNT_ID); + + CallContext callContext = mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(callingAccount); + + callContextMock = Mockito.mockStatic(CallContext.class); + callContextMock.when(CallContext::current).thenReturn(callContext); + } + + @After + public void tearDown() { + callContextMock.close(); + } + + /** + * Sets a private/inherited field value via reflection. + */ + protected void setField(Object target, String fieldName, Object value) throws Exception { + Field field = null; + Class clazz = target.getClass(); + while (clazz != null) { + try { + field = clazz.getDeclaredField(fieldName); + break; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } + } + if (field == null) { + throw new NoSuchFieldException(fieldName + " not found in hierarchy of " + target.getClass().getName()); + } + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmdTest.java new file mode 100644 index 000000000000..c37930dd2289 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsRecordCmdTest.java @@ -0,0 +1,117 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import java.util.Arrays; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsRecordResponse; +import org.apache.cloudstack.dns.DnsRecord; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; + +public class CreateDnsRecordCmdTest extends BaseDnsCmdTest { + + private CreateDnsRecordCmd createCmd() throws Exception { + CreateDnsRecordCmd cmd = new CreateDnsRecordCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "dnsZoneId", ENTITY_ID); + setField(cmd, "name", "www"); + setField(cmd, "type", "A"); + setField(cmd, "contents", Arrays.asList("192.168.1.1")); + setField(cmd, "ttl", 7200); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + + assertEquals(Long.valueOf(ENTITY_ID), cmd.getDnsZoneId()); + assertEquals("www", cmd.getName()); + assertEquals(DnsRecord.RecordType.A, cmd.getType()); + assertEquals(Arrays.asList("192.168.1.1"), cmd.getContents()); + assertEquals(Integer.valueOf(7200), cmd.getTtl()); + } + + @Test + public void testGetTtlDefault() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + setField(cmd, "ttl", null); + assertEquals(Integer.valueOf(3600), cmd.getTtl()); + } + + @Test + public void testGetTypeCname() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + setField(cmd, "type", "CNAME"); + assertEquals(DnsRecord.RecordType.CNAME, cmd.getType()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testGetTypeInvalid() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + setField(cmd, "type", "INVALID"); + cmd.getType(); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testEventType() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_DNS_RECORD_CREATE, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + assertEquals("Creating DNS Record: www", cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + + DnsRecordResponse mockResponse = new DnsRecordResponse(); + mockResponse.setName("www"); + when(dnsProviderManager.createDnsRecord(cmd)).thenReturn(mockResponse); + + cmd.execute(); + + DnsRecordResponse response = (DnsRecordResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("creatednsrecordresponse", response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + CreateDnsRecordCmd cmd = createCmd(); + when(dnsProviderManager.createDnsRecord(cmd)).thenThrow(new RuntimeException("Provider error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmdTest.java new file mode 100644 index 000000000000..dc37874e6599 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/CreateDnsZoneCmdTest.java @@ -0,0 +1,157 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.dns.DnsZone; +import org.junit.Test; + +import com.cloud.event.EventTypes; + +public class CreateDnsZoneCmdTest extends BaseDnsCmdTest { + + private CreateDnsZoneCmd createCmd() throws Exception { + CreateDnsZoneCmd cmd = new CreateDnsZoneCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "name", "example.com"); + setField(cmd, "dnsServerId", ENTITY_ID); + setField(cmd, "type", "Public"); + setField(cmd, "description", "Test zone"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + + assertEquals("example.com", cmd.getName()); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getDnsServerId()); + assertEquals(DnsZone.ZoneType.Public, cmd.getType()); + assertEquals("Test zone", cmd.getDescription()); + } + + @Test + public void testGetTypePrivate() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + setField(cmd, "type", "Private"); + assertEquals(DnsZone.ZoneType.Private, cmd.getType()); + } + + @Test + public void testGetTypeDefaultsToPublicWhenNull() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + setField(cmd, "type", null); + assertEquals(DnsZone.ZoneType.Public, cmd.getType()); + } + + @Test + public void testGetTypeDefaultsToPublicWhenBlank() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + setField(cmd, "type", ""); + assertEquals(DnsZone.ZoneType.Public, cmd.getType()); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testEventType() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_DNS_ZONE_CREATE, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + assertEquals("creating DNS zone: example.com", cmd.getEventDescription()); + } + + @Test + public void testCreateSuccess() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + + DnsZone mockZone = mock(DnsZone.class); + when(mockZone.getId()).thenReturn(ENTITY_ID); + when(mockZone.getUuid()).thenReturn("uuid-123"); + when(dnsProviderManager.allocateDnsZone(cmd)).thenReturn(mockZone); + + cmd.create(); + + assertEquals(Long.valueOf(ENTITY_ID), cmd.getEntityId()); + assertEquals("uuid-123", cmd.getEntityUuid()); + } + + @Test(expected = ServerApiException.class) + public void testCreateReturnsNull() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.allocateDnsZone(cmd)).thenReturn(null); + cmd.create(); + } + + @Test(expected = ServerApiException.class) + public void testCreateThrowsException() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.allocateDnsZone(cmd)).thenThrow(new RuntimeException("DB error")); + cmd.create(); + } + + @Test + public void testExecuteSuccess() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + cmd.setEntityId(ENTITY_ID); + + DnsZone mockZone = mock(DnsZone.class); + DnsZoneResponse mockResponse = new DnsZoneResponse(); + mockResponse.setName("example.com"); + + when(dnsProviderManager.provisionDnsZone(ENTITY_ID, false)).thenReturn(mockZone); + when(dnsProviderManager.createDnsZoneResponse(mockZone)).thenReturn(mockResponse); + + cmd.execute(); + + DnsZoneResponse response = (DnsZoneResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("creatednszoneresponse", response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + cmd.setEntityId(ENTITY_ID); + + when(dnsProviderManager.provisionDnsZone(ENTITY_ID, false)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + CreateDnsZoneCmd cmd = createCmd(); + cmd.setEntityId(ENTITY_ID); + + when(dnsProviderManager.provisionDnsZone(ENTITY_ID, false)).thenThrow(new RuntimeException("Provider error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmdTest.java new file mode 100644 index 000000000000..46aacf5a68ce --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsRecordCmdTest.java @@ -0,0 +1,102 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.dns.DnsRecord; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; + +public class DeleteDnsRecordCmdTest extends BaseDnsCmdTest { + + private DeleteDnsRecordCmd createCmd() throws Exception { + DeleteDnsRecordCmd cmd = new DeleteDnsRecordCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "dnsZoneId", ENTITY_ID); + setField(cmd, "name", "www"); + setField(cmd, "type", "A"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + + assertEquals(Long.valueOf(ENTITY_ID), cmd.getDnsZoneId()); + assertEquals("www", cmd.getName()); + assertEquals(DnsRecord.RecordType.A, cmd.getType()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testGetTypeInvalid() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + setField(cmd, "type", "BOGUS"); + cmd.getType(); + } + + @Test + public void testGetEntityOwnerId() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testEventType() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_DNS_RECORD_DELETE, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + assertEquals("Deleting DNS Record: www", cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsRecord(cmd)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + verify(dnsProviderManager).deleteDnsRecord(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsRecord(cmd)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + DeleteDnsRecordCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsRecord(cmd)).thenThrow(new RuntimeException("Error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmdTest.java new file mode 100644 index 000000000000..adebd339d710 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsServerCmdTest.java @@ -0,0 +1,119 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.dns.DnsServer; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.user.Account; + +public class DeleteDnsServerCmdTest extends BaseDnsCmdTest { + + private DeleteDnsServerCmd createCmd() throws Exception { + DeleteDnsServerCmd cmd = new DeleteDnsServerCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testGetId() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetCleanupDefault() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + assertTrue(cmd.getCleanup()); + } + + @Test + public void testGetCleanupFalse() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + setField(cmd, "cleanup", false); + assertFalse(cmd.getCleanup()); + } + + @Test + public void testGetEntityOwnerIdWithServer() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + DnsServer mockServer = mock(DnsServer.class); + when(mockServer.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(DnsServer.class, ENTITY_ID)).thenReturn(mockServer); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdServerNotFound() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + when(entityManager.findById(DnsServer.class, ENTITY_ID)).thenReturn(null); + + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testEventType() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_DNS_SERVER_DELETE, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + assertEquals("Deleting DNS server ID: " + ENTITY_ID, cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsServer(cmd)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + verify(dnsProviderManager).deleteDnsServer(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsServer(cmd)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + DeleteDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsServer(cmd)).thenThrow(new RuntimeException("Error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmdTest.java new file mode 100644 index 000000000000..350cea76e5c9 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DeleteDnsZoneCmdTest.java @@ -0,0 +1,104 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.dns.DnsZone; +import org.junit.Test; + +import com.cloud.event.EventTypes; +import com.cloud.user.Account; + +public class DeleteDnsZoneCmdTest extends BaseDnsCmdTest { + + private DeleteDnsZoneCmd createCmd() throws Exception { + DeleteDnsZoneCmd cmd = new DeleteDnsZoneCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "id", ENTITY_ID); + return cmd; + } + + @Test + public void testGetId() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + } + + @Test + public void testGetEntityOwnerIdWithZone() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + DnsZone mockZone = mock(DnsZone.class); + when(mockZone.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(DnsZone.class, ENTITY_ID)).thenReturn(mockZone); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdZoneNotFound() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + when(entityManager.findById(DnsZone.class, ENTITY_ID)).thenReturn(null); + + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testEventType() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + assertEquals(EventTypes.EVENT_DNS_ZONE_DELETE, cmd.getEventType()); + } + + @Test + public void testEventDescription() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + assertEquals("Deleting DNS Zone ID: " + ENTITY_ID, cmd.getEventDescription()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsZone(ENTITY_ID, false)).thenReturn(true); + + cmd.execute(); + + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + verify(dnsProviderManager).deleteDnsZone(ENTITY_ID, false); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsZone(ENTITY_ID, false)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + DeleteDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.deleteDnsZone(ENTITY_ID, false)).thenThrow(new RuntimeException("Error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmdTest.java new file mode 100644 index 000000000000..34ef07efce3b --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/DisassociateDnsZoneFromNetworkCmdTest.java @@ -0,0 +1,100 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.junit.Test; + +import com.cloud.network.Network; +import com.cloud.user.Account; + +public class DisassociateDnsZoneFromNetworkCmdTest extends BaseDnsCmdTest { + + private static final long NETWORK_ID = 200L; + + private DisassociateDnsZoneFromNetworkCmd createCmd() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = + new DisassociateDnsZoneFromNetworkCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "networkId", NETWORK_ID); + return cmd; + } + + @Test + public void testGetNetworkId() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + assertEquals(Long.valueOf(NETWORK_ID), cmd.getNetworkId()); + } + + @Test + public void testGetEntityOwnerIdWithNetwork() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + Network mockNetwork = mock(Network.class); + when(mockNetwork.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(Network.class, NETWORK_ID)) + .thenReturn(mockNetwork); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdNetworkNotFound() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + when(entityManager.findById(Network.class, NETWORK_ID)) + .thenReturn(null); + + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + when(dnsProviderManager.disassociateZoneFromNetwork(cmd)) + .thenReturn(true); + + cmd.execute(); + + SuccessResponse response = + (SuccessResponse) cmd.getResponseObject(); + assertNotNull(response); + verify(dnsProviderManager).disassociateZoneFromNetwork(cmd); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsFalse() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + when(dnsProviderManager.disassociateZoneFromNetwork(cmd)) + .thenReturn(false); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + DisassociateDnsZoneFromNetworkCmd cmd = createCmd(); + when(dnsProviderManager.disassociateZoneFromNetwork(cmd)) + .thenThrow(new RuntimeException("Error")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmdTest.java new file mode 100644 index 000000000000..62340742c878 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsProvidersCmdTest.java @@ -0,0 +1,83 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; + +import org.apache.cloudstack.api.response.DnsProviderResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListDnsProvidersCmdTest extends BaseDnsCmdTest { + + private ListDnsProvidersCmd createCmd() throws Exception { + ListDnsProvidersCmd cmd = new ListDnsProvidersCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + return cmd; + } + + @Test + public void testExecute() throws Exception { + ListDnsProvidersCmd cmd = createCmd(); + when(dnsProviderManager.listProviderNames()).thenReturn(Arrays.asList("PowerDNS")); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = + (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listdnsprovidersresponse", response.getResponseName()); + assertNotNull(response.getResponses()); + assertEquals(1, response.getResponses().size()); + } + + @Test + public void testExecuteMultipleProviders() throws Exception { + ListDnsProvidersCmd cmd = createCmd(); + when(dnsProviderManager.listProviderNames()) + .thenReturn(Arrays.asList("PowerDNS", "Cloudflare")); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = + (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals(2, response.getResponses().size()); + } + + @Test + public void testExecuteEmptyList() throws Exception { + ListDnsProvidersCmd cmd = createCmd(); + when(dnsProviderManager.listProviderNames()) + .thenReturn(Collections.emptyList()); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = + (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals(0, response.getResponses().size()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmdTest.java new file mode 100644 index 000000000000..e75713b55d57 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsRecordsCmdTest.java @@ -0,0 +1,56 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.response.DnsRecordResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListDnsRecordsCmdTest extends BaseDnsCmdTest { + + private ListDnsRecordsCmd createCmd() throws Exception { + ListDnsRecordsCmd cmd = new ListDnsRecordsCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "dnsZoneId", ENTITY_ID); + return cmd; + } + + @Test + public void testGetDnsZoneId() throws Exception { + ListDnsRecordsCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getDnsZoneId()); + } + + @Test + public void testExecute() throws Exception { + ListDnsRecordsCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(dnsProviderManager.listDnsRecords(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listdnsrecordsresponse", response.getResponseName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmdTest.java new file mode 100644 index 000000000000..710aa497167f --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsServersCmdTest.java @@ -0,0 +1,67 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.dns.DnsProviderType; +import org.junit.Test; + +public class ListDnsServersCmdTest extends BaseDnsCmdTest { + + private ListDnsServersCmd createCmd() throws Exception { + ListDnsServersCmd cmd = new ListDnsServersCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "providerType", "PowerDNS"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + ListDnsServersCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals(DnsProviderType.PowerDNS, cmd.getProviderType()); + } + + @Test + public void testGetProviderTypeNull() throws Exception { + ListDnsServersCmd cmd = createCmd(); + setField(cmd, "providerType", null); + assertEquals(DnsProviderType.PowerDNS, cmd.getProviderType()); + } + + @Test + public void testExecute() throws Exception { + ListDnsServersCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(dnsProviderManager.listDnsServers(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listdnsserversresponse", response.getResponseName()); + assertEquals("dnsserver", response.getObjectName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmdTest.java new file mode 100644 index 000000000000..7d47266ae6e6 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/ListDnsZonesCmdTest.java @@ -0,0 +1,60 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.junit.Test; + +public class ListDnsZonesCmdTest extends BaseDnsCmdTest { + + private ListDnsZonesCmd createCmd() throws Exception { + ListDnsZonesCmd cmd = new ListDnsZonesCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "dnsServerId", 200L); + setField(cmd, "name", "example.com"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + ListDnsZonesCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals(Long.valueOf(200L), cmd.getDnsServerId()); + assertEquals("example.com", cmd.getName()); + } + + @Test + public void testExecute() throws Exception { + ListDnsZonesCmd cmd = createCmd(); + + ListResponse mockListResponse = new ListResponse<>(); + when(dnsProviderManager.listDnsZones(cmd)).thenReturn(mockListResponse); + + cmd.execute(); + + @SuppressWarnings("unchecked") + ListResponse response = (ListResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("listdnszonesresponse", response.getResponseName()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmdTest.java new file mode 100644 index 000000000000..f3c0fcd4fe07 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsServerCmdTest.java @@ -0,0 +1,145 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsServerResponse; +import org.apache.cloudstack.dns.DnsServer; +import org.junit.Test; + +import com.cloud.user.Account; + +public class UpdateDnsServerCmdTest extends BaseDnsCmdTest { + + private UpdateDnsServerCmd createCmd() throws Exception { + UpdateDnsServerCmd cmd = new UpdateDnsServerCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "name", "updated-dns"); + setField(cmd, "url", "http://updated.dns.com"); + setField(cmd, "dnsApiKey", "new-api-key"); + setField(cmd, "port", 9090); + setField(cmd, "isPublic", true); + setField(cmd, "publicDomainSuffix", "updated.example.com"); + setField(cmd, "nameServers", Arrays.asList("ns1.updated.com", "ns2.updated.com")); + setField(cmd, "state", "Enabled"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals("updated-dns", cmd.getName()); + assertEquals("http://updated.dns.com", cmd.getUrl()); + assertEquals("new-api-key", cmd.getDnsApiKey()); + assertEquals(Integer.valueOf(9090), cmd.getPort()); + assertTrue(cmd.isPublic()); + assertEquals("updated.example.com", cmd.getPublicDomainSuffix()); + assertEquals("ns1.updated.com,ns2.updated.com", cmd.getNameServers()); + assertEquals(DnsServer.State.Enabled, cmd.getState()); + } + + @Test + public void testGetStateDisabled() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + setField(cmd, "state", "Disabled"); + assertEquals(DnsServer.State.Disabled, cmd.getState()); + } + + @Test + public void testGetStateNull() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + setField(cmd, "state", null); + assertNull(cmd.getState()); + } + + @Test + public void testGetStateBlank() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + setField(cmd, "state", ""); + assertNull(cmd.getState()); + } + + @Test(expected = IllegalArgumentException.class) + public void testGetStateInvalid() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + setField(cmd, "state", "InvalidState"); + cmd.getState(); + } + + @Test + public void testGetEntityOwnerIdWithServer() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + DnsServer mockServer = mock(DnsServer.class); + when(mockServer.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(DnsServer.class, ENTITY_ID)).thenReturn(mockServer); + + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdServerNotFound() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + when(entityManager.findById(DnsServer.class, ENTITY_ID)).thenReturn(null); + + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + + DnsServer mockServer = mock(DnsServer.class); + DnsServerResponse mockResponse = new DnsServerResponse(); + mockResponse.setName("updated-dns"); + + when(dnsProviderManager.updateDnsServer(cmd)).thenReturn(mockServer); + when(dnsProviderManager.createDnsServerResponse(mockServer)).thenReturn(mockResponse); + + cmd.execute(); + + DnsServerResponse response = (DnsServerResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("updatednsserverresponse", response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.updateDnsServer(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + UpdateDnsServerCmd cmd = createCmd(); + when(dnsProviderManager.updateDnsServer(cmd)).thenThrow(new RuntimeException("Update failed")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmdTest.java new file mode 100644 index 000000000000..cac16903185a --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/dns/UpdateDnsZoneCmdTest.java @@ -0,0 +1,96 @@ +// 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 org.apache.cloudstack.api.command.user.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.DnsZoneResponse; +import org.apache.cloudstack.dns.DnsZone; +import org.junit.Test; + +import com.cloud.user.Account; + +public class UpdateDnsZoneCmdTest extends BaseDnsCmdTest { + + private UpdateDnsZoneCmd createCmd() throws Exception { + UpdateDnsZoneCmd cmd = new UpdateDnsZoneCmd(); + setField(cmd, "dnsProviderManager", dnsProviderManager); + setField(cmd, "_entityMgr", entityManager); + setField(cmd, "id", ENTITY_ID); + setField(cmd, "description", "Updated description"); + return cmd; + } + + @Test + public void testAccessors() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + assertEquals(Long.valueOf(ENTITY_ID), cmd.getId()); + assertEquals("Updated description", cmd.getDescription()); + } + + @Test + public void testGetEntityOwnerIdWhenZoneExists() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + DnsZone mockZone = mock(DnsZone.class); + when(mockZone.getAccountId()).thenReturn(ACCOUNT_ID); + when(entityManager.findById(DnsZone.class, ENTITY_ID)).thenReturn(mockZone); + assertEquals(ACCOUNT_ID, cmd.getEntityOwnerId()); + } + + @Test + public void testGetEntityOwnerIdWhenZoneNotFound() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + when(entityManager.findById(DnsZone.class, ENTITY_ID)).thenReturn(null); + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testExecuteSuccess() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + + DnsZone mockZone = mock(DnsZone.class); + DnsZoneResponse mockResponse = new DnsZoneResponse(); + mockResponse.setName("example.com"); + + when(dnsProviderManager.updateDnsZone(cmd)).thenReturn(mockZone); + when(dnsProviderManager.createDnsZoneResponse(mockZone)).thenReturn(mockResponse); + + cmd.execute(); + + DnsZoneResponse response = (DnsZoneResponse) cmd.getResponseObject(); + assertNotNull(response); + assertEquals("updatednszoneresponse", response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteReturnsNull() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.updateDnsZone(cmd)).thenReturn(null); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteThrowsException() throws Exception { + UpdateDnsZoneCmd cmd = createCmd(); + when(dnsProviderManager.updateDnsZone(cmd)).thenThrow(new RuntimeException("Update failed")); + cmd.execute(); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmdTest.java index c905974b2be9..504d3914b70e 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/firewall/CreateFirewallRuleCmdTest.java @@ -21,10 +21,15 @@ import java.util.Collections; import java.util.List; +import com.cloud.network.IpAddress; +import com.cloud.network.NetworkService; +import com.cloud.utils.db.EntityManager; import org.apache.commons.collections.CollectionUtils; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.test.util.ReflectionTestUtils; @@ -33,6 +38,12 @@ @RunWith(MockitoJUnitRunner.class) public class CreateFirewallRuleCmdTest { + @Mock + private EntityManager entityManager; + + @Mock + private NetworkService networkService; + private void validateAllIp4Cidr(final CreateFirewallRuleCmd cmd) { Assert.assertTrue(CollectionUtils.isNotEmpty(cmd.getSourceCidrList())); Assert.assertEquals(1, cmd.getSourceCidrList().size()); @@ -88,4 +99,22 @@ public void testGetSourceCidrList_EmptyFirstElementButMore() { Assert.assertEquals(2, cmd.getSourceCidrList().size()); Assert.assertEquals(cidr, cmd.getSourceCidrList().get(1)); } + + @Test + public void testGetNetworkIdVpcWithoutAssociatedNetworkUsesVpcFallbackAndSyncObjId() { + final CreateFirewallRuleCmd cmd = new CreateFirewallRuleCmd(); + final IpAddress ip = Mockito.mock(IpAddress.class); + + cmd._entityMgr = entityManager; + cmd._networkService = networkService; + ReflectionTestUtils.setField(cmd, "ipAddressId", 42L); + + Mockito.when(networkService.getIp(42L)).thenReturn(ip); + Mockito.when(ip.getAssociatedWithNetworkId()).thenReturn(null); + Mockito.when(ip.getVpcId()).thenReturn(100L); + Mockito.when(ip.getNetworkId()).thenReturn(2L); + + Assert.assertEquals(Long.valueOf(2L), cmd.getNetworkId()); + Assert.assertEquals(Long.valueOf(2L), cmd.getSyncObjId()); + } } diff --git a/api/src/test/java/org/apache/cloudstack/dns/DnsRecordTest.java b/api/src/test/java/org/apache/cloudstack/dns/DnsRecordTest.java new file mode 100644 index 000000000000..bd993380e068 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/dns/DnsRecordTest.java @@ -0,0 +1,105 @@ +// 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 org.apache.cloudstack.dns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; + +import com.cloud.utils.exception.CloudRuntimeException; + +public class DnsRecordTest { + + @Test + public void testDefaultConstructor() { + DnsRecord record = new DnsRecord(); + assertNull(record.getName()); + assertNull(record.getType()); + assertNull(record.getContents()); + assertEquals(0, record.getTtl()); + } + + @Test + public void testParameterizedConstructor() { + List contents = Arrays.asList("192.168.1.1"); + DnsRecord record = new DnsRecord("www", DnsRecord.RecordType.A, contents, 3600); + + assertEquals("www", record.getName()); + assertEquals(DnsRecord.RecordType.A, record.getType()); + assertEquals(contents, record.getContents()); + assertEquals(3600, record.getTtl()); + } + + @Test + public void testSettersAndGetters() { + DnsRecord record = new DnsRecord(); + List contents = Arrays.asList("10.0.0.1", "10.0.0.2"); + + record.setName("mail"); + record.setType(DnsRecord.RecordType.AAAA); + record.setContents(contents); + record.setTtl(7200); + + assertEquals("mail", record.getName()); + assertEquals(DnsRecord.RecordType.AAAA, record.getType()); + assertEquals(contents, record.getContents()); + assertEquals(7200, record.getTtl()); + } + + // RecordType.fromString tests + + @Test + public void testFromStringValid() { + assertEquals(DnsRecord.RecordType.A, DnsRecord.RecordType.fromString("A")); + assertEquals(DnsRecord.RecordType.AAAA, DnsRecord.RecordType.fromString("AAAA")); + assertEquals(DnsRecord.RecordType.CNAME, DnsRecord.RecordType.fromString("CNAME")); + assertEquals(DnsRecord.RecordType.MX, DnsRecord.RecordType.fromString("MX")); + assertEquals(DnsRecord.RecordType.TXT, DnsRecord.RecordType.fromString("TXT")); + assertEquals(DnsRecord.RecordType.SRV, DnsRecord.RecordType.fromString("SRV")); + assertEquals(DnsRecord.RecordType.PTR, DnsRecord.RecordType.fromString("PTR")); + assertEquals(DnsRecord.RecordType.NS, DnsRecord.RecordType.fromString("NS")); + } + + @Test + public void testFromStringCaseInsensitive() { + assertEquals(DnsRecord.RecordType.A, DnsRecord.RecordType.fromString("a")); + assertEquals(DnsRecord.RecordType.CNAME, DnsRecord.RecordType.fromString("cname")); + assertEquals(DnsRecord.RecordType.MX, DnsRecord.RecordType.fromString("mx")); + } + + @Test + public void testFromStringNull() { + assertNull(DnsRecord.RecordType.fromString(null)); + } + + @Test(expected = CloudRuntimeException.class) + public void testFromStringInvalid() { + DnsRecord.RecordType.fromString("INVALID"); + } + + @Test + public void testRecordTypeValues() { + DnsRecord.RecordType[] values = DnsRecord.RecordType.values(); + assertNotNull(values); + assertEquals(8, values.length); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java new file mode 100644 index 000000000000..9b7346f332cb --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/extension/CustomActionResultResponseTest.java @@ -0,0 +1,92 @@ +// 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 org.apache.cloudstack.extension; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +public class CustomActionResultResponseTest { + + private CustomActionResultResponse response; + + @Before + public void setUp() { + response = new CustomActionResultResponse(); + } + + @Test + public void getResultReturnsNullByDefault() { + assertNull(response.getResult()); + } + + @Test + public void getResultReturnsSetValue() { + Map result = Map.of("message", "OK", "details", "All good"); + response.setResult(result); + assertEquals(result, response.getResult()); + assertEquals("OK", response.getResult().get("message")); + } + + @Test + public void isSuccessReturnsFalseWhenSuccessIsNull() { + // success is null by default + assertFalse(response.isSuccess()); + } + + @Test + public void isSuccessReturnsFalseWhenSuccessIsFalse() { + response.setSuccess(false); + assertFalse(response.isSuccess()); + } + + @Test + public void isSuccessReturnsTrueWhenSuccessIsTrue() { + response.setSuccess(true); + assertTrue(response.isSuccess()); + } + + @Test + public void getSuccessReturnsNullByDefault() { + assertNull(response.getSuccess()); + } + + @Test + public void getSuccessReturnsTrueAfterSetSuccessTrue() { + response.setSuccess(true); + assertTrue(response.getSuccess()); + } + + @Test + public void getSuccessReturnsFalseAfterSetSuccessFalse() { + response.setSuccess(false); + assertFalse(response.getSuccess()); + } + + @Test + public void setAndGetResultWithNullResult() { + response.setResult(null); + assertNull(response.getResult()); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java index ae4314aa11a8..a6bd49a2d8be 100644 --- a/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java +++ b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java @@ -40,6 +40,12 @@ public void testResourceType() { assertEquals(com.cloud.vm.VirtualMachine.class, vmType.getAssociatedClass()); } + @Test + public void testNetworkResourceType() { + ExtensionCustomAction.ResourceType networkType = ExtensionCustomAction.ResourceType.Network; + assertEquals(com.cloud.network.Network.class, networkType.getAssociatedClass()); + } + @Test public void testParameterTypeSupportsOptions() { assertTrue(ExtensionCustomAction.Parameter.Type.STRING.canSupportsOptions()); diff --git a/client/pom.xml b/client/pom.xml index b7dbaa427c7e..968f735af600 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -627,6 +627,11 @@ cloud-plugin-integrations-veeam-control-service ${project.version} + + org.apache.cloudstack + cloud-plugin-backup-kvm-backup-on-secondary-storage + ${project.version} + org.apache.cloudstack cloud-plugin-integrations-kubernetes-service @@ -677,6 +682,12 @@ cloud-utils ${project.version} + + org.apache.cloudstack + cloud-plugin-dns-powerdns + ${project.version} + + diff --git a/core/src/main/java/com/cloud/agent/api/CleanupConvertedInstanceDisksCommand.java b/core/src/main/java/com/cloud/agent/api/CleanupConvertedInstanceDisksCommand.java new file mode 100644 index 000000000000..00373ec75364 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/CleanupConvertedInstanceDisksCommand.java @@ -0,0 +1,57 @@ +// +// 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.agent.api; + +import com.cloud.agent.api.to.DataStoreTO; + +/** + * This command is used to cleanup the converted instance disks from the storage pool: vmVolumesStore and the prefix: vmVolumesPrefix. + */ +public class CleanupConvertedInstanceDisksCommand extends Command { + + private DataStoreTO vmVolumesStore; + private String vmVolumesPrefix; + + public CleanupConvertedInstanceDisksCommand(DataStoreTO vmVolumesStore, String vmVolumesPrefix) { + this.vmVolumesStore = vmVolumesStore; + this.vmVolumesPrefix = vmVolumesPrefix; + } + + public DataStoreTO getVmVolumesStore() { + return vmVolumesStore; + } + + public void setVmVolumesStore(DataStoreTO vmVolumesStore) { + this.vmVolumesStore = vmVolumesStore; + } + + public String getVmVolumesPrefix() { + return vmVolumesPrefix; + } + + public void setVmVolumesPrefix(String vmVolumesPrefix) { + this.vmVolumesPrefix = vmVolumesPrefix; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/MigrateBackupsBetweenSecondaryStoragesCommand.java b/core/src/main/java/com/cloud/agent/api/MigrateBackupsBetweenSecondaryStoragesCommand.java new file mode 100644 index 000000000000..7794b4a53083 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/MigrateBackupsBetweenSecondaryStoragesCommand.java @@ -0,0 +1,41 @@ +// 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.agent.api; + +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.DataTO; + +import java.util.List; + +public class MigrateBackupsBetweenSecondaryStoragesCommand extends MigrateBetweenSecondaryStoragesCommand { + + List> backupChain; + + public MigrateBackupsBetweenSecondaryStoragesCommand() { + } + + public MigrateBackupsBetweenSecondaryStoragesCommand(List> backupChain, DataStoreTO srcDataStore, DataStoreTO destDataStore) { + super(srcDataStore, destDataStore); + this.backupChain = backupChain; + } + + public List> getBackupChain() { + return backupChain; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommand.java b/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommand.java new file mode 100644 index 000000000000..48dc7e2c85f4 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommand.java @@ -0,0 +1,48 @@ +// 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.agent.api; + +import com.cloud.agent.api.to.DataStoreTO; + +public abstract class MigrateBetweenSecondaryStoragesCommand extends Command { + + DataStoreTO srcDataStore; + DataStoreTO destDataStore; + + public MigrateBetweenSecondaryStoragesCommand() { + } + + public MigrateBetweenSecondaryStoragesCommand(DataStoreTO srcDataStore, DataStoreTO destDataStore) { + this.srcDataStore = srcDataStore; + this.destDataStore = destDataStore; + } + + @Override + public boolean executeInSequence() { + return false; + } + + public DataStoreTO getSrcDataStore() { + return srcDataStore; + } + + public DataStoreTO getDestDataStore() { + return destDataStore; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommandAnswer.java b/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommandAnswer.java new file mode 100644 index 000000000000..fd303093e092 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/MigrateBetweenSecondaryStoragesCommandAnswer.java @@ -0,0 +1,41 @@ +// +// 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.agent.api; + +import com.cloud.utils.Pair; + +import java.util.List; + +public class MigrateBetweenSecondaryStoragesCommandAnswer extends Answer { + + List> migratedResourcesIdAndCheckpointPath; + + public MigrateBetweenSecondaryStoragesCommandAnswer() { + } + + public MigrateBetweenSecondaryStoragesCommandAnswer(MigrateBetweenSecondaryStoragesCommand cmd, boolean success, String result, List> migratedResourcesIdAndCheckpointPath) { + super(cmd, success, result); + this.migratedResourcesIdAndCheckpointPath = migratedResourcesIdAndCheckpointPath; + } + + public List> getMigratedResources() { + return migratedResourcesIdAndCheckpointPath; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/StartCommand.java b/core/src/main/java/com/cloud/agent/api/StartCommand.java index 24b0ac3787b5..ea763d6cd4bd 100644 --- a/core/src/main/java/com/cloud/agent/api/StartCommand.java +++ b/core/src/main/java/com/cloud/agent/api/StartCommand.java @@ -22,13 +22,15 @@ import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.host.Host; +import java.util.List; + /** */ public class StartCommand extends Command { VirtualMachineTO vm; String hostIp; boolean executeInSequence = false; - String secondaryStorage; + private List secondaryStorages; public VirtualMachineTO getVirtualMachine() { return vm; @@ -50,18 +52,18 @@ public StartCommand(VirtualMachineTO vm, Host host, boolean executeInSequence) { this.vm = vm; this.hostIp = host.getPrivateIpAddress(); this.executeInSequence = executeInSequence; - this.secondaryStorage = null; + this.secondaryStorages = null; } public String getHostIp() { return this.hostIp; } - public String getSecondaryStorage() { - return this.secondaryStorage; + public List getSecondaryStorages() { + return this.secondaryStorages; } - public void setSecondaryStorage(String secondary) { - this.secondaryStorage = secondary; + public void setSecondaryStorages(List secondary) { + this.secondaryStorages = secondary; } } diff --git a/core/src/main/java/com/cloud/agent/api/routing/DhcpEntryCommand.java b/core/src/main/java/com/cloud/agent/api/routing/DhcpEntryCommand.java index 7fb65fe15cf9..67ca447e3853 100644 --- a/core/src/main/java/com/cloud/agent/api/routing/DhcpEntryCommand.java +++ b/core/src/main/java/com/cloud/agent/api/routing/DhcpEntryCommand.java @@ -36,6 +36,7 @@ public class DhcpEntryCommand extends NetworkElementCommand { private boolean isDefault; boolean executeInSequence = false; boolean remove; + Long leaseTime; public boolean isRemove() { return remove; @@ -152,4 +153,12 @@ public boolean isDefault() { public void setDefault(boolean isDefault) { this.isDefault = isDefault; } + + public Long getLeaseTime() { + return leaseTime; + } + + public void setLeaseTime(Long leaseTime) { + this.leaseTime = leaseTime; + } } diff --git a/core/src/main/java/com/cloud/agent/api/routing/NetworkElementCommand.java b/core/src/main/java/com/cloud/agent/api/routing/NetworkElementCommand.java index 400b6bb80917..20d2ea0a443d 100644 --- a/core/src/main/java/com/cloud/agent/api/routing/NetworkElementCommand.java +++ b/core/src/main/java/com/cloud/agent/api/routing/NetworkElementCommand.java @@ -37,6 +37,7 @@ public abstract class NetworkElementCommand extends Command { public static final String ZONE_NETWORK_TYPE = "zone.network.type"; public static final String GUEST_BRIDGE = "guest.bridge"; public static final String VPC_PRIVATE_GATEWAY = "vpc.gateway.private"; + public static final String VPC_ID = "vpc.id"; public static final String FIREWALL_EGRESS_DEFAULT = "firewall.egress.default"; public static final String NETWORK_PUB_LAST_IP = "network.public.last.ip"; public static final String HYPERVISOR_HOST_PRIVATE_IP = "hypervisor.private.ip"; diff --git a/core/src/main/java/com/cloud/agent/api/routing/SetFirewallRulesCommand.java b/core/src/main/java/com/cloud/agent/api/routing/SetFirewallRulesCommand.java index c56f8d20fbe6..ff81ab7749c5 100644 --- a/core/src/main/java/com/cloud/agent/api/routing/SetFirewallRulesCommand.java +++ b/core/src/main/java/com/cloud/agent/api/routing/SetFirewallRulesCommand.java @@ -32,6 +32,7 @@ */ public class SetFirewallRulesCommand extends NetworkElementCommand { FirewallRuleTO[] rules; + Long vpcId; protected SetFirewallRulesCommand() { } @@ -40,10 +41,19 @@ public SetFirewallRulesCommand(List rules) { this.rules = rules.toArray(new FirewallRuleTO[rules.size()]); } + public SetFirewallRulesCommand(List rules, Long vpcId) { + this.rules = rules.toArray(new FirewallRuleTO[rules.size()]); + this.vpcId = vpcId; + } + public FirewallRuleTO[] getRules() { return rules; } + public Long getVpcId() { + return vpcId; + } + public String[][] generateFwRules() { String[][] result = new String[2][]; Set toAdd = new HashSet(); diff --git a/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotAnswer.java b/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotAnswer.java index 4d61249c7cbc..9a571e34c7e6 100644 --- a/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotAnswer.java +++ b/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotAnswer.java @@ -20,20 +20,19 @@ import com.cloud.agent.api.Answer; import com.cloud.agent.api.Command; -import com.cloud.utils.Pair; import java.util.Map; public class CreateDiskOnlyVmSnapshotAnswer extends Answer { - protected Map> mapVolumeToSnapshotSizeAndNewVolumePath; + protected Map mapVolumeToSnapshotSize; - public CreateDiskOnlyVmSnapshotAnswer(Command command, boolean success, String details, Map> mapVolumeToSnapshotSizeAndNewVolumePath) { + public CreateDiskOnlyVmSnapshotAnswer(Command command, boolean success, String details, Map mapVolumeToSnapshotSize) { super(command, success, details); - this.mapVolumeToSnapshotSizeAndNewVolumePath = mapVolumeToSnapshotSizeAndNewVolumePath; + this.mapVolumeToSnapshotSize = mapVolumeToSnapshotSize; } - public Map> getMapVolumeToSnapshotSizeAndNewVolumePath() { - return mapVolumeToSnapshotSizeAndNewVolumePath; + public Map getMapVolumeToSnapshotSize() { + return mapVolumeToSnapshotSize; } } diff --git a/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotCommand.java b/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotCommand.java index 952bf0c971de..da1b420625f8 100644 --- a/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotCommand.java +++ b/core/src/main/java/com/cloud/agent/api/storage/CreateDiskOnlyVmSnapshotCommand.java @@ -21,6 +21,7 @@ import com.cloud.agent.api.VMSnapshotBaseCommand; import com.cloud.agent.api.VMSnapshotTO; +import com.cloud.utils.Pair; import com.cloud.vm.VirtualMachine; import org.apache.cloudstack.storage.to.VolumeObjectTO; @@ -30,12 +31,19 @@ public class CreateDiskOnlyVmSnapshotCommand extends VMSnapshotBaseCommand { protected VirtualMachine.State vmState; - public CreateDiskOnlyVmSnapshotCommand(String vmName, VMSnapshotTO snapshot, List volumeTOs, String guestOSType, VirtualMachine.State vmState) { - super(vmName, snapshot, volumeTOs, guestOSType); + List> volumeTosAndNewPaths; + + public CreateDiskOnlyVmSnapshotCommand(String vmName, VMSnapshotTO snapshot, List> volumeTosAndNewPaths, String guestOSType, VirtualMachine.State vmState) { + super(vmName, snapshot, null, guestOSType); this.vmState = vmState; + this.volumeTosAndNewPaths = volumeTosAndNewPaths; } public VirtualMachine.State getVmState() { return vmState; } + + public List> getVolumeTosAndNewPaths() { + return volumeTosAndNewPaths; + } } diff --git a/core/src/main/java/com/cloud/agent/api/storage/MergeDiskOnlyVmSnapshotCommand.java b/core/src/main/java/com/cloud/agent/api/storage/MergeDiskOnlyVmSnapshotCommand.java index b6396c24d10a..1a47d97d5e25 100644 --- a/core/src/main/java/com/cloud/agent/api/storage/MergeDiskOnlyVmSnapshotCommand.java +++ b/core/src/main/java/com/cloud/agent/api/storage/MergeDiskOnlyVmSnapshotCommand.java @@ -19,28 +19,28 @@ package com.cloud.agent.api.storage; import com.cloud.agent.api.Command; -import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; import java.util.List; public class MergeDiskOnlyVmSnapshotCommand extends Command { - private List snapshotMergeTreeToList; - private VirtualMachine.State vmState; + private List snapshotMergeTreeToList; + private boolean isVmRunning; private String vmName; - public MergeDiskOnlyVmSnapshotCommand(List snapshotMergeTreeToList, VirtualMachine.State vmState, String vmName) { + public MergeDiskOnlyVmSnapshotCommand(List snapshotMergeTreeToList, boolean isVmRunning, String vmName) { this.snapshotMergeTreeToList = snapshotMergeTreeToList; - this.vmState = vmState; + this.isVmRunning = isVmRunning; this.vmName = vmName; } - public List getSnapshotMergeTreeToList() { + public List getDeltaMergeTreeToList() { return snapshotMergeTreeToList; } - public VirtualMachine.State getVmState() { - return vmState; + public boolean isVmRunning() { + return isVmRunning; } public String getVmName() { diff --git a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/facade/DhcpEntryConfigItem.java b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/facade/DhcpEntryConfigItem.java index 9c5b657bb4e4..041645046b23 100644 --- a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/facade/DhcpEntryConfigItem.java +++ b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/facade/DhcpEntryConfigItem.java @@ -35,7 +35,7 @@ public List generateConfig(final NetworkElementCommand cmd) { final DhcpEntryCommand command = (DhcpEntryCommand) cmd; final VmDhcpConfig vmDhcpConfig = new VmDhcpConfig(command.getVmName(), command.getVmMac(), command.getVmIpAddress(), command.getVmIp6Address(), command.getDuid(), command.getDefaultDns(), - command.getDefaultRouter(), command.getStaticRoutes(), command.isDefault(), command.isRemove()); + command.getDefaultRouter(), command.getStaticRoutes(), command.isDefault(), command.isRemove(), command.getLeaseTime()); return generateConfigItems(vmDhcpConfig); } diff --git a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/model/VmDhcpConfig.java b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/model/VmDhcpConfig.java index d9cb8b0b2645..1c43cd1823b9 100644 --- a/core/src/main/java/com/cloud/agent/resource/virtualnetwork/model/VmDhcpConfig.java +++ b/core/src/main/java/com/cloud/agent/resource/virtualnetwork/model/VmDhcpConfig.java @@ -29,6 +29,7 @@ public class VmDhcpConfig extends ConfigBase { private String defaultGateway; private String staticRoutes; private boolean defaultEntry; + private Long leaseTime; // Indicate if the entry should be removed when set to true private boolean remove; @@ -39,6 +40,11 @@ public VmDhcpConfig() { public VmDhcpConfig(String hostName, String macAddress, String ipv4Address, String ipv6Address, String ipv6Duid, String dnsAddresses, String defaultGateway, String staticRoutes, boolean defaultEntry, boolean remove) { + this(hostName, macAddress, ipv4Address, ipv6Address, ipv6Duid, dnsAddresses, defaultGateway, staticRoutes, defaultEntry, remove, null); + } + + public VmDhcpConfig(String hostName, String macAddress, String ipv4Address, String ipv6Address, String ipv6Duid, String dnsAddresses, String defaultGateway, + String staticRoutes, boolean defaultEntry, boolean remove, Long leaseTime) { super(VM_DHCP); this.hostName = hostName; this.macAddress = macAddress; @@ -50,6 +56,7 @@ public VmDhcpConfig(String hostName, String macAddress, String ipv4Address, Stri this.staticRoutes = staticRoutes; this.defaultEntry = defaultEntry; this.remove = remove; + this.leaseTime = leaseTime; } public String getHostName() { @@ -132,4 +139,12 @@ public void setDefaultEntry(boolean defaultEntry) { this.defaultEntry = defaultEntry; } + public Long getLeaseTime() { + return leaseTime; + } + + public void setLeaseTime(Long leaseTime) { + this.leaseTime = leaseTime; + } + } diff --git a/core/src/main/java/com/cloud/storage/resource/StorageProcessor.java b/core/src/main/java/com/cloud/storage/resource/StorageProcessor.java index dd8e2abcd643..31c384eab3a0 100644 --- a/core/src/main/java/com/cloud/storage/resource/StorageProcessor.java +++ b/core/src/main/java/com/cloud/storage/resource/StorageProcessor.java @@ -85,4 +85,8 @@ public interface StorageProcessor { public Answer checkDataStoreStoragePolicyCompliance(CheckDataStoreStoragePolicyComplianceCommand cmd); public Answer syncVolumePath(SyncVolumePathCommand cmd); + + default Answer deleteBackup(DeleteCommand cmd) { + return new Answer(cmd, false, "Operation not implemented"); + } } diff --git a/core/src/main/java/com/cloud/storage/resource/StorageSubsystemCommandHandlerBase.java b/core/src/main/java/com/cloud/storage/resource/StorageSubsystemCommandHandlerBase.java index 318c069b0b0b..3d2608c0c4b8 100644 --- a/core/src/main/java/com/cloud/storage/resource/StorageSubsystemCommandHandlerBase.java +++ b/core/src/main/java/com/cloud/storage/resource/StorageSubsystemCommandHandlerBase.java @@ -154,6 +154,8 @@ protected Answer execute(DeleteCommand cmd) { answer = processor.deleteVolume(cmd); } else if (data.getObjectType() == DataObjectType.SNAPSHOT) { answer = processor.deleteSnapshot(cmd); + } else if (data.getObjectType() == DataObjectType.BACKUP) { + answer = processor.deleteBackup(cmd); } else { answer = new Answer(cmd, false, "unsupported type"); } diff --git a/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java index ffc67b628a7e..abe78ee5553d 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java +++ b/core/src/main/java/org/apache/cloudstack/backup/BackupAnswer.java @@ -29,6 +29,12 @@ public class BackupAnswer extends Answer { private Long virtualSize; private Map volumes; Boolean needsCleanup; + // Set by the NAS backup provider after a checkpoint/bitmap was created during this backup. + // The provider persists it in backup_details under NASBackupChainKeys.BITMAP_NAME. + private String bitmapCreated; + // Set when an incremental was requested but the agent had to fall back to a full + // (e.g. VM was stopped). Provider should record this backup as type=full. + private Boolean incrementalFallback; public BackupAnswer(final Command command, final boolean success, final String details) { super(command, success, details); @@ -68,4 +74,21 @@ public Boolean getNeedsCleanup() { public void setNeedsCleanup(Boolean needsCleanup) { this.needsCleanup = needsCleanup; } + + public String getBitmapCreated() { + return bitmapCreated; + } + + public void setBitmapCreated(String bitmapCreated) { + this.bitmapCreated = bitmapCreated; + } + + public Boolean getIncrementalFallback() { + return incrementalFallback != null && incrementalFallback; + } + + public void setIncrementalFallback(Boolean incrementalFallback) { + this.incrementalFallback = incrementalFallback; + } + } diff --git a/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorAnswer.java new file mode 100644 index 000000000000..34b65adc2467 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorAnswer.java @@ -0,0 +1,46 @@ +/* + * 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 org.apache.cloudstack.backup; + +import java.util.List; + +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.commons.collections4.CollectionUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; + +public class CleanupKbossBackupErrorAnswer extends Answer { + private List volumeObjectTos; + private boolean vmRunning; + + public CleanupKbossBackupErrorAnswer(Command cmd, List volumeObjectTos, boolean vmRunning) { + super(cmd, CollectionUtils.isNotEmpty(volumeObjectTos), null); + this.volumeObjectTos = volumeObjectTos; + this.vmRunning = vmRunning; + } + + public List getVolumeObjectTos() { + return volumeObjectTos; + } + + public boolean isVmRunning() { + return vmRunning; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorCommand.java b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorCommand.java new file mode 100644 index 000000000000..3257851d11ba --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossBackupErrorCommand.java @@ -0,0 +1,61 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.KbossTO; + +import java.util.List; + +public class CleanupKbossBackupErrorCommand extends Command { + + private boolean runningVM; + + private String vmName; + + private String imageStoreUrl; + + private List kbossTOS; + + public CleanupKbossBackupErrorCommand(boolean runningVM, String vmName, String imageStoreUrl, List kbossTOS) { + this.runningVM = runningVM; + this.vmName = vmName; + this.imageStoreUrl = imageStoreUrl; + this.kbossTOS = kbossTOS; + } + + public boolean isRunningVM() { + return runningVM; + } + + public String getVmName() { + return vmName; + } + + public String getImageStoreUrl() { + return imageStoreUrl; + } + + public List getKbossTOs() { + return kbossTOS; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossValidationCommand.java b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossValidationCommand.java new file mode 100644 index 000000000000..8a345aeba176 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/CleanupKbossValidationCommand.java @@ -0,0 +1,49 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; + +import java.util.Set; + +public class CleanupKbossValidationCommand extends Command { + + private String vmName; + + private Set secondaryStorages; + + public CleanupKbossValidationCommand(String vmName, Set secondaryStorages) { + this.vmName = vmName; + this.secondaryStorages = secondaryStorages; + } + + public String getVmName() { + return vmName; + } + + public Set getSecondaryStorages() { + return secondaryStorages; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/CompressBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/CompressBackupCommand.java new file mode 100644 index 000000000000..a551f246a0bb --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/CompressBackupCommand.java @@ -0,0 +1,78 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; + +import java.util.List; + +public class CompressBackupCommand extends Command { + + private List backupDeltasToCompress; + + private List backupChainImageStoreUrls; + + private long minFreeStorage; + + private Backup.CompressionLibrary compressionLib; + + private int coroutines; + + private int rateLimit; + + public CompressBackupCommand(List backupDeltasToCompress, List backupChainImageStoreUrls, long minFreeStorage, Backup.CompressionLibrary compressionLib, int coroutines, int rateLimit) { + this.backupChainImageStoreUrls = backupChainImageStoreUrls; + this.backupDeltasToCompress = backupDeltasToCompress; + this.minFreeStorage = minFreeStorage; + this.compressionLib = compressionLib; + this.coroutines = coroutines; + this.rateLimit = rateLimit; + } + + public List getBackupDeltasToCompress() { + return backupDeltasToCompress; + } + + public List getBackupChainImageStoreUrls() { + return backupChainImageStoreUrls; + } + + public long getMinFreeStorage() { + return minFreeStorage; + } + + public Backup.CompressionLibrary getCompressionLib() { + return compressionLib; + } + + public int getCoroutines() { + return coroutines; + } + + public int getRateLimit() { + return rateLimit; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesAnswer.java new file mode 100644 index 000000000000..9f230deb8d75 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesAnswer.java @@ -0,0 +1,37 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.VolumeObjectTO; + +import java.util.List; + +public class ConsolidateVolumesAnswer extends Answer { + + private List successfullyConsolidatedVolumes; + + public ConsolidateVolumesAnswer(Command command, boolean success, String details, List successfullyConsolidatedVolumes) { + super(command, success, details); + this.successfullyConsolidatedVolumes = successfullyConsolidatedVolumes; + } + + public List getSuccessfullyConsolidatedVolumes() { + return successfullyConsolidatedVolumes; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesCommand.java b/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesCommand.java new file mode 100644 index 000000000000..7b2bc2245939 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/ConsolidateVolumesCommand.java @@ -0,0 +1,56 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.storage.to.VolumeObjectTO; + +import java.util.List; +import java.util.stream.Collectors; + +public class ConsolidateVolumesCommand extends Command { + + private List volumesToConsolidate; + + private List secondaryStorageUuids; + + private String vmName; + + public ConsolidateVolumesCommand(List volumesToConsolidate, List secondaryStorageUuids, String vmName) { + this.volumesToConsolidate = volumesToConsolidate.stream().map(vol -> (VolumeObjectTO)vol.getTO()).collect(Collectors.toList()); + this.secondaryStorageUuids = secondaryStorageUuids; + this.vmName = vmName; + } + + public List getVolumesToConsolidate() { + return volumesToConsolidate; + } + + public List getSecondaryStorageUuids() { + return secondaryStorageUuids; + } + + public String getVmName() { + return vmName; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/FinalizeBackupCompressionCommand.java b/core/src/main/java/org/apache/cloudstack/backup/FinalizeBackupCompressionCommand.java new file mode 100644 index 000000000000..a4cbb69611f7 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/FinalizeBackupCompressionCommand.java @@ -0,0 +1,49 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.BackupDeltaTO; + +import java.util.List; + +public class FinalizeBackupCompressionCommand extends Command { + private boolean cleanup; + + private List backupDeltaTO; + + public FinalizeBackupCompressionCommand(boolean cleanup, List backupDeltaTO) { + this.cleanup = cleanup; + this.backupDeltaTO = backupDeltaTO; + } + + public boolean isCleanup() { + return cleanup; + } + + public List getBackupDeltaTOList() { + return backupDeltaTO; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/PrepareValidationCommand.java b/core/src/main/java/org/apache/cloudstack/backup/PrepareValidationCommand.java new file mode 100644 index 000000000000..bae2fee95774 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/PrepareValidationCommand.java @@ -0,0 +1,52 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import com.cloud.utils.Pair; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; + +import java.util.List; +import java.util.Set; + +public class PrepareValidationCommand extends Command { + + private List> backupToVolumeList; + + private Set imageStoreSet; + + public PrepareValidationCommand(List> backupToVolumeList, Set imageStoreSet) { + this.backupToVolumeList = backupToVolumeList; + this.imageStoreSet = imageStoreSet; + } + + public List> getBackupToVolumeList() { + return backupToVolumeList; + } + + public Set getImageStoreSet() { + return imageStoreSet; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupAnswer.java new file mode 100644 index 000000000000..925ceca6b3b3 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupAnswer.java @@ -0,0 +1,41 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; + +import java.util.Set; + +public class RestoreKbossBackupAnswer extends Answer { + + private Set secondaryStorageUuids; + + public RestoreKbossBackupAnswer(Command command, Set secondaryStorageUuids) { + super(command); + this.secondaryStorageUuids = secondaryStorageUuids; + } + + public RestoreKbossBackupAnswer(Command command, Exception e, Set secondaryStorageUuids) { + super(command, e); + this.secondaryStorageUuids = secondaryStorageUuids; + } + + public Set getSecondaryStorageUuids() { + return secondaryStorageUuids; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupCommand.java new file mode 100644 index 000000000000..d173aab324a2 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/RestoreKbossBackupCommand.java @@ -0,0 +1,66 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import com.cloud.utils.Pair; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; + +import java.util.Set; + +public class RestoreKbossBackupCommand extends Command { + + private Set deltasToRemove; + + private Set> backupAndVolumePairs; + + private Set secondaryStorageUrls; + + private boolean quickRestore; + + public RestoreKbossBackupCommand(Set deltasToRemove, Set> backupAndVolumePairs, Set secondaryStorageUrls, + boolean quickRestore) { + this.deltasToRemove = deltasToRemove; + this.backupAndVolumePairs = backupAndVolumePairs; + this.secondaryStorageUrls = secondaryStorageUrls; + this.quickRestore = quickRestore; + } + + @Override + public boolean executeInSequence() { + return false; + } + + public Set getDeltasToRemove() { + return deltasToRemove; + } + + public Set> getBackupAndVolumePairs() { + return backupAndVolumePairs; + } + + public Set getSecondaryStorageUrls() { + return secondaryStorageUrls; + } + + public boolean isQuickRestore() { + return quickRestore; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java index 5402b6b24760..34f8d7b8bcdd 100644 --- a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupCommand.java @@ -36,6 +36,17 @@ public class TakeBackupCommand extends Command { @LogLevel(LogLevel.Log4jLevel.Off) private String mountOptions; + // Incremental backup fields (NAS provider; null/empty for legacy full-only callers). + private String mode; // "full" or "incremental"; null => legacy behaviour (script default) + private String bitmapNew; // Checkpoint/bitmap name to create with this backup (timestamp-based) + private String bitmapParent; // Incremental: parent bitmap to read changes since + + // Per-volume parent backup file paths (one per VM volume, ordered by deviceId — same + // order as volumePaths). The script rebases each new qcow2 onto the matching parent. + // Backup file UUIDs differ across volumes, so a single parentPath would have rebased + // every data disk onto the root file. New callers MUST populate parentPaths. + private List parentPaths; + public TakeBackupCommand(String vmName, String backupPath) { super(); this.vmName = vmName; @@ -106,6 +117,38 @@ public void setQuiesce(Boolean quiesce) { this.quiesce = quiesce; } + public String getMode() { + return mode; + } + + public void setMode(String mode) { + this.mode = mode; + } + + public String getBitmapNew() { + return bitmapNew; + } + + public void setBitmapNew(String bitmapNew) { + this.bitmapNew = bitmapNew; + } + + public String getBitmapParent() { + return bitmapParent; + } + + public void setBitmapParent(String bitmapParent) { + this.bitmapParent = bitmapParent; + } + + public List getParentPaths() { + return parentPaths; + } + + public void setParentPaths(List parentPaths) { + this.parentPaths = parentPaths; + } + @Override public boolean executeInSequence() { return true; diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeBackupHashCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupHashCommand.java new file mode 100644 index 000000000000..7effe40686b2 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeBackupHashCommand.java @@ -0,0 +1,47 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.BackupDeltaTO; + +import java.util.List; + +public class TakeBackupHashCommand extends Command { + + private List backupDeltaTOList; + + private String backupUuid; + + public TakeBackupHashCommand(List backupDeltaTOList, String backupUuid) { + this.backupDeltaTOList = backupDeltaTOList; + this.backupUuid = backupUuid; + } + + public List getBackupDeltaTOList() { + return backupDeltaTOList; + } + + public String getBackupUuid() { + return backupUuid; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupAnswer.java new file mode 100644 index 000000000000..1827c766f573 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupAnswer.java @@ -0,0 +1,59 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; + +import java.util.Map; + +public class TakeKbossBackupAnswer extends Answer { + + private Map mapVolumeUuidToNewVolumePath; + private Map> mapVolumeUuidToDeltaPathOnSecondaryAndSize; + private boolean isVmConsistent = true; + + public TakeKbossBackupAnswer(Command command, boolean success, Map mapVolumeUuidToNewVolumePath, + Map> mapVolumeUuidToDeltaPathOnSecondaryAndSize) { + super(command, success, null); + this.mapVolumeUuidToNewVolumePath = mapVolumeUuidToNewVolumePath; + this.mapVolumeUuidToDeltaPathOnSecondaryAndSize = mapVolumeUuidToDeltaPathOnSecondaryAndSize; + } + + public TakeKbossBackupAnswer(Command command, Exception e) { + super(command, e); + if (e instanceof BackupException) { + this.isVmConsistent = ((BackupException)e).isVmConsistent(); + } + } + + public Map getMapVolumeUuidToNewVolumePath() { + return mapVolumeUuidToNewVolumePath; + } + + public Map> getMapVolumeUuidToDeltaPathOnSecondaryAndSize() { + return mapVolumeUuidToDeltaPathOnSecondaryAndSize; + } + + public boolean isVmConsistent() { + return isVmConsistent; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupCommand.java b/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupCommand.java new file mode 100644 index 000000000000..b145fa6257b1 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/TakeKbossBackupCommand.java @@ -0,0 +1,92 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import org.apache.cloudstack.storage.to.KbossTO; + +import java.util.List; + +public class TakeKbossBackupCommand extends Command { + + private boolean quiesceVm; + + private boolean runningVM; + + private boolean endChain; + + private String vmName; + + private String imageStoreUrl; + + private List backupChainImageStoreUrls; + + private List kbossTOS; + + private boolean isolated; + + public TakeKbossBackupCommand(boolean quiesceVm, boolean runningVM, boolean endChain, String vmName, String imageStoreUrl, List backupChainImageStoreUrls, List kbossTOS, boolean isolated) { + this.quiesceVm = quiesceVm; + this.runningVM = runningVM; + this.endChain = endChain; + this.vmName = vmName; + this.imageStoreUrl = imageStoreUrl; + this.backupChainImageStoreUrls = backupChainImageStoreUrls; + this.kbossTOS = kbossTOS; + this.isolated = isolated; + } + + public boolean isQuiesceVm() { + return quiesceVm; + } + + public boolean isRunningVM() { + return runningVM; + } + + public boolean isEndChain() { + return endChain; + } + + public String getVmName() { + return vmName; + } + + public String getImageStoreUrl() { + return imageStoreUrl; + } + + public List getBackupChainImageStoreUrls() { + return backupChainImageStoreUrls; + } + + public List getKbossTOs() { + return kbossTOS; + } + + public boolean isIsolated() { + return isolated; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmAnswer.java b/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmAnswer.java new file mode 100644 index 000000000000..fcff6a8cce8c --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmAnswer.java @@ -0,0 +1,46 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; + +public class ValidateKbossVmAnswer extends Answer { + + private boolean bootValidated; + private String screenshotPath; + private String scriptResult; + + public ValidateKbossVmAnswer(Command command, boolean bootValidated, String screenshotPath, String scriptResult) { + super(command); + this.bootValidated = bootValidated; + this.screenshotPath = screenshotPath; + this.scriptResult = scriptResult; + } + + public boolean isBootValidated() { + return bootValidated; + } + + public String getScreenshotPath() { + return screenshotPath; + } + + public String getScriptResult() { + return scriptResult; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmCommand.java b/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmCommand.java new file mode 100644 index 000000000000..9e10499f53d9 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/backup/ValidateKbossVmCommand.java @@ -0,0 +1,133 @@ +// +// 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 org.apache.cloudstack.backup; + +import com.cloud.agent.api.Command; +import com.cloud.agent.api.to.VirtualMachineTO; +import org.apache.cloudstack.storage.to.BackupDeltaTO; + +public class ValidateKbossVmCommand extends Command { + + private VirtualMachineTO vm; + private BackupDeltaTO backupDeltaTO; + + private String scriptToExecute; + + private String scriptArguments; + private String expectedResult; + + private Integer scriptTimeout; + private Integer bootTimeout; + private Integer screenshotWait; + + private boolean takeScreenshot; + private boolean waitForBoot; + private boolean executeScript; + + public ValidateKbossVmCommand(VirtualMachineTO vm, BackupDeltaTO backupDeltaTO) { + this.vm = vm; + this.backupDeltaTO = backupDeltaTO; + } + + public void setScriptToExecute(String scriptToExecute) { + this.scriptToExecute = scriptToExecute; + } + + public void setScriptArguments(String scriptArguments) { + this.scriptArguments = scriptArguments; + } + + public void setExpectedResult(String expectedResult) { + this.expectedResult = expectedResult; + } + + public void setScriptTimeout(Integer scriptTimeout) { + this.scriptTimeout = scriptTimeout; + } + + public void setTakeScreenshot(boolean takeScreenshot) { + this.takeScreenshot = takeScreenshot; + } + + public void setWaitForBoot(boolean waitForBoot) { + this.waitForBoot = waitForBoot; + } + + public void setExecuteScript(boolean executeScript) { + this.executeScript = executeScript; + } + + public void setBootTimeout(Integer bootTimeout) { + this.bootTimeout = bootTimeout; + } + + public void setScreenshotWait(Integer screenshotWait) { + this.screenshotWait = screenshotWait; + } + + public VirtualMachineTO getVm() { + return vm; + } + + public BackupDeltaTO getBackupDeltaTO() { + return backupDeltaTO; + } + + public String getScriptToExecute() { + return scriptToExecute; + } + + public String getScriptArguments() { + return scriptArguments; + } + + public String getExpectedResult() { + return expectedResult; + } + + public Integer getScriptTimeout() { + return scriptTimeout; + } + + public Integer getBootTimeout() { + return bootTimeout; + } + + public Integer getScreenshotWait() { + return screenshotWait; + } + + public boolean isTakeScreenshot() { + return takeScreenshot; + } + + public boolean isWaitForBoot() { + return waitForBoot; + } + + public boolean isExecuteScript() { + return executeScript; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/storage/command/BackupDeleteAnswer.java b/core/src/main/java/org/apache/cloudstack/storage/command/BackupDeleteAnswer.java new file mode 100644 index 000000000000..6cc48ea296d1 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/storage/command/BackupDeleteAnswer.java @@ -0,0 +1,36 @@ +// +// 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 org.apache.cloudstack.storage.command; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; + +public class BackupDeleteAnswer extends Answer { + + private long backupId; + + public BackupDeleteAnswer(Command command, boolean success, String details) { + super(command, success, details); + backupId = ((DeleteCommand) command).getData().getId(); + } + + public long getBackupId() { + return backupId; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/storage/command/DeleteCommand.java b/core/src/main/java/org/apache/cloudstack/storage/command/DeleteCommand.java index 6f82fa97818d..9aa6f26b5d9c 100644 --- a/core/src/main/java/org/apache/cloudstack/storage/command/DeleteCommand.java +++ b/core/src/main/java/org/apache/cloudstack/storage/command/DeleteCommand.java @@ -24,6 +24,8 @@ public final class DeleteCommand extends StorageSubSystemCommand { private DataTO data; + private boolean deleteChain; + public DeleteCommand(final DataTO data) { super(); this.data = data; @@ -42,6 +44,14 @@ public DataTO getData() { return data; } + public void setDeleteChain(boolean deleteChain) { + this.deleteChain = deleteChain; + } + + public boolean isDeleteChain() { + return deleteChain; + } + @Override public void setExecuteInSequence(final boolean inSeq) { diff --git a/core/src/main/java/org/apache/cloudstack/storage/command/RevertSnapshotCommand.java b/core/src/main/java/org/apache/cloudstack/storage/command/RevertSnapshotCommand.java index 174302252a55..42926d37cdfe 100644 --- a/core/src/main/java/org/apache/cloudstack/storage/command/RevertSnapshotCommand.java +++ b/core/src/main/java/org/apache/cloudstack/storage/command/RevertSnapshotCommand.java @@ -25,6 +25,8 @@ public final class RevertSnapshotCommand extends StorageSubSystemCommand { private SnapshotObjectTO dataOnPrimaryStorage; private boolean _executeInSequence = false; + private boolean deleteChain; + public RevertSnapshotCommand(SnapshotObjectTO data, SnapshotObjectTO dataOnPrimaryStorage) { super(); this.data = data; @@ -43,6 +45,14 @@ public SnapshotObjectTO getDataOnPrimaryStorage() { return dataOnPrimaryStorage; } + public boolean isDeleteChain() { + return deleteChain; + } + + public void setDeleteChain(boolean deleteChain) { + this.deleteChain = deleteChain; + } + @Override public void setExecuteInSequence(final boolean executeInSequence) { _executeInSequence = executeInSequence; diff --git a/core/src/main/java/org/apache/cloudstack/storage/to/BackupDeltaTO.java b/core/src/main/java/org/apache/cloudstack/storage/to/BackupDeltaTO.java new file mode 100644 index 000000000000..662a2d486560 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/storage/to/BackupDeltaTO.java @@ -0,0 +1,102 @@ +// 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 org.apache.cloudstack.storage.to; + +import com.cloud.agent.api.to.DataObjectType; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.DataTO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.Storage; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +public class BackupDeltaTO implements DataTO { + private DataStoreTO dataStoreTO; + + private Hypervisor.HypervisorType hypervisorType; + + private String path; + + private String screenshotPath; + + private Storage.ImageFormat format; + + // When set, represents the Backup ID, not the delta ID. + private long id = 0; + + public BackupDeltaTO(DataStoreTO dataStoreTO, Hypervisor.HypervisorType hypervisorType, String path) { + this.dataStoreTO = dataStoreTO; + this.hypervisorType = hypervisorType; + this.path = path; + this.format = Storage.ImageFormat.QCOW2; + } + + public BackupDeltaTO(long id, DataStoreTO dataStoreTO, Hypervisor.HypervisorType hypervisorType, String path) { + this(dataStoreTO, hypervisorType, path); + this.id = id; + } + + @Override + public DataObjectType getObjectType() { + return DataObjectType.BACKUP; + } + + @Override + public DataStoreTO getDataStore() { + return dataStoreTO; + } + + @Override + public Hypervisor.HypervisorType getHypervisorType() { + return hypervisorType; + } + + @Override + public String getPath() { + return path; + } + + @Override + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public Storage.ImageFormat getFormat() { + return this.format; + } + + public void setScreenshotPath(String screenshotPath) { + this.screenshotPath = screenshotPath; + } + + public String getScreenshotPath() { + return screenshotPath; + } + + public void setPath(String path) { + this.path = path; + } + + @Override + public String toString() { + return new ReflectionToStringBuilder(this, ToStringStyle.JSON_STYLE).setExcludeFieldNames("id").toString(); + } +} diff --git a/core/src/main/java/com/cloud/agent/api/storage/SnapshotMergeTreeTO.java b/core/src/main/java/org/apache/cloudstack/storage/to/DeltaMergeTreeTO.java similarity index 71% rename from core/src/main/java/com/cloud/agent/api/storage/SnapshotMergeTreeTO.java rename to core/src/main/java/org/apache/cloudstack/storage/to/DeltaMergeTreeTO.java index 78f23105e192..143cf6fe22bb 100644 --- a/core/src/main/java/com/cloud/agent/api/storage/SnapshotMergeTreeTO.java +++ b/core/src/main/java/org/apache/cloudstack/storage/to/DeltaMergeTreeTO.java @@ -16,28 +16,40 @@ * specific language governing permissions and limitations * under the License. */ -package com.cloud.agent.api.storage; +package org.apache.cloudstack.storage.to; import com.cloud.agent.api.to.DataTO; import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; import java.util.List; -public class SnapshotMergeTreeTO { +public class DeltaMergeTreeTO { + + VolumeObjectTO volumeObjectTO; DataTO parent; DataTO child; List grandChildren; - public SnapshotMergeTreeTO(DataTO parent, DataTO child, List grandChildren) { + public DeltaMergeTreeTO(VolumeObjectTO volumeObjectTO, DataTO parent, DataTO child, List grandChildren) { + this.volumeObjectTO = volumeObjectTO; this.parent = parent; this.child = child; this.grandChildren = grandChildren; } + public VolumeObjectTO getVolumeObjectTO() { + return volumeObjectTO; + } + public DataTO getParent() { return parent; } + public void setParent(DataTO parent) { + this.parent = parent; + } + public DataTO getChild() { return child; } @@ -52,6 +64,6 @@ public void addGrandChild(DataTO grandChild) { @Override public String toString() { - return ReflectionToStringBuilder.toString(this); + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); } } diff --git a/core/src/main/java/org/apache/cloudstack/storage/to/KbossTO.java b/core/src/main/java/org/apache/cloudstack/storage/to/KbossTO.java new file mode 100644 index 000000000000..0280ffa27a1f --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/storage/to/KbossTO.java @@ -0,0 +1,101 @@ +package org.apache.cloudstack.storage.to; +// 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. + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +public class KbossTO { + + private String pathBackupParentOnSecondary; + private VolumeObjectTO volumeObjectTO; + private String deltaPathOnPrimary; + private String parentDeltaPathOnPrimary; + private String deltaPathOnSecondary; + + private DeltaMergeTreeTO deltaMergeTreeTO; + + private List vmSnapshotDeltaPaths; + + public KbossTO(VolumeObjectTO volumeObjectTO, List snapshotDataStoreVOs) { + this.volumeObjectTO = volumeObjectTO; + this.vmSnapshotDeltaPaths = snapshotDataStoreVOs.stream().map(SnapshotDataStoreVO::getInstallPath).collect(Collectors.toList()); + } + + public KbossTO(VolumeObjectTO volumeObjectTO, String deltaPathOnPrimary, String deltaPathOnSecondary) { + this.volumeObjectTO = volumeObjectTO; + this.deltaPathOnPrimary = deltaPathOnPrimary; + this.deltaPathOnSecondary = deltaPathOnSecondary; + } + + public String getPathBackupParentOnSecondary() { + return pathBackupParentOnSecondary; + } + + public VolumeObjectTO getVolumeObjectTO() { + return volumeObjectTO; + } + + public DeltaMergeTreeTO getDeltaMergeTreeTO() { + return deltaMergeTreeTO; + } + + public List getVmSnapshotDeltaPaths() { + return vmSnapshotDeltaPaths; + } + + public String getDeltaPathOnPrimary() { + return deltaPathOnPrimary; + } + + public String getDeltaPathOnSecondary() { + return deltaPathOnSecondary; + } + + public String getParentDeltaPathOnPrimary() { + return parentDeltaPathOnPrimary; + } + + public void setParentDeltaPathOnPrimary(String parentDeltaPathOnPrimary) { + this.parentDeltaPathOnPrimary = parentDeltaPathOnPrimary; + } + + public void setPathBackupParentOnSecondary(String pathBackupParentOnSecondary) { + this.pathBackupParentOnSecondary = pathBackupParentOnSecondary; + } + + public void setDeltaMergeTreeTO(DeltaMergeTreeTO deltaMergeTreeTO) { + this.deltaMergeTreeTO = deltaMergeTreeTO; + } + + public void setDeltaPathOnPrimary(String deltaPathOnPrimary) { + this.deltaPathOnPrimary = deltaPathOnPrimary; + } + + public void setDeltaPathOnSecondary(String deltaPathOnSecondary) { + this.deltaPathOnSecondary = deltaPathOnSecondary; + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/core/src/main/java/org/apache/cloudstack/storage/to/VolumeObjectTO.java b/core/src/main/java/org/apache/cloudstack/storage/to/VolumeObjectTO.java index 827403ac5ef8..df98149faab6 100644 --- a/core/src/main/java/org/apache/cloudstack/storage/to/VolumeObjectTO.java +++ b/core/src/main/java/org/apache/cloudstack/storage/to/VolumeObjectTO.java @@ -32,11 +32,12 @@ import com.cloud.storage.Volume; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import java.io.Serializable; import java.util.Arrays; import java.util.List; import java.util.Set; -public class VolumeObjectTO extends DownloadableObjectTO implements DataTO { +public class VolumeObjectTO extends DownloadableObjectTO implements DataTO, Serializable { private String uuid; private Volume.Type volumeType; private DataStoreTO dataStore; diff --git a/core/src/main/resources/META-INF/cloudstack/backup/spring-core-lifecycle-backup-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/backup/spring-core-lifecycle-backup-context-inheritable.xml index 175d45e26752..fcbcb18c2bdf 100644 --- a/core/src/main/resources/META-INF/cloudstack/backup/spring-core-lifecycle-backup-context-inheritable.xml +++ b/core/src/main/resources/META-INF/cloudstack/backup/spring-core-lifecycle-backup-context-inheritable.xml @@ -29,4 +29,9 @@ + + + + + diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index 15465c22f8fc..cf43b8527a97 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -339,6 +339,10 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> + + + @@ -369,4 +373,6 @@ + + diff --git a/core/src/main/resources/META-INF/cloudstack/dns/module.properties b/core/src/main/resources/META-INF/cloudstack/dns/module.properties new file mode 100644 index 000000000000..a2bb467be751 --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/dns/module.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +name=dns +parent=core diff --git a/core/src/main/resources/META-INF/cloudstack/dns/spring-core-lifecycle-dns-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/dns/spring-core-lifecycle-dns-context-inheritable.xml new file mode 100644 index 000000000000..27cac9400284 --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/dns/spring-core-lifecycle-dns-context-inheritable.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/debian/cloudstack-common.postinst b/debian/cloudstack-common.postinst index aa99edaee064..b11e6a3fe502 100644 --- a/debian/cloudstack-common.postinst +++ b/debian/cloudstack-common.postinst @@ -19,14 +19,14 @@ set -e CLOUDUTILS_DIR="/usr/share/pyshared/" -DIST_DIR=$(python3 -c "from distutils.sysconfig import get_python_lib; print(get_python_lib(1))") -if which pycompile >/dev/null 2>&1; then - pycompile -p cloudstack-common -fi +# distutils was removed in Python 3.12 (Ubuntu 24.04); the Debian/Ubuntu-patched +# sysconfig 'deb_system' scheme gives the same /usr/lib/python3/dist-packages path. +DIST_DIR=$(python3 -c "import sysconfig; print(sysconfig.get_path('platlib', 'deb_system'))") -if which pycompile >/dev/null 2>&1; then - pycompile -p cloudstack-common /usr/share/cloudstack-common +if command -v py3compile >/dev/null 2>&1; then + py3compile -p cloudstack-common 2>/dev/null || echo "Warning: py3compile failed for cloudstack-common" >&2 + py3compile -p cloudstack-common /usr/share/cloudstack-common 2>/dev/null || echo "Warning: py3compile failed for cloudstack-common (/usr/share/cloudstack-common)" >&2 fi cp $CLOUDUTILS_DIR/cloud_utils.py $DIST_DIR diff --git a/debian/rules b/debian/rules index 842fc2408af7..327447823308 100755 --- a/debian/rules +++ b/debian/rules @@ -95,6 +95,7 @@ override_dh_auto_install: # nast hack for a couple of configuration files mv $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/server/cloudstack-limits.conf $(DESTDIR)/$(SYSCONFDIR)/security/limits.d/ mv $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/server/cloudstack-sudoers $(DESTDIR)/$(SYSCONFDIR)/sudoers.d/$(PACKAGE) + sed -i '/requiretty/d' $(DESTDIR)/$(SYSCONFDIR)/sudoers.d/$(PACKAGE) chmod 0440 $(DESTDIR)/$(SYSCONFDIR)/sudoers.d/$(PACKAGE) install -D client/target/utilities/bin/cloud-update-xenserver-licenses $(DESTDIR)/usr/bin/cloudstack-update-xenserver-licenses diff --git a/engine/api/src/main/java/com/cloud/vm/VirtualMachineManager.java b/engine/api/src/main/java/com/cloud/vm/VirtualMachineManager.java index 5eca02f33d51..c7238a513693 100644 --- a/engine/api/src/main/java/com/cloud/vm/VirtualMachineManager.java +++ b/engine/api/src/main/java/com/cloud/vm/VirtualMachineManager.java @@ -114,6 +114,8 @@ public interface VirtualMachineManager extends Manager { interface Topics { String VM_POWER_STATE = "vm.powerstate"; + String VM_LIFECYCLE_STATE = "vm.lifecycle.state"; + String VM_ACTION = "vm.action"; } /** diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java index 030c1277efe2..109a44488ec4 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java @@ -91,6 +91,10 @@ public interface NetworkOrchestrationService { ConfigKey NetworkThrottlingRate = new ConfigKey<>("Network", Integer.class, NetworkThrottlingRateCK, "200", "Default data transfer rate in megabits per second allowed in network.", true, ConfigKey.Scope.Zone); + ConfigKey DhcpLeaseTimeout = new ConfigKey<>("Network", Integer.class, "dhcp.lease.timeout", "0", + "DHCP lease time in seconds for VMs. Use 0 for infinite lease time (default). A non-zero value sets the lease duration in seconds.", + true, ConfigKey.Scope.Zone); + ConfigKey PromiscuousMode = new ConfigKey<>("Advanced", Boolean.class, "network.promiscuous.mode", "false", "Whether to allow or deny promiscuous mode on NICs for applicable network elements such as for vswitch/dvswitch portgroups.", true); @@ -131,6 +135,9 @@ public interface NetworkOrchestrationService { true, Scope.Global); + ConfigKey VmNetworkThrottlingRate = new ConfigKey("Network", Integer.class, "vm.network.throttling.rate", "200", + "Default data transfer rate in megabits per second allowed in User vm's default network.", true, ConfigKey.Scope.Zone); + List setupNetwork(Account owner, NetworkOffering offering, DeploymentPlan plan, String name, String displayText, boolean isDefault) throws ConcurrentOperationException; @@ -138,6 +145,8 @@ List setupNetwork(Account owner, NetworkOffering offering, Ne boolean errorIfAlreadySetup, Long domainId, ACLType aclType, Boolean subdomainAccess, Long vpcId, Boolean isDisplayNetworkEnabled) throws ConcurrentOperationException; + boolean isIsolationMethodNetworkExtension(Long networkOfferingId); + void allocate(VirtualMachineProfile vm, LinkedHashMap> networks, Map> extraDhcpOptions) throws InsufficientCapacityException, ConcurrentOperationException; diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java index 56624df1346b..141596407fe8 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java @@ -122,7 +122,7 @@ VolumeInfo moveVolume(VolumeInfo volume, long destPoolDcId, Long destPoolPodId, DiskProfile allocateRawVolume(Type type, String name, DiskOffering offering, Long size, Long minIops, Long maxIops, VirtualMachine vm, VirtualMachineTemplate template, Account owner, Long deviceId, Long kmsKeyId, boolean incrementResourceCount); - VolumeInfo createVolumeOnPrimaryStorage(VirtualMachine vm, VolumeInfo volume, HypervisorType rootDiskHyperType, StoragePool storagePool) throws NoTransitionException; + VolumeInfo createVolumeOnPrimaryStorage(VirtualMachine vm, VolumeInfo volume, HypervisorType rootDiskHyperType, StoragePool storagePool, Long clusterId, Long podId) throws NoTransitionException; void release(VirtualMachineProfile profile); diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java index 41c1d9407454..7ff206db3c99 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/DataStoreProvider.java @@ -29,6 +29,11 @@ public interface DataStoreProvider { String SAMPLE_IMAGE = "Sample"; String SMB = "NFS"; String DEFAULT_PRIMARY = "DefaultPrimary"; + /** + * Primary storage provider name for NetApp ONTAP ({@code OntapPrimaryDatastoreProvider#getName}). + * Single canonical value; use this instead of duplicating the string across modules. + */ + String ONTAP_PLUGIN_NAME = "NetApp ONTAP"; enum DataStoreProviderType { PRIMARY, IMAGE, ImageCache, OBJECT diff --git a/engine/components-api/src/main/java/com/cloud/network/rules/StaticNatRuleImpl.java b/engine/components-api/src/main/java/com/cloud/network/rules/StaticNatRuleImpl.java index 4d8270ca078d..980606176024 100644 --- a/engine/components-api/src/main/java/com/cloud/network/rules/StaticNatRuleImpl.java +++ b/engine/components-api/src/main/java/com/cloud/network/rules/StaticNatRuleImpl.java @@ -80,10 +80,15 @@ public long getDomainId() { } @Override - public long getNetworkId() { + public Long getNetworkId() { return networkId; } + @Override + public Long getVpcId() { + return null; + } + @Override public long getId() { return id; diff --git a/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java b/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java index 3c62738f9ed5..032fcbe76dce 100644 --- a/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java +++ b/engine/components-api/src/main/java/com/cloud/storage/StorageManager.java @@ -195,6 +195,14 @@ public interface StorageManager extends StorageService { true, ConfigKey.Scope.StoragePool, null); + ConfigKey XenserverCreateCloneFull = new ConfigKey<>(Boolean.class, + "xenserver.create.full.clone", + "Storage", + "false", + "If set to true, creates VMs as full clones on XenServer hypervisor (uses VDI.copy instead of VDI.clone, removing the linked-clone parent relationship).", + true, + ConfigKey.Scope.StoragePool, + null); ConfigKey VmwareAllowParallelExecution = new ConfigKey<>(Boolean.class, "vmware.allow.parallel.command.execution", "Advanced", @@ -233,6 +241,9 @@ public interface StorageManager extends StorageService { "while adding a new Secondary Storage. If the copy operation fails, the system falls back to downloading the template from the source URL.", true, ConfigKey.Scope.Zone, null); + ConfigKey AgentMaxDataMigrationWaitTime = new ConfigKey<>("Advanced", Integer.class, "agent.max.data.migration.wait.time", "3600", + "The maximum time (in seconds) that the secondary storage data migration command sent to the KVM Agent will be executed before a timeout occurs.", true, ConfigKey.Scope.Cluster); + /** * should we execute in sequence not involving any storages? * @return true if commands should execute in sequence diff --git a/engine/components-api/src/main/java/com/cloud/vm/VmWorkDeleteBackup.java b/engine/components-api/src/main/java/com/cloud/vm/VmWorkDeleteBackup.java new file mode 100644 index 000000000000..b9d2907ef780 --- /dev/null +++ b/engine/components-api/src/main/java/com/cloud/vm/VmWorkDeleteBackup.java @@ -0,0 +1,38 @@ +// 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.vm; + +public class VmWorkDeleteBackup extends VmWork { + + private long backupId; + + private boolean forced; + + public VmWorkDeleteBackup(long userId, long accountId, long vmId, String handlerName, long backupId, boolean forced) { + super(userId, accountId, vmId, handlerName); + this.backupId = backupId; + this.forced = forced; + } + + public long getBackupId() { + return backupId; + } + + public boolean isForced() { + return forced; + } +} diff --git a/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreBackup.java b/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreBackup.java new file mode 100644 index 000000000000..421430cfbe9d --- /dev/null +++ b/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreBackup.java @@ -0,0 +1,45 @@ +// 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.vm; + +public class VmWorkRestoreBackup extends VmWork { + + private long backupId; + + private boolean quickRestore; + + private Long hostId; + + public VmWorkRestoreBackup(long userId, long accountId, long vmId, String handlerName, long backupId, boolean quickRestore, Long hostId) { + super(userId, accountId, vmId, handlerName); + this.backupId = backupId; + this.quickRestore = quickRestore; + this.hostId = hostId; + } + + public long getBackupId() { + return backupId; + } + + public boolean isQuickRestore() { + return quickRestore; + } + + public Long getHostId() { + return hostId; + } +} diff --git a/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreVolumeBackupAndAttach.java b/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreVolumeBackupAndAttach.java new file mode 100644 index 000000000000..a34b11abbdb4 --- /dev/null +++ b/engine/components-api/src/main/java/com/cloud/vm/VmWorkRestoreVolumeBackupAndAttach.java @@ -0,0 +1,55 @@ +// 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.vm; + +import org.apache.cloudstack.backup.Backup; + +public class VmWorkRestoreVolumeBackupAndAttach extends VmWork { + + private long backupId; + + private Backup.VolumeInfo backupVolumeInfo; + + private String hostIp; + + private boolean quickRestore; + + public VmWorkRestoreVolumeBackupAndAttach(long userId, long accountId, long vmId, String handlerName, long backupId, Backup.VolumeInfo backupVolumeInfo, + String hostIp, boolean quickRestore) { + super(userId, accountId, vmId, handlerName); + this.backupId = backupId; + this.backupVolumeInfo = backupVolumeInfo; + this.hostIp = hostIp; + this.quickRestore = quickRestore; + } + + public long getBackupId() { + return backupId; + } + + public Backup.VolumeInfo getBackupVolumeInfo() { + return backupVolumeInfo; + } + + public String getHostIp() { + return hostIp; + } + + public boolean isQuickRestore() { + return quickRestore; + } +} diff --git a/engine/components-api/src/main/java/com/cloud/vm/VmWorkTakeBackup.java b/engine/components-api/src/main/java/com/cloud/vm/VmWorkTakeBackup.java new file mode 100644 index 000000000000..57367d368b86 --- /dev/null +++ b/engine/components-api/src/main/java/com/cloud/vm/VmWorkTakeBackup.java @@ -0,0 +1,50 @@ +// 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.vm; + +public class VmWorkTakeBackup extends VmWork { + + private long backupId; + + private boolean quiesceVm; + + private boolean isolated; + + public VmWorkTakeBackup(long userId, long accountId, long vmId, long backupId, String handlerName, boolean quiesceVm, boolean isolated) { + super(userId, accountId, vmId, handlerName); + this.quiesceVm = quiesceVm; + this.backupId = backupId; + this.isolated = isolated; + } + + public boolean isQuiesceVm() { + return quiesceVm; + } + + public long getBackupId() { + return backupId; + } + + public boolean isIsolated() { + return isolated; + } + + @Override + public String toString() { + return super.toStringAfterRemoveParams(null, null); + } +} diff --git a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java index a232bdd05daa..364db685c9de 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -499,7 +499,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac static final ConfigKey ClusterVMMetaDataSyncInterval = new ConfigKey("Advanced", Integer.class, "vmmetadata.sync.interval", "180", "Cluster VM metadata sync interval in seconds", false); - static final ConfigKey VmJobCheckInterval = new ConfigKey("Advanced", + public static final ConfigKey VmJobCheckInterval = new ConfigKey("Advanced", Long.class, "vm.job.check.interval", "3000", "Interval in milliseconds to check if the job is complete", false); static final ConfigKey VmJobTimeout = new ConfigKey("Advanced", @@ -1029,7 +1029,7 @@ public Ternary doInTransaction(final if (stateTransitTo(vm, Event.StartRequested, null, work.getId())) { logger.debug("Successfully transitioned to start state for {} reservation id = {}", vm, work.getId()); if (VirtualMachine.Type.User.equals(vm.type) && ResourceCountRunningVMsonly.value()) { - _resourceLimitMgr.incrementVmResourceCount(owner.getAccountId(), vm.isDisplay(), offering, template); + _resourceLimitMgr.incrementVmResourceCount(owner.getAccountId(), vm.isDisplay(), offering, template, null); } return new Ternary<>(vm, context, work); } @@ -1396,6 +1396,7 @@ public void orchestrateStart(final String vmUuid, final Map templates, List snapshots, List volumes) { + public boolean filesReadyToMigrate(Long srcDataStoreId, List templates, List snapshots, List volumes, List backups) { State[] validStates = {State.Ready, State.Allocated, State.Destroying, State.Destroyed, State.Failed}; boolean isReady = true; for (TemplateDataStoreVO template : templates) { @@ -109,14 +117,48 @@ public boolean filesReadyToMigrate(Long srcDataStoreId, List backups) { + List invalidBackupStates = Arrays.asList(Backup.Status.BackingUp, Backup.Status.Restoring); + List invalidBackupCompressionStatus = Arrays.asList(Backup.CompressionStatus.Compressing, Backup.CompressionStatus.FinalizingCompression); + + List> backupChains; + Set backupIdsAlreadyInChain = new HashSet<>(); + + for (InternalBackupJoinVO backup : backups) { + if (backup.getStatus() == Backup.Status.BackedUp && !backupIdsAlreadyInChain.contains(backup.getId())) { + backupChains = createBackupChain(backup); + backupChains.forEach(list -> backupIdsAlreadyInChain.add(list.stream().map(BackupObject::getId).findFirst().get())); + + for (List backupVolumeChain : backupChains) { + BackupObject backupObject = backupVolumeChain.get(0); + + if (invalidBackupStates.contains(backupObject.getStatus())) { + logger.debug("Migration is not possible because backup {} is in {} state.", backupObject.getUuid(), backupObject.getStatus()); + return false; + } + + if (invalidBackupCompressionStatus.contains(backupObject.getCompressionStatus())) { + logger.debug("Migration is not possible because backup {} is currently being compressed. Current compression status: {}.", backupObject.getUuid(), backupObject.getCompressionStatus()); + return false; + } + } + } + } + + return true; + } + private boolean filesReadyToMigrate(Long srcDataStoreId) { List templates = templateDataStoreDao.listByStoreId(srcDataStoreId); List snapshots = snapshotDataStoreDao.listByStoreId(srcDataStoreId, DataStoreRole.Image); List volumes = volumeDataStoreDao.listByStoreId(srcDataStoreId); - return filesReadyToMigrate(srcDataStoreId, templates, snapshots, volumes); + List backups = internalBackupJoinDao.listByImageStoreId(srcDataStoreId); + + return filesReadyToMigrate(srcDataStoreId, templates, snapshots, volumes, backups); } protected void checkIfCompleteMigrationPossible(ImageStoreService.MigrationPolicy policy, Long srcDataStoreId) { @@ -175,19 +217,58 @@ protected List getSortedValidSourcesList(DataStore srcDataStore, Map return files; } - protected List getSortedValidSourcesList(DataStore srcDataStore, Map, Long>> snapshotChains, - Map, Long>> childTemplates) { + Map, Long>> childTemplates, Map>, Long>> backupChains) { List files = new ArrayList<>(); files.addAll(getAllReadyTemplates(srcDataStore, childTemplates)); files.addAll(getAllReadySnapshotsAndChains(srcDataStore, snapshotChains)); files.addAll(getAllReadyVolumes(srcDataStore)); + files.addAll(getAllReadyBackupsAndChains(srcDataStore, backupChains)); files = sortFilesOnSize(files, snapshotChains); return files; } + protected List getAllReadyBackupsAndChains(DataStore srcDataStore, Map>, Long>> backupChains) { + List backups = internalBackupJoinDao.listByImageStoreId(srcDataStore.getId()); + return getAllReadyBackupsAndChains(backupChains, backups); + } + + private List getAllReadyBackupsAndChains(Map>, Long>> backupsChains, List backups) { + Set backupIdsToMigrate = backups.stream().map(InternalBackupJoinVO::getId).collect(Collectors.toSet()); + List> backupChains; + Set backupIdsAlreadyInChain = new HashSet<>(); + List files = new LinkedList<>(); + + for (InternalBackupJoinVO backup : backups) { + long backupId = backup.getId(); + + if (backup.getStatus() == Backup.Status.BackedUp && !backupIdsAlreadyInChain.contains(backupId)) { + backupChains = createBackupChain(backup); + backupChains.forEach(list -> backupIdsAlreadyInChain.add(list.stream().map(BackupObject::getId).findFirst().get())); + BackupObject parent = backupChains.get(0).get(0); + files.add(parent); + backupsChains.put(parent, new Pair<>(backupChains, backupChains.stream().map(list -> getTotalChainSize(list.stream() + .filter(back -> backupIdsToMigrate.contains(parent.getId())).collect(Collectors.toList())) + ).reduce(Long::sum).get())); + } + } + + return (List) (List) files; + } + + private List> createBackupChain(InternalBackupJoinVO backup) { + List> chain = new LinkedList<>(); + BackupObject backupObject = BackupObject.getBackupObject(backup); + + chain.addAll(backupObject.getParents(backup.getParentId())); + chain.add(internalBackupJoinDao.listById(backup.getId()).stream().map(BackupObject::getBackupObject).collect(Collectors.toList())); + chain.addAll(backupObject.getChildren()); + + return chain; + } + protected List sortFilesOnSize(List files, Map, Long>> snapshotChains) { Collections.sort(files, new Comparator() { @Override diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java index 4262ee701aab..84a397349cec 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java @@ -177,6 +177,10 @@ import com.cloud.network.dao.RemoteAccessVpnDao; import com.cloud.network.dao.RemoteAccessVpnVO; import com.cloud.network.dao.RouterNetworkDao; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; + import com.cloud.network.element.AggregatedCommandExecutor; import com.cloud.network.element.ConfigDriveNetworkElement; import com.cloud.network.element.DhcpServiceProvider; @@ -313,6 +317,8 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra @Inject NetworkOfferingDetailsDao _ntwkOffDetailsDao; @Inject + NetworkOfferingServiceMapDao networkOfferingServiceMapDao; + @Inject AccountGuestVlanMapDao _accountGuestVlanMapDao; @Inject DataCenterVnetDao _datacenterVnetDao; @@ -368,6 +374,10 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra private BGPService bgpService; @Inject private Ipv6GuestPrefixSubnetNetworkMapDao ipv6GuestPrefixSubnetNetworkMapDao; + @Inject + protected ExtensionHelper extensionHelper; + @Inject + private NetworkExtensionElement networkExtensionElement; @Override public List getNetworkGurus() { @@ -461,6 +471,28 @@ public void setDhcpProviders(final List dhcpProviders) { HashMap _lastNetworkIdsToFree = new HashMap<>(); + /** + * Returns the full list of network elements to iterate when implementing, + * shutting down, or otherwise orchestrating a network. + * + *

The base list ({@link #networkElements}, wired by Spring) is extended + * at runtime with one transient {@link NetworkExtensionElement} per + * registered {@code NetworkOrchestrator} extension. This keeps the + * Spring bean list free from {@code NetworkExtensionElement} and allows + * dynamic discovery of extensions without a restart.

+ */ + protected List getNetworkElementsIncludingExtensions() { + List extensions = extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator); + if (CollectionUtils.isEmpty(extensions)) { + return networkElements; + } + List combined = new ArrayList<>(networkElements); + for (Extension ext : extensions) { + combined.add(networkExtensionElement.withProviderName(ext.getName())); + } + return combined; + } + private void updateRouterDefaultDns(final VirtualMachineProfile vmProfile, final NicProfile nicProfile) { if (!Type.DomainRouter.equals(vmProfile.getType()) || !nicProfile.isDefaultNic()) { return; @@ -561,6 +593,7 @@ public boolean configure(final String name, final Map params) th defaultVPCOffProviders.put(Service.StaticNat, defaultProviders); defaultVPCOffProviders.put(Service.PortForwarding, defaultProviders); defaultVPCOffProviders.put(Service.Vpn, defaultProviders); + defaultVPCOffProviders.put(Service.Firewall, defaultProviders); Transaction.execute(new TransactionCallbackNoReturn() { @Override @@ -776,7 +809,7 @@ public List setupNetwork(final Account owner, final NetworkOf long related = -1; - for (final NetworkGuru guru : networkGurus) { + for (final NetworkGuru guru : getDesignNetworkGurus(offering)) { final Network network = guru.design(offering, plan, predefined, name, vpcId, owner); if (network == null) { continue; @@ -852,6 +885,46 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } + private List getDesignNetworkGurus(final NetworkOffering offering) { + if (!isIsolationMethodNetworkExtension(offering.getId())) { + return networkGurus; + } + + List extensionGurus = networkGurus.stream() + .filter(guru -> ExtensionHelper.NETWORK_EXTENSION_GURU_NAME.equals(guru.getName())) + .collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(extensionGurus)) { + return extensionGurus; + } + + logger.warn("Network offering {} requests {}={}, but {} is not registered; using default guru ordering", + offering.getUuid(), ExtensionHelper.NETWORK_ISOLATION_METHOD_DETAIL_KEY, + ExtensionHelper.NETWORK_EXTENSION_ISOLATION_METHOD, ExtensionHelper.NETWORK_EXTENSION_GURU_NAME); + return networkGurus; + } + + @Override + public boolean isIsolationMethodNetworkExtension(final Long networkOfferingId) { + if (networkOfferingId == null) { + return false; + } + + List providers = networkOfferingServiceMapDao.getDistinctProviders(networkOfferingId); + if (CollectionUtils.isEmpty(providers)) { + return false; + } + + for (String providerName : providers) { + if (!extensionHelper.isNetworkExtensionProvider(providerName)) { + continue; + } + if (extensionHelper.usesNetworkExtensionIsolation(providerName)) { + return true; + } + } + return false; + } + @NotNull private static NetworkVO getNetworkVO(long id, final NetworkOffering offering, final DeploymentPlan plan, final Network predefined, Network network, final NetworkGuru guru, final Account owner, @@ -1593,6 +1666,9 @@ public Pair implementNetwork(final long networkId, final // implement network elements and re-apply all the network rules implementNetworkElementsAndResources(dest, context, network, offering); + // reload network after implementing the network + network = _networksDao.findById(networkId); + long dcId = dest.getDataCenter().getId(); if (networkMeetsPersistenceCriteria(network, offering, false)) { setupPersistentNetwork(network, offering, dcId); @@ -1686,7 +1762,8 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f } } - for (final NetworkElement element : networkElements) { + List allNetworkElements = getNetworkElementsIncludingExtensions(); + for (final NetworkElement element : allNetworkElements) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { ((AggregatedCommandExecutor) element).prepareAggregatedExecution(network, dest); } @@ -1703,7 +1780,7 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f ex.addProxyObject(_entityMgr.findById(DataCenter.class, network.getDataCenterId()).getUuid()); throw ex; } - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : allNetworkElements) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { if (!((AggregatedCommandExecutor) element).completeAggregatedExecution(network, dest)) { logger.warn("Failed to re-program the network as a part of network {} implement due to aggregated commands execution failure!", network); @@ -1717,7 +1794,7 @@ public void implementNetworkElementsAndResources(final DeployDestination dest, f } reconfigureAndApplyStaticRouteForVpcVpn(network); } finally { - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : allNetworkElements) { if (element instanceof AggregatedCommandExecutor && providersToImplement.contains(element.getProvider())) { ((AggregatedCommandExecutor) element).cleanupAggregatedExecution(network, dest); } @@ -1738,7 +1815,7 @@ private void reconfigureAndApplyStaticRouteForVpcVpn(Network network) { private void implementNetworkElements(final DeployDestination dest, final ReservationContext context, final Network network, final NetworkOffering offering, final List providersToImplement) throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { // The physicalNetworkId will not get translated into a uuid by the response serializer, @@ -2031,7 +2108,7 @@ public void doInTransactionWithoutResult(TransactionStatus status) { @Override public void configureUpdateInSequence(Network network) { List providers = getNetworkProviders(network.getId()); - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { if (element instanceof RedundantResource) { ((RedundantResource) element).configureResource(network); @@ -2044,7 +2121,7 @@ public void configureUpdateInSequence(Network network) { public int getResourceCount(Network network) { List providers = getNetworkProviders(network.getId()); int resourceCount = 0; - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { //currently only one element implements the redundant resource interface if (element instanceof RedundantResource) { @@ -2075,7 +2152,7 @@ public void configureExtraDhcpOptions(Network network, long nicId) { @Override public void finalizeUpdateInSequence(Network network, boolean success) { List providers = getNetworkProviders(network.getId()); - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providers.contains(element.getProvider())) { //currently only one element implements the redundant resource interface if (element instanceof RedundantResource) { @@ -2102,7 +2179,7 @@ public void setHypervisorHostname(VirtualMachineProfile vm, DeployDestination de } private void setHypervisorHostnameInNetwork(VirtualMachineProfile vm, DeployDestination dest, Network network, NicProfile profile, boolean migrationSuccessful) { - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (_networkModel.areServicesSupportedInNetwork(network.getId(), Service.UserData) && element instanceof UserDataServiceProvider && (element instanceof ConfigDriveNetworkElement && !migrationSuccessful || element instanceof VirtualRouterElement && migrationSuccessful)) { String errorMsg = String.format("Failed to add hypervisor host name while applying the userdata during the migration of VM %s, " + @@ -2230,7 +2307,7 @@ public NicProfile prepareNic(final VirtualMachineProfile vmProfile, final Deploy updateNic(nic, network, 1); final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2285,7 +2362,7 @@ public void prepareNicForMigration(final VirtualMachineProfile vm, final DeployD } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2329,7 +2406,7 @@ public void prepareAllNicsForMigration(final VirtualMachineProfile vm, final Dep } } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException(String.format("Service provider %s either doesn't exist or is not enabled in physical network: %s", @@ -2411,7 +2488,7 @@ public void commitNicForMigration(final VirtualMachineProfile src, final Virtual } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2447,7 +2524,7 @@ public void rollbackNicForMigration(final VirtualMachineProfile src, final Virtu } final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -2534,7 +2611,7 @@ public Pair doInTransaction(final TransactionStatus status) final Network network = networkToRelease.first(); final NicProfile profile = networkToRelease.second(); final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { logger.debug("Asking {} to release {}", element.getName(), profile); //NOTE: Context appear to never be used in release method @@ -2597,7 +2674,7 @@ protected void removeNic(final VirtualMachineProfile vm, final NicVO nic) { */ if (nic.getReservationStrategy() == Nic.ReservationStrategy.Create) { final List providersToImplement = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToImplement.contains(element.getProvider())) { logger.debug("Asking {} to release {}, according to the reservation strategy {}.", element.getName(), nic, nic.getReservationStrategy()); try { @@ -3328,7 +3405,7 @@ public boolean shutdownNetworkElementsAndResources(final ReservationContext cont // 2) Shutdown all the network elements boolean success = true; - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToShutdown.contains(element.getProvider())) { try { logger.debug("Sending network shutdown to {}", element.getName()); @@ -3439,7 +3516,7 @@ public boolean destroyNetwork(final long networkId, final ReservationContext con // get providers to destroy final List providersToDestroy = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (providersToDestroy.contains(element.getProvider())) { try { logger.debug("Sending destroy to {}", element); @@ -3810,7 +3887,7 @@ public boolean areRoutersRunning(final List routers) { public void cleanupNicDhcpDnsEntry(Network network, VirtualMachineProfile vmProfile, NicProfile nicProfile) { final List networkProviders = getNetworkProviders(network.getId()); - for (final NetworkElement element : networkElements) { + for (final NetworkElement element : getNetworkElementsIncludingExtensions()) { if (networkProviders.contains(element.getProvider())) { if (!_networkModel.isProviderEnabledInPhysicalNetwork(_networkModel.getPhysicalNetworkId(network), element.getProvider().getName())) { throw new CloudRuntimeException("Service provider " + element.getProvider().getName() + " either doesn't exist or is not enabled in physical network id: " @@ -3827,6 +3904,17 @@ public void cleanupNicDhcpDnsEntry(Network network, VirtualMachineProfile vmProf } } } + if (vmProfile.getType() == Type.User && element.getProvider() != null) { + if (_networkModel.areServicesSupportedInNetwork(network.getId(), Service.Dns) + && _networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.Dns, element.getProvider()) && element instanceof DnsServiceProvider) { + final DnsServiceProvider sp = (DnsServiceProvider) element; + try { + sp.removeDnsEntry(network, nicProfile, vmProfile); + } catch (ResourceUnavailableException e) { + logger.error("Failed to remove dns entry due to: ", e); + } + } + } } } } @@ -3846,7 +3934,7 @@ public void cleanupNicDhcpDnsEntry(Network network, VirtualMachineProfile vmProf * @throws InsufficientCapacityException */ private boolean rollingRestartRouters(final NetworkVO network, final NetworkOffering offering, final DeployDestination dest, final ReservationContext context) throws ResourceUnavailableException, ConcurrentOperationException, InsufficientCapacityException { - if (!NetworkOrchestrationService.RollingRestartEnabled.value()) { + if (!isRollingRestartSupport(network)) { if (shutdownNetworkElementsAndResources(context, true, network)) { implementNetworkElementsAndResources(dest, context, network, offering); return true; @@ -3894,6 +3982,20 @@ private boolean rollingRestartRouters(final NetworkVO network, final NetworkOffe return areRoutersRunning(routerDao.findByNetwork(network.getId())); } + private boolean isRollingRestartSupport(final NetworkVO network) { + if (!NetworkOrchestrator.RollingRestartEnabled.value()) { + return false; + } + List services = _ntwkSrvcDao.getServicesInNetwork(network.getId()); + for (NetworkServiceMapVO service : services) { + NetworkElement element = _networkModel.getElementImplementingProvider(service.getProvider()); + if (element == null || !element.rollingRestartSupported()) { + return false; + } + } + return true; + } + private void setRestartRequired(final NetworkVO network, final boolean restartRequired) { logger.debug("Marking network {} with restartRequired={}", network, restartRequired); network.setRestartRequired(restartRequired); @@ -4455,6 +4557,12 @@ public Map finalizeServicesAndProvidersForNetwork(final NetworkO if (provider == null) { provider = _networkModel.getDefaultUniqueProviderForService(service).getName(); + } else { + final Provider resolvedProvider = _networkModel.resolveProvider(provider); + if (resolvedProvider == null) { + throw new InvalidParameterValueException("Invalid provider " + provider + " configured for service " + service); + } + provider = resolvedProvider.getName(); } // check that provider is supported @@ -4480,7 +4588,10 @@ private List getNetworkProviders(final long networkId) { final List providerNames = _ntwkSrvcDao.getDistinctProviders(networkId); final List providers = new ArrayList<>(); for (final String providerName : providerNames) { - providers.add(Network.Provider.getProvider(providerName)); + final Provider provider = _networkModel.resolveProvider(providerName); + if (provider != null) { + providers.add(provider); + } } return providers; @@ -4646,7 +4757,7 @@ private Map> getServiceProvidersMap(final long networkId) if (providers == null) { providers = new HashSet<>(); } - providers.add(Provider.getProvider(nsm.getProvider())); + providers.add(_networkModel.resolveProvider(nsm.getProvider())); map.put(Service.getService(nsm.getService()), providers); } return map; @@ -4931,10 +5042,10 @@ public void unmanageNics(VirtualMachineProfile vm) { @Override public void expungeLbVmRefs(List vmIds, Long batchSize) { - if (CollectionUtils.isEmpty(networkElements) || CollectionUtils.isEmpty(vmIds)) { + if (CollectionUtils.isEmpty(vmIds)) { return; } - for (NetworkElement element : networkElements) { + for (NetworkElement element : getNetworkElementsIncludingExtensions()) { if (element instanceof LoadBalancingServiceProvider) { LoadBalancingServiceProvider lbProvider = (LoadBalancingServiceProvider)element; lbProvider.expungeLbVmRefs(vmIds, batchSize); @@ -4955,7 +5066,7 @@ public String getConfigComponentName() { @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{NetworkGcWait, NetworkGcInterval, NetworkLockTimeout, DeniedRoutes, - GuestDomainSuffix, NetworkThrottlingRate, MinVRVersion, + GuestDomainSuffix, NetworkThrottlingRate, VmNetworkThrottlingRate, MinVRVersion, DhcpLeaseTimeout, PromiscuousMode, MacAddressChanges, ForgedTransmits, MacLearning, RollingRestartEnabled, TUNGSTEN_ENABLED, NSX_ENABLED, NETRIS_ENABLED, NETWORK_LB_HAPROXY_MAX_CONN, NETWORK_LB_HAPROXY_IDLE_TIMEOUT}; diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/StorageOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/StorageOrchestrator.java index 933b4e0c5ce6..403e83bccf99 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/StorageOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/StorageOrchestrator.java @@ -25,9 +25,13 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -36,10 +40,22 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.MigrateBackupsBetweenSecondaryStoragesCommand; +import com.cloud.agent.api.MigrateBetweenSecondaryStoragesCommandAnswer; import com.cloud.dc.dao.DataCenterDao; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.template.TemplateManager; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; import org.apache.cloudstack.api.response.MigrationResponse; +import org.apache.cloudstack.backup.BackupDetailVO; +import org.apache.cloudstack.backup.dao.BackupDetailsDao; import org.apache.cloudstack.engine.orchestration.service.StorageOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; @@ -57,6 +73,7 @@ import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.storage.ImageStoreService.MigrationPolicy; +import org.apache.cloudstack.storage.backup.BackupObject; import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; import org.apache.cloudstack.storage.datastore.db.TemplateDataStoreDao; @@ -115,6 +132,12 @@ public class StorageOrchestrator extends ManagerBase implements StorageOrchestra TemplateDataFactory templateDataFactory; @Inject DataCenterDao dcDao; + @Inject + AgentManager agentManager; + @Inject + HostDao hostDao; + @Inject + BackupDetailsDao backupDetailDao; ConfigKey ImageStoreImbalanceThreshold = new ConfigKey<>("Advanced", Double.class, @@ -128,6 +151,7 @@ public class StorageOrchestrator extends ManagerBase implements StorageOrchestra private final Map zoneExecutorMap = new HashMap<>(); private final Map zonePendingWorkCountMap = new HashMap<>(); + private final Map zoneKvmIncrementalExecutorMap = new ConcurrentHashMap<>(); @Override public String getConfigComponentName() { @@ -171,7 +195,9 @@ public MigrationResponse migrateData(Long srcDataStoreId, List destDatasto DataStore srcDatastore = dataStoreManager.getDataStore(srcDataStoreId, DataStoreRole.Image); Map, Long>> snapshotChains = new HashMap<>(); Map, Long>> childTemplates = new HashMap<>(); - files = migrationHelper.getSortedValidSourcesList(srcDatastore, snapshotChains, childTemplates); + Map>, Long>> backupChains = new HashMap<>(); + files = migrationHelper.getSortedValidSourcesList(srcDatastore, snapshotChains, childTemplates, backupChains); + if (files.isEmpty()) { return new MigrationResponse(String.format("No files in Image store: %s to migrate", srcDatastore), migrationPolicy.toString(), true); @@ -227,7 +253,7 @@ public MigrationResponse migrateData(Long srcDataStoreId, List destDatasto } if (shouldMigrate(chosenFileForMigration, srcDatastore.getId(), destDatastoreId, storageCapacities, snapshotChains, childTemplates, migrationPolicy)) { - storageCapacities = migrateAway(chosenFileForMigration, storageCapacities, snapshotChains, childTemplates, srcDatastore, destDatastoreId, futures); + storageCapacities = migrateAway(chosenFileForMigration, storageCapacities, snapshotChains, childTemplates, backupChains, srcDatastore, destDatastoreId, futures); } else { if (migrationPolicy == MigrationPolicy.BALANCE) { continue; @@ -256,7 +282,7 @@ public MigrationResponse migrateResources(Long srcImgStoreId, Long destImgStoreI List templates = templateDataStoreDao.listByStoreIdAndTemplateIds(srcImgStoreId, templateIdList); List snapshots = snapshotDataStoreDao.listByStoreAndSnapshotIds(srcImgStoreId, DataStoreRole.Image, snapshotIdList); - if (!migrationHelper.filesReadyToMigrate(srcImgStoreId, templates, snapshots, Collections.emptyList())) { + if (!migrationHelper.filesReadyToMigrate(srcImgStoreId, templates, snapshots, Collections.emptyList(), Collections.emptyList())) { throw new CloudRuntimeException("Migration failed as there are data objects which are not Ready - i.e, they may be in Migrating, creating, copying, etc. states"); } files = migrationHelper.getSortedValidSourcesList(srcDatastore, snapshotChains, childTemplates, templates, snapshots); @@ -291,7 +317,7 @@ public MigrationResponse migrateResources(Long srcImgStoreId, Long destImgStoreI } if (storageCapacityBelowThreshold(storageCapacities, destImgStoreId)) { - storageCapacities = migrateAway(chosenFileForMigration, storageCapacities, snapshotChains, childTemplates, srcDatastore, destImgStoreId, futures); + storageCapacities = migrateAway(chosenFileForMigration, storageCapacities, snapshotChains, childTemplates, null, srcDatastore, destImgStoreId, futures); } else { message = "Migration failed. Destination store doesn't have enough capacity for migration"; success = false; @@ -355,15 +381,89 @@ protected Map> migrateAway( Map, Long>> snapshotChains, Map, Long>> templateChains, + Map>, Long>> backupChains, DataStore srcDatastore, Long destDatastoreId, List> futures) { Long fileSize = migrationHelper.getFileSize(chosenFileForMigration, snapshotChains, templateChains); storageCapacities = assumeMigrate(storageCapacities, srcDatastore.getId(), destDatastoreId, fileSize); + DataStore destDataStore = dataStoreManager.getDataStore(destDatastoreId, DataStoreRole.Image); + + boolean isKvmIncrementalBackup = backupChains != null && chosenFileForMigration instanceof BackupObject && backupChains.containsKey(chosenFileForMigration); + + if (isKvmIncrementalBackup) { + MigrateKvmIncrementalBackupTask task = new MigrateKvmIncrementalBackupTask(chosenFileForMigration, backupChains, srcDatastore, destDataStore); + futures.add(submitKvmIncrementalMigration(srcDatastore.getScope().getScopeId(), task)); + logger.debug("Incremental backup migration {} submitted to incremental pool.", chosenFileForMigration.getUuid()); + } else { + createMigrateDataTask(chosenFileForMigration, snapshotChains, templateChains, srcDatastore, destDataStore, futures); + } + + return storageCapacities; + } + + private void migrateKvmIncrementalBackupChain(DataObject chosenFileForMigration, Map>, Long>> backupChains, DataStore srcDatastore, DataStore destDataStore) { + Transaction.execute((TransactionCallback) status -> { + MigrateBetweenSecondaryStoragesCommandAnswer answer = null; + + try { + List> backupChain = backupChains.get(chosenFileForMigration).first(); + MigrateBackupsBetweenSecondaryStoragesCommand migrateBetweenSecondaryStoragesCmd = new MigrateBackupsBetweenSecondaryStoragesCommand(backupChain.stream().map(list -> list.stream().map(BackupObject::getTO).collect(Collectors.toList())) + .collect(Collectors.toList()), srcDatastore.getTO(), destDataStore.getTO()); + + HostVO host = getAvailableHost(((BackupObject) chosenFileForMigration).getZoneId()); + if (host == null) { + throw new CloudRuntimeException("No hosts found to send migrate command."); + } + + migrateBetweenSecondaryStoragesCmd.setWait(StorageManager.AgentMaxDataMigrationWaitTime.valueIn(host.getClusterId())); + answer = (MigrateBetweenSecondaryStoragesCommandAnswer) agentManager.send(host.getId(), migrateBetweenSecondaryStoragesCmd); + if (answer == null || !answer.getResult()) { + logger.warn("Unable to migrate backups [{}].", backupChain); + throw new CloudRuntimeException("Unable to migrate KVM incremental backups to another secondary storage"); + } + + } catch (final OperationTimedoutException | AgentUnavailableException e) { + throw new CloudRuntimeException("Error while migrating KVM incremental backup chain. Check the logs for more information.", e); + } finally { + if (answer != null) { + updateBackupsReference(destDataStore, answer); + } + } + return answer.getResult(); + }); + } + + private void updateBackupsReference(DataStore destDataStore, MigrateBetweenSecondaryStoragesCommandAnswer answer) { + for (Pair backupIdAndUpdatedCheckpointPath : answer.getMigratedResources()) { + Long backupId = backupIdAndUpdatedCheckpointPath.first(); + BackupDetailVO backupDetail = backupDetailDao.findDetail(backupId, BackupDetailsDao.IMAGE_STORE_ID); + String destDataStoreId = String.valueOf(destDataStore.getId()); + + if (backupDetail == null) { + logger.warn("No details found for backup [{}]. Creating new entry with image store ID [{}].", backupId, destDataStoreId); + backupDetailDao.addDetail(backupId, BackupDetailsDao.IMAGE_STORE_ID, destDataStoreId, false); + continue; + } + + backupDetail.setValue(destDataStoreId); + backupDetailDao.update(backupDetail.getId(), backupDetail); + } + } - MigrateDataTask task = new MigrateDataTask(chosenFileForMigration, srcDatastore, dataStoreManager.getDataStore(destDatastoreId, DataStoreRole.Image)); - if (chosenFileForMigration instanceof SnapshotInfo ) { + private HostVO getAvailableHost(long zoneId) throws AgentUnavailableException, OperationTimedoutException { + List hosts = hostDao.listByDataCenterIdAndHypervisorType(zoneId, Hypervisor.HypervisorType.KVM); + if (CollectionUtils.isNotEmpty(hosts)) { + return hosts.get(new Random().nextInt(hosts.size())); + } + + return null; + } + + private void createMigrateDataTask(DataObject chosenFileForMigration, Map, Long>> snapshotChains, Map, Long>> templateChains, DataStore srcDatastore, DataStore destDataStore, List> futures) { + MigrateDataTask task = new MigrateDataTask(chosenFileForMigration, srcDatastore, destDataStore); + if (chosenFileForMigration instanceof SnapshotInfo) { task.setSnapshotChains(snapshotChains); } if (chosenFileForMigration instanceof TemplateInfo) { @@ -371,7 +471,6 @@ protected Map> migrateAway( } futures.add(submit(srcDatastore.getScope().getScopeId(), task)); logger.debug("Migration of {}: {} is initiated.", chosenFileForMigration.getType().name(), chosenFileForMigration.getUuid()); - return storageCapacities; } protected Future submit(Long zoneId, Callable task) { @@ -390,6 +489,13 @@ protected Future submit(Long zoneId, Callable task) { } + protected synchronized Future submitKvmIncrementalMigration(Long zoneId, Callable task) { + if (!zoneKvmIncrementalExecutorMap.containsKey(zoneId)) { + zoneKvmIncrementalExecutorMap.put(zoneId, Executors.newSingleThreadExecutor()); + } + return zoneKvmIncrementalExecutorMap.get(zoneId).submit(task); + } + protected void scaleExecutorIfNecessary(Long zoneId) { long activeSsvms = migrationHelper.activeSSVMCount(zoneId); long totalJobs = activeSsvms * numConcurrentCopyTasksPerSSVM; @@ -666,4 +772,32 @@ public TemplateApiResult call() { return result; } } + + private class MigrateKvmIncrementalBackupTask implements Callable { + private final DataObject chosenFile; + private final Map>, Long>> backupChains; + private final DataStore srcDataStore; + private final DataStore destDataStore; + + public MigrateKvmIncrementalBackupTask(DataObject chosenFile, Map>, Long>> backupChains, DataStore srcDataStore, DataStore destDataStore) { + this.chosenFile = chosenFile; + this.backupChains = backupChains; + this.srcDataStore = srcDataStore; + this.destDataStore = destDataStore; + } + + @Override + public DataObjectResult call() { + try { + migrateKvmIncrementalBackupChain(chosenFile, backupChains, srcDataStore, destDataStore); + return new DataObjectResult(chosenFile); + } catch (Exception e) { + logger.warn("Failed migrating incremental backup {} due to {}.", chosenFile.getUuid(), e); + DataObjectResult result = new DataObjectResult(chosenFile); + result.setResult(e.toString()); + return result; + } + } + } + } diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index b4c104da07e7..f4198819dd16 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -53,6 +53,7 @@ import org.apache.cloudstack.api.command.admin.vm.MigrateVMCmd; import org.apache.cloudstack.api.command.admin.volume.MigrateVolumeCmdByAdmin; import org.apache.cloudstack.api.command.user.volume.MigrateVolumeCmd; +import org.apache.cloudstack.backup.InternalBackupService; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.VolumeOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.ChapInfo; @@ -301,6 +302,9 @@ public enum UserVmCloneType { @Inject private KMSWrappedKeyDao kmsWrappedKeyDao; + @Inject + private InternalBackupService internalBackupService; + private final StateMachine2 _volStateMachine; protected List _storagePoolAllocators; @@ -1358,21 +1362,27 @@ private VolumeInfo copyVolume(StoragePool rootDiskPool, VolumeInfo volumeInfo, V } @Override - public VolumeInfo createVolumeOnPrimaryStorage(VirtualMachine vm, VolumeInfo volumeInfo, HypervisorType rootDiskHyperType, StoragePool storagePool) throws NoTransitionException { + public VolumeInfo createVolumeOnPrimaryStorage(VirtualMachine vm, VolumeInfo volumeInfo, HypervisorType rootDiskHyperType, StoragePool storagePool, Long clusterId, Long podId) + throws NoTransitionException { String volumeToString = getReflectOnlySelectedFields(volumeInfo.getVolume()); VirtualMachineTemplate rootDiskTmplt = _entityMgr.findById(VirtualMachineTemplate.class, vm.getTemplateId()); DataCenter dcVO = _entityMgr.findById(DataCenter.class, vm.getDataCenterId()); - logger.trace("storage-pool {}/{} is associated with pod {}",storagePool.getName(), storagePool.getUuid(), storagePool.getPodId()); - Long podId = storagePool.getPodId() != null ? storagePool.getPodId() : vm.getPodIdToDeployIn(); + + if (storagePool != null) { + logger.trace("storage-pool {}/{} is associated with pod {}", storagePool.getName(), storagePool.getUuid(), storagePool.getPodId()); + podId = storagePool.getPodId() != null ? storagePool.getPodId() : vm.getPodIdToDeployIn(); + clusterId = storagePool.getClusterId(); + logger.trace("storage-pool {}/{} is associated with cluster {}",storagePool.getName(), storagePool.getUuid(), clusterId); + } + Pod pod = _entityMgr.findById(Pod.class, podId); ServiceOffering svo = _entityMgr.findById(ServiceOffering.class, vm.getServiceOfferingId()); DiskOffering diskVO = _entityMgr.findById(DiskOffering.class, volumeInfo.getDiskOfferingId()); - Long clusterId = storagePool.getClusterId(); - logger.trace("storage-pool {}/{} is associated with cluster {}",storagePool.getName(), storagePool.getUuid(), clusterId); + Long hostId = vm.getHostId(); - if (hostId == null && (storagePool.isLocal() || ClvmPoolManager.isClvmPoolType(storagePool.getPoolType()))) { + if (hostId == null && storagePool != null && (storagePool.isLocal() || ClvmPoolManager.isClvmPoolType(storagePool.getPoolType()))) { if (ClvmPoolManager.isClvmPoolType(storagePool.getPoolType())) { hostId = getClvmLockHostFromVmVolumes(vm.getId()); if (hostId != null) { @@ -1632,6 +1642,7 @@ public Volume migrateVolume(Volume volume, StoragePool destPool) throws StorageU _snapshotDao.updateVolumeIds(vol.getId(), result.getVolume().getId()); _snapshotDataStoreDao.updateVolumeIds(vol.getId(), result.getVolume().getId()); } + internalBackupService.updateVolumeId(vol.getId(), result.getVolume().getId()); // For CLVM volumes attached to a VM, update the CLVM_LOCK_HOST_ID after migration updateClvmLockHostAfterMigration(result.getVolume(), destPool, "migrated"); @@ -1695,6 +1706,8 @@ public void migrateVolumes(VirtualMachine vm, VirtualMachineTO vmTo, Host srcHos throw new CloudRuntimeException(String.format("Failed to find the destination storage pool [%s] to migrate the volume [%s] to.", storagePoolToString, volumeToString)); } + internalBackupService.prepareVolumeForMigration(volume); + volumeMap.put(volFactory.getVolume(volume.getId()), (DataStore)destPool); } @@ -2531,7 +2544,8 @@ public void destroyVolume(Volume volume) { if (volume.getState() == Volume.State.Allocated) { _volsDao.remove(volume.getId()); stateTransitTo(volume, Volume.Event.DestroyRequested); - _resourceLimitMgr.decrementVolumeResourceCount(volume.getAccountId(), volume.isDisplay(), volume.getSize(), diskOfferingDao.findByIdIncludingRemoved(volume.getDiskOfferingId())); + _resourceLimitMgr.decrementVolumeResourceCount(volume.getAccountId(), volume.isDisplay(), volume.getSize(), diskOfferingDao.findByIdIncludingRemoved(volume.getDiskOfferingId()), + null); } else { destroyVolumeInContext(volume); } diff --git a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml index 8f93ae5b35a3..e17302e68a1f 100644 --- a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml +++ b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml @@ -91,6 +91,7 @@ +
diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java index e3989737112d..66f5b699cc46 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestratorTest.java @@ -27,6 +27,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -35,11 +36,15 @@ import com.cloud.exception.InsufficientVirtualNetworkCapacityException; import com.cloud.network.IpAddressManager; import com.cloud.utils.Pair; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.framework.extensions.network.NetworkExtensionElement; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.springframework.test.util.ReflectionTestUtils; import org.mockito.ArgumentMatchers; import org.mockito.MockedStatic; import org.mockito.Mockito; @@ -68,6 +73,7 @@ import com.cloud.network.dao.PhysicalNetworkVO; import com.cloud.network.dao.RouterNetworkDao; import com.cloud.network.element.DhcpServiceProvider; +import com.cloud.network.element.NetworkElement; import com.cloud.network.guru.GuestNetworkGuru; import com.cloud.network.guru.NetworkGuru; import com.cloud.network.vpc.VpcManager; @@ -105,6 +111,7 @@ public class NetworkOrchestratorTest extends TestCase { private String guruName = "GuestNetworkGuru"; private String dhcpProvider = "VirtualRouter"; private NetworkGuru guru = mock(NetworkGuru.class); + private NetworkExtensionElement networkExtensionElement; NetworkOfferingVO networkOffering = mock(NetworkOfferingVO.class); @@ -135,6 +142,9 @@ public void setUp() { testOrchestrator.routerJoinDao = mock(DomainRouterJoinDao.class); testOrchestrator._ipAddrMgr = mock(IpAddressManager.class); testOrchestrator._entityMgr = mock(EntityManager.class); + testOrchestrator.extensionHelper = mock(ExtensionHelper.class); + networkExtensionElement = mock(NetworkExtensionElement.class); + ReflectionTestUtils.setField(testOrchestrator, "networkExtensionElement", networkExtensionElement); DhcpServiceProvider provider = mock(DhcpServiceProvider.class); Map capabilities = new HashMap(); @@ -388,7 +398,7 @@ private void configureTestConfigureNicProfileBasedOnRequestedIpTests(NicProfile when(testOrchestrator._ipAddressDao.findByIpAndSourceNetworkId(Mockito.anyLong(), Mockito.anyString())).thenReturn(ipVoSpy); } - VlanVO vlanSpy = Mockito.spy(new VlanVO(Vlan.VlanType.DirectAttached, "vlanTag", vlanGateway, vlanNetmask, 0l, "192.168.100.100 - 192.168.100.200", 0l, new Long(0l), + VlanVO vlanSpy = Mockito.spy(new VlanVO(Vlan.VlanType.DirectAttached, "vlanTag", vlanGateway, vlanNetmask, 0l, "192.168.100.100 - 192.168.100.200", 0l, 0l, "ip6Gateway", "ip6Cidr", "ip6Range")); Mockito.doReturn(0l).when(vlanSpy).getId(); @@ -1010,4 +1020,57 @@ public void testImportNicWithIP4Address() throws Exception { assertEquals("testtag", nicProfile.getName()); } } + + // ----------------------------------------------------------------------- + // Tests for getNetworkElementsIncludingExtensions + // ----------------------------------------------------------------------- + + @Test + public void getNetworkElementsIncludingExtensionsReturnsBaseListWhenNoExtensions() { + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(Collections.emptyList()); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + List elements = new ArrayList<>(List.of(dhcpProvider)); + testOrchestrator.networkElements = elements; + + List result = testOrchestrator.getNetworkElementsIncludingExtensions(); + + assertNotNull(result); + assertEquals(elements.size(), result.size()); + } + + @Test + public void getNetworkElementsIncludingExtensionsAddsExtensionElements() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("my-net-ext"); + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(List.of(ext)); + + NetworkExtensionElement extElement = mock(NetworkExtensionElement.class); + when(networkExtensionElement.withProviderName("my-net-ext")).thenReturn(extElement); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + testOrchestrator.networkElements = new ArrayList<>(List.of(dhcpProvider)); + + List result = testOrchestrator.getNetworkElementsIncludingExtensions(); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains(extElement)); + } + + @Test + public void getNetworkElementsIncludingExtensionsReturnsBaseListWhenExtensionHelperReturnsNull() { + when(testOrchestrator.extensionHelper.listExtensionsByType(Extension.Type.NetworkOrchestrator)) + .thenReturn(null); + + DhcpServiceProvider dhcpProvider = mock(DhcpServiceProvider.class); + testOrchestrator.networkElements = new ArrayList<>(List.of(dhcpProvider)); + + List result = testOrchestrator.getNetworkElementsIncludingExtensions(); + + assertNotNull(result); + assertEquals(1, result.size()); + } } diff --git a/engine/schema/src/main/java/com/cloud/dc/dao/VlanDao.java b/engine/schema/src/main/java/com/cloud/dc/dao/VlanDao.java index a6c267bb189b..beab7ce2cd56 100644 --- a/engine/schema/src/main/java/com/cloud/dc/dao/VlanDao.java +++ b/engine/schema/src/main/java/com/cloud/dc/dao/VlanDao.java @@ -51,6 +51,8 @@ public interface VlanDao extends GenericDao { List listVlansByNetworkId(long networkId); + List listVlansByNetworkIds(List networkIds); + List listVlansByNetworkIdIncludingRemoved(long networkId); List listVlansByPhysicalNetworkId(long physicalNetworkId); diff --git a/engine/schema/src/main/java/com/cloud/dc/dao/VlanDaoImpl.java b/engine/schema/src/main/java/com/cloud/dc/dao/VlanDaoImpl.java index d9fad3cad12a..5687e8588772 100644 --- a/engine/schema/src/main/java/com/cloud/dc/dao/VlanDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/dc/dao/VlanDaoImpl.java @@ -20,6 +20,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -27,6 +28,7 @@ import javax.naming.ConfigurationException; import com.cloud.dc.VlanDetailsVO; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; @@ -134,7 +136,7 @@ public VlanDaoImpl() { ZoneTypeSearch.done(); NetworkVlanSearch = createSearchBuilder(); - NetworkVlanSearch.and("networkId", NetworkVlanSearch.entity().getNetworkId(), SearchCriteria.Op.EQ); + NetworkVlanSearch.and("networkId", NetworkVlanSearch.entity().getNetworkId(), SearchCriteria.Op.IN); NetworkVlanSearch.done(); PhysicalNetworkVlanSearch = createSearchBuilder(); @@ -392,6 +394,16 @@ public List listVlansByNetworkId(long networkId) { return listBy(sc); } + @Override + public List listVlansByNetworkIds(List networkIds) { + if (CollectionUtils.isEmpty(networkIds)) { + return Collections.emptyList(); + } + SearchCriteria sc = NetworkVlanSearch.create(); + sc.setParameters("networkId", networkIds.toArray()); + return listBy(sc); + } + @Override public List listVlansByNetworkIdIncludingRemoved(long networkId) { SearchCriteria sc = NetworkVlanSearch.create(); sc.setParameters("networkId", networkId); diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java index 5c0d04fb2be8..d8bdabc3dcbb 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDao.java @@ -121,6 +121,8 @@ public interface HostDao extends GenericDao, StateDao listIdsForUpEnabledByZoneAndHypervisor(Long zoneId, HypervisorType hypervisorType); + List findRoutingByClusterId(Long clusterId); + List findByClusterIdAndEncryptionSupport(Long clusterId); /** @@ -139,6 +141,8 @@ public interface HostDao extends GenericDao, StateDao listAllHostsByZoneAndHypervisorType(long zoneId, HypervisorType hypervisorType); + List listAllRoutingHostsByZoneAndHypervisorType(long zoneId, HypervisorType hypervisorType); + List listAllHostsThatHaveNoRuleTag(Host.Type type, Long clusterId, Long podId, Long dcId); HostVO findByPublicIp(String publicIp); diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java index 5b8a38b8e5b4..15727d9d8e66 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java @@ -1341,6 +1341,14 @@ public List listIdsForUpEnabledByZoneAndHypervisor(Long zoneId, Hypervisor return listIdsBy(null, Status.Up, ResourceState.Enabled, hypervisorType, zoneId, null, null); } + @Override + public List findRoutingByClusterId(Long clusterId) { + SearchCriteria sc = ClusterSearch.create(); + sc.setParameters("clusterId", clusterId); + sc.setParameters("type", Type.Routing); + return listBy(sc); + } + @Override public List findByClusterIdAndEncryptionSupport(Long clusterId) { SearchBuilder hostCapabilitySearch = _detailsDao.createSearchBuilder(); @@ -1456,6 +1464,16 @@ public List listAllHostsByZoneAndHypervisorType(long zoneId, HypervisorT return listBy(sc); } + @Override + public List listAllRoutingHostsByZoneAndHypervisorType(long zoneId, HypervisorType hypervisorType) { + SearchCriteria sc = DcSearch.create(); + sc.setParameters("dc", zoneId); + sc.setParameters("hypervisorType", hypervisorType.toString()); + sc.setParameters("type", Type.Routing); + + return listBy(sc); + } + @Override public List listAllHostsThatHaveNoRuleTag(Type type, Long clusterId, Long podId, Long dcId) { SearchCriteria sc = searchBuilderFindByIdTypeClusterIdPodIdDcIdAndWithoutRuleTag.create(); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDao.java b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDao.java index 7f322ae6c037..3527ce84dcf6 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDao.java @@ -69,6 +69,8 @@ public interface FirewallRulesDao extends GenericDao { List listByNetworkPurposeTrafficType(long networkId, FirewallRule.Purpose purpose, FirewallRule.TrafficType trafficType); + List listByVpcPurposeTrafficType(long vpcId, FirewallRule.Purpose purpose, FirewallRule.TrafficType trafficType); + List listByIpAndPurposeWithState(Long addressId, FirewallRule.Purpose purpose, FirewallRule.State state); void loadSourceCidrs(FirewallRuleVO rule); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java index 57d53f92572c..5a1e1aae6b66 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java @@ -74,6 +74,7 @@ protected FirewallRulesDaoImpl() { AllFieldsSearch.and("domain", AllFieldsSearch.entity().getDomainId(), Op.EQ); AllFieldsSearch.and("id", AllFieldsSearch.entity().getId(), Op.EQ); AllFieldsSearch.and("networkId", AllFieldsSearch.entity().getNetworkId(), Op.EQ); + AllFieldsSearch.and("vpcId", AllFieldsSearch.entity().getVpcId(), Op.EQ); AllFieldsSearch.and("related", AllFieldsSearch.entity().getRelated(), Op.EQ); AllFieldsSearch.and("trafficType", AllFieldsSearch.entity().getTrafficType(), Op.EQ); AllFieldsSearch.done(); @@ -356,6 +357,22 @@ public List listByNetworkPurposeTrafficType(long networkId, Purp return listBy(sc); } + @Override + public List listByVpcPurposeTrafficType(long vpcId, Purpose purpose, TrafficType trafficType) { + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("vpcId", vpcId); + + if (purpose != null) { + sc.setParameters("purpose", purpose); + } + + if (trafficType != null) { + sc.setParameters("trafficType", trafficType); + } + + return listBy(sc); + } + @Override @DB public boolean remove(Long id) { diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java index 8406f80ed09d..b656d896e37f 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDao.java @@ -141,4 +141,6 @@ List listByZonesTrafficTypeAndOwners(List zoneIds, final Traffi List listByPhysicalNetworkPvlan(long physicalNetworkId, String broadcastUri); List getAllPersistentNetworksFromZone(long dataCenterId); + + NetworkVO findByZoneIdAndAccountIdAndGuestTypeAndName(long zoneId, long accountId, GuestType guestType, String name); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java index f3ece4879040..fbf6a2ac2261 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkDaoImpl.java @@ -38,7 +38,6 @@ import com.cloud.network.Network; import com.cloud.network.Network.Event; import com.cloud.network.Network.GuestType; -import com.cloud.network.Network.Provider; import com.cloud.network.Network.Service; import com.cloud.network.Network.State; import com.cloud.network.Networks.BroadcastDomainType; @@ -392,7 +391,7 @@ public void persistNetworkServiceProviders(final long networkId, final Map listByPhysicalNetworkPvlan(long physicalNetworkId, String return overlappingNetworks; } + + @Override + public NetworkVO findByZoneIdAndAccountIdAndGuestTypeAndName(long zoneId, long accountId, GuestType guestType, String name) { + SearchCriteria sc = AllFieldsSearch.create(); + + sc.setParameters("datacenter", zoneId); + sc.setParameters("account", accountId); + sc.setParameters("guestType", guestType); + sc.setParameters("name", name); + + return findOneBy(sc); + } } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkServiceMapVO.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkServiceMapVO.java index 4e1e9aff4561..65598eda7399 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkServiceMapVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkServiceMapVO.java @@ -81,6 +81,12 @@ public NetworkServiceMapVO(long networkId, Service service, Provider provider) { this.provider = provider.getName(); } + public NetworkServiceMapVO(long networkId, String serviceName, String providerName) { + this.networkId = networkId; + this.service = serviceName; + this.provider = providerName; + } + @Override public String toString() { StringBuilder buf = new StringBuilder("[Network Service["); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java index 9557c7465bff..4be6c5bc344d 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkServiceProviderVO.java @@ -97,6 +97,9 @@ public class PhysicalNetworkServiceProviderVO implements PhysicalNetworkServiceP @Column(name = "networkacl_service_provided") boolean networkAclServiceProvided; + @Column(name = "custom_action_service_provided") + boolean customActionServiceProvided; + @Column(name = GenericDao.REMOVED_COLUMN) Date removed; @@ -278,6 +281,7 @@ public void setEnabledServices(List services) { this.setUserdataServiceProvided(services.contains(Service.UserData)); this.setSecuritygroupServiceProvided(services.contains(Service.SecurityGroup)); this.setNetworkAclServiceProvided(services.contains(Service.NetworkACL)); + this.setCustomActionServiceProvided(services.contains(Service.CustomAction)); } @Override @@ -301,21 +305,27 @@ public List getEnabledServices() { if (this.isLbServiceProvided()) { services.add(Service.Lb); } - if (this.sourcenatServiceProvided) { + if (this.isSourcenatServiceProvided()) { services.add(Service.SourceNat); } - if (this.staticnatServiceProvided) { + if (this.isStaticnatServiceProvided()) { services.add(Service.StaticNat); } - if (this.portForwardingServiceProvided) { + if (this.isPortForwardingServiceProvided()) { services.add(Service.PortForwarding); } + if (this.isNetworkAclServiceProvided()) { + services.add(Service.NetworkACL); + } if (this.isUserdataServiceProvided()) { services.add(Service.UserData); } if (this.isSecuritygroupServiceProvided()) { services.add(Service.SecurityGroup); } + if (this.isCustomActionServiceProvided()) { + services.add(Service.CustomAction); + } return services; } @@ -327,4 +337,12 @@ public boolean isNetworkAclServiceProvided() { public void setNetworkAclServiceProvided(boolean networkAclServiceProvided) { this.networkAclServiceProvided = networkAclServiceProvided; } + + public boolean isCustomActionServiceProvided() { + return customActionServiceProvided; + } + + public void setCustomActionServiceProvided(boolean customActionServiceProvided) { + this.customActionServiceProvided = customActionServiceProvided; + } } diff --git a/engine/schema/src/main/java/com/cloud/network/rules/FirewallRuleVO.java b/engine/schema/src/main/java/com/cloud/network/rules/FirewallRuleVO.java index 6ce9e6a118b7..2b34e23fa4d4 100644 --- a/engine/schema/src/main/java/com/cloud/network/rules/FirewallRuleVO.java +++ b/engine/schema/src/main/java/com/cloud/network/rules/FirewallRuleVO.java @@ -91,6 +91,9 @@ public class FirewallRuleVO implements FirewallRule { @Column(name = "network_id") Long networkId; + @Column(name = "vpc_id") + Long vpcId; + @Column(name = "icmp_code") Integer icmpCode; @@ -196,10 +199,18 @@ public State getState() { } @Override - public long getNetworkId() { + public Long getNetworkId() { return networkId; } + public Long getVpcId() { + return vpcId; + } + + public void setVpcId(Long vpcId) { + this.vpcId = vpcId; + } + @Override public FirewallRuleType getType() { return type; @@ -217,7 +228,7 @@ protected FirewallRuleVO() { uuid = UUID.randomUUID().toString(); } - public FirewallRuleVO(String xId, Long ipAddressId, Integer portStart, Integer portEnd, String protocol, long networkId, long accountId, long domainId, + public FirewallRuleVO(String xId, Long ipAddressId, Integer portStart, Integer portEnd, String protocol, Long networkId, long accountId, long domainId, Purpose purpose, List sourceCidrs, Integer icmpCode, Integer icmpType, Long related, TrafficType trafficType) { this.xId = xId; if (xId == null) { @@ -261,7 +272,7 @@ public FirewallRuleVO(String xId, long ipAddressId, int port, String protocol, l } - public FirewallRuleVO(String xId, Long ipAddressId, Integer portStart, Integer portEnd, String protocol, long networkId, long accountId, long domainId, + public FirewallRuleVO(String xId, Long ipAddressId, Integer portStart, Integer portEnd, String protocol, Long networkId, long accountId, long domainId, Purpose purpose, List sourceCidrs, List destCidrs, Integer icmpCode, Integer icmpType, Long related, TrafficType trafficType) { this(xId,ipAddressId, portStart, portEnd, protocol, networkId, accountId, domainId, purpose, sourceCidrs, icmpCode, icmpType, related, trafficType); this.destinationCidrs = destCidrs; diff --git a/engine/schema/src/main/java/com/cloud/network/vpc/VpcServiceMapVO.java b/engine/schema/src/main/java/com/cloud/network/vpc/VpcServiceMapVO.java index 9fa4b505c650..ae156d7d4304 100644 --- a/engine/schema/src/main/java/com/cloud/network/vpc/VpcServiceMapVO.java +++ b/engine/schema/src/main/java/com/cloud/network/vpc/VpcServiceMapVO.java @@ -25,8 +25,6 @@ import javax.persistence.Id; import javax.persistence.Table; -import com.cloud.network.Network.Provider; -import com.cloud.network.Network.Service; import com.cloud.utils.db.GenericDao; @Entity @@ -72,10 +70,10 @@ public Date getCreated() { public VpcServiceMapVO() { } - public VpcServiceMapVO(long vpcId, Service service, Provider provider) { + public VpcServiceMapVO(long vpcId, String serviceName, String providerName) { this.vpcId = vpcId; - this.service = service.getName(); - this.provider = provider.getName(); + this.service = serviceName; + this.provider = providerName; } @Override diff --git a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcDaoImpl.java index 4e13fe4f5d0d..cecdce1aba42 100644 --- a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcDaoImpl.java @@ -142,7 +142,7 @@ public void persistVpcServiceProviders(long vpcId, Map> ser txn.start(); for (String service : serviceProviderMap.keySet()) { for (String provider : serviceProviderMap.get(service)) { - VpcServiceMapVO serviceMap = new VpcServiceMapVO(vpcId, Network.Service.getService(service), Network.Provider.getProvider(provider)); + VpcServiceMapVO serviceMap = new VpcServiceMapVO(vpcId, Network.Service.getService(service).getName(), provider); _vpcSvcMap.persist(serviceMap); } } diff --git a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDao.java b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDao.java index 020536e97ec9..4bc9ad646d38 100644 --- a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDao.java +++ b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDao.java @@ -42,4 +42,6 @@ public interface VpcOfferingServiceMapDao extends GenericDao listProvidersForServiceForVpcOffering(long vpcOfferingId, Service service); + List listOfferingIdsByServiceAndProvider(Service service, String provider); + } diff --git a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDaoImpl.java index dcb1becf9e88..de36b6c3219b 100644 --- a/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/vpc/dao/VpcOfferingServiceMapDaoImpl.java @@ -37,6 +37,7 @@ public class VpcOfferingServiceMapDaoImpl extends GenericDaoBase AllFieldsSearch; final SearchBuilder MultipleServicesSearch; final GenericSearchBuilder ServicesSearch; + final GenericSearchBuilder OfferingIdsByServiceAndProviderSearch; protected VpcOfferingServiceMapDaoImpl() { super(); @@ -56,6 +57,12 @@ protected VpcOfferingServiceMapDaoImpl() { ServicesSearch.and("offeringId", ServicesSearch.entity().getVpcOfferingId(), SearchCriteria.Op.EQ); ServicesSearch.select(null, Func.DISTINCT, ServicesSearch.entity().getService()); ServicesSearch.done(); + + OfferingIdsByServiceAndProviderSearch = createSearchBuilder(Long.class); + OfferingIdsByServiceAndProviderSearch.selectFields(OfferingIdsByServiceAndProviderSearch.entity().getVpcOfferingId()); + OfferingIdsByServiceAndProviderSearch.and("service", OfferingIdsByServiceAndProviderSearch.entity().getService(), SearchCriteria.Op.EQ); + OfferingIdsByServiceAndProviderSearch.and("provider", OfferingIdsByServiceAndProviderSearch.entity().getProvider(), SearchCriteria.Op.EQ); + OfferingIdsByServiceAndProviderSearch.done(); } @Override @@ -129,4 +136,12 @@ public List listProvidersForServiceForVpcOffering(long return customSearch(sc, null); } + + @Override + public List listOfferingIdsByServiceAndProvider(Service service, String provider) { + SearchCriteria sc = OfferingIdsByServiceAndProviderSearch.create(); + sc.setParameters("service", service.getName()); + sc.setParameters("provider", provider); + return customSearch(sc, null); + } } diff --git a/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDao.java b/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDao.java index 8fb5d6dff84b..5954f95b3c57 100644 --- a/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDao.java +++ b/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDao.java @@ -44,4 +44,6 @@ public interface NetworkOfferingServiceMapDao extends GenericDao listServicesForNetworkOffering(long networkOfferingId); List getDistinctProviders(long offId); + + List listOfferingIdsByServiceAndProvider(Service service, String provider); } diff --git a/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDaoImpl.java b/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDaoImpl.java index 67b341a93618..5fe86760d50a 100644 --- a/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/offerings/dao/NetworkOfferingServiceMapDaoImpl.java @@ -40,6 +40,7 @@ public class NetworkOfferingServiceMapDaoImpl extends GenericDaoBase ProvidersSearch; final GenericSearchBuilder ServicesSearch; final GenericSearchBuilder DistinctProvidersSearch; + final GenericSearchBuilder OfferingIdsByServiceAndProviderSearch; protected NetworkOfferingServiceMapDaoImpl() { super(); @@ -71,6 +72,12 @@ protected NetworkOfferingServiceMapDaoImpl() { DistinctProvidersSearch.and("provider", DistinctProvidersSearch.entity().getProvider(), SearchCriteria.Op.EQ); DistinctProvidersSearch.selectFields(DistinctProvidersSearch.entity().getProvider()); DistinctProvidersSearch.done(); + + OfferingIdsByServiceAndProviderSearch = createSearchBuilder(Long.class); + OfferingIdsByServiceAndProviderSearch.selectFields(OfferingIdsByServiceAndProviderSearch.entity().getNetworkOfferingId()); + OfferingIdsByServiceAndProviderSearch.and("service", OfferingIdsByServiceAndProviderSearch.entity().getService(), SearchCriteria.Op.EQ); + OfferingIdsByServiceAndProviderSearch.and("provider", OfferingIdsByServiceAndProviderSearch.entity().getProvider(), SearchCriteria.Op.EQ); + OfferingIdsByServiceAndProviderSearch.done(); } @Override @@ -170,4 +177,12 @@ public List getDistinctProviders(long offId) { List results = customSearch(sc, null); return results; } + + @Override + public List listOfferingIdsByServiceAndProvider(Service service, String provider) { + SearchCriteria sc = OfferingIdsByServiceAndProviderSearch.create(); + sc.setParameters("service", service.getName()); + sc.setParameters("provider", provider); + return customSearch(sc, null); + } } diff --git a/engine/schema/src/main/java/com/cloud/storage/SnapshotVO.java b/engine/schema/src/main/java/com/cloud/storage/SnapshotVO.java index 4a504333344f..be7ba2843321 100644 --- a/engine/schema/src/main/java/com/cloud/storage/SnapshotVO.java +++ b/engine/schema/src/main/java/com/cloud/storage/SnapshotVO.java @@ -248,6 +248,10 @@ public Date getRemoved() { return removed; } + public void setRemoved(Date removed) { + this.removed = removed; + } + @Override public State getState() { return state; diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java index f167b5731878..a5596ed840cc 100755 --- a/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/SnapshotDaoImpl.java @@ -56,10 +56,18 @@ public class SnapshotDaoImpl extends GenericDaoBase implements // TODO: we should remove these direct sqls private static final String GET_LAST_SNAPSHOT = "SELECT snapshots.id FROM snapshot_store_ref, snapshots where snapshots.id = snapshot_store_ref.snapshot_id AND snapshosts.volume_id = ? AND snapshot_store_ref.role = ? ORDER BY created DESC"; - private static final String VOLUME_ID = "volumeId"; - private static final String NOT_TYPE = "notType"; + private static final String TYPE = "type"; private static final String STATUS = "status"; + private static final String VERSION = "version"; + private static final String ACCOUNT_ID = "accountId"; + private static final String REMOVED = "removed"; + private static final String NOT_TYPE = "notType"; + private static final String ID = "id"; + private static final String INSTANCE_ID = "instanceId"; + private static final String STATE = "state"; + private static final String INSTANCE_VOLUMES = "instanceVolumes"; + private static final String INSTANCE_SNAPSHOTS = "instanceSnapshots"; private SearchBuilder snapshotIdsSearch; private SearchBuilder VolumeIdSearch; @@ -83,9 +91,9 @@ public class SnapshotDaoImpl extends GenericDaoBase implements @Override public List listByVolumeIdTypeNotDestroyed(long volumeId, Type type) { SearchCriteria sc = VolumeIdTypeNotDestroyedSearch.create(); - sc.setParameters("volumeId", volumeId); - sc.setParameters("type", type.ordinal()); - sc.setParameters("status", State.Destroyed); + sc.setParameters(VOLUME_ID, volumeId); + sc.setParameters(TYPE, type.ordinal()); + sc.setParameters(STATUS, State.Destroyed); return listBy(sc, null); } @@ -102,28 +110,28 @@ public List listByVolumeId(long volumeId) { @Override public List listByVolumeId(Filter filter, long volumeId) { SearchCriteria sc = VolumeIdSearch.create(); - sc.setParameters("volumeId", volumeId); + sc.setParameters(VOLUME_ID, volumeId); return listBy(sc, filter); } @Override public List listByVolumeIdIncludingRemoved(long volumeId) { SearchCriteria sc = VolumeIdSearch.create(); - sc.setParameters("volumeId", volumeId); + sc.setParameters(VOLUME_ID, volumeId); return listIncludingRemovedBy(sc, null); } public List listByVolumeIdType(Filter filter, long volumeId, Type type) { SearchCriteria sc = VolumeIdTypeSearch.create(); - sc.setParameters("volumeId", volumeId); - sc.setParameters("type", type.ordinal()); + sc.setParameters(VOLUME_ID, volumeId); + sc.setParameters(TYPE, type.ordinal()); return listBy(sc, filter); } public List listByVolumeIdVersion(Filter filter, long volumeId, String version) { SearchCriteria sc = VolumeIdVersionSearch.create(); - sc.setParameters("volumeId", volumeId); - sc.setParameters("version", version); + sc.setParameters(VOLUME_ID, volumeId); + sc.setParameters(VERSION, version); return listBy(sc, filter); } @@ -133,61 +141,61 @@ public SnapshotDaoImpl() { @PostConstruct protected void init() { VolumeIdSearch = createSearchBuilder(); - VolumeIdSearch.and("volumeId", VolumeIdSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + VolumeIdSearch.and(VOLUME_ID, VolumeIdSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); VolumeIdSearch.done(); VolumeIdTypeSearch = createSearchBuilder(); - VolumeIdTypeSearch.and("volumeId", VolumeIdTypeSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); - VolumeIdTypeSearch.and("type", VolumeIdTypeSearch.entity().getSnapshotType(), SearchCriteria.Op.EQ); + VolumeIdTypeSearch.and(VOLUME_ID, VolumeIdTypeSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + VolumeIdTypeSearch.and(TYPE, VolumeIdTypeSearch.entity().getSnapshotType(), SearchCriteria.Op.EQ); VolumeIdTypeSearch.done(); VolumeIdTypeNotDestroyedSearch = createSearchBuilder(); - VolumeIdTypeNotDestroyedSearch.and("volumeId", VolumeIdTypeNotDestroyedSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); - VolumeIdTypeNotDestroyedSearch.and("type", VolumeIdTypeNotDestroyedSearch.entity().getSnapshotType(), SearchCriteria.Op.EQ); - VolumeIdTypeNotDestroyedSearch.and("status", VolumeIdTypeNotDestroyedSearch.entity().getState(), SearchCriteria.Op.NEQ); + VolumeIdTypeNotDestroyedSearch.and(VOLUME_ID, VolumeIdTypeNotDestroyedSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + VolumeIdTypeNotDestroyedSearch.and(TYPE, VolumeIdTypeNotDestroyedSearch.entity().getSnapshotType(), SearchCriteria.Op.EQ); + VolumeIdTypeNotDestroyedSearch.and(STATUS, VolumeIdTypeNotDestroyedSearch.entity().getState(), SearchCriteria.Op.NEQ); VolumeIdTypeNotDestroyedSearch.done(); VolumeIdVersionSearch = createSearchBuilder(); - VolumeIdVersionSearch.and("volumeId", VolumeIdVersionSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); - VolumeIdVersionSearch.and("version", VolumeIdVersionSearch.entity().getVersion(), SearchCriteria.Op.EQ); + VolumeIdVersionSearch.and(VOLUME_ID, VolumeIdVersionSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + VolumeIdVersionSearch.and(VERSION, VolumeIdVersionSearch.entity().getVersion(), SearchCriteria.Op.EQ); VolumeIdVersionSearch.done(); AccountIdSearch = createSearchBuilder(); - AccountIdSearch.and("accountId", AccountIdSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + AccountIdSearch.and(ACCOUNT_ID, AccountIdSearch.entity().getAccountId(), SearchCriteria.Op.EQ); AccountIdSearch.done(); StatusSearch = createSearchBuilder(); - StatusSearch.and("volumeId", StatusSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); - StatusSearch.and("status", StatusSearch.entity().getState(), SearchCriteria.Op.IN); + StatusSearch.and(VOLUME_ID, StatusSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + StatusSearch.and(STATUS, StatusSearch.entity().getState(), SearchCriteria.Op.IN); StatusSearch.done(); notInStatusSearch = createSearchBuilder(); - notInStatusSearch.and("volumeId", notInStatusSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); - notInStatusSearch.and("status", notInStatusSearch.entity().getState(), SearchCriteria.Op.NOTIN); + notInStatusSearch.and(VOLUME_ID, notInStatusSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + notInStatusSearch.and(STATUS, notInStatusSearch.entity().getState(), SearchCriteria.Op.NOTIN); notInStatusSearch.done(); CountSnapshotsByAccount = createSearchBuilder(Long.class); CountSnapshotsByAccount.select(null, Func.COUNT, null); - CountSnapshotsByAccount.and("account", CountSnapshotsByAccount.entity().getAccountId(), SearchCriteria.Op.EQ); - CountSnapshotsByAccount.and("status", CountSnapshotsByAccount.entity().getState(), SearchCriteria.Op.NIN); + CountSnapshotsByAccount.and(ACCOUNT_ID, CountSnapshotsByAccount.entity().getAccountId(), SearchCriteria.Op.EQ); + CountSnapshotsByAccount.and(STATUS, CountSnapshotsByAccount.entity().getState(), SearchCriteria.Op.NIN); CountSnapshotsByAccount.and("snapshotTypeNEQ", CountSnapshotsByAccount.entity().getSnapshotType(), SearchCriteria.Op.NIN); - CountSnapshotsByAccount.and("removed", CountSnapshotsByAccount.entity().getRemoved(), SearchCriteria.Op.NULL); + CountSnapshotsByAccount.and(REMOVED, CountSnapshotsByAccount.entity().getRemoved(), SearchCriteria.Op.NULL); CountSnapshotsByAccount.done(); InstanceIdSearch = createSearchBuilder(); - InstanceIdSearch.and("status", InstanceIdSearch.entity().getState(), SearchCriteria.Op.IN); + InstanceIdSearch.and(STATUS, InstanceIdSearch.entity().getState(), SearchCriteria.Op.IN); snapshotIdsSearch = createSearchBuilder(); - snapshotIdsSearch.and("id", snapshotIdsSearch.entity().getId(), SearchCriteria.Op.IN); + snapshotIdsSearch.and(ID, snapshotIdsSearch.entity().getId(), SearchCriteria.Op.IN); SearchBuilder instanceSearch = _instanceDao.createSearchBuilder(); - instanceSearch.and("instanceId", instanceSearch.entity().getId(), SearchCriteria.Op.EQ); + instanceSearch.and(INSTANCE_ID, instanceSearch.entity().getId(), SearchCriteria.Op.EQ); SearchBuilder volumeSearch = _volumeDao.createSearchBuilder(); - volumeSearch.and("state", volumeSearch.entity().getState(), SearchCriteria.Op.EQ); - volumeSearch.join("instanceVolumes", instanceSearch, instanceSearch.entity().getId(), volumeSearch.entity().getInstanceId(), JoinType.INNER); + volumeSearch.and(STATE, volumeSearch.entity().getState(), SearchCriteria.Op.EQ); + volumeSearch.join(INSTANCE_VOLUMES, instanceSearch, instanceSearch.entity().getId(), volumeSearch.entity().getInstanceId(), JoinType.INNER); - InstanceIdSearch.join("instanceSnapshots", volumeSearch, volumeSearch.entity().getId(), InstanceIdSearch.entity().getVolumeId(), JoinType.INNER); + InstanceIdSearch.join(INSTANCE_SNAPSHOTS, volumeSearch, volumeSearch.entity().getId(), InstanceIdSearch.entity().getVolumeId(), JoinType.INNER); InstanceIdSearch.done(); volumeIdAndTypeNotInSearch = createSearchBuilder(); @@ -219,8 +227,8 @@ public long getLastSnapshot(long volumeId, DataStoreRole role) { @Override public Long countSnapshotsForAccount(long accountId) { SearchCriteria sc = CountSnapshotsByAccount.create(); - sc.setParameters("account", accountId); - sc.setParameters("status", State.Error, State.Destroyed); + sc.setParameters(ACCOUNT_ID, accountId); + sc.setParameters(STATUS, State.Error, State.Destroyed); sc.setParameters("snapshotTypeNEQ", Snapshot.Type.GROUP.ordinal()); return customSearch(sc, null).get(0); } @@ -230,19 +238,19 @@ public List listByInstanceId(long instanceId, Snapshot.State... stat SearchCriteria sc = InstanceIdSearch.create(); if (status != null && status.length != 0) { - sc.setParameters("status", (Object[])status); + sc.setParameters(STATUS, (Object[])status); } - sc.setJoinParameters("instanceSnapshots", "state", Volume.State.Ready); - sc.setJoinParameters("instanceVolumes", "instanceId", instanceId); + sc.setJoinParameters(INSTANCE_SNAPSHOTS, STATE, Volume.State.Ready); + sc.setJoinParameters(INSTANCE_VOLUMES, INSTANCE_ID, instanceId); return listBy(sc, null); } @Override public List listByStatus(long volumeId, Snapshot.State... status) { SearchCriteria sc = StatusSearch.create(); - sc.setParameters("volumeId", volumeId); - sc.setParameters("status", (Object[])status); + sc.setParameters(VOLUME_ID, volumeId); + sc.setParameters(STATUS, (Object[])status); return listBy(sc, null); } @@ -263,7 +271,7 @@ public boolean remove(Long id) { @Override public List listAllByStatus(Snapshot.State... status) { SearchCriteria sc = StatusSearch.create(); - sc.setParameters("status", (Object[])status); + sc.setParameters(STATUS, (Object[])status); return listBy(sc, null); } @@ -277,7 +285,7 @@ public List listAllByStatusIncludingRemoved(Snapshot.State... status @Override public List listByIds(Object... ids) { SearchCriteria sc = snapshotIdsSearch.create(); - sc.setParameters("id", ids); + sc.setParameters(ID, ids); return listBy(sc, null); } @@ -295,7 +303,7 @@ public boolean updateState(State currentState, Event event, State nextState, Sna @Override public void updateVolumeIds(long oldVolId, long newVolId) { SearchCriteria sc = VolumeIdSearch.create(); - sc.setParameters("volumeId", oldVolId); + sc.setParameters(VOLUME_ID, oldVolId); SnapshotVO snapshot = createForUpdate(); snapshot.setVolumeId(newVolId); UpdateBuilder ub = getUpdateBuilder(snapshot); @@ -305,8 +313,8 @@ public void updateVolumeIds(long oldVolId, long newVolId) { @Override public List listByStatusNotIn(long volumeId, Snapshot.State... status) { SearchCriteria sc = this.notInStatusSearch.create(); - sc.setParameters("volumeId", volumeId); - sc.setParameters("status", (Object[]) status); + sc.setParameters(VOLUME_ID, volumeId); + sc.setParameters(STATUS, (Object[]) status); return listBy(sc, null); } diff --git a/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java b/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java index 0e784d961b3d..c3a982aa70e5 100644 --- a/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java +++ b/engine/schema/src/main/java/com/cloud/upgrade/DatabaseUpgradeChecker.java @@ -88,7 +88,9 @@ import com.cloud.upgrade.dao.Upgrade41900to41910; import com.cloud.upgrade.dao.Upgrade41910to42000; import com.cloud.upgrade.dao.Upgrade42000to42010; -import com.cloud.upgrade.dao.Upgrade42010to42100; +import com.cloud.upgrade.dao.Upgrade42020to42030; +import com.cloud.upgrade.dao.Upgrade42030to42040; +import com.cloud.upgrade.dao.Upgrade42040to42100; import com.cloud.upgrade.dao.Upgrade42100to42200; import com.cloud.upgrade.dao.Upgrade42200to42210; import com.cloud.upgrade.dao.Upgrade420to421; @@ -240,7 +242,9 @@ public DatabaseUpgradeChecker() { .next("4.19.0.0", new Upgrade41900to41910()) .next("4.19.1.0", new Upgrade41910to42000()) .next("4.20.0.0", new Upgrade42000to42010()) - .next("4.20.1.0", new Upgrade42010to42100()) + .next("4.20.2.0", new Upgrade42020to42030()) + .next("4.20.3.0", new Upgrade42030to42040()) + .next("4.20.4.0", new Upgrade42040to42100()) .next("4.21.0.0", new Upgrade42100to42200()) .next("4.22.0.0", new Upgrade42200to42210()) .next("4.22.1.0", new Upgrade42210to42300()) diff --git a/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42030to42040.java b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42030to42040.java new file mode 100644 index 000000000000..90f69a87cb03 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42030to42040.java @@ -0,0 +1,58 @@ +// 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.upgrade.dao; + +import java.io.InputStream; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; + +public class Upgrade42030to42040 extends DbUpgradeAbstractImpl implements DbUpgrade, DbUpgradeSystemVmTemplate { + + @Override + public String[] getUpgradableVersionRange() { + return new String[]{"4.20.3.0", "4.20.4.0"}; + } + + @Override + public String getUpgradedVersion() { + return "4.20.4.0"; + } + + @Override + public boolean supportsRollingUpgrade() { + return false; + } + + @Override + public InputStream[] getPrepareScripts() { + return null; + } + + @Override + public void performDataMigration(Connection conn) { + final List indexList = new ArrayList(); + logger.debug("Dropping index vm_instance_id from usage_vm_instance table if it exists"); + indexList.add("vm_instance_id"); + DbUpgradeUtils.dropKeysIfExist(conn, "cloud_usage.usage_vm_instance", indexList, false); + } + + @Override + public InputStream[] getCleanupScripts() { + return null; + } +} diff --git a/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42010to42100.java b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42040to42100.java similarity index 97% rename from engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42010to42100.java rename to engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42040to42100.java index 786ee5afbc8e..64ac2c90bc07 100644 --- a/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42010to42100.java +++ b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42040to42100.java @@ -32,12 +32,12 @@ import com.cloud.utils.db.TransactionLegacy; import com.cloud.utils.exception.CloudRuntimeException; -public class Upgrade42010to42100 extends DbUpgradeAbstractImpl implements DbUpgrade, DbUpgradeSystemVmTemplate { +public class Upgrade42040to42100 extends DbUpgradeAbstractImpl implements DbUpgrade, DbUpgradeSystemVmTemplate { private SystemVmTemplateRegistration systemVmTemplateRegistration; @Override public String[] getUpgradableVersionRange() { - return new String[] {"4.20.1.0", "4.21.0.0"}; + return new String[] {"4.20.4.0", "4.21.0.0"}; } @Override @@ -52,7 +52,7 @@ public boolean supportsRollingUpgrade() { @Override public InputStream[] getPrepareScripts() { - final String scriptFile = "META-INF/db/schema-42010to42100.sql"; + final String scriptFile = "META-INF/db/schema-42040to42100.sql"; final InputStream script = Thread.currentThread().getContextClassLoader().getResourceAsStream(scriptFile); if (script == null) { throw new CloudRuntimeException("Unable to find " + scriptFile); @@ -69,7 +69,7 @@ public void performDataMigration(Connection conn) { @Override public InputStream[] getCleanupScripts() { - final String scriptFile = "META-INF/db/schema-42010to42100-cleanup.sql"; + final String scriptFile = "META-INF/db/schema-42040to42100-cleanup.sql"; final InputStream script = Thread.currentThread().getContextClassLoader().getResourceAsStream(scriptFile); if (script == null) { throw new CloudRuntimeException("Unable to find " + scriptFile); diff --git a/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42210to42300.java b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42210to42300.java index 393f20399503..b60a52bba88b 100644 --- a/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42210to42300.java +++ b/engine/schema/src/main/java/com/cloud/upgrade/dao/Upgrade42210to42300.java @@ -21,6 +21,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; import com.cloud.utils.crypt.DBEncryptionUtil; import com.cloud.utils.exception.CloudRuntimeException; @@ -51,6 +53,7 @@ public InputStream[] getPrepareScripts() { @Override public void performDataMigration(Connection conn) { unhideJsInterpretationEnabled(conn); + dropUsageVmInstanceIndex(conn); } protected void unhideJsInterpretationEnabled(Connection conn) { @@ -89,4 +92,11 @@ protected void updateJsInterpretationEnabledFields(Connection conn, String encry logger.warn("Error while decrypting configuration 'js.interpretation.enabled'. The configuration may already be decrypted."); } } + + private void dropUsageVmInstanceIndex(Connection conn) { + final List indexList = new ArrayList<>(); + logger.debug("Dropping index vm_instance_id from usage_vm_instance table if it exists"); + indexList.add("vm_instance_id"); + DbUpgradeUtils.dropKeysIfExist(conn, "cloud_usage.usage_vm_instance", indexList, false); + } } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java index 7767b7512b91..ad599053ff0c 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDao.java @@ -99,6 +99,8 @@ public interface NicDao extends GenericDao { NicVO findByMacAddress(String macAddress, long networkId); + List listByMacAddresses(List macAddresses); + NicVO findByNetworkIdAndMacAddressIncludingRemoved(long networkId, String mac); List findNicsByIpv6GatewayIpv6CidrAndReserver(String ipv6Gateway, String ipv6Cidr, String reserverName); diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java index 6202dabf730e..e468b1b9a3bf 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDaoImpl.java @@ -18,12 +18,13 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import javax.annotation.PostConstruct; import javax.inject.Inject; -import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.stereotype.Component; import com.cloud.utils.db.Filter; @@ -71,7 +72,7 @@ protected void init() { AllFieldsSearch.and("strategy", AllFieldsSearch.entity().getReservationStrategy(), Op.EQ); AllFieldsSearch.and("strategyNEQ", AllFieldsSearch.entity().getReservationStrategy(), Op.NEQ); AllFieldsSearch.and("reserverName",AllFieldsSearch.entity().getReserver(),Op.EQ); - AllFieldsSearch.and("macAddress", AllFieldsSearch.entity().getMacAddress(), Op.EQ); + AllFieldsSearch.and("macAddress", AllFieldsSearch.entity().getMacAddress(), Op.IN); AllFieldsSearch.and("deviceid", AllFieldsSearch.entity().getDeviceId(), Op.EQ); AllFieldsSearch.and("ipv6Gateway", AllFieldsSearch.entity().getIPv6Gateway(), Op.EQ); AllFieldsSearch.and("ipv6Cidr", AllFieldsSearch.entity().getIPv6Cidr(), Op.EQ); @@ -434,6 +435,16 @@ public NicVO findByMacAddress(String macAddress, long networkId) { return findOneBy(sc); } + @Override + public List listByMacAddresses(List macAddresses) { + if (CollectionUtils.isEmpty(macAddresses)) { + return Collections.emptyList(); + } + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("macAddress", macAddresses.toArray()); + return listBy(sc); + } + @Override public List findNicsByIpv6GatewayIpv6CidrAndReserver(String ipv6Gateway, String ipv6Cidr, String reserverName) { SearchCriteria sc = AllFieldsSearch.create(); diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDao.java index 2ca901fa3e55..403727ed9c61 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDao.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDao.java @@ -16,10 +16,14 @@ // under the License. package com.cloud.vm.dao; +import java.util.Set; + import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; import com.cloud.utils.db.GenericDao; import com.cloud.vm.NicDetailVO; public interface NicDetailsDao extends GenericDao, ResourceDetailsDao { + + void removeDetailsForNicIds(String resourceName, Set nicIds); } diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDaoImpl.java index 2a0b02aae1d9..1011f0588bbc 100644 --- a/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/vm/dao/NicDetailsDaoImpl.java @@ -17,17 +17,43 @@ package com.cloud.vm.dao; -import org.springframework.stereotype.Component; +import java.util.Set; +import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; +import org.apache.commons.collections.CollectionUtils; +import org.springframework.stereotype.Component; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; import com.cloud.vm.NicDetailVO; @Component public class NicDetailsDaoImpl extends ResourceDetailsDaoBase implements NicDetailsDao { + private final SearchBuilder ResourceIdNameSearch; + + public NicDetailsDaoImpl() { + super(); + ResourceIdNameSearch = createSearchBuilder(); + ResourceIdNameSearch.and(ApiConstants.NAME, ResourceIdNameSearch.entity().getName(), SearchCriteria.Op.EQ); + ResourceIdNameSearch.and(ApiConstants.RESOURCE_ID, ResourceIdNameSearch.entity().getResourceId(), SearchCriteria.Op.IN); + ResourceIdNameSearch.done(); + } + @Override public void addDetail(long resourceId, String key, String value, boolean display) { super.addDetail(new NicDetailVO(resourceId, key, value, display)); } + + @Override + public void removeDetailsForNicIds(String resourceName, Set nicIds) { + if (CollectionUtils.isEmpty(nicIds)) { + return; + } + SearchCriteria sc = ResourceIdNameSearch.create(); + sc.setParameters(ApiConstants.NAME, resourceName); + sc.setParameters(ApiConstants.RESOURCE_ID, nicIds.toArray()); + remove(sc); + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupOfferingVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupOfferingVO.java index ebeb7d4a2d59..9156f8f5ff84 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupOfferingVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupOfferingVO.java @@ -83,6 +83,11 @@ public BackupOfferingVO(final long zoneId, final String externalId, final String this.created = new Date(); } + public BackupOfferingVO(final long zoneId, final String provider, final String name, final String description, final boolean userDrivenBackupAllowed) { + this(zoneId, null, provider, name, description, userDrivenBackupAllowed); + this.externalId = this.uuid; + } + public String getUuid() { return uuid; } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupScheduleVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupScheduleVO.java index 1ee2cff78b65..aa02bb077163 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupScheduleVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupScheduleVO.java @@ -74,10 +74,14 @@ public class BackupScheduleVO implements BackupSchedule { @Column(name = "domain_id") Long domainId; + @Column(name = "isolated") + private boolean isolated; + public BackupScheduleVO() { } - public BackupScheduleVO(Long vmId, DateUtil.IntervalType scheduleType, String schedule, String timezone, Date scheduledTimestamp, int maxBackups, Boolean quiesceVM, Long accountId, Long domainId) { + public BackupScheduleVO(Long vmId, DateUtil.IntervalType scheduleType, String schedule, String timezone, Date scheduledTimestamp, int maxBackups, Boolean quiesceVM, + Long accountId, Long domainId, boolean isolated) { this.vmId = vmId; this.scheduleType = (short) scheduleType.ordinal(); this.schedule = schedule; @@ -87,6 +91,7 @@ public BackupScheduleVO(Long vmId, DateUtil.IntervalType scheduleType, String sc this.quiesceVM = quiesceVM; this.accountId = accountId; this.domainId = domainId; + this.isolated = isolated; } @Override @@ -197,4 +202,13 @@ public void setAccountId(Long accountId) { public void setDomainId(Long domainId) { this.domainId = domainId; } + + @Override + public boolean isIsolated() { + return isolated; + } + + public void setIsolated(boolean isolated) { + this.isolated = isolated; + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java index d589f9e6bef8..b4cad92d8770 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/BackupVO.java @@ -19,7 +19,6 @@ import com.cloud.utils.db.GenericDao; import com.google.gson.Gson; - import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import org.apache.commons.lang3.StringUtils; @@ -66,7 +65,7 @@ public class BackupVO implements Backup { private String externalId; @Column(name = "type") - private String backupType; + private String type; @Column(name = "date") @Temporal(value = TemporalType.DATE) @@ -81,6 +80,9 @@ public class BackupVO implements Backup { @Column(name = "protected_size") private Long protectedSize; + @Column(name = "uncompressed_size") + private Long uncompressedSize; + @Enumerated(value = EnumType.STRING) @Column(name = "status") private Backup.Status status; @@ -103,6 +105,12 @@ public class BackupVO implements Backup { @Column(name = "backup_schedule_id") private Long backupScheduleId; + @Column(name = "compression_status") + private CompressionStatus compressionStatus; + + @Column(name = "validation_status") + private ValidationStatus validationStatus; + @Column(name = "from_checkpoint_id") private String fromCheckpointId; @@ -122,10 +130,27 @@ public BackupVO() { this.uuid = UUID.randomUUID().toString(); } + public BackupVO(String name, long vmId, long backupOfferingId, long accountId, long domainId, long zoneId, long virtualSize, + Status status, Long backupScheduleId, CompressionStatus compressionStatus, ValidationStatus validationStatus) { + this.name = name; + this.vmId = vmId; + this.backupOfferingId = backupOfferingId; + this.accountId = accountId; + this.domainId = domainId; + this.zoneId = zoneId; + this.protectedSize = virtualSize; + this.status = status; + this.setType("FULL"); + this.uuid = UUID.randomUUID().toString(); + this.backupScheduleId = backupScheduleId; + this.compressionStatus = compressionStatus; + this.validationStatus = validationStatus; + } + @Override public String toString() { return String.format("Backup %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( - this, "id", "uuid", "vmId", "backupType", "externalId")); + this, "id", "uuid", "vmId", "type", "externalId")); } @Override @@ -156,12 +181,13 @@ public void setExternalId(String externalId) { this.externalId = externalId; } + @Override public String getType() { - return backupType; + return type; } public void setType(String type) { - this.backupType = type; + this.type = type; } @Override @@ -301,6 +327,32 @@ public void setBackupScheduleId(Long backupScheduleId) { this.backupScheduleId = backupScheduleId; } + @Override + public CompressionStatus getCompressionStatus() { + return compressionStatus; + } + + public void setCompressionStatus(CompressionStatus compressionStatus) { + this.compressionStatus = compressionStatus; + } + + @Override + public ValidationStatus getValidationStatus() { + return validationStatus; + } + + public void setValidationStatus(ValidationStatus validationStatus) { + this.validationStatus = validationStatus; + } + + public Long getUncompressedSize() { + return uncompressedSize; + } + + public void setUncompressedSize(Long uncompressedSize) { + this.uncompressedSize = uncompressedSize; + } + @Override public String getFromCheckpointId() { return fromCheckpointId; diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupDataStoreVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupDataStoreVO.java new file mode 100644 index 000000000000..5266a4129905 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupDataStoreVO.java @@ -0,0 +1,95 @@ +//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 +//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 org.apache.cloudstack.backup; + +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +import javax.persistence.Column; +import javax.persistence.Entity; + +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + + +@Entity +@Table(name = "internal_backup_store_ref") +public class InternalBackupDataStoreVO implements InternalIdentity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "backup_id") + private long backupId; + + @Column(name = "volume_id") + private long volumeId; + + @Column (name = "device_id") + private long deviceId; + + @Column(name = "path") + private String backupPath; + + public InternalBackupDataStoreVO() { + } + + public InternalBackupDataStoreVO(long backupId, long volumeId, long deviceId, String backupPath) { + this.backupId = backupId; + this.volumeId = volumeId; + this.deviceId = deviceId; + this.backupPath = backupPath; + } + + @Override + public long getId() { + return id; + } + + public long getBackupId() { + return backupId; + } + + public long getVolumeId() { + return volumeId; + } + + public long getDeviceId() { + return deviceId; + } + + public String getBackupPath() { + return backupPath; + } + + public void setVolumeId(long volumeId) { + this.volumeId = volumeId; + } + + public void setBackupPath(String backupPath) { + this.backupPath = backupPath; + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupJoinVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupJoinVO.java new file mode 100644 index 000000000000..acc54ccd41fa --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupJoinVO.java @@ -0,0 +1,190 @@ +// 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 org.apache.cloudstack.backup; + +import com.google.gson.Gson; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import org.apache.commons.lang3.StringUtils; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +@Entity +@Table(name = "internal_backup_view") +public class InternalBackupJoinVO { + + @Id + @Column(name="id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "vm_id") + private long vmId; + + @Column(name = "backed_volumes", length = 65535) + private String backedUpVolumes; + + @Column(name = "backup_offering_id") + private long backupOfferingId; + + @Column(name = "image_store_id") + private long imageStoreId; + + @Column(name = "parent_id") + private long parentId; + + @Column(name = "type") + private String type; + + @Column(name = "date") + @Temporal(value = TemporalType.DATE) + private Date date; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status") + private Backup.Status status; + + @Enumerated(value = EnumType.STRING) + @Column(name = "compression_status") + private Backup.CompressionStatus compressionStatus; + + @Column(name = "end_of_chain") + private Boolean endOfChain; + + @Column(name = "current") + private Boolean current; + + @Column(name = "image_store_path") + private String imageStorePath; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "size") + private long size; + + @Column(name = "protected_size") + private long protectedSize; + + @Column(name = "volume_id") + private long volumeId; + + @Column(name = "isolated") + private Boolean isolated; + + public InternalBackupJoinVO() { + } + + public long getId() { + return id; + } + + public String getUuid() { + return uuid; + } + + public long getVmId() { + return vmId; + } + + public List getBackedUpVolumes() { + if (StringUtils.isEmpty(this.backedUpVolumes)) { + return Collections.emptyList(); + } + return Arrays.asList(new Gson().fromJson(this.backedUpVolumes, Backup.VolumeInfo[].class)); + } + + public long getBackupOfferingId() { + return backupOfferingId; + } + + public long getImageStoreId() { + return imageStoreId; + } + + public long getParentId() { + return parentId; + } + + public String getType() { + return type; + } + + public Date getDate() { + return date; + } + + public Backup.Status getStatus() { + return status; + } + + public Boolean getEndOfChain() { + return BooleanUtils.isTrue(endOfChain); + } + + public Boolean getCurrent() { + return BooleanUtils.isTrue(current); + } + + public String getImageStorePath() { + return imageStorePath; + } + + public long getZoneId() { + return zoneId; + } + + public long getSize() { + return size; + } + + public long getProtectedSize() { + return protectedSize; + } + + public long getVolumeId() { + return volumeId; + } + + public Boolean getIsolated() { + return BooleanUtils.isTrue(isolated); + } + + public Backup.CompressionStatus getCompressionStatus() { + return compressionStatus; + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobType.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobType.java new file mode 100644 index 000000000000..287b64c2e6fc --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobType.java @@ -0,0 +1,21 @@ +// 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 org.apache.cloudstack.backup; + +public enum InternalBackupServiceJobType { + StartCompression, FinalizeCompression, BackupValidation +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobVO.java new file mode 100644 index 000000000000..87b4a53cb615 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupServiceJobVO.java @@ -0,0 +1,179 @@ +// 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 org.apache.cloudstack.backup; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; + +@Entity +@Table(name = "internal_backup_service_job") +public class InternalBackupServiceJobVO implements InternalIdentity, Comparable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "backup_id") + private long backupId; + + @Column(name = "instance_id") + private long instanceId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "host_id") + private Long hostId; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "attempts") + private int attempts; + + @Column (name = "type") + private InternalBackupServiceJobType type; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = "scheduled_start_time") + @Temporal(value = TemporalType.TIMESTAMP) + private Date scheduledStartTime; + + @Column(name = "start_time") + @Temporal(value = TemporalType.TIMESTAMP) + private Date startTime; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + + public InternalBackupServiceJobVO() { + } + + public InternalBackupServiceJobVO(long backupId, long zoneId, long instanceId, long accountId, InternalBackupServiceJobType type) { + this.created = new Date(); + this.backupId = backupId; + this.zoneId = zoneId; + this.instanceId = instanceId; + this.accountId = accountId; + this.type = type; + this.scheduledStartTime = this.created; + } + + public InternalBackupServiceJobVO(long backupId, long zoneId, long instanceId, long accountId, InternalBackupServiceJobType type, Date scheduledStartTime) { + this(backupId, zoneId, instanceId, accountId, type); + this.scheduledStartTime = scheduledStartTime; + } + + @Override + public long getId() { + return id; + } + + public long getBackupId() { + return backupId; + } + + public long getInstanceId() { + return instanceId; + } + + public long getAccountId() { + return accountId; + } + + public Long getHostId() { + return hostId; + } + + public void setHostId(Long hostId) { + this.hostId = hostId; + } + + public long getZoneId() { + return zoneId; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public InternalBackupServiceJobType getType() { + return type; + } + + public Date getCreated() { + return created; + } + + public Date getScheduledStartTime() { + return scheduledStartTime; + } + + public void setScheduledStartTime(Date scheduledStartTime) { + this.scheduledStartTime = scheduledStartTime; + } + + public Date getStartTime() { + return startTime; + } + + public void setStartTime(Date startTime) { + this.startTime = startTime; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public int compareTo(InternalBackupServiceJobVO that) { + return this.created.compareTo(that.created); + } + + @Override + public String toString() { + return ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "backupId", "zoneId", "hostId", "created", "scheduledStartTime", "startTime", "attempts", + "type"); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupStoragePoolVO.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupStoragePoolVO.java new file mode 100644 index 000000000000..f6d4cd10ffb7 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/InternalBackupStoragePoolVO.java @@ -0,0 +1,101 @@ +//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 +//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 org.apache.cloudstack.backup; + +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.commons.lang3.builder.ReflectionToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Table(name = "internal_backup_pool_ref") +public class InternalBackupStoragePoolVO implements InternalIdentity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "backup_id") + private long backupId; + + @Column(name = "storage_pool_id") + private long storagePoolId; + + @Column(name = "volume_id") + private long volumeId; + + @Column(name = "backup_delta_path") + private String backupDeltaPath; + + @Column(name = "backup_parent_path") + private String backupDeltaParentPath; + + public InternalBackupStoragePoolVO() { + } + + public InternalBackupStoragePoolVO(long backupId, long storagePoolId, long volumeId, String backupDeltaPath, String backupDeltaParentPath) { + this.backupId = backupId; + this.storagePoolId = storagePoolId; + this.volumeId = volumeId; + this.backupDeltaPath = backupDeltaPath; + this.backupDeltaParentPath = backupDeltaParentPath; + } + + @Override + public long getId() { + return id; + } + + public long getBackupId() { + return backupId; + } + + public long getStoragePoolId() { + return storagePoolId; + } + + public long getVolumeId() { + return volumeId; + } + + public String getBackupDeltaPath() { + return backupDeltaPath; + } + + public String getBackupDeltaParentPath() { + return backupDeltaParentPath; + } + + public void setBackupDeltaPath(String backupDeltaPath) { + this.backupDeltaPath = backupDeltaPath; + } + + public void setBackupDeltaParentPath(String backupDeltaParentPath) { + this.backupDeltaParentPath = backupDeltaParentPath; + } + + @Override + public String toString() { + return ReflectionToStringBuilder.toString(this, ToStringStyle.JSON_STYLE); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDao.java index e60e49e1a0c2..3829777d9d8f 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDao.java @@ -42,4 +42,5 @@ public interface BackupDao extends GenericDao { void loadDetails(BackupVO backup); void saveDetails(BackupVO backup); List listBySchedule(Long backupScheduleId); + BackupVO findLatestByStatusAndVmId(Backup.Status status, long vmId); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDaoImpl.java index fd29da72c718..9859f29701b7 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDaoImpl.java @@ -90,6 +90,7 @@ protected void init() { backupSearch.and("external_id", backupSearch.entity().getExternalId(), SearchCriteria.Op.EQ); backupSearch.and("backup_offering_id", backupSearch.entity().getBackupOfferingId(), SearchCriteria.Op.EQ); backupSearch.and("zone_id", backupSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + backupSearch.and("status", backupSearch.entity().getStatus(), SearchCriteria.Op.IN); backupSearch.done(); backupVmSearchInZone = createSearchBuilder(Long.class); @@ -280,4 +281,13 @@ public List listVmIdsWithBackupsInZone(Long zoneId) { sc.setParameters("zone_id", zoneId); return customSearchIncludingRemoved(sc, null); } + + @Override + public BackupVO findLatestByStatusAndVmId(Backup.Status status, long vmId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters("vm_id", vmId); + sc.setParameters("status", status); + Filter filter = new Filter(BackupVO.class, "date", false, 0L, 1L); + return findOneBy(sc, filter); + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDao.java index 664650074bce..c6c8fc3a322e 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDao.java @@ -23,4 +23,19 @@ public interface BackupDetailsDao extends GenericDao, ResourceDetailsDao { + String END_OF_CHAIN = "end_of_chain"; + + String CURRENT = "current"; + + String IMAGE_STORE_ID = "image_store_id"; + + String PARENT_ID = "parent_id"; + + String ISOLATED = "isolated"; + + String SCREENSHOT_PATH = "screenshot_path"; + + String BACKUP_HASH = "backup_hash"; + + void removeDetailsExcept(long backupId, String exception); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDaoImpl.java index 08c7192af909..5f257c23892c 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupDetailsDaoImpl.java @@ -17,13 +17,39 @@ package org.apache.cloudstack.backup.dao; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; import org.apache.cloudstack.backup.BackupDetailVO; import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; import org.springframework.stereotype.Component; +import javax.annotation.PostConstruct; + @Component public class BackupDetailsDaoImpl extends ResourceDetailsDaoBase implements BackupDetailsDao { + private SearchBuilder backupDetailSearch; + + private static final String BACKUP_ID = "backup_id"; + + private static final String KEY = "key"; + + @PostConstruct + protected void init() { + backupDetailSearch = createSearchBuilder(); + backupDetailSearch.and(BACKUP_ID, backupDetailSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + backupDetailSearch.and(KEY, backupDetailSearch.entity().getName(), SearchCriteria.Op.NEQ); + backupDetailSearch.done(); + } + + @Override + public void removeDetailsExcept(long backupId, String exception) { + SearchCriteria sc = backupDetailSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + sc.setParameters(KEY, exception); + super.expunge(sc); + } + @Override public void addDetail(long resourceId, String key, String value, boolean display) { super.addDetail(new BackupDetailVO(resourceId, key, value, display)); diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java index 708faeef4643..1d7a55d7caf7 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/BackupOfferingDaoImpl.java @@ -22,8 +22,10 @@ import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; +import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.response.BackupOfferingResponse; import org.apache.cloudstack.backup.BackupOffering; +import org.apache.cloudstack.backup.BackupOfferingDetailsVO; import org.apache.cloudstack.backup.BackupOfferingVO; import com.cloud.dc.DataCenterVO; @@ -32,7 +34,9 @@ import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class BackupOfferingDaoImpl extends GenericDaoBase implements BackupOfferingDao { @@ -62,6 +66,7 @@ public BackupOfferingResponse newBackupOfferingResponse(BackupOffering offering, DataCenterVO zone = dataCenterDao.findById(offering.getZoneId()); List domainIds = backupOfferingDetailsDao.findDomainIds(offering.getId()); + List details = backupOfferingDetailsDao.listDetails(offering.getId()); BackupOfferingResponse response = new BackupOfferingResponse(); response.setId(offering.getUuid()); response.setName(offering.getName()); @@ -88,6 +93,14 @@ public BackupOfferingResponse newBackupOfferingResponse(BackupOffering offering, if (crossZoneInstanceCreation) { response.setCrossZoneInstanceCreation(true); } + details.removeIf(backupOfferingDetailsVO -> ApiConstants.DOMAIN_ID.equals(backupOfferingDetailsVO.getName())); + Map detailString = new HashMap<>(); + for (BackupOfferingDetailsVO detail : details) { + detailString.put(detail.getName(), detail.getValue()); + } + if (!detailString.isEmpty()) { + response.setDetails(detailString); + } response.setCreated(offering.getCreated()); response.setObjectName("backupoffering"); return response; diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDao.java new file mode 100644 index 000000000000..e71ffe5ebde9 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDao.java @@ -0,0 +1,33 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.backup.InternalBackupDataStoreVO; + +import java.util.List; + +public interface InternalBackupDataStoreDao extends GenericDao { + + List listByBackupId(long backupId); + + InternalBackupDataStoreVO findByBackupIdAndVolumeId(long backupId, long volumeId); + + void expungeByBackupId(long backupId); + + void updateVolumeId(long oldVolumeId, long newVolumeId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDaoImpl.java new file mode 100644 index 000000000000..34a064ff62a2 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupDataStoreDaoImpl.java @@ -0,0 +1,74 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.db.UpdateBuilder; +import org.apache.cloudstack.backup.InternalBackupDataStoreVO; + +import javax.annotation.PostConstruct; +import java.util.List; + +public class InternalBackupDataStoreDaoImpl extends GenericDaoBase implements InternalBackupDataStoreDao { + + private SearchBuilder backupSearch; + + private static final String BACKUP_ID = "backup_id"; + private static final String VOLUME_ID = "volume_id"; + + @PostConstruct + protected void init() { + backupSearch = createSearchBuilder(); + backupSearch.and(BACKUP_ID, backupSearch.entity().getBackupId(), SearchCriteria.Op.EQ); + backupSearch.and(VOLUME_ID, backupSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + backupSearch.done(); + } + + @Override + public List listByBackupId(long backupId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + return listBy(sc); + } + + @Override + public InternalBackupDataStoreVO findByBackupIdAndVolumeId(long backupId, long volumeId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + sc.setParameters(VOLUME_ID, volumeId); + return findOneBy(sc); + } + + @Override + public void expungeByBackupId(long backupId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + expunge(sc); + } + + @Override + public void updateVolumeId(long oldVolumeId, long newVolumeId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VOLUME_ID, oldVolumeId); + InternalBackupDataStoreVO delta = createForUpdate(); + delta.setVolumeId(newVolumeId); + UpdateBuilder ub = getUpdateBuilder(delta); + update(ub, sc, null); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDao.java new file mode 100644 index 000000000000..2f9c3dd1ff8f --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDao.java @@ -0,0 +1,40 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.backup.InternalBackupJoinVO; + +import java.util.Date; +import java.util.List; + +public interface InternalBackupJoinDao extends GenericDao { + + List listByBackedUpAndVmIdAndDateBeforeOrAfterOrderBy(long vmId, Date date, boolean before, boolean ascending); + + List listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(long vmId, Date beforeDate); + + InternalBackupJoinVO findCurrent(long vmId); + + InternalBackupJoinVO findByParentId(long parentId); + + List listByImageStoreId(long imageStoreId); + + List listById(long id); + + List listByParentId(long parentId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDaoImpl.java new file mode 100644 index 000000000000..e8d823bb69c5 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupJoinDaoImpl.java @@ -0,0 +1,130 @@ +// 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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.InternalBackupJoinVO; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class InternalBackupJoinDaoImpl extends GenericDaoBase implements InternalBackupJoinDao { + + private static final String ID = "id"; + private static final String VM_ID = "vm_id"; + private static final String STATUS = "status"; + private static final String CREATED_BEFORE = "created_before"; + private static final String CREATED_AFTER = "created_after"; + private static final String CURRENT = "current"; + private static final String ISOLATED = "isolated"; + private static final String PARENT_ID = "parent_id"; + private static final String IMAGE_STORE_ID = "image_store_id"; + private SearchBuilder backupSearch; + private SearchBuilder allBackupsSearch; + + @PostConstruct + protected void init() { + backupSearch = createSearchBuilder(); + backupSearch.and(VM_ID, backupSearch.entity().getVmId(), SearchCriteria.Op.EQ); + backupSearch.and(STATUS, backupSearch.entity().getStatus(), SearchCriteria.Op.IN); + backupSearch.and(CREATED_BEFORE, backupSearch.entity().getDate(), SearchCriteria.Op.LT); + backupSearch.and(CREATED_AFTER, backupSearch.entity().getDate(), SearchCriteria.Op.GT); + backupSearch.and(CURRENT, backupSearch.entity().getCurrent(), SearchCriteria.Op.EQ); + backupSearch.and(PARENT_ID, backupSearch.entity().getParentId(), SearchCriteria.Op.EQ); + backupSearch.and(ISOLATED, backupSearch.entity().getIsolated(), SearchCriteria.Op.EQ); + backupSearch.groupBy(backupSearch.entity().getId()); + backupSearch.done(); + + allBackupsSearch = createSearchBuilder(); + allBackupsSearch.and(ID, allBackupsSearch.entity().getId(), SearchCriteria.Op.EQ); + allBackupsSearch.and(STATUS, allBackupsSearch.entity().getStatus(), SearchCriteria.Op.IN); + allBackupsSearch.and(PARENT_ID, allBackupsSearch.entity().getParentId(), SearchCriteria.Op.EQ); + allBackupsSearch.and(IMAGE_STORE_ID, allBackupsSearch.entity().getImageStoreId(), SearchCriteria.Op.EQ); + allBackupsSearch.done(); + } + + @Override + public List listByBackedUpAndVmIdAndDateBeforeOrAfterOrderBy(long vmId, Date date, boolean before, boolean ascending) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VM_ID, vmId); + sc.setParameters(STATUS, Backup.Status.BackedUp); + if (before) { + sc.setParameters(CREATED_BEFORE, date); + } else { + sc.setParameters(CREATED_AFTER, date); + } + sc.setParameters(ISOLATED, Boolean.FALSE.toString()); + Filter filter = new Filter(InternalBackupJoinVO.class, "date", ascending); + return new ArrayList<>(listBy(sc, filter)); + } + + @Override + public List listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(long vmId, Date beforeDate) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VM_ID, vmId); + sc.setParameters(STATUS, Backup.Status.BackedUp, Backup.Status.Removed); + sc.setParameters(CREATED_BEFORE, beforeDate); + sc.setParameters(ISOLATED, Boolean.FALSE.toString()); + Filter filter = new Filter(InternalBackupJoinVO.class, "date", false); + return new ArrayList<>(listIncludingRemovedBy(sc, filter)); + } + + @Override + public InternalBackupJoinVO findCurrent(long vmId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VM_ID, vmId); + sc.setParameters(CURRENT, Boolean.TRUE.toString()); + return findOneBy(sc); + } + + @Override + public InternalBackupJoinVO findByParentId(long parentId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(PARENT_ID, parentId); + return findOneIncludingRemovedBy(sc); + } + + @Override + public List listByImageStoreId(long imageStoreId) { + SearchCriteria sc = allBackupsSearch.create(); + sc.setParameters(IMAGE_STORE_ID, imageStoreId); + sc.setParameters(STATUS, Backup.Status.BackedUp); + return listBy(sc); + } + + @Override + public List listById(long id) { + SearchCriteria sc = allBackupsSearch.create(); + sc.setParameters(ID, id); + sc.setParameters(STATUS, Backup.Status.BackedUp); + return listBy(sc); + } + + @Override + public List listByParentId(long parentId) { + SearchCriteria sc = allBackupsSearch.create(); + sc.setParameters(PARENT_ID, parentId); + sc.setParameters(STATUS, Backup.Status.BackedUp); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDao.java new file mode 100644 index 000000000000..c80734b9cb3c --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDao.java @@ -0,0 +1,39 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.backup.InternalBackupServiceJobType; +import org.apache.cloudstack.backup.InternalBackupServiceJobVO; + +import java.util.Date; +import java.util.List; + +public interface InternalBackupServiceJobDao extends GenericDao { + + List listExecutingJobsByZoneIdAndJobType(long zoneId, InternalBackupServiceJobType... jobTypes); + + List listWaitingJobsAndScheduledToBeforeNow(long zoneId, InternalBackupServiceJobType... jobTypes); + + List listExecutingJobsByHostsAndStartTimeBeforeAndTypeIn(Object[] hostIds, Date date, InternalBackupServiceJobType... jobTypes); + + Pair, Integer> searchAndCountForListApi(Long id, Long backupId, Long hostId, Long zoneId, InternalBackupServiceJobType type, boolean executing, + boolean scheduled, Long startIndex, Long pageSize); + + void update(InternalBackupServiceJobVO job); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDaoImpl.java new file mode 100644 index 000000000000..32e81261838d --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupServiceJobDaoImpl.java @@ -0,0 +1,138 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.DateUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.backup.InternalBackupServiceJobType; +import org.apache.cloudstack.backup.InternalBackupServiceJobVO; + +import javax.annotation.PostConstruct; +import java.util.Date; +import java.util.List; + +public class InternalBackupServiceJobDaoImpl extends GenericDaoBase implements InternalBackupServiceJobDao { + private static final String ID = "id"; + private static final String BACKUP_ID = "backup_id"; + private SearchBuilder executingBeforeAndHostInAndTypeInSearch; + private SearchBuilder scheduledAndNotStartedSearch; + + private SearchBuilder executingAndZoneIdAndTypeSearch; + + private static final String HOST_ID = "host_id"; + private static final String TYPE = "type"; + private static final String START_TIME = "start_time"; + private static final String SCHEDULED = "scheduled"; + private static final String ZONE_ID = "zone_id"; + + @PostConstruct + protected void init() { + executingBeforeAndHostInAndTypeInSearch = createSearchBuilder(); + executingBeforeAndHostInAndTypeInSearch.and(HOST_ID, executingBeforeAndHostInAndTypeInSearch.entity().getHostId(), SearchCriteria.Op.IN); + executingBeforeAndHostInAndTypeInSearch.and(START_TIME, executingBeforeAndHostInAndTypeInSearch.entity().getStartTime(), SearchCriteria.Op.LTEQ); + executingBeforeAndHostInAndTypeInSearch.and(TYPE, executingBeforeAndHostInAndTypeInSearch.entity().getType(), SearchCriteria.Op.IN); + executingBeforeAndHostInAndTypeInSearch.done(); + + scheduledAndNotStartedSearch = createSearchBuilder(); + scheduledAndNotStartedSearch.and(SCHEDULED, scheduledAndNotStartedSearch.entity().getScheduledStartTime(), SearchCriteria.Op.LTEQ); + scheduledAndNotStartedSearch.and(START_TIME, scheduledAndNotStartedSearch.entity().getStartTime(), SearchCriteria.Op.NULL); + scheduledAndNotStartedSearch.and(ZONE_ID, scheduledAndNotStartedSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + scheduledAndNotStartedSearch.and(TYPE, scheduledAndNotStartedSearch.entity().getType(), SearchCriteria.Op.IN); + scheduledAndNotStartedSearch.done(); + + executingAndZoneIdAndTypeSearch = createSearchBuilder(); + executingAndZoneIdAndTypeSearch.and(START_TIME, executingAndZoneIdAndTypeSearch.entity().getStartTime(), SearchCriteria.Op.NNULL); + executingAndZoneIdAndTypeSearch.and(ZONE_ID, executingAndZoneIdAndTypeSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + executingAndZoneIdAndTypeSearch.and(TYPE, executingAndZoneIdAndTypeSearch.entity().getType(), SearchCriteria.Op.IN); + executingAndZoneIdAndTypeSearch.done(); + } + + @Override + public List listExecutingJobsByZoneIdAndJobType(long zoneId, InternalBackupServiceJobType... jobTypes) { + SearchCriteria sc = executingAndZoneIdAndTypeSearch.create(); + sc.setParameters(TYPE, (Object[]) jobTypes); + sc.setParameters(ZONE_ID, zoneId); + + return listBy(sc); + } + + @Override + public List listWaitingJobsAndScheduledToBeforeNow(long zoneId, InternalBackupServiceJobType... jobTypes) { + SearchCriteria sc = scheduledAndNotStartedSearch.create(); + + sc.setParameters(SCHEDULED, DateUtil.now()); + sc.setParameters(ZONE_ID, zoneId); + sc.setParameters(TYPE, (Object[]) jobTypes); + + Filter filter = new Filter(InternalBackupServiceJobVO.class, "scheduledStartTime", true); + return listBy(sc, filter); + } + + @Override + public List listExecutingJobsByHostsAndStartTimeBeforeAndTypeIn(Object[] hostIds, Date date, InternalBackupServiceJobType... jobTypes) { + SearchCriteria sc = executingBeforeAndHostInAndTypeInSearch.create(); + sc.setParameters(HOST_ID, hostIds); + sc.setParameters(START_TIME, date); + sc.setParameters(TYPE, (Object[]) jobTypes); + + return listBy(sc); + } + + @Override + public Pair, Integer> searchAndCountForListApi(Long id, Long backupId, Long hostId, Long zoneId, InternalBackupServiceJobType type, boolean executing, + boolean scheduled, Long startIndex, Long pageSize) { + SearchBuilder sb = createSearchBuilder(); + + sb.and(ID, sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and(BACKUP_ID, sb.entity().getBackupId(), SearchCriteria.Op.EQ); + sb.and(HOST_ID, sb.entity().getHostId(), SearchCriteria.Op.EQ); + sb.and(ZONE_ID, sb.entity().getZoneId(), SearchCriteria.Op.EQ); + sb.and(TYPE, sb.entity().getType(), SearchCriteria.Op.EQ); + + boolean removed = !executing && !scheduled; + if (executing && !scheduled) { + sb.and("executing", sb.entity().getStartTime(), SearchCriteria.Op.NNULL); + } else if (scheduled && !executing) { + sb.and("scheduled", sb.entity().getStartTime(), SearchCriteria.Op.NULL); + } + + SearchCriteria sc = sb.create(); + + sc.setParametersIfNotNull(ID, id); + sc.setParametersIfNotNull(BACKUP_ID, backupId); + sc.setParametersIfNotNull(HOST_ID, hostId); + sc.setParametersIfNotNull(ZONE_ID, zoneId); + if (type != null) { + sc.setParameters(TYPE, type); + } + + Filter filter = new Filter(InternalBackupServiceJobVO.class, "created", false, startIndex, pageSize); + + return searchAndCount(sc, filter, removed); + } + + @Override + @DB + public void update(InternalBackupServiceJobVO job) { + super.update(job.getId(), job); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDao.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDao.java new file mode 100644 index 000000000000..7e2ac5a249e4 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDao.java @@ -0,0 +1,33 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.backup.InternalBackupStoragePoolVO; + +import java.util.List; + +public interface InternalBackupStoragePoolDao extends GenericDao { + + List listByBackupId(long backupId); + + InternalBackupStoragePoolVO findOneByVolumeId(long volumeId); + + void expungeByBackupId(long backupId); + + void expungeByVolumeId(long volumeId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDaoImpl.java new file mode 100644 index 000000000000..443ceed02e7d --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/backup/dao/InternalBackupStoragePoolDaoImpl.java @@ -0,0 +1,70 @@ +//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 +//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 org.apache.cloudstack.backup.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.backup.InternalBackupStoragePoolVO; + +import javax.annotation.PostConstruct; +import java.util.List; + +public class InternalBackupStoragePoolDaoImpl extends GenericDaoBase implements InternalBackupStoragePoolDao { + + private SearchBuilder backupSearch; + + private static final String BACKUP_ID = "backup_id"; + + private static final String VOLUME_ID = "volume_id"; + + @PostConstruct + protected void init() { + backupSearch = createSearchBuilder(); + backupSearch.and(BACKUP_ID, backupSearch.entity().getBackupId(), SearchCriteria.Op.EQ); + backupSearch.and(VOLUME_ID, backupSearch.entity().getVolumeId(), SearchCriteria.Op.EQ); + backupSearch.done(); + } + + @Override + public List listByBackupId(long backupId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + return listBy(sc); + } + + @Override + public InternalBackupStoragePoolVO findOneByVolumeId(long volumeId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VOLUME_ID, volumeId); + return findOneBy(sc); + } + + @Override + public void expungeByBackupId(long backupId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(BACKUP_ID, backupId); + expunge(sc); + } + + @Override + public void expungeByVolumeId(long volumeId) { + SearchCriteria sc = backupSearch.create(); + sc.setParameters(VOLUME_ID, volumeId); + expunge(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java index 1102de16e4ea..4383278d7c48 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java @@ -113,4 +113,6 @@ public interface ResourceDetailsDao extends GenericDao long batchExpungeForResources(List ids, Long batchSize); String getActualValue(ResourceDetail resourceDetail); + + List listDetailsForResourceIdsAndKey(List resourceIds, String key); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java index eafaed182abd..8f376a71f66f 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java @@ -16,11 +16,13 @@ // under the License. package org.apache.cloudstack.resourcedetail; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import com.cloud.utils.StringUtils; import org.apache.commons.collections.CollectionUtils; import com.cloud.utils.Pair; @@ -48,6 +50,7 @@ public abstract class ResourceDetailsDaoBase extends G public ResourceDetailsDaoBase() { AllFieldsSearch = createSearchBuilder(); AllFieldsSearch.and("resourceId", AllFieldsSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + AllFieldsSearch.and("resourceIdIn", AllFieldsSearch.entity().getResourceId(), SearchCriteria.Op.IN); AllFieldsSearch.and("name", AllFieldsSearch.entity().getName(), SearchCriteria.Op.EQ); AllFieldsSearch.and("value", AllFieldsSearch.entity().getValue(), SearchCriteria.Op.EQ); // FIXME SnapshotDetailsVO doesn't have a display field @@ -266,4 +269,15 @@ public String getActualValue(ResourceDetail resourceDetail) { } return resourceDetail.getValue(); } + + @Override + public List listDetailsForResourceIdsAndKey(List resourceIds, String key) { + if (CollectionUtils.isEmpty(resourceIds) || StringUtils.isBlank(key)) { + return Collections.emptyList(); + } + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("name", key); + sc.setParameters("resourceIdIn", resourceIds.toArray()); + return search(sc, null); + } } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDao.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDao.java index 3329983d711e..2d337fe07fc7 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDao.java @@ -99,6 +99,8 @@ public interface SnapshotDataStoreDao extends GenericDao findByVolume(long snapshotId, long volumeId, DataStoreRole role); + void expungeBySnapshotIdAndStoreRole(long snapshotId, DataStoreRole role); + /** * List all snapshots in 'snapshot_store_ref' by volume and data store role. Therefore, it is possible to list all snapshots that are in the primary storage or in the secondary storage. */ diff --git a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImpl.java index 8b7a2b78de7e..cb88e21cf9d6 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/storage/datastore/db/SnapshotDataStoreDaoImpl.java @@ -63,7 +63,7 @@ public class SnapshotDataStoreDaoImpl extends GenericDaoBase searchFilteringStoreIdEqStoreRoleEqStateNeqRefCntNeq; protected SearchBuilder searchFilteringStoreIdEqStateEqStoreRoleEqIdEqUpdateCountEqSnapshotIdEqVolumeIdEq; private SearchBuilder stateSearch; @@ -76,7 +76,9 @@ public class SnapshotDataStoreDaoImpl extends GenericDaoBase storeSnapshotDownloadStatusSearch; private SearchBuilder searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqKVMCheckpointNotNull; private SearchBuilder searchFilterStateAndDownloadUrlNotNullAndDownloadUrlCreatedBefore; - private SearchBuilder searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq; + private SearchBuilder searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq; + + private SearchBuilder searchFilteringVolumeIdAndStateAndCreatedAfter; private SearchBuilder searchBySnapshotId; @@ -198,17 +200,24 @@ public boolean configure(String name, Map params) throws Configu searchFilterStateAndDownloadUrlNotNullAndDownloadUrlCreatedBefore.and(URL_CREATED_BEFORE, searchFilterStateAndDownloadUrlNotNullAndDownloadUrlCreatedBefore.entity().getExtractUrlCreated(), SearchCriteria.Op.LT); searchFilterStateAndDownloadUrlNotNullAndDownloadUrlCreatedBefore.done(); - searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq = createSearchBuilder(); - searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.and(STATE, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.entity().getState(), SearchCriteria.Op.EQ); - searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.and(VOLUME_ID, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.entity().getVolumeId(), SearchCriteria.Op.EQ); - searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.and(STORE_ROLE, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.entity().getRole(), SearchCriteria.Op.EQ); - searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.and(STORE_ID, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.entity().getDataStoreId(), SearchCriteria.Op.IN); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq = createSearchBuilder(); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.and(STATE, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.entity().getState(), SearchCriteria.Op.EQ); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.and(VOLUME_ID, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.entity().getVolumeId(), SearchCriteria.Op.EQ); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.and(STORE_ROLE, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.entity().getRole(), SearchCriteria.Op.EQ); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.and(STORE_ID, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.entity().getDataStoreId(), SearchCriteria.Op.IN); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.and(INSTALL_PATH, searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.entity().getInstallPath(), SearchCriteria.Op.EQ); + searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.done(); searchBySnapshotId = createSearchBuilder(); searchBySnapshotId.and(SNAPSHOT_ID, searchBySnapshotId.entity().getSnapshotId(), SearchCriteria.Op.EQ); searchBySnapshotId.and(STATE, searchBySnapshotId.entity().getState(), SearchCriteria.Op.EQ); searchBySnapshotId.done(); + searchFilteringVolumeIdAndStateAndCreatedAfter = createSearchBuilder(); + searchFilteringVolumeIdAndStateAndCreatedAfter.and(STATE, searchFilteringVolumeIdAndStateAndCreatedAfter.entity().getState(), SearchCriteria.Op.EQ); + searchFilteringVolumeIdAndStateAndCreatedAfter.and(VOLUME_ID, searchFilteringVolumeIdAndStateAndCreatedAfter.entity().getVolumeId(), SearchCriteria.Op.EQ); + searchFilteringVolumeIdAndStateAndCreatedAfter.and(CREATED, searchFilteringVolumeIdAndStateAndCreatedAfter.entity().getCreated(), SearchCriteria.Op.GT); + searchFilteringVolumeIdAndStateAndCreatedAfter.done(); return true; } @@ -356,7 +365,7 @@ public SnapshotDataStoreVO findParent(DataStoreRole role, Long storeId, Long zon if (kvmIncrementalSnapshot && Hypervisor.HypervisorType.KVM.equals(hypervisorType)) { sc = searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqKVMCheckpointNotNull.create(); } else { - sc = searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEq.create(); + sc = searchFilteringStoreIdInVolumeIdEqStoreRoleEqStateEqPathEq.create(); } sc.setParameters(VOLUME_ID, volumeId); @@ -469,6 +478,12 @@ public SnapshotDataStoreVO findBySnapshotIdInAnyState(long snapshotId, DataStore return findOneBy(sc); } + @Override + public void expungeBySnapshotIdAndStoreRole(long snapshotId, DataStoreRole role) { + SearchCriteria sc = createSearchCriteriaBySnapshotIdAndStoreRole(snapshotId, role); + expunge(sc); + } + @Override public List listAllByVolumeAndDataStore(long volumeId, DataStoreRole role) { SearchCriteria sc = searchFilteringStoreIdEqStateEqStoreRoleEqIdEqUpdateCountEqSnapshotIdEqVolumeIdEq.create(); diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-common-daos-between-management-and-usage-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-common-daos-between-management-and-usage-context.xml index 2c6869bd81e3..a0dada545617 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-common-daos-between-management-and-usage-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-common-daos-between-management-and-usage-context.xml @@ -46,6 +46,7 @@ + diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index ab166eff806f..932db538f30b 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -272,6 +272,10 @@ + + + + @@ -316,7 +320,14 @@ - + + + + + + + + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100-cleanup.sql b/engine/schema/src/main/resources/META-INF/db/schema-42040to42100-cleanup.sql similarity index 93% rename from engine/schema/src/main/resources/META-INF/db/schema-42010to42100-cleanup.sql rename to engine/schema/src/main/resources/META-INF/db/schema-42040to42100-cleanup.sql index 5f257f2965bd..b63e918b389b 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100-cleanup.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42040to42100-cleanup.sql @@ -16,5 +16,5 @@ -- under the License. --; --- Schema upgrade cleanup from 4.20.1.0 to 4.21.0.0 +-- Schema upgrade cleanup from 4.20.4.0 to 4.21.0.0 --; diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql b/engine/schema/src/main/resources/META-INF/db/schema-42040to42100.sql similarity index 99% rename from engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql rename to engine/schema/src/main/resources/META-INF/db/schema-42040to42100.sql index 000b54b72078..bf7f3aeaf0f8 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42040to42100.sql @@ -16,7 +16,7 @@ -- under the License. --; --- Schema upgrade from 4.20.1.0 to 4.21.0.0 +-- Schema upgrade from 4.20.4.0 to 4.21.0.0 --; CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_schedule', 'max_backups', 'INT(8) UNSIGNED NOT NULL DEFAULT 0 COMMENT ''Maximum number of backups to be retained'''); diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 97c40115b5ad..ab5ac7b2b875 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -19,6 +19,12 @@ -- Schema upgrade from 4.22.1.0 to 4.23.0.0 --; +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider', 'domain_id', 'bigint unsigned DEFAULT NULL COMMENT "NULL for global provider, domain ID for domain-specific" AFTER `redirect_uri`'); +CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.oauth_provider', 'fk_oauth_provider__domain_id', '(`domain_id`)', '`domain`(`id`)'); +CALL `cloud`.`IDEMPOTENT_ADD_KEY`('i_oauth_provider__domain_id', 'cloud.oauth_provider', '(`domain_id`)'); + +CALL `cloud`.`IDEMPOTENT_ADD_UNIQUE_KEY`('cloud.oauth_provider', 'uk_oauth_provider__provider_domain', '(`provider`, `domain_id`)'); + CREATE TABLE `cloud`.`backup_offering_details` ( `id` bigint unsigned NOT NULL auto_increment, `backup_offering_id` bigint unsigned NOT NULL COMMENT 'Backup offering id', @@ -34,6 +40,19 @@ CREATE TABLE `cloud`.`backup_offering_details` ( UPDATE `cloud`.`configuration` SET value='random' WHERE name IN ('vm.allocation.algorithm', 'volume.allocation.algorithm') AND value='userconcentratedpod_random'; UPDATE `cloud`.`configuration` SET value='firstfit' WHERE name IN ('vm.allocation.algorithm', 'volume.allocation.algorithm') AND value='userconcentratedpod_firstfit'; +-- Add Windows Server 2025 guest OS and mappings +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '7.0', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '7.0.1.0', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '7.0.2.0', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '7.0.3.0', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.0.1', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.0.2', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.0.3', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.1', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.2', 'windows2022srvNext_64Guest'); +CALL ADD_GUEST_OS_AND_HYPERVISOR_MAPPING (6, 'Windows Server 2025 (64-bit)', 'VMware', '8.0.3', 'windows2022srvNext_64Guest'); + -- Create kubernetes_cluster_affinity_group_map table for CKS per-node-type affinity groups CREATE TABLE IF NOT EXISTS `cloud`.`kubernetes_cluster_affinity_group_map` ( `id` bigint unsigned NOT NULL AUTO_INCREMENT, @@ -451,12 +470,28 @@ SELECT uuid(), role_id, 'quotaResourceStatement', permission, sort_order FROM cloud.role_permissions rp WHERE rule = 'quotaStatement' AND NOT EXISTS(SELECT 1 FROM cloud.role_permissions rp_ WHERE rp.role_id = rp_.role_id AND rp_.rule = 'quotaResourceStatement'); +-- Increase length of value of extension details from 255 to 4096 to support longer details value +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.extension_details', 'value', 'value', 'VARCHAR(4096)'); +CALL `cloud`.`IDEMPOTENT_CHANGE_COLUMN`('cloud.extension_resource_map_details', 'value', 'value', 'VARCHAR(4096)'); + +-- Add CustomAction service support to physical_network_service_providers +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.physical_network_service_providers', 'custom_action_service_provided', 'tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT "Is Custom Action service provided" AFTER `networkacl_service_provided`'); + --- Gui theme login base domain CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.gui_themes', 'login_base_domain', 'TEXT DEFAULT NULL'); -- Add description for secondary IP addresses CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nic_secondary_ips', 'description', 'VARCHAR(2048) DEFAULT NULL'); +--- Change nw_rate and mc_rate column types from smallint unsigned to int unsigned to support larger rate values +ALTER TABLE `cloud`.`service_offering` + MODIFY COLUMN `nw_rate` int unsigned DEFAULT 200 COMMENT 'network rate throttle mbits/s', + MODIFY COLUMN `mc_rate` int unsigned DEFAULT 10 COMMENT 'mcast rate throttle mbits/s'; + +ALTER TABLE `cloud`.`network_offerings` + MODIFY COLUMN `nw_rate` int unsigned COMMENT 'network rate throttle mbits/s', + MODIFY COLUMN `mc_rate` int unsigned COMMENT 'mcast rate throttle mbits/s'; + -- Soft delete port forwarding, load balancing and firewall rules CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.firewall_rules', 'removed', 'datetime DEFAULT NULL'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.load_balancer_vm_map', 'removed', 'datetime DEFAULT NULL'); @@ -475,3 +510,139 @@ ADD UNIQUE KEY `load_balancer_id` (`load_balancer_id`, `instance_id`, `instance_ ALTER TABLE `cloud`.`global_load_balancer_lb_rule_map` DROP KEY `gslb_rule_id`, ADD UNIQUE KEY `gslb_rule_id` (`gslb_rule_id`, `lb_rule_id`, `removed`); + +-- ====================================================================== +-- DNS Framework Schema +-- ====================================================================== + +-- DNS Server Table (Stores DNS Server Configurations) +CREATE TABLE IF NOT EXISTS `cloud`.`dns_server` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of the dns server', + `uuid` varchar(40) COMMENT 'uuid of the dns server', + `name` varchar(255) NOT NULL COMMENT 'display name of the dns server', + `provider_type` varchar(255) NOT NULL COMMENT 'Provider type such as PowerDns', + `url` varchar(1024) NOT NULL COMMENT 'dns server url', + `dns_username` varchar(255) COMMENT 'username or email for dns server credentials', + `dns_api_key` varchar(255) NOT NULL COMMENT 'api key or token for the dns server ', + `port` int(11) DEFAULT NULL COMMENT 'optional dns server port', + `name_servers` varchar(1024) DEFAULT NULL COMMENT 'Comma separated list of name servers', + `is_public` tinyint(1) NOT NULL DEFAULT '0', + `public_domain_suffix` VARCHAR(255), + `state` ENUM('Enabled', 'Disabled') NOT NULL DEFAULT 'Disabled', + `domain_id` bigint unsigned COMMENT 'for domain-specific ownership', + `account_id` bigint(20) unsigned NOT NULL, + `created` datetime NOT NULL COMMENT 'date created', + `removed` datetime DEFAULT NULL COMMENT 'Date removed (soft delete)', + PRIMARY KEY (`id`), + KEY `i_dns_server__account_id` (`account_id`), + CONSTRAINT `fk_dns_server__account_id` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- DNS Server Details Table +CREATE TABLE IF NOT EXISTS `cloud`.`dns_server_details` ( + `id` bigint unsigned UNIQUE NOT NULL AUTO_INCREMENT COMMENT 'id', + `dns_server_id` bigint unsigned NOT NULL COMMENT 'dns_server the detail is related to', + `name` varchar(255) NOT NULL COMMENT 'name of the detail', + `value` varchar(255) NOT NULL COMMENT 'value of the detail', + `display` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'Should detail be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_dns_server_details__dns_server_id` FOREIGN KEY (`dns_server_id`) REFERENCES `dns_server`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- DNS Zone Table (Stores DNS Zone Metadata) +CREATE TABLE IF NOT EXISTS `cloud`.`dns_zone` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of the dns zone', + `uuid` varchar(40) COMMENT 'uuid of the dns zone', + `name` varchar(255) NOT NULL COMMENT 'dns zone name (e.g. example.com)', + `dns_server_id` bigint unsigned NOT NULL COMMENT 'fk to dns_server.id', + `external_reference` VARCHAR(255) COMMENT 'id of external provider resource', + `domain_id` bigint unsigned COMMENT 'for domain-specific ownership', + `account_id` bigint unsigned COMMENT 'account id. foreign key to account table', + `description` varchar(1024) DEFAULT NULL, + `type` ENUM('Private', 'Public') NOT NULL DEFAULT 'Public', + `state` ENUM('Active', 'Inactive') NOT NULL DEFAULT 'Inactive', + `created` datetime NOT NULL COMMENT 'date created', + `removed` datetime DEFAULT NULL COMMENT 'Date removed (soft delete)', + PRIMARY KEY (`id`), + CONSTRAINT `uc_dns_zone__uuid` UNIQUE (`uuid`), + KEY `i_dns_zone__dns_server` (`dns_server_id`), + KEY `i_dns_zone__account_id` (`account_id`), + CONSTRAINT `fk_dns_zone__dns_server_id` FOREIGN KEY (`dns_server_id`) REFERENCES `dns_server` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dns_zone__account_id` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dns_zone__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- DNS Zone Network Map (One-to-Many Link) +CREATE TABLE IF NOT EXISTS `cloud`.`dns_zone_network_map` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of the dns zone to network mapping', + `uuid` varchar(40), + `dns_zone_id` bigint(20) unsigned NOT NULL, + `network_id` bigint(20) unsigned NOT NULL COMMENT 'network to which dns zone is associated to', + `sub_domain` varchar(255) DEFAULT NULL COMMENT 'Subdomain for auto-registration', + `created` datetime NOT NULL COMMENT 'date created', + `removed` datetime DEFAULT NULL COMMENT 'Date removed (soft delete)', + PRIMARY KEY (`id`), + CONSTRAINT `uc_dns_zone_network_map__uuid` UNIQUE (`uuid`), + KEY `fk_dns_map__zone_id` (`dns_zone_id`), + KEY `fk_dns_map__network_id` (`network_id`), + CONSTRAINT `fk_dns_map__zone_id` FOREIGN KEY (`dns_zone_id`) REFERENCES `dns_zone` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_dns_map__network_id` FOREIGN KEY (`network_id`) REFERENCES `networks` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- This is part of allowing firewall rules on public IP addresses in VPC network +ALTER TABLE `cloud`.`firewall_rules` MODIFY COLUMN `network_id` BIGINT UNSIGNED NULL; + +-- KBOSS + +CREATE TABLE IF NOT EXISTS `cloud`.`internal_backup_pool_ref` ( + `id` bigint NOT NULL UNIQUE AUTO_INCREMENT, + `backup_id` bigint unsigned NOT NULL COMMENT 'The backup ID. Foreign key that points to the backups table.', + `storage_pool_id` bigint unsigned NOT NULL COMMENT 'The storage ID. Foreign key that points to the storage_pool table.', + `volume_id` bigint unsigned NOT NULL COMMENT 'The volumes ID. Foreign key that points to the volumes table.', + `backup_delta_path` varchar(255) COMMENT 'Path of the created delta.', + `backup_parent_path` varchar(255) COMMENT 'Path of the created delta parent.', + PRIMARY KEY (`id`), + CONSTRAINT `fk_internal_backup_pool_ref__backup_id` FOREIGN KEY (`backup_id`) REFERENCES `backups`(`id`), + CONSTRAINT `fk_internal_backup_pool_ref__storage_pool_id` FOREIGN KEY (`storage_pool_id`) REFERENCES `storage_pool`(`id`), + CONSTRAINT `fk_internal_backup_pool_ref__volume_id` FOREIGN KEY (`volume_id`) REFERENCES `volumes`(`id`) + ); + +CREATE TABLE IF NOT EXISTS `cloud`.`internal_backup_store_ref` ( + `id` bigint NOT NULL UNIQUE AUTO_INCREMENT, + `backup_id` bigint unsigned NOT NULL COMMENT 'The backup ID. Foreign key that points to the backups table.', + `volume_id` bigint unsigned NOT NULL COMMENT 'The volume ID. Foreign key that points to the volumes table.', + `device_id` bigint unsigned COMMENT 'device ID of the volume', + `path` varchar(255) COMMENT 'Path of the backup.', + PRIMARY KEY (`id`), + CONSTRAINT `fk_internal_backup_store_ref__backup_id` FOREIGN KEY (`backup_id`) REFERENCES `backups`(`id`), + CONSTRAINT `fk_internal_backup_store_ref__volume_id` FOREIGN KEY (`volume_id`) REFERENCES `volumes`(`id`) + ); + +CREATE TABLE IF NOT EXISTS `cloud`.`internal_backup_service_job` ( + `id` bigint NOT NULL UNIQUE AUTO_INCREMENT, + `backup_id` bigint unsigned NOT NULL COMMENT 'The backup ID. Foreign key that points to the backups table.', + `instance_id` bigint unsigned NOT NULL COMMENT 'The instance ID. Foreign key that points to the vm_instance table.', + `account_id` bigint(20) unsigned COMMENT 'Account ID of the owner of the VM.', + `host_id` bigint unsigned COMMENT 'The host ID that is executing the compression. Foreign key that points to the host table.', + `zone_id` bigint unsigned NOT NULL COMMENT 'The zone ID of the where the VM is. Foreign key that points to the data_center table', + `attempts` int(32) unsigned NOT NULL DEFAULT 0, + `type` varchar(55) NOT NULL, + `created` datetime NOT NULL, + `scheduled_start_time` datetime NOT NULL, + `start_time` datetime, + `removed` datetime, + PRIMARY KEY (`id`), + CONSTRAINT `fk_internal_backup_service_job__backup_id` FOREIGN KEY (`backup_id`) REFERENCES `backups`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_internal_backup_service_job__instance_id` FOREIGN KEY (`instance_id`) REFERENCES `vm_instance`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_internal_backup_service_job__host_id` FOREIGN KEY (`host_id`) REFERENCES `host`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_internal_backup_service_job__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_internal_backup_service_job__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE + ); + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backups', 'uncompressed_size', 'bigint unsigned'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backups', 'compression_status', 'varchar(55)'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backups', 'validation_status', 'varchar(55)'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_schedule', 'isolated', 'TINYINT(1) NOT NULL DEFAULT 0 COMMENT "Whether the scheduled backups will be isolated or not."'); + +UPDATE `cloud`.`configuration` SET `value`=CONCAT(`value`, ', backupValidationCommandTimeout, backupValidationScreenshotWait, backupValidationBootTimeout') +WHERE `name`='user.vm.readonly.details' AND `value` IS NOT NULL; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_server_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_server_view.sql new file mode 100644 index 000000000000..a1bc1a1141a9 --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_server_view.sql @@ -0,0 +1,44 @@ +-- 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. + +-- VIEW `cloud`.`dns_server_view`; + +DROP VIEW IF EXISTS `cloud`.`dns_server_view`; +CREATE VIEW `cloud`.`dns_server_view` AS + SELECT + dns.id, + dns.uuid, + dns.name, + dns.provider_type, + dns.url, + dns.port, + dns.name_servers, + dns.is_public, + dns.public_domain_suffix, + dns.state, + dns.created, + dns.removed, + account.account_name account_name, + domain.name domain_name, + domain.uuid domain_uuid, + domain.path domain_path + FROM + `cloud`.`dns_server` dns + INNER JOIN + `cloud`.`account` account ON dns.account_id = account.id + INNER JOIN + `cloud`.`domain` domain ON dns.domain_id = domain.id; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_zone_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_zone_view.sql new file mode 100644 index 000000000000..a41e003ae4f2 --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.dns_zone_view.sql @@ -0,0 +1,45 @@ +-- 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. + +-- VIEW `cloud`.`dns_zone_view`; + +DROP VIEW IF EXISTS `cloud`.`dns_zone_view`; +CREATE VIEW `cloud`.`dns_zone_view` AS + SELECT + zone.id, + zone.uuid, + zone.name, + zone.dns_server_id, + zone.state, + zone.description, + server.uuid dns_server_uuid, + server.name dns_server_name, + server_account.account_name dns_server_account_name, + account.account_name account_name, + domain.name domain_name, + domain.uuid domain_uuid, + domain.path domain_path + FROM + `cloud`.`dns_zone` zone + INNER JOIN + `cloud`.`dns_server` server ON zone.dns_server_id = server.id + INNER JOIN + `cloud`.`account` server_account ON server.account_id = server_account.id + INNER JOIN + `cloud`.`account` account ON zone.account_id = account.id + INNER JOIN + `cloud`.`domain` domain ON zone.domain_id = domain.id; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.internal_backup_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.internal_backup_view.sql new file mode 100644 index 000000000000..a1fbd102630b --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.internal_backup_view.sql @@ -0,0 +1,47 @@ +-- 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. + +-- VIEW `cloud`.`internal_backup_view`; + +DROP VIEW IF EXISTS `cloud`.`internal_backup_view`; +CREATE VIEW `cloud`.`internal_backup_view` AS +SELECT b.id, + b.uuid, + b.vm_id, + b.backed_volumes, + b.type, + b.date, + b.status, + b.compression_status, + b.backup_offering_id, + b.size, + b.protected_size, + b.zone_id, + MAX(CASE WHEN bd.name = 'image_store_id' THEN bd.value END) image_store_id, + MAX(CASE WHEN bd.name = 'parent_id' THEN bd.value END) parent_id, + MAX(CASE WHEN bd.name = 'end_of_chain' THEN bd.value END) end_of_chain, + MAX(CASE WHEN bd.name = 'current' THEN bd.value END) current, + COALESCE(MAX(CASE WHEN bd.name = 'isolated' THEN bd.value END), 'false') isolated, + nbpr.volume_id, + nbsr.path image_store_path +FROM backups b +LEFT JOIN backup_details bd ON b.id = bd.backup_id +LEFT JOIN backup_offering bo ON b.backup_offering_id = bo.id +LEFT JOIN internal_backup_store_ref nbsr ON b.id = nbsr.backup_id +LEFT JOIN internal_backup_pool_ref nbpr ON nbpr.volume_id = nbsr.volume_id +WHERE bo.provider='kboss' +GROUP BY b.id, nbsr.volume_id; diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_vm_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_vm_view.sql index d77ba27689be..fbf126608eae 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.user_vm_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.user_vm_view.sql @@ -144,6 +144,7 @@ SELECT `nics`.`broadcast_uri` AS `broadcast_uri`, `nics`.`isolation_uri` AS `isolation_uri`, `nics`.`enabled` AS `is_nic_enabled`, + `nic_details`.`value` AS `nic_dns_name`, `vpc`.`id` AS `vpc_id`, `vpc`.`uuid` AS `vpc_uuid`, `networks`.`uuid` AS `network_uuid`, @@ -188,7 +189,7 @@ SELECT `lease_expiry_action`.`value` AS `lease_expiry_action`, `lease_action_execution`.`value` AS `lease_action_execution` FROM - (((((((((((((((((((((((((((((((((((((`user_vm` + ((((((((((((((((((((((((((((((((((((((`user_vm` JOIN `vm_instance` ON (((`vm_instance`.`id` = `user_vm`.`id`) AND ISNULL(`vm_instance`.`removed`)))) JOIN `account` ON ((`vm_instance`.`account_id` = `account`.`id`))) @@ -215,6 +216,7 @@ FROM LEFT JOIN `user_data` ON ((`user_data`.`id` = `user_vm`.`user_data_id`))) LEFT JOIN `nics` ON (((`vm_instance`.`id` = `nics`.`instance_id`) AND ISNULL(`nics`.`removed`)))) + LEFT JOIN `nic_details` ON ((`nic_details`.`nic_id` = `nics`.`id`) AND (`nic_details`.`name` = 'nicdnsname'))) LEFT JOIN `networks` ON ((`nics`.`network_id` = `networks`.`id`))) LEFT JOIN `vpc` ON (((`networks`.`vpc_id` = `vpc`.`id`) AND ISNULL(`vpc`.`removed`)))) diff --git a/engine/schema/src/main/resources/META-INF/db/views/nic_dns_view.sql b/engine/schema/src/main/resources/META-INF/db/views/nic_dns_view.sql new file mode 100644 index 000000000000..8c7737db0884 --- /dev/null +++ b/engine/schema/src/main/resources/META-INF/db/views/nic_dns_view.sql @@ -0,0 +1,40 @@ +-- 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. + +-- VIEW `cloud`.`nic_dns_view`; + +DROP VIEW IF EXISTS `cloud`.`nic_dns_view`; +CREATE VIEW `cloud`.`nic_dns_view` AS +SELECT + n.id AS id, + n.uuid AS uuid, + n.instance_id AS instance_id, + n.network_id AS network_id, + n.ip4_address AS ip4_address, + n.ip6_address AS ip6_address, + n.removed AS removed, + nd.value AS nic_dns_name, + map.dns_zone_id AS dns_zone_id, + map.sub_domain AS sub_domain +FROM + `cloud`.`nics` n + INNER JOIN + `cloud`.`dns_zone_network_map` map ON n.network_id = map.network_id + LEFT JOIN + `cloud`.`nic_details` nd ON n.id = nd.nic_id AND nd.name = 'nicdnsname' +WHERE + n.instance_id IS NOT NULL AND map.removed IS NULL; diff --git a/engine/schema/src/test/java/com/cloud/upgrade/DatabaseUpgradeCheckerTest.java b/engine/schema/src/test/java/com/cloud/upgrade/DatabaseUpgradeCheckerTest.java index ab64e4698f01..884398cf410d 100644 --- a/engine/schema/src/test/java/com/cloud/upgrade/DatabaseUpgradeCheckerTest.java +++ b/engine/schema/src/test/java/com/cloud/upgrade/DatabaseUpgradeCheckerTest.java @@ -44,7 +44,9 @@ import com.cloud.upgrade.dao.Upgrade41120to41200; import com.cloud.upgrade.dao.Upgrade41510to41520; import com.cloud.upgrade.dao.Upgrade41610to41700; -import com.cloud.upgrade.dao.Upgrade42010to42100; +import com.cloud.upgrade.dao.Upgrade42020to42030; +import com.cloud.upgrade.dao.Upgrade42030to42040; +import com.cloud.upgrade.dao.Upgrade42040to42100; import com.cloud.upgrade.dao.Upgrade452to453; import com.cloud.upgrade.dao.Upgrade453to460; import com.cloud.upgrade.dao.Upgrade460to461; @@ -382,12 +384,12 @@ public void isNotStandalone() throws SQLException { } @Test - public void testCalculateUpgradePath42010to42100() { + public void testCalculateUpgradePath42010to42030() { final CloudStackVersion dbVersion = CloudStackVersion.parse("4.20.1.0"); assertNotNull(dbVersion); - final CloudStackVersion currentVersion = CloudStackVersion.parse("4.21.0.0"); + final CloudStackVersion currentVersion = CloudStackVersion.parse("4.20.3.0"); assertNotNull(currentVersion); final DatabaseUpgradeChecker checker = new DatabaseUpgradeChecker(); @@ -395,9 +397,29 @@ public void testCalculateUpgradePath42010to42100() { assertNotNull(upgrades); assertEquals(1, upgrades.length); - assertTrue(upgrades[0] instanceof Upgrade42010to42100); + assertTrue(upgrades[0] instanceof Upgrade42020to42030); - assertArrayEquals(new String[]{"4.20.1.0", "4.21.0.0"}, upgrades[0].getUpgradableVersionRange()); + assertArrayEquals(new String[]{"4.20.2.0", "4.20.3.0"}, upgrades[0].getUpgradableVersionRange()); assertEquals(currentVersion.toString(), upgrades[0].getUpgradedVersion()); } + + @Test + public void testCalculateUpgradePath42010to42100() { + + final CloudStackVersion dbVersion = CloudStackVersion.parse("4.20.1.0"); + assertNotNull(dbVersion); + + final CloudStackVersion currentVersion = CloudStackVersion.parse("4.21.0.0"); + assertNotNull(currentVersion); + + final DatabaseUpgradeChecker checker = new DatabaseUpgradeChecker(); + final DbUpgrade[] upgrades = checker.calculateUpgradePath(dbVersion, currentVersion); + + assertNotNull(upgrades); + assertEquals(3, upgrades.length); + assertTrue(upgrades[0] instanceof Upgrade42020to42030); + assertTrue(upgrades[1] instanceof Upgrade42030to42040); + assertTrue(upgrades[2] instanceof Upgrade42040to42100); + assertEquals(currentVersion.toString(), upgrades[2].getUpgradedVersion()); + } } diff --git a/engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42010to42100Test.java b/engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42040to42100Test.java similarity index 98% rename from engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42010to42100Test.java rename to engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42040to42100Test.java index 16908f6aaac0..5a2b55f03384 100644 --- a/engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42010to42100Test.java +++ b/engine/schema/src/test/java/com/cloud/upgrade/dao/Upgrade42040to42100Test.java @@ -35,9 +35,9 @@ import com.cloud.utils.db.TransactionLegacy; @RunWith(MockitoJUnitRunner.class) -public class Upgrade42010to42100Test { +public class Upgrade42040to42100Test { @Spy - Upgrade42010to42100 upgrade; + Upgrade42040to42100 upgrade; @Mock private Connection conn; diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java index 1144a29986a6..bbf775de27ad 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java @@ -191,7 +191,7 @@ protected Answer copyObject(DataObject srcData, DataObject destData, Host destHo srcForCopy = cacheData = cacheMgr.createCacheObject(srcData, destScope); } - CopyCommand cmd = new CopyCommand(srcForCopy.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(destData.getTO()), primaryStorageDownloadWait, + CopyCommand cmd = new CopyCommand(srcForCopy.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(destData.getTO()), primaryStorageDownloadWait, VirtualMachineManager.ExecuteInSequence.value()); EndPoint ep = destHost != null ? RemoteHostEndPoint.getHypervisorHostEndPoint(destHost) : selector.select(srcForCopy, destData); if (ep == null) { @@ -257,6 +257,43 @@ protected DataTO addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(DataTO return dataTO; } + /** + * Adds {@code 'xenserver.create.full.clone'} value for a given primary storage, whose HV is XenServer, on datastore's {@code fullCloneFlag} field + * @param dataTO Dest data store TO + * @return dataTO including fullCloneFlag, if provided + */ + protected DataTO addFullCloneAndDiskprovisiongStrictnessFlagOnXenServerDest(DataTO dataTO) { + if (dataTO != null && dataTO.getHypervisorType().equals(Hypervisor.HypervisorType.XenServer)){ + DataStoreTO dataStoreTO = dataTO.getDataStore(); + if (dataStoreTO != null && dataStoreTO instanceof PrimaryDataStoreTO){ + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) dataStoreTO; + primaryDataStoreTO.setFullCloneFlag(StorageManager.XenserverCreateCloneFull.valueIn(primaryDataStoreTO.getId())); + } + } + return dataTO; + } + + /** + * Dispatches to the per-hypervisor {@code addFullCloneAndDiskprovisiongStrictnessFlagOn*Dest} helper + * based on {@code dataTO.getHypervisorType()}. Returns {@code dataTO} unchanged for hypervisors + * that do not have a full-clone toggle. + * @param dataTO Dest data store TO + * @return dataTO including fullCloneFlag, if provided + */ + protected DataTO addFullCloneAndDiskprovisiongStrictnessFlagOnDest(DataTO dataTO) { + if (dataTO == null) { + return dataTO; + } + Hypervisor.HypervisorType hypervisorType = dataTO.getHypervisorType(); + if (Hypervisor.HypervisorType.VMware.equals(hypervisorType)) { + return addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(dataTO); + } + if (Hypervisor.HypervisorType.XenServer.equals(hypervisorType)) { + return addFullCloneAndDiskprovisiongStrictnessFlagOnXenServerDest(dataTO); + } + return dataTO; + } + protected Answer copyObject(DataObject srcData, DataObject destData) { return copyObject(srcData, destData, null); } @@ -315,7 +352,7 @@ protected Answer copyVolumeFromSnapshot(DataObject snapObj, DataObject volObj) { updateLockHostForVolume(ep, volObj); - CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(volObj.getTO()), _createVolumeFromSnapshotWait, VirtualMachineManager.ExecuteInSequence.value()); + CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(volObj.getTO()), _createVolumeFromSnapshotWait, VirtualMachineManager.ExecuteInSequence.value()); Answer answer = null; if (ep == null) { @@ -361,7 +398,7 @@ private void updateLockHostForVolume(EndPoint ep, DataObject volObj) { } protected Answer cloneVolume(DataObject template, DataObject volume) { - CopyCommand cmd = new CopyCommand(template.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(volume.getTO()), 0, VirtualMachineManager.ExecuteInSequence.value()); + CopyCommand cmd = new CopyCommand(template.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(volume.getTO()), 0, VirtualMachineManager.ExecuteInSequence.value()); try { EndPoint ep = selector.select(volume, anyVolumeRequiresEncryption(volume)); Answer answer = null; @@ -408,6 +445,9 @@ protected Answer copyVolumeBetweenPools(DataObject srcData, DataObject destData) answer = new Answer(cmd, false, errMsg); } else { answer = ep.sendMessage(cmd); + if (answer != null && answer.getResult()) { + setClvmLockHostIdIfApplicable(destData, ep); + } } return answer; } @@ -445,7 +485,7 @@ protected Answer copyVolumeBetweenPools(DataObject srcData, DataObject destData) objOnImageStore.processEvent(Event.CopyingRequested); - CopyCommand cmd = new CopyCommand(objOnImageStore.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(destData.getTO()), _copyvolumewait, VirtualMachineManager.ExecuteInSequence.value()); + CopyCommand cmd = new CopyCommand(objOnImageStore.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(destData.getTO()), _copyvolumewait, VirtualMachineManager.ExecuteInSequence.value()); EndPoint ep = selector.select(objOnImageStore, destData, encryptionRequired); if (ep == null) { String errMsg = String.format(NO_REMOTE_ENDPOINT_WITH_ENCRYPTION, encryptionRequired); @@ -463,6 +503,7 @@ protected Answer copyVolumeBetweenPools(DataObject srcData, DataObject destData) imageStore.delete(objOnImageStore); return answer; } + setClvmLockHostIdIfApplicable(destData, ep); } catch (Exception e) { if (imageStore.exists(objOnImageStore)) { objOnImageStore.processEvent(Event.OperationFailed); @@ -486,6 +527,9 @@ protected Answer copyVolumeBetweenPools(DataObject srcData, DataObject destData) answer = new Answer(cmd, false, errMsg); } else { answer = ep.sendMessage(cmd); + if (answer != null && answer.getResult()) { + setClvmLockHostIdIfApplicable(destData, ep); + } } // delete volume on cache store if (cacheData != null) { @@ -495,6 +539,17 @@ protected Answer copyVolumeBetweenPools(DataObject srcData, DataObject destData) } } + private void setClvmLockHostIdIfApplicable(DataObject destData, EndPoint ep) { + if (ep == null || !(destData instanceof VolumeInfo)) { + return; + } + VolumeInfo destVolume = (VolumeInfo) destData; + if (ClvmPoolManager.isClvmPoolType(destVolume.getStoragePoolType())) { + clvmPoolManager.setClvmLockHostId(destVolume.getId(), ep.getId()); + logger.debug("Set CLVM lock host {} for migrated volume {}", ep.getId(), destVolume.getUuid()); + } + } + private boolean canBypassSecondaryStorage(DataObject srcData, DataObject destData) { if (srcData instanceof VolumeInfo) { if (((VolumeInfo)srcData).isDirectDownload()) { @@ -613,6 +668,9 @@ protected Answer migrateVolumeToPool(DataObject srcData, DataObject destData) { if (destPool.getPoolType() == StoragePoolType.CLVM) { volumeVo.setFormat(ImageFormat.RAW); } + if (ClvmPoolManager.isClvmPoolType(destPool.getPoolType())) { + clvmPoolManager.setClvmLockHostId(volume.getId(), ep.getId()); + } // For SMB, pool credentials are also stored in the uri query string. We trim the query string // part here to make sure the credentials do not get stored in the db unencrypted. String folder = destPool.getPath(); @@ -692,7 +750,7 @@ protected Answer createTemplateFromSnapshot(DataObject srcData, DataObject destD ep = selector.select(srcData, destData); } - CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(destData.getTO()), _createprivatetemplatefromsnapshotwait, VirtualMachineManager.ExecuteInSequence.value()); + CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(destData.getTO()), _createprivatetemplatefromsnapshotwait, VirtualMachineManager.ExecuteInSequence.value()); Answer answer = null; if (ep == null) { logger.error(NO_REMOTE_ENDPOINT_SSVM); @@ -730,7 +788,7 @@ protected Answer copySnapshot(DataObject srcData, DataObject destData) { Scope selectedScope = pickCacheScopeForCopy(srcData, destData); cacheData = cacheMgr.getCacheObject(srcData, selectedScope); - CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(destData.getTO()), _backupsnapshotwait, VirtualMachineManager.ExecuteInSequence.value()); + CopyCommand cmd = new CopyCommand(srcData.getTO(), addFullCloneAndDiskprovisiongStrictnessFlagOnDest(destData.getTO()), _backupsnapshotwait, VirtualMachineManager.ExecuteInSequence.value()); cmd.setCacheTO(cacheData.getTO()); cmd.setOptions(options); EndPoint ep = selector.select(srcData, destData, encryptionRequired); @@ -741,7 +799,7 @@ protected Answer copySnapshot(DataObject srcData, DataObject destData) { answer = ep.sendMessage(cmd); } } else { - addFullCloneAndDiskprovisiongStrictnessFlagOnVMwareDest(destData.getTO()); + addFullCloneAndDiskprovisiongStrictnessFlagOnDest(destData.getTO()); CopyCommand cmd = new CopyCommand(srcData.getTO(), destData.getTO(), _backupsnapshotwait, VirtualMachineManager.ExecuteInSequence.value()); cmd.setOptions(options); EndPoint ep = selector.select(srcData, destData, StorageAction.BACKUPSNAPSHOT, encryptionRequired); diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java index 2be0b981455d..7674f1ce25a1 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java @@ -37,6 +37,7 @@ import com.cloud.agent.api.PrepareForMigrationAnswer; import com.cloud.resource.ResourceManager; import com.cloud.storage.clvm.ClvmPoolManager; +import org.apache.cloudstack.backup.InternalBackupService; import org.apache.cloudstack.storage.clvm.command.ClvmLockTransferCommand; import org.apache.cloudstack.engine.subsystem.api.storage.ChapInfo; import org.apache.cloudstack.engine.subsystem.api.storage.ClusterScope; @@ -212,6 +213,9 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { @Inject private ClvmPoolManager clvmPoolManager; + @Inject + private InternalBackupService internalBackupService; + @Override public StrategyPriority canHandle(DataObject srcData, DataObject destData) { if (srcData instanceof SnapshotInfo) { @@ -2527,6 +2531,7 @@ private void handlePostMigration(boolean success, Map sr _snapshotDao.updateVolumeIds(srcVolumeInfo.getId(), destVolumeInfo.getId()); _snapshotDataStoreDao.updateVolumeIds(srcVolumeInfo.getId(), destVolumeInfo.getId()); } + internalBackupService.updateVolumeId(srcVolumeInfo.getId(), destVolumeInfo.getId()); } else { try { diff --git a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java index e84163656b10..86af81899e8f 100755 --- a/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java +++ b/engine/storage/datamotion/src/test/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategyTest.java @@ -113,6 +113,14 @@ public void testAddFullCloneFlagOnVMwareDest(){ verify(dataStoreTO).setFullCloneFlag(FULL_CLONE_FLAG); } + @Test + public void testAddFullCloneFlagOnXenServerDest() throws IllegalAccessException, NoSuchFieldException { + overrideDefaultConfigValue(StorageManager.XenserverCreateCloneFull, String.valueOf(FULL_CLONE_FLAG)); + when(dataTO.getHypervisorType()).thenReturn(HypervisorType.XenServer); + strategy.addFullCloneAndDiskprovisiongStrictnessFlagOnXenServerDest(dataTO); + verify(dataStoreTO).setFullCloneFlag(FULL_CLONE_FLAG); + } + @Test public void testAddFullCloneFlagOnNotVmwareDest(){ verify(dataStoreTO, never()).setFullCloneFlag(any(Boolean.class)); diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java index 95345bdf9e0e..a94c04ff4aae 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/snapshot/SnapshotServiceImpl.java @@ -30,6 +30,7 @@ import com.cloud.storage.Volume; import com.cloud.storage.snapshot.SnapshotManager; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.backup.InternalBackupService; import org.apache.cloudstack.engine.subsystem.api.storage.CopyCommandResult; import org.apache.cloudstack.engine.subsystem.api.storage.CreateCmdResult; import org.apache.cloudstack.engine.subsystem.api.storage.DataMotionService; @@ -116,6 +117,8 @@ public class SnapshotServiceImpl implements SnapshotService { ConfigurationDao _configDao; @Inject HostDao hostDao; + @Inject + private InternalBackupService internalBackupService; @Inject private HeuristicRuleHelper heuristicRuleHelper; @@ -603,6 +606,7 @@ protected Void revertSnapshotCallback(AsyncCallbackDispatcher volumeInfoToSnapshotObjectMap = new HashMap<>(); @@ -137,11 +158,13 @@ public boolean deleteVMSnapshot(VMSnapshot vmSnapshot) { mergeOldSiblingWithOldParentIfOldParentIsDead(vmSnapshotDao.findByIdIncludingRemoved(vmSnapshotBeingDeleted.getParent()), userVm, hostId, volumeTOs); } else if (!isCurrent && numberOfChildren == 1) { VMSnapshotVO childSnapshot = snapshotChildren.get(0); - volumeSnapshotVos = mergeSnapshots(vmSnapshotBeingDeleted, childSnapshot, userVm, volumeTOs, hostId); + volumeSnapshotVos = mergeSnapshots(vmSnapshotBeingDeleted, childSnapshot, userVm, hostId); } + Date removedDate = DateUtil.now(); for (SnapshotVO snapshotVO : volumeSnapshotVos) { snapshotVO.setState(Snapshot.State.Destroyed); + snapshotVO.setRemoved(removedDate); snapshotDao.update(snapshotVO.getId(), snapshotVO); } @@ -179,7 +202,9 @@ public boolean revertVMSnapshot(VMSnapshot vmSnapshot) { transitStateWithoutThrow(vmSnapshotBeingReverted, VMSnapshot.Event.RevertRequested); - List volumeSnapshots = getVolumeSnapshotsAssociatedWithVmSnapshot(vmSnapshotBeingReverted); + internalBackupService.prepareVmForSnapshotRevert(vmSnapshot); + + List volumeSnapshots = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(vmSnapshotBeingReverted.getId()); List volumeSnapshotTos = volumeSnapshots.stream() .map(snapshot -> (SnapshotObjectTO) snapshotDataFactory.getSnapshot(snapshot.getSnapshotId(), snapshot.getDataStoreId(), DataStoreRole.Primary).getTO()) .collect(Collectors.toList()); @@ -268,7 +293,7 @@ private void mergeOldSiblingWithOldParentIfOldParentIsDead(VMSnapshotVO oldParen VMSnapshotVO oldSibling = oldSiblings.get(0); logger.debug("Merging VM snapshot [{}] with [{}] as the former was hidden and only the latter depends on it.", oldParent.getUuid(), oldSibling.getUuid()); - snapshotVos = mergeSnapshots(oldParent, oldSibling, userVm, volumeTOs, hostId); + snapshotVos = mergeSnapshots(oldParent, oldSibling, userVm, hostId); } for (SnapshotVO snapshotVO : snapshotVos) { @@ -325,8 +350,13 @@ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMe List volumes = volumeDao.findByInstance(vmId); for (VolumeVO volume : volumes) { StoragePoolVO storagePoolVO = storagePool.findById(volume.getPoolId()); + if (storagePoolVO.isManaged() && DataStoreProvider.ONTAP_PLUGIN_NAME.equals(storagePoolVO.getStorageProviderName())) { + logger.debug("{} as the VM has a volume on ONTAP managed storage pool [{}]. " + + "ONTAP managed storage has its own dedicated VM snapshot strategy.", cantHandleLog, storagePoolVO.getName()); + return StrategyPriority.CANT_HANDLE; + } if (!supportedStoragePoolTypes.contains(storagePoolVO.getPoolType())) { - logger.debug(String.format("%s as the VM has a volume that is in a storage with unsupported type [%s].", cantHandleLog, storagePoolVO.getPoolType())); + logger.debug("{} as the VM has a volume that is in a storage with unsupported type [{}].", cantHandleLog, storagePoolVO.getPoolType()); return StrategyPriority.CANT_HANDLE; } List snapshots = snapshotDao.listByVolumeIdAndTypeNotInAndStateNotRemoved(volume.getId(), Snapshot.Type.GROUP); @@ -338,8 +368,8 @@ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMe } BackupOfferingVO backupOffering = backupOfferingDao.findById(vm.getBackupOfferingId()); - if (backupOffering != null) { - logger.debug("{} as the VM has a backup offering. This strategy does not support snapshots on VMs with current backup providers.", cantHandleLog); + if (backupOffering != null && !backupOffering.getProvider().equals(BackupManagerImpl.KBOSS_BACKUP_PROVIDER)) { + logger.debug("{} as the VM has a backup offering for a provider that is not supported. This strategy only supports the KBOSS backup provider.", cantHandleLog); return StrategyPriority.CANT_HANDLE; } @@ -347,7 +377,7 @@ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMe } private List deleteSnapshot(VMSnapshotVO vmSnapshotVO, Long hostId) { - List volumeSnapshots = getVolumeSnapshotsAssociatedWithVmSnapshot(vmSnapshotVO); + List volumeSnapshots = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot((vmSnapshotVO.getId())); List volumeSnapshotTOList = volumeSnapshots.stream() .map(snapshotDataStoreVO -> snapshotDataFactory.getSnapshot(snapshotDataStoreVO.getSnapshotId(), snapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO()) .collect(Collectors.toList()); @@ -368,7 +398,7 @@ private List deleteSnapshot(VMSnapshotVO vmSnapshotVO, Long hostId) return snapshotVOList; } - private List mergeSnapshots(VMSnapshotVO vmSnapshotVO, VMSnapshotVO childSnapshot, UserVmVO userVm, List volumeObjectTOS, Long hostId) { + private List mergeSnapshots(VMSnapshotVO vmSnapshotVO, VMSnapshotVO childSnapshot, UserVmVO userVm, Long hostId) { logger.debug("Merging VM snapshot [{}] with its child [{}].", vmSnapshotVO.getUuid(), childSnapshot.getUuid()); List snapshotGrandChildren = vmSnapshotDao.listByParentAndStateIn(childSnapshot.getId(), VMSnapshot.State.Ready, VMSnapshot.State.Hidden); @@ -378,18 +408,10 @@ private List mergeSnapshots(VMSnapshotVO vmSnapshotVO, VMSnapshotVO removeCurrentBackingChainSnapshotFromVmSnapshotList(snapshotGrandChildren, userVm); } - List snapshotMergeTreeToList = generateSnapshotMergeTrees(vmSnapshotVO, childSnapshot, snapshotGrandChildren); - - if (childSnapshot.getCurrent() && !VirtualMachine.State.Running.equals(userVm.getState())) { - for (VolumeObjectTO volumeObjectTO : volumeObjectTOS) { - snapshotMergeTreeToList.stream().filter(snapshotTree -> Objects.equals(((SnapshotObjectTO) snapshotTree.getParent()).getVolume().getId(), volumeObjectTO.getId())) - .findFirst() - .orElseThrow(() -> new CloudRuntimeException(String.format("Failed to find volume snapshot for volume [%s].", volumeObjectTO.getUuid()))) - .addGrandChild(volumeObjectTO); - } - } + List deltaMergeTreeTOs = generateDeltaMergeTrees(vmSnapshotVO, childSnapshot, snapshotGrandChildren, + !userVm.getState().equals(VirtualMachine.State.Running)); - MergeDiskOnlyVmSnapshotCommand mergeDiskOnlyVMSnapshotCommand = new MergeDiskOnlyVmSnapshotCommand(snapshotMergeTreeToList, userVm.getState(), userVm.getName()); + MergeDiskOnlyVmSnapshotCommand mergeDiskOnlyVMSnapshotCommand = new MergeDiskOnlyVmSnapshotCommand(deltaMergeTreeTOs, userVm.getState().equals(VirtualMachine.State.Running), userVm.getName()); Answer answer = agentMgr.easySend(hostId, mergeDiskOnlyVMSnapshotCommand); if (answer == null || !answer.getResult()) { throw new CloudRuntimeException(String.format("Failed to merge VM snapshot [%s] due to %s.", vmSnapshotVO.getUuid(), answer != null ? answer.getDetails() : "Communication failure")); @@ -397,15 +419,23 @@ private List mergeSnapshots(VMSnapshotVO vmSnapshotVO, VMSnapshotVO logger.debug("Updating metadata of VM snapshot [{}] and its child [{}].", vmSnapshotVO.getUuid(), childSnapshot.getUuid()); List snapshotVOList = new ArrayList<>(); - for (SnapshotMergeTreeTO snapshotMergeTreeTO : snapshotMergeTreeToList) { - SnapshotObjectTO childTO = (SnapshotObjectTO) snapshotMergeTreeTO.getChild(); - SnapshotObjectTO parentTO = (SnapshotObjectTO) snapshotMergeTreeTO.getParent(); - - SnapshotDataStoreVO childSnapshotDataStoreVO = snapshotDataStoreDao.findBySnapshotIdInAnyState(childTO.getId(), DataStoreRole.Primary); - childSnapshotDataStoreVO.setInstallPath(parentTO.getPath()); - snapshotDataStoreDao.update(childSnapshotDataStoreVO.getId(), childSnapshotDataStoreVO); + for (DeltaMergeTreeTO deltaMergeTreeTO : deltaMergeTreeTOs) { + DataTO childTO = deltaMergeTreeTO.getChild(); + SnapshotObjectTO parentTO = (SnapshotObjectTO) deltaMergeTreeTO.getParent(); + + if (childTO instanceof BackupDeltaTO) { + InternalBackupStoragePoolVO backupDelta = internalBackupStoragePoolDao.findOneByVolumeId(parentTO.getVolume().getVolumeId()); + backupDelta.setBackupDeltaParentPath(parentTO.getPath()); + logger.debug("The child was also a KBOSS backup delta, will update the backup delta metadata. Updating backupDeltaParentPath of backupDelta [{}] to [{}].", backupDelta.getId(), parentTO.getPath()); + internalBackupStoragePoolDao.update(backupDelta.getId(), backupDelta); + } else { + SnapshotDataStoreVO childSnapshotDataStoreVO = snapshotDataStoreDao.findBySnapshotIdInAnyState(childTO.getId(), DataStoreRole.Primary); + childSnapshotDataStoreVO.setInstallPath(parentTO.getPath()); + logger.debug("Updating the child path [{}] to [{}].", childSnapshotDataStoreVO.getId(), parentTO.getPath()); + snapshotDataStoreDao.update(childSnapshotDataStoreVO.getId(), childSnapshotDataStoreVO); + } - snapshotDataStoreDao.expungeReferenceBySnapshotIdAndDataStoreRole(parentTO.getId(), childSnapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary); + snapshotDataStoreDao.expungeBySnapshotIdAndStoreRole(parentTO.getId(), DataStoreRole.Primary); snapshotVOList.add(snapshotDao.findById(parentTO.getId())); } @@ -417,18 +447,32 @@ private List mergeSnapshots(VMSnapshotVO vmSnapshotVO, VMSnapshotVO private List mergeCurrentDeltaOnSnapshot(VMSnapshotVO vmSnapshotVo, UserVmVO userVmVO, Long hostId, List volumeObjectTOS) { logger.debug("Merging VM snapshot [{}] with the current volume delta.", vmSnapshotVo.getUuid()); - List snapshotMergeTreeTOList = new ArrayList<>(); - List volumeSnapshots = getVolumeSnapshotsAssociatedWithVmSnapshot(vmSnapshotVo); + List deltaMergeTreeTOs = new ArrayList<>(); + List volumeSnapshots = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(vmSnapshotVo.getId()); for (VolumeObjectTO volumeObjectTO : volumeObjectTOS) { SnapshotDataStoreVO volumeParentSnapshot = volumeSnapshots.stream().filter(snapshot -> Objects.equals(snapshot.getVolumeId(), volumeObjectTO.getId())) .findFirst() .orElseThrow(() -> new CloudRuntimeException(String.format("Failed to find volume snapshot for volume [%s].", volumeObjectTO.getUuid()))); DataTO parentSnapshot = snapshotDataFactory.getSnapshot(volumeParentSnapshot.getSnapshotId(), volumeParentSnapshot.getDataStoreId(), DataStoreRole.Primary).getTO(); - snapshotMergeTreeTOList.add(new SnapshotMergeTreeTO(parentSnapshot, volumeObjectTO, new ArrayList<>())); + + InternalBackupStoragePoolVO backupDelta = internalBackupStoragePoolDao.findOneByVolumeId(volumeObjectTO.getVolumeId()); + + if (backupDelta != null && backupDelta.getBackupDeltaPath().equals(volumeObjectTO.getPath())) { + logger.debug("The current volume delta is also a KBOSS backup delta. Will merge the snapshot delta of volume [{}] with the parent backup delta at [{}].", + volumeObjectTO.getUuid(), backupDelta.getBackupDeltaParentPath()); + BackupDeltaTO childTo = new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, backupDelta.getBackupDeltaParentPath()); + ArrayList grandChildren = new ArrayList<>(); + if (userVmVO.getState().equals(VirtualMachine.State.Stopped)) { + grandChildren.add(new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, backupDelta.getBackupDeltaPath())); + } + deltaMergeTreeTOs.add(new DeltaMergeTreeTO(volumeObjectTO, parentSnapshot, childTo, grandChildren)); + } else { + deltaMergeTreeTOs.add(new DeltaMergeTreeTO(volumeObjectTO, parentSnapshot, volumeObjectTO, new ArrayList<>())); + } } - MergeDiskOnlyVmSnapshotCommand mergeDiskOnlyVMSnapshotCommand = new MergeDiskOnlyVmSnapshotCommand(snapshotMergeTreeTOList, userVmVO.getState(), userVmVO.getName()); + MergeDiskOnlyVmSnapshotCommand mergeDiskOnlyVMSnapshotCommand = new MergeDiskOnlyVmSnapshotCommand(deltaMergeTreeTOs, userVmVO.getState().equals(VirtualMachine.State.Running), userVmVO.getName()); Answer answer = agentMgr.easySend(hostId, mergeDiskOnlyVMSnapshotCommand); if (answer == null || !answer.getResult()) { @@ -437,13 +481,20 @@ private List mergeCurrentDeltaOnSnapshot(VMSnapshotVO vmSnapshotVo, logger.debug("Updating metadata of VM snapshot [{}].", vmSnapshotVo.getUuid()); List snapshotVOList = new ArrayList<>(); - for (SnapshotMergeTreeTO snapshotMergeTreeTO : snapshotMergeTreeTOList) { - VolumeObjectTO volumeObjectTO = (VolumeObjectTO) snapshotMergeTreeTO.getChild(); - SnapshotObjectTO parentTO = (SnapshotObjectTO) snapshotMergeTreeTO.getParent(); - - VolumeVO volumeVO = volumeDao.findById(volumeObjectTO.getId()); - volumeVO.setPath(parentTO.getPath()); - volumeDao.update(volumeVO.getId(), volumeVO); + for (DeltaMergeTreeTO deltaMergeTreeTO : deltaMergeTreeTOs) { + DataTO dataTO = deltaMergeTreeTO.getChild(); + SnapshotObjectTO parentTO = (SnapshotObjectTO) deltaMergeTreeTO.getParent(); + VolumeVO volumeVO = volumeDao.findById(parentTO.getVolume().getId()); + + if (dataTO instanceof BackupDeltaTO) { + logger.debug("The child of deltaMergeTree [{}] is a backupDeltaTO, thus, we will update the backup delta metadata.", deltaMergeTreeTO); + InternalBackupStoragePoolVO backupDelta = internalBackupStoragePoolDao.findOneByVolumeId(parentTO.getVolume().getVolumeId()); + backupDelta.setBackupDeltaParentPath(parentTO.getPath()); + internalBackupStoragePoolDao.update(backupDelta.getId(), backupDelta); + } else { + volumeVO.setPath(parentTO.getPath()); + volumeDao.update(volumeVO.getId(), volumeVO); + } snapshotDataStoreDao.expungeReferenceBySnapshotIdAndDataStoreRole(parentTO.getId(), volumeVO.getPoolId(), DataStoreRole.Primary); snapshotVOList.add(snapshotDao.findById(parentTO.getId())); @@ -489,11 +540,12 @@ protected VMSnapshot takeVmSnapshotInternal(VMSnapshot vmSnapshot, Map> volumeTosAndNewPaths = volumeTOs.stream().map(volume -> new Pair<>(volume, UUID.randomUUID().toString())).collect(Collectors.toList()); + long virtualSize = createVolumeSnapshotMetadataAndCalculateVirtualSize(vmSnapshot, volumeInfoToSnapshotObjectMap, volumeTosAndNewPaths); VMSnapshotTO target = new VMSnapshotTO(vmSnapshot.getId(), vmSnapshot.getName(), vmSnapshot.getType(), null, vmSnapshot.getDescription(), false, parentSnapshotTo, quiesceVm); - CreateDiskOnlyVmSnapshotCommand ccmd = new CreateDiskOnlyVmSnapshotCommand(userVm.getInstanceName(), target, volumeTOs, null, userVm.getState()); + CreateDiskOnlyVmSnapshotCommand ccmd = new CreateDiskOnlyVmSnapshotCommand(userVm.getInstanceName(), target, volumeTosAndNewPaths, null, userVm.getState()); logger.info("Sending disk-only VM snapshot creation of VM Snapshot [{}] command for host [{}].", vmSnapshot.getUuid(), hostId); Answer answer = agentMgr.easySend(hostId, ccmd); @@ -503,8 +555,9 @@ protected VMSnapshot takeVmSnapshotInternal(VMSnapshot vmSnapshot, Map volumeInfoToSnapshotObjectMap, CreateDiskOnlyVmSnapshotAnswer answer, UserVm userVm, VMSnapshotVO vmSnapshotVO, long virtualSize, VMSnapshotVO parentSnapshotVo) throws NoTransitionException { logger.debug("Processing CreateDiskOnlyVMSnapshotCommand answer for disk-only VM snapshot [{}].", vmSnapshot.getUuid()); - Map> volumeUuidToSnapshotSizeAndNewVolumePathMap = answer.getMapVolumeToSnapshotSizeAndNewVolumePath(); + Map volumeUuidToSnapshotSize = answer.getMapVolumeToSnapshotSize(); long vmSnapshotSize = 0; for (VolumeInfo volumeInfo : volumeInfoToSnapshotObjectMap.keySet()) { VolumeVO volumeVO = (VolumeVO) volumeInfo.getVolume(); - Pair snapSizeAndNewVolumePath = volumeUuidToSnapshotSizeAndNewVolumePathMap.get(volumeVO.getUuid()); + Long snapSize = volumeUuidToSnapshotSize.get(volumeVO.getUuid()); SnapshotObject snapshot = volumeInfoToSnapshotObjectMap.get(volumeInfo); snapshot.markBackedUp(); @@ -525,14 +578,15 @@ private VMSnapshot processCreateVmSnapshotAnswer(VMSnapshot vmSnapshot, Map volumeInfoToSnapshotObjectMap, List volumeTOs) throws NoTransitionException { + private long createVolumeSnapshotMetadataAndCalculateVirtualSize(VMSnapshot vmSnapshot, Map volumeInfoToSnapshotObjectMap, + List> volumeToAndNewPaths) throws NoTransitionException { long virtualSize = 0; - for (VolumeObjectTO volumeObjectTO : volumeTOs) { + for (Pair volumeToAndPath : volumeToAndNewPaths) { + VolumeObjectTO volumeObjectTO = volumeToAndPath.first(); VolumeInfo volumeInfo = volumeDataFactory.getVolume(volumeObjectTO.getId()); volumeInfo.stateTransit(Volume.Event.SnapshotRequested); virtualSize += volumeInfo.getSize(); @@ -577,61 +633,77 @@ private long createVolumeSnapshotMetadataAndCalculateVirtualSize(VMSnapshot vmSn snapshotOnPrimary.processEvent(Snapshot.Event.CreateRequested); snapshotOnPrimary.processEvent(ObjectInDataStoreStateMachine.Event.CreateOnlyRequested); + SnapshotDataStoreVO snapshotDataStoreVO = snapshotDataStoreDao.findBySnapshotId(snapshot.getId()).get(0); + snapshotDataStoreVO.setInstallPath(volumeToAndPath.second()); + snapshotDataStoreDao.update(snapshotDataStoreVO.getId(), snapshotDataStoreVO); volumeInfoToSnapshotObjectMap.put(volumeInfo, snapshotOnPrimary); } return virtualSize; } - private List generateSnapshotMergeTrees(VMSnapshotVO parent, VMSnapshotVO child, List grandChildren) throws NoSuchElementException { + /** + * Generates the delta merge trees, taking internal backups into account. + * */ + private List generateDeltaMergeTrees(VMSnapshotVO parent, VMSnapshotVO child, List grandChildren, boolean stoppedVm) throws NoSuchElementException { logger.debug("Generating list of Snapshot Merge Trees for the merge process of VM Snapshot [{}].", parent.getUuid()); - List snapshotMergeTrees = new ArrayList<>(); - List parentVolumeSnapshots = getVolumeSnapshotsAssociatedWithVmSnapshot(parent); - List childVolumeSnapshots = getVolumeSnapshotsAssociatedWithVmSnapshot(child); + List snapshotMergeTrees = new ArrayList<>(); + List parentVolumeSnapshots = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(parent.getId()); + List childVolumeSnapshots = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(child.getId()); List grandChildrenVolumeSnapshots = new ArrayList<>(); for (VMSnapshotVO grandChild : grandChildren) { - grandChildrenVolumeSnapshots.addAll(getVolumeSnapshotsAssociatedWithVmSnapshot(grandChild)); + grandChildrenVolumeSnapshots.addAll(vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(grandChild.getId())); } for (SnapshotDataStoreVO parentSnapshotDataStoreVO : parentVolumeSnapshots) { - DataTO parentTO = snapshotDataFactory.getSnapshot(parentSnapshotDataStoreVO.getSnapshotId(), parentSnapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO(); + SnapshotObjectTO parentTO = (SnapshotObjectTO) snapshotDataFactory.getSnapshot(parentSnapshotDataStoreVO.getSnapshotId(), parentSnapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO(); + VolumeObjectTO volumeObjectTO = parentTO.getVolume(); - DataTO childTO = childVolumeSnapshots.stream() + SnapshotDataStoreVO childVO = childVolumeSnapshots.stream() .filter(childSnapshot -> Objects.equals(parentSnapshotDataStoreVO.getVolumeId(), childSnapshot.getVolumeId())) - .map(snapshotDataStoreVO -> snapshotDataFactory.getSnapshot(snapshotDataStoreVO.getSnapshotId(), snapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO()) .findFirst().orElseThrow(() -> new CloudRuntimeException(String.format("Could not find child snapshot of parent [%s].", parentSnapshotDataStoreVO.getSnapshotId()))); - List grandChildrenTOList = grandChildrenVolumeSnapshots.stream() - .filter(grandChildSnapshot -> Objects.equals(parentSnapshotDataStoreVO.getVolumeId(), grandChildSnapshot.getVolumeId())) - .map(snapshotDataStoreVO -> snapshotDataFactory.getSnapshot(snapshotDataStoreVO.getSnapshotId(), snapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO()) - .collect(Collectors.toList()); + InternalBackupStoragePoolVO backupDelta = internalBackupStoragePoolDao.findOneByVolumeId(childVO.getVolumeId()); + List grandChildrenTOList = new ArrayList<>(); + DataTO childTO = getChildAndGrandChildren(child, stoppedVm, parentSnapshotDataStoreVO, backupDelta, childVO, volumeObjectTO, grandChildrenTOList, + grandChildrenVolumeSnapshots); - snapshotMergeTrees.add(new SnapshotMergeTreeTO(parentTO, childTO, grandChildrenTOList)); + snapshotMergeTrees.add(new DeltaMergeTreeTO(volumeObjectTO, parentTO, childTO, grandChildrenTOList)); } - logger.debug("Generated the following list of Snapshot Merge Trees for the VM snapshot [{}]: [{}].", parent.getUuid(), snapshotMergeTrees); + logger.debug(String.format("Generated the following list of Snapshot Merge Trees for the VM snapshot [%s]: [%s].", parent.getUuid(), snapshotMergeTrees)); return snapshotMergeTrees; } /** - * For a given {@code VMSnapshotVO}, populates the {@code associatedVolumeSnapshots} list with all the volume snapshots that are - * part of the VMSnapshot. - * @param vmSnapshot the VMSnapshotVO that will have its size calculated - * @return the list that will be populated with the volume snapshots associated with the VM snapshot. + * Gets the correct children and grandchildren, taking KBOSS backups into account. * */ - private List getVolumeSnapshotsAssociatedWithVmSnapshot(VMSnapshotVO vmSnapshot) { - List associatedVolumeSnapshots = new ArrayList<>(); - List snapshotDetailList = vmSnapshotDetailsDao.findDetails(vmSnapshot.getId(), KVM_FILE_BASED_STORAGE_SNAPSHOT); - for (VMSnapshotDetailsVO vmSnapshotDetailsVO : snapshotDetailList) { - SnapshotDataStoreVO snapshot = snapshotDataStoreDao.findOneBySnapshotAndDatastoreRole(Long.parseLong(vmSnapshotDetailsVO.getValue()), DataStoreRole.Primary); - if (snapshot == null) { - throw new CloudRuntimeException(String.format("Could not find snapshot for VM snapshot [%s].", vmSnapshot.getUuid())); + private DataTO getChildAndGrandChildren(VMSnapshotVO child, boolean stoppedVm, SnapshotDataStoreVO parentSnapshotDataStoreVO, InternalBackupStoragePoolVO backupDelta, + SnapshotDataStoreVO childVO, VolumeObjectTO volumeObjectTO, List grandChildrenTOList, List grandChildrenVolumeSnapshots) { + + DataTO childTO; + if (backupDelta != null && backupDelta.getBackupDeltaPath().equals(childVO.getInstallPath())) { + logger.debug("The child snapshot delta is also a backup delta. We will set the backup delta parent path [{}] as the child and the backup delta path [{}] " + + "as the grand-child.", backupDelta.getBackupDeltaParentPath(), backupDelta.getBackupDeltaPath()); + childTO = new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, backupDelta.getBackupDeltaParentPath()); + if (!child.getCurrent() && stoppedVm) { + grandChildrenTOList.add(new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, backupDelta.getBackupDeltaPath())); } - associatedVolumeSnapshots.add(snapshot); + } else { + childTO = snapshotDataFactory.getSnapshot(childVO.getSnapshotId(), childVO.getDataStoreId(), DataStoreRole.Primary).getTO(); + grandChildrenTOList.addAll(grandChildrenVolumeSnapshots.stream() + .filter(grandChildSnapshot -> Objects.equals(parentSnapshotDataStoreVO.getVolumeId(), grandChildSnapshot.getVolumeId())) + .map(snapshotDataStoreVO -> snapshotDataFactory.getSnapshot(snapshotDataStoreVO.getSnapshotId(), snapshotDataStoreVO.getDataStoreId(), DataStoreRole.Primary).getTO()) + .collect(Collectors.toList())); } - return associatedVolumeSnapshots; + + if (child.getCurrent() && stoppedVm) { + grandChildrenTOList.add(volumeObjectTO); + } + + return childTO; } /** diff --git a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java index 4ae6e26fbd96..fd306414ad49 100644 --- a/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java +++ b/engine/storage/snapshot/src/main/java/org/apache/cloudstack/storage/vmsnapshot/StorageVMSnapshotStrategy.java @@ -37,6 +37,8 @@ import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; import org.apache.commons.collections.CollectionUtils; @@ -95,6 +97,9 @@ public class StorageVMSnapshotStrategy extends DefaultVMSnapshotStrategy { @Inject VMSnapshotDetailsDao vmSnapshotDetailsDao; + @Inject + private SnapshotDataStoreDao snapshotDataStoreDao; + @Override public boolean configure(String name, Map params) throws ConfigurationException { return super.configure(name, params); @@ -374,6 +379,17 @@ public StrategyPriority canHandle(Long vmId, Long rootPoolId, boolean snapshotMe return StrategyPriority.CANT_HANDLE; } + for (VolumeVO volume : volumeDao.findByInstance(vmId)) { + List snapshots = snapshotDataStoreDao.listReadyByVolumeIdAndCheckpointPathNotNull(volume.getId()); + if (CollectionUtils.isNotEmpty(snapshots)) { + logger.debug( + "{} as VM has a volume with incremental snapshots {}. Incremental volume snapshots and StorageVmSnapshotStrategy are not compatible," + + " as restoring VM snapshots will erase the bitmaps and destroy snapshot chains.", + cantHandleLog, snapshots); + return StrategyPriority.CANT_HANDLE; + } + } + if (SnapshotManager.VmStorageSnapshotKvm.value() && !snapshotMemory) { return StrategyPriority.HYPERVISOR; } diff --git a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotStrategyKVMTest.java b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotStrategyKVMTest.java index 7d5d3c786e87..7584ae1c986c 100644 --- a/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotStrategyKVMTest.java +++ b/engine/storage/snapshot/src/test/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotStrategyKVMTest.java @@ -44,6 +44,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; import org.apache.cloudstack.test.utils.SpringUtils; @@ -442,5 +443,10 @@ public BackupOfferingDao backupOfferingDao() { public BackupManager backupManager() { return Mockito.mock(BackupManager.class); } + + @Bean + public SnapshotDataStoreDao snapshotDataStoreDao() { + return Mockito.mock(SnapshotDataStoreDao.class); + } } } diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/backup/BackupObject.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/backup/BackupObject.java new file mode 100644 index 000000000000..7475f5619528 --- /dev/null +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/backup/BackupObject.java @@ -0,0 +1,198 @@ +// +// 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 org.apache.cloudstack.storage.backup; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.to.DataObjectType; +import com.cloud.agent.api.to.DataTO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.DataStoreRole; +import com.cloud.utils.component.ComponentContext; +import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.InternalBackupJoinVO; +import org.apache.cloudstack.backup.dao.InternalBackupJoinDao; +import org.apache.cloudstack.engine.subsystem.api.storage.DataObject; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; +import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.commons.collections.CollectionUtils; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.stream.Collectors; + +public class BackupObject implements DataObject { + + private long id; + private String uuid; + private Long zoneId; + private Long size; + private long physicalSize; + private DataStore dataStore; + private String imageStorePath; + private Backup.Status status; + private Backup.CompressionStatus compressionStatus; + + @Inject + InternalBackupJoinDao internalBackupJoinDao; + @Inject + DataStoreManager storeManager; + + public BackupObject() { + + } + + public static BackupObject getBackupObject(InternalBackupJoinVO internalBackupJoinVO) { + BackupObject backupObject = ComponentContext.inject(BackupObject.class); + backupObject.configure(internalBackupJoinVO); + return backupObject; + } + + private void configure(InternalBackupJoinVO internalBackupJoin) { + this.id = internalBackupJoin.getId(); + this.uuid = internalBackupJoin.getUuid(); + this.zoneId = internalBackupJoin.getZoneId(); + this.size = internalBackupJoin.getProtectedSize(); + this.physicalSize = internalBackupJoin.getSize(); + this.imageStorePath = internalBackupJoin.getImageStorePath(); + this.status = internalBackupJoin.getStatus(); + this.compressionStatus = internalBackupJoin.getCompressionStatus(); + this.dataStore = storeManager.getDataStore(internalBackupJoin.getImageStoreId(), DataStoreRole.Image); + } + + public List> getChildren() { + List> children = new ArrayList<>(); + + List backups = internalBackupJoinDao.listByParentId(id); + while (CollectionUtils.isNotEmpty(backups)) { + children.add(backups.stream().map(BackupObject::getBackupObject).collect(Collectors.toList())); + backups = internalBackupJoinDao.listByParentId(backups.get(0).getId()); + } + + return children; + } + + public List> getParents(long parentId) { + LinkedList> parents = new LinkedList<>(); + + List backups = internalBackupJoinDao.listById(parentId); + while (CollectionUtils.isNotEmpty(backups)) { + parents.addFirst(backups.stream().map(BackupObject::getBackupObject).collect(Collectors.toList())); + backups = internalBackupJoinDao.listById(backups.get(0).getParentId()); + } + + return parents; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUri() { + return ""; + } + + @Override + public DataTO getTO() { + DataTO to = dataStore.getDriver().getTO(this); + if (to == null) { + return new BackupDeltaTO(id, dataStore.getTO(), Hypervisor.HypervisorType.KVM, imageStorePath); + } + return to; + } + + @Override + public DataStore getDataStore() { + return dataStore; + } + + @Override + public Long getSize() { + return size; + } + + @Override + public long getPhysicalSize() { + return physicalSize; + } + + @Override + public DataObjectType getType() { + return DataObjectType.BACKUP; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public boolean delete() { + return false; + } + + @Override + public void processEvent(ObjectInDataStoreStateMachine.Event event) { + } + + @Override + public void processEvent(ObjectInDataStoreStateMachine.Event event, Answer answer) { + } + + @Override + public void incRefCount() { + } + + @Override + public void decRefCount() { + } + + @Override + public Long getRefCount() { + return 0L; + } + + @Override + public String getName() { + return ""; + } + + public Long getZoneId() { + return zoneId; + } + + @Override + public String toString() { + return uuid; + } + + public Backup.CompressionStatus getCompressionStatus() { + return compressionStatus; + } + + public Backup.Status getStatus() { + return status; + } +} diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/helper/VMSnapshotHelperImpl.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/helper/VMSnapshotHelperImpl.java index 55551772a08a..d5b96be71ee8 100644 --- a/engine/storage/src/main/java/org/apache/cloudstack/storage/helper/VMSnapshotHelperImpl.java +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/helper/VMSnapshotHelperImpl.java @@ -26,9 +26,16 @@ import javax.inject.Inject; import com.cloud.uservm.UserVm; +import com.cloud.storage.DataStoreRole; +import com.cloud.storage.VolumeApiServiceImpl; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.snapshot.VMSnapshotDetailsVO; +import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; import org.apache.cloudstack.storage.vmsnapshot.VMSnapshotHelper; @@ -64,6 +71,12 @@ public class VMSnapshotHelperImpl implements VMSnapshotHelper { @Inject VolumeDataFactory volumeDataFactory; + @Inject + private VMSnapshotDetailsDao vmSnapshotDetailsDao; + + @Inject + private SnapshotDataStoreDao snapshotDataStoreDao; + StateMachine2 _vmSnapshottateMachine; public VMSnapshotHelperImpl() { @@ -115,10 +128,14 @@ public List getVolumeTOList(Long vmId) { List volumeTOs = new ArrayList(); List volumeVos = volumeDao.findByInstance(vmId); VolumeInfo volumeInfo = null; - for (VolumeVO volume : volumeVos) { - volumeInfo = volumeDataFactory.getVolume(volume.getId()); + try { + for (VolumeVO volume : volumeVos) { + volumeInfo = volumeDataFactory.getVolume(volume.getId()); - volumeTOs.add((VolumeObjectTO)volumeInfo.getTO()); + volumeTOs.add((VolumeObjectTO)volumeInfo.getTO()); + } + } catch (NullPointerException npe) { + throw new CloudRuntimeException(String.format("Unable to get list of volumeTOs for VM [%s]. Have the volumes already been created on the storage?", vmId), npe); } return volumeTOs; } @@ -150,6 +167,26 @@ public VMSnapshotTO getSnapshotWithParents(VMSnapshotVO snapshot) { return result; } + /** + * For a given {@code vmSnapshotId}, gets the list with all the volume snapshots that are part of the VMSnapshot. + * + * @param vmSnapshotId the id of the VM snapshot; + * @return the list that will be populated with the volume snapshots associated with the VM snapshot. + */ + @Override + public List getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(long vmSnapshotId) { + List associatedVolumeSnapshots = new ArrayList<>(); + List snapshotDetailList = vmSnapshotDetailsDao.findDetails(vmSnapshotId, VolumeApiServiceImpl.KVM_FILE_BASED_STORAGE_SNAPSHOT); + for (VMSnapshotDetailsVO vmSnapshotDetailsVO : snapshotDetailList) { + SnapshotDataStoreVO snapshot = snapshotDataStoreDao.findOneBySnapshotAndDatastoreRole(Long.parseLong(vmSnapshotDetailsVO.getValue()), DataStoreRole.Primary); + if (snapshot == null) { + throw new CloudRuntimeException(String.format("Could not find snapshot for VM snapshot [%s].", vmSnapshotId)); + } + associatedVolumeSnapshots.add(snapshot); + } + return associatedVolumeSnapshots; + } + @Override public StoragePoolVO getStoragePoolForVM(UserVm vm) { List rootVolumes = volumeDao.findReadyRootVolumesByInstance(vm.getId()); diff --git a/engine/storage/src/main/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotHelper.java b/engine/storage/src/main/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotHelper.java index 6d6cb7b70a93..868d634a30a6 100644 --- a/engine/storage/src/main/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotHelper.java +++ b/engine/storage/src/main/java/org/apache/cloudstack/storage/vmsnapshot/VMSnapshotHelper.java @@ -22,6 +22,7 @@ import com.cloud.uservm.UserVm; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; import com.cloud.agent.api.VMSnapshotTO; @@ -39,6 +40,8 @@ public interface VMSnapshotHelper { VMSnapshotTO getSnapshotWithParents(VMSnapshotVO snapshot); + List getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(long vmSnapshotId); + StoragePoolVO getStoragePoolForVM(UserVm vm); Storage.StoragePoolType getStoragePoolType(Long poolId); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java index 6c7ce95769b8..6502de70a1ff 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java @@ -38,6 +38,7 @@ import org.apache.cloudstack.annotation.AnnotationService; import org.apache.cloudstack.annotation.dao.AnnotationDao; import org.apache.cloudstack.api.command.user.volume.CheckAndRepairVolumeCmd; +import org.apache.cloudstack.backup.InternalBackupService; import org.apache.cloudstack.engine.cloud.entity.api.VolumeEntity; import org.apache.cloudstack.engine.orchestration.service.VolumeOrchestrationService; import org.apache.cloudstack.engine.subsystem.api.storage.ChapInfo; @@ -48,6 +49,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreCapabilities; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreDriver; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreProvider; import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; @@ -86,6 +88,7 @@ import org.apache.cloudstack.storage.to.TemplateObjectTO; import org.apache.cloudstack.storage.to.VolumeObjectTO; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; @@ -226,6 +229,8 @@ public class VolumeServiceImpl implements VolumeService { protected DiskOfferingDao diskOfferingDao; @Inject ClvmPoolManager clvmPoolManager; + @Inject + private InternalBackupService internalBackupService; @Inject private KMSManager kmsManager; @@ -524,6 +529,8 @@ public Void deleteVolumeCallback(AsyncCallbackDispatcher snapStoreVOs = _snapshotStoreDao.listAllByVolumeAndDataStore(vo.getId(), DataStoreRole.Primary); for (SnapshotDataStoreVO snapStoreVo : snapStoreVOs) { @@ -1362,6 +1369,13 @@ private void createManagedVolumeCopyTemplateAsync(VolumeInfo volumeInfo, Primary primaryDataStore.setDetails(details); grantAccess(volumeInfo, destHost, primaryDataStore); + if (DataStoreProvider.ONTAP_PLUGIN_NAME.equals(primaryDataStore.getStorageProviderName())) { + // For Netapp ONTAP iscsiName or Lun path is available only after grantAccess + volumeInfo = volFactory.getVolume(volumeInfo.getId(), primaryDataStore); + String managedStoreTarget = ObjectUtils.defaultIfNull(volumeInfo.get_iScsiName(), volumeInfo.getUuid()); + details.put(PrimaryDataStore.MANAGED_STORE_TARGET, managedStoreTarget); + primaryDataStore.setDetails(details); + } try { motionSrv.copyAsync(srcTemplateInfo, destTemplateInfo, destHost, caller); @@ -1690,7 +1704,7 @@ public void destroyVolume(long volumeId) { if (vol.getAttachedVM() == null || vol.getAttachedVM().getType() == VirtualMachine.Type.User) { // Decrement the resource count for volumes and primary storage belonging user VM's only - _resourceLimitMgr.decrementVolumeResourceCount(vol.getAccountId(), vol.isDisplay(), vol.getSize(), diskOfferingDao.findById(vol.getDiskOfferingId())); + _resourceLimitMgr.decrementVolumeResourceCount(vol.getAccountId(), vol.isDisplay(), vol.getSize(), diskOfferingDao.findById(vol.getDiskOfferingId()), null); } } @@ -3112,6 +3126,12 @@ public boolean isLockTransferRequired(VolumeInfo volumeToAttach, StoragePoolType } if (volumePoolId == null || !volumePoolId.equals(vmPoolId)) { + Long volumeLockHostId = findVolumeLockHost(volumeToAttach); + if (volumeLockHostId != null && vmHostId != null && !volumeLockHostId.equals(vmHostId)) { + logger.info("CLVM cross-pool lock transfer required: Volume {} on pool {} lock is on host {} but VM is on host {}", + volumeToAttach.getUuid(), volumePoolId, volumeLockHostId, vmHostId); + return true; + } return false; } diff --git a/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java index 38af2a7550b3..8d355263a6c3 100644 --- a/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java +++ b/engine/storage/volume/src/test/java/org/apache/cloudstack/storage/volume/VolumeServiceImplClvmTest.java @@ -167,14 +167,49 @@ public void testIsLockTransferRequired_NonCLVMPool() { } @Test - public void testIsLockTransferRequired_DifferentPools() { + public void testIsLockTransferRequired_DifferentPools_LockOnDifferentHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_2); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_2, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DifferentPools_LockOnSameHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_1); + + assertFalse(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, POOL_ID_2, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_DifferentPools_NoLockHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(null); + assertFalse(volumeService.isLockTransferRequired( volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, POOL_ID_1, POOL_ID_2, HOST_ID_1)); } @Test - public void testIsLockTransferRequired_NullPoolIds() { + public void testIsLockTransferRequired_NullPoolIds_LockOnDifferentHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(HOST_ID_2); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + null, POOL_ID_1, HOST_ID_1)); + + assertTrue(volumeService.isLockTransferRequired( + volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, + POOL_ID_1, null, HOST_ID_1)); + } + + @Test + public void testIsLockTransferRequired_NullPoolIds_NoLockHost() { + when(volumeService.findVolumeLockHost(volumeInfoMock)).thenReturn(null); + assertFalse(volumeService.isLockTransferRequired( volumeInfoMock, StoragePoolType.CLVM, StoragePoolType.CLVM, null, POOL_ID_1, HOST_ID_1)); diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java index ef50064050f8..fd007f12957b 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java @@ -269,6 +269,17 @@ public String toString() { private String _defaultValueIfEmpty = null; + private boolean _strictScope = false; + + public boolean isStrictScope() { + return _strictScope; + } + + public ConfigKey withStrictScope() { + this._strictScope = true; + return this; + } + public static void init(ConfigDepotImpl depot) { s_depot = depot; } @@ -429,11 +440,18 @@ public T valueInDomain(Long domainId) { } public T valueInScope(Scope scope, Long id) { + return valueInScope(scope, id, false); + } + + public T valueInScope(Scope scope, Long id, boolean strictScope) { if (id == null) { - return value(); + return strictScope ? null : value(); } String value = s_depot != null ? s_depot.getConfigStringValue(_name, scope, id) : null; if (value == null) { + if (strictScope) { + return null; + } return valueInGlobalOrAvailableParentScope(scope, id); } logger.trace("Scope({}) value for config ({}): {}", scope, _name, _value); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java index 4426f259380b..453578a95d75 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java @@ -70,6 +70,11 @@ public class ListExtensionsCmd extends BaseListCmd { + " When no parameters are passed, all the details are returned.") private List details; + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, + description = "Type of the extension (e.g. Orchestrator, NetworkOrchestrator)", + since = "4.23.0") + private String type; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -82,6 +87,10 @@ public Long getExtensionId() { return extensionId; } + public String getType() { + return type; + } + public EnumSet getDetails() throws InvalidParameterValueException { if (CollectionUtils.isEmpty(details)) { return EnumSet.of(ApiConstants.ExtensionDetails.all); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java new file mode 100644 index 000000000000..6c755ecd1a54 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmd.java @@ -0,0 +1,116 @@ +// 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 org.apache.cloudstack.framework.extensions.api; + +import java.util.EnumSet; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.user.Account; + +@APICommand(name = "updateRegisteredExtension", + description = "Update details for an extension registered with a resource", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class UpdateRegisteredExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionResponse.class, description = "ID of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, + description = "ID of the resource where the extension is registered") + private String resourceId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, + description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].endpoint.url=urlvalue") + protected Map details; + + @Parameter(name = ApiConstants.CLEAN_UP_DETAILS, + type = CommandType.BOOLEAN, + description = "Optional boolean field, which indicates if details should be cleaned up or not " + + "(If set to true, details removed for this registration, details field ignored; " + + "if false or not set, details can be updated through details map)") + private Boolean cleanupDetails; + + public Long getExtensionId() { + return extensionId; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceType() { + return resourceType; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + public Boolean isCleanupDetails() { + return cleanupDetails; + } + + @Override + public void execute() throws ServerApiException { + Extension extension = extensionsManager.updateRegisteredExtensionWithResource(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getExtensionId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java index 3355457ed25b..9adb625b19ed 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java @@ -16,6 +16,9 @@ // under the License. package org.apache.cloudstack.framework.extensions.dao; +import java.util.List; + +import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import com.cloud.utils.db.GenericDao; @@ -23,4 +26,8 @@ public interface ExtensionDao extends GenericDao { ExtensionVO findByName(String name); + + List listByType(Extension.Type type); + + ExtensionVO findByNameAndType(String name, Extension.Type type); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java index 8e17199de6ca..f3c3173585b2 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java @@ -17,6 +17,9 @@ package org.apache.cloudstack.framework.extensions.dao; +import java.util.List; + +import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import com.cloud.utils.db.GenericDaoBase; @@ -39,7 +42,21 @@ public ExtensionDaoImpl() { public ExtensionVO findByName(String name) { SearchCriteria sc = AllFieldSearch.create(); sc.setParameters("name", name); + return findOneBy(sc); + } + + @Override + public List listByType(Extension.Type type) { + SearchCriteria sc = AllFieldSearch.create(); + sc.setParameters("type", type); + return listBy(sc); + } + @Override + public ExtensionVO findByNameAndType(String name, Extension.Type type) { + SearchCriteria sc = AllFieldSearch.create(); + sc.setParameters("name", name); + sc.setParameters("type", type); return findOneBy(sc); } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java index 930ef8675531..b9fadb76d068 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java @@ -28,5 +28,9 @@ public interface ExtensionResourceMapDao extends GenericDao listResourceIdsByExtensionIdAndType(long extensionId,ExtensionResourceMap.ResourceType resourceType); + ExtensionResourceMapVO findResourceByExtensionIdAndResourceIdAndType(long extensionId, long resourceId, ExtensionResourceMap.ResourceType resourceType); + + List listResourceIdsByExtensionIdAndType(long extensionId, ExtensionResourceMap.ResourceType resourceType); + + List listResourceIdsByType(ExtensionResourceMap.ResourceType resourceType); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java index 6f19ef8b8b66..11c64e15cd21 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java @@ -55,6 +55,17 @@ public ExtensionResourceMapVO findByResourceIdAndType(long resourceId, return findOneBy(sc); } + + @Override + public ExtensionResourceMapVO findResourceByExtensionIdAndResourceIdAndType(long extensionId, long resourceId, + ExtensionResourceMap.ResourceType resourceType) { + SearchCriteria sc = genericSearch.create(); + sc.setParameters("extensionId", extensionId); + sc.setParameters("resourceId", resourceId); + sc.setParameters("resourceType", resourceType); + return findOneBy(sc); + } + @Override public List listResourceIdsByExtensionIdAndType(long extensionId, ExtensionResourceMap.ResourceType resourceType) { GenericSearchBuilder sb = createSearchBuilder(Long.class); @@ -67,4 +78,15 @@ public List listResourceIdsByExtensionIdAndType(long extensionId, Extensio sc.setParameters("resourceType", resourceType); return customSearch(sc, null); } + + @Override + public List listResourceIdsByType(ExtensionResourceMap.ResourceType resourceType) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getResourceId()); + sb.and("resourceType", sb.entity().getResourceType(), SearchCriteria.Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("resourceType", resourceType); + return customSearch(sc, null); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java index 1b1a175c5975..cd033badff15 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -42,6 +42,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; import com.cloud.agent.api.Answer; @@ -65,6 +66,8 @@ public interface ExtensionsManager extends Manager { Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd); + Extension updateRegisteredExtensionWithResource(UpdateRegisteredExtensionCmd cmd); + Extension updateExtension(UpdateExtensionCmd cmd); Extension registerExtensionWithResource(RegisterExtensionCmd cmd); diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java index f6fd08b6da2c..a209acf6442e 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -61,6 +61,7 @@ import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionCustomAction; import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; import org.apache.cloudstack.extension.ExtensionResourceMap; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -75,6 +76,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionRoutingUpdateCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; @@ -120,11 +122,27 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.OperationTimedoutException; import com.cloud.host.Host; -import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.ExternalProvisioner; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Service; +import com.cloud.network.NetworkModel; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderVO; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; +import com.cloud.network.vpc.dao.VpcServiceMapDao; +import com.cloud.offerings.dao.NetworkOfferingServiceMapDao; import com.cloud.org.Cluster; import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; @@ -141,13 +159,15 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.Transaction; import com.cloud.utils.db.TransactionCallbackWithException; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; -import com.cloud.vm.dao.VMInstanceDao; public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsManager, ExtensionHelper, PluggableService, Configurable { @@ -171,6 +191,12 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject ClusterDao clusterDao; + @Inject + PhysicalNetworkDao physicalNetworkDao; + + @Inject + PhysicalNetworkServiceProviderDao physicalNetworkServiceProviderDao; + @Inject AgentManager agentMgr; @@ -189,9 +215,6 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject ExtensionCustomActionDetailsDao extensionCustomActionDetailsDao; - @Inject - VMInstanceDao vmInstanceDao; - @Inject VirtualMachineManager virtualMachineManager; @@ -210,6 +233,24 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana @Inject VMTemplateDao templateDao; + @Inject + NetworkDao networkDao; + + @Inject + NetworkServiceMapDao networkServiceMapDao; + + @Inject + VpcServiceMapDao vpcServiceMapDao; + + @Inject + NetworkOfferingServiceMapDao networkOfferingServiceMapDao; + + @Inject + VpcOfferingServiceMapDao vpcOfferingServiceMapDao; + + @Inject + NetworkModel networkModel; + @Inject RoleService roleService; @@ -249,7 +290,7 @@ protected String getValidatedExtensionRelativePath(String name, String relativeP protected Pair getResultFromAnswersString(String answersStr, Extension extension, ManagementServerHostVO msHost, String op) { - Answer[] answers = null; + Answer[] answers; try { answers = GsonHelper.getGson().fromJson(answersStr, Answer[].class); } catch (Exception e) { @@ -339,6 +380,39 @@ protected Pair getChecksumForExtensionPathOnMSPeer(Extension ex return getResultFromAnswersString(answersStr, extension, msHost, "get path checksum"); } + protected List buildExtensionResourceDetailsArray(long extensionResourceMapId, + Map details) { + List detailsList = new ArrayList<>(); + if (MapUtils.isEmpty(details)) { + return detailsList; + } + for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); + detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, entry.getKey(), + entry.getValue(), display)); + } + return detailsList; + } + + protected void appendHiddenExtensionResourceDetails(long extensionResourceMapId, + List detailsList) { + if (CollectionUtils.isEmpty(detailsList)) { + return; + } + Map hiddenDetails = extensionResourceMapDetailsDao.listDetailsKeyPairs(extensionResourceMapId, false); + if (MapUtils.isEmpty(hiddenDetails)) { + return; + } + Set requestedKeys = detailsList.stream() + .map(ExtensionResourceMapDetailsVO::getName) + .collect(Collectors.toSet()); + hiddenDetails.forEach((key, value) -> { + if (!requestedKeys.contains(key)) { + detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, key, value, false)); + } + }); + } + protected List getParametersListFromMap(String actionName, Map parametersMap) { if (MapUtils.isEmpty(parametersMap)) { return Collections.emptyList(); @@ -360,26 +434,48 @@ protected void unregisterExtensionWithCluster(String clusterUuid, Long extension unregisterExtensionWithCluster(cluster, extensionId); } - protected Extension getExtensionFromResource(ExtensionCustomAction.ResourceType resourceType, String resourceUuid) { + protected Extension getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType resourceType, String resourceUuid) { Object object = entityManager.findByUuid(resourceType.getAssociatedClass(), resourceUuid); if (object == null) { return null; } - Long clusterId = null; - if (resourceType == ExtensionCustomAction.ResourceType.VirtualMachine) { + if (ExtensionCustomAction.ResourceType.VirtualMachine.equals(resourceType)) { VirtualMachine vm = (VirtualMachine) object; Pair clusterHostId = virtualMachineManager.findClusterAndHostIdForVm(vm, false); - clusterId = clusterHostId.first(); - } - if (clusterId == null) { + Long clusterId = clusterHostId.first(); + if (clusterId == null) { + return null; + } + ExtensionResourceMapVO mapVO = + extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMap.ResourceType.Cluster); + if (mapVO == null) { + return null; + } + return extensionDao.findById(mapVO.getExtensionId()); + } else if (ExtensionCustomAction.ResourceType.Network.equals(resourceType)) { + Network network = (Network) object; + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + return null; + } + // Use provider-based lookup: match the network's service-map providers + // against extension names registered on the physical network. + // This correctly handles multiple different extensions on the same physical network. + String providerName = networkServiceMapDao.getProviderForServiceInNetwork(network.getId(), Service.CustomAction); + if (providerName != null) { + return getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + } return null; - } - ExtensionResourceMapVO mapVO = - extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMap.ResourceType.Cluster); - if (mapVO == null) { + } else if (ExtensionCustomAction.ResourceType.Vpc.equals(resourceType)) { + Vpc vpc = (Vpc) object; + // Find extension via the VPC's CustomAction service provider + String providerName = vpcServiceMapDao.getProviderForServiceInVpc(vpc.getId(), Service.CustomAction); + if (providerName != null) { + return extensionDao.findByNameAndType(providerName, Extension.Type.NetworkOrchestrator); + } return null; } - return extensionDao.findById(mapVO.getExtensionId()); + return null; } protected String getActionMessage(boolean success, ExtensionCustomAction action, Extension extension, @@ -694,12 +790,16 @@ public List listExtensions(ListExtensionsCmd cmd) { Long id = cmd.getExtensionId(); String name = cmd.getName(); String keyword = cmd.getKeyword(); + String typeStr = cmd.getType(); + final SearchBuilder sb = extensionDao.createSearchBuilder(); final Filter searchFilter = new Filter(ExtensionVO.class, "id", false, cmd.getStartIndex(), cmd.getPageSizeVal()); sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ); sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.and("type", sb.entity().getType(), SearchCriteria.Op.EQ); + sb.done(); final SearchCriteria sc = sb.create(); if (id != null) { @@ -714,6 +814,14 @@ public List listExtensions(ListExtensionsCmd cmd) { sc.setParameters("keyword", "%" + keyword + "%"); } + if (typeStr != null) { + Extension.Type type = EnumUtils.getEnum(Extension.Type.class, typeStr); + if (type == null) { + throw new InvalidParameterValueException("Invalid type: " + typeStr); + } + sc.setParameters("type", type); + } + final Pair, Integer> result = extensionDao.searchAndCount(sc, searchFilter); List responses = new ArrayList<>(); for (ExtensionVO extension : result.first()) { @@ -757,6 +865,8 @@ public Extension updateExtension(UpdateExtensionCmd cmd) { } } final boolean updateNeededFinal = updateNeeded; + final Set oldNetworkServices = Extension.Type.NetworkOrchestrator.equals(extensionVO.getType()) ? + resolveExtensionServices(extensionVO) : Collections.emptySet(); ExtensionVO result = Transaction.execute((TransactionCallbackWithException) status -> { if (updateNeededFinal && !extensionDao.update(id, extensionVO)) { throw new CloudRuntimeException(String.format("Failed to updated the extension: %s", @@ -764,6 +874,13 @@ public Extension updateExtension(UpdateExtensionCmd cmd) { } updateExtensionsDetails(cleanupDetails, details, orchestratorRequiresPrepareVm, reservedResourceDetails, id); + if (Extension.Type.NetworkOrchestrator.equals(extensionVO.getType())) { + Set newNetworkServices = resolveExtensionServices(extensionVO); + if (!newNetworkServices.equals(oldNetworkServices)) { + updateNetworkExtensionServicesOnPhysicalNetworks(extensionVO, oldNetworkServices, + newNetworkServices); + } + } return extensionVO; }); if (StringUtils.isNotBlank(stateStr)) { @@ -880,19 +997,88 @@ public Extension registerExtensionWithResource(RegisterExtensionCmd cmd) { String resourceType = cmd.getResourceType(); if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { throw new InvalidParameterValueException( - String.format("Currently only [%s] can be used to register an extension of type Orchestrator", + String.format("Currently only [%s] can be used to register an extension", EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); } - ClusterVO clusterVO = clusterDao.findByUuid(resourceId); - if (clusterVO == null) { - throw new InvalidParameterValueException("Invalid cluster ID specified"); + ExtensionVO extension = extensionDao.findById(extensionId); + if (extension == null) { + throw new InvalidParameterValueException("Invalid extension specified"); + } + ExtensionResourceMap.ResourceType resType = EnumUtils.getEnum(ExtensionResourceMap.ResourceType.class, resourceType); + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + ExtensionResourceMap extensionResourceMap = registerExtensionWithPhysicalNetwork(physicalNetwork, extension, cmd.getDetails()); + return extensionDao.findById(extensionResourceMap.getExtensionId()); + } else if (ExtensionResourceMap.ResourceType.Cluster.equals(resType)) { + ClusterVO clusterVO = clusterDao.findByUuid(resourceId); + if (clusterVO == null) { + throw new InvalidParameterValueException("Invalid cluster ID specified"); + } + ExtensionResourceMap extensionResourceMap = registerExtensionWithCluster(clusterVO, extension, cmd.getDetails()); + return extensionDao.findById(extensionResourceMap.getExtensionId()); + } else { + throw new InvalidParameterValueException("Unsupported resource type specified"); + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UPDATE, eventDescription = "updating extension resource") + public Extension updateRegisteredExtensionWithResource(UpdateRegisteredExtensionCmd cmd) { + final String resourceId = cmd.getResourceId(); + final Long extensionId = cmd.getExtensionId(); + final String resourceType = cmd.getResourceType(); + final Map details = cmd.getDetails(); + final Boolean cleanupDetails = cmd.isCleanupDetails(); + + if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { + throw new InvalidParameterValueException( + String.format("Currently only [%s] can be used to update an extension registration", + EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); } ExtensionVO extension = extensionDao.findById(extensionId); if (extension == null) { throw new InvalidParameterValueException("Invalid extension specified"); } - ExtensionResourceMap extensionResourceMap = registerExtensionWithCluster(clusterVO, extension, cmd.getDetails()); - return extensionDao.findById(extensionResourceMap.getExtensionId()); + + ExtensionResourceMap.ResourceType resType = EnumUtils.getEnum(ExtensionResourceMap.ResourceType.class, resourceType); + long resolvedResourceId; + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + resolvedResourceId = physicalNetwork.getId(); + } else { + ClusterVO clusterVO = clusterDao.findByUuid(resourceId); + if (clusterVO == null) { + throw new InvalidParameterValueException("Invalid cluster ID specified"); + } + resolvedResourceId = clusterVO.getId(); + } + + ExtensionResourceMapVO targetMapping = extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType( + extensionId, resolvedResourceId, resType); + if (targetMapping == null) { + throw new InvalidParameterValueException(String.format( + "Extension '%s' is not registered with resource %s (%s)", + extension.getName(), resourceId, resourceType)); + } + + if (Boolean.TRUE.equals(cleanupDetails)) { + extensionResourceMapDetailsDao.removeDetails(targetMapping.getId()); + } else if (MapUtils.isNotEmpty(details)) { + List detailsList = buildExtensionResourceDetailsArray(targetMapping.getId(), details); + appendHiddenExtensionResourceDetails(targetMapping.getId(), detailsList); + detailsList = detailsList.stream() + .filter(detail -> detail.getValue() != null) + .collect(Collectors.toList()); + extensionResourceMapDetailsDao.saveDetails(detailsList); + } + + return extensionDao.findById(extensionId); } @Override @@ -923,8 +1109,9 @@ public ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extens List detailsVOList = new ArrayList<>(); if (MapUtils.isNotEmpty(details)) { for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); detailsVOList.add(new ExtensionResourceMapDetailsVO(savedExtensionMap.getId(), - entry.getKey(), entry.getValue())); + entry.getKey(), entry.getValue(), display)); } extensionResourceMapDetailsDao.saveDetails(detailsVOList); } @@ -934,6 +1121,227 @@ public ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extens return result; } + protected ExtensionResourceMap registerExtensionWithPhysicalNetwork(PhysicalNetworkVO physicalNetwork, + Extension extension, Map details) { + // Only NetworkOrchestrator extensions can be registered with physical networks + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new InvalidParameterValueException(String.format( + "Only extensions of type %s can be registered with a physical network. " + + "Extension '%s' is of type %s.", + Extension.Type.NetworkOrchestrator.name(), + extension.getName(), extension.getType().name())); + } + + // Block registering the exact same extension twice on the same physical network + final ExtensionResourceMap.ResourceType resourceType = ExtensionResourceMap.ResourceType.PhysicalNetwork; + ExtensionResourceMapVO existing = extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType( + extension.getId(), physicalNetwork.getId(), resourceType); + if (existing != null) { + throw new CloudRuntimeException(String.format( + "Extension '%s' is already registered with physical network %s", + extension.getName(), physicalNetwork)); + } + + // Resolve which services this extension provides from its network.services detail + Set services = resolveExtensionServices(extension); + + return Transaction.execute((TransactionCallbackWithException) status -> { + // 1. Persist the extension<->physical-network mapping + ExtensionResourceMapVO extensionMap = new ExtensionResourceMapVO(extension.getId(), + physicalNetwork.getId(), resourceType); + ExtensionResourceMapVO savedExtensionMap = extensionResourceMapDao.persist(extensionMap); + + // 2. Persist device credentials / details + List detailsVOList = new ArrayList<>(); + if (MapUtils.isNotEmpty(details)) { + for (Map.Entry entry : details.entrySet()) { + boolean display = !SENSITIVE_DETAIL_KEYS.contains(entry.getKey().toLowerCase()); + detailsVOList.add(new ExtensionResourceMapDetailsVO(savedExtensionMap.getId(), + entry.getKey(), entry.getValue(), display)); + } + extensionResourceMapDetailsDao.saveDetails(detailsVOList); + } + + // 3. Auto-create the NetworkServiceProvider entry for this extension so that + // the services are visible in the UI and in listSupportedNetworkServices. + // The NSP name equals the extension name; state is Enabled by default. + PhysicalNetworkServiceProviderVO existingNsp = + physicalNetworkServiceProviderDao.findByServiceProvider( + physicalNetwork.getId(), extension.getName()); + if (existingNsp == null) { + PhysicalNetworkServiceProviderVO nsp = + new PhysicalNetworkServiceProviderVO(physicalNetwork.getId(), extension.getName()); + applyServicesToNsp(nsp, services); + nsp.setState(PhysicalNetworkServiceProvider.State.Enabled); + physicalNetworkServiceProviderDao.persist(nsp); + logger.info("Auto-created NetworkServiceProvider '{}' (Enabled) for physical network {} " + + "with services {}", extension, physicalNetwork, services); + } + + return savedExtensionMap; + }); + } + + /** + * Resolves the set of network service names declared in the extension's + * {@code network.services} detail. Falls back to an empty set if not present + */ + private Set resolveExtensionServices(Extension extension) { + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + Set parsed = parseNetworkServicesFromDetailKeys(extDetails); + if (!parsed.isEmpty()) { + return parsed; + } + // Falls back to an empty set if not present + return new HashSet<>(); + } + + /** + * Resolves the set of service names from the extension detail map. + * From {@code network.services} comma-separated key. + */ + private Set parseNetworkServicesFromDetailKeys(Map extDetails) { + if (extDetails == null) { + return Collections.emptySet(); + } + // New format: "network.services" = "SourceNat,StaticNat,..." + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY)) { + String value = extDetails.get(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY); + if (StringUtils.isNotBlank(value)) { + Set services = new HashSet<>(); + for (String s : value.split(",")) { + Service service = Network.Service.getService(s.trim()); + if (service != null) { + services.add(service); + } + } + if (!services.isEmpty()) { + return services; + } + } + } + + return Collections.emptySet(); + } + + /** + * Builds a full {@code Map>} from the + * extension detail map. From the split keys + * {@code network.services} + {@code network.service.capabilities}. + */ + private Map> buildCapabilitiesFromDetailKeys( + Map extDetails) { + if (extDetails == null) { + return new HashMap<>(); + } + // New split format + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY)) { + Set serviceNames = parseNetworkServicesFromDetailKeys(extDetails); + if (!serviceNames.isEmpty()) { + JsonObject capsObj = null; + if (extDetails.containsKey(ExtensionHelper.NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY)) { + try { + capsObj = JsonParser.parseString( + extDetails.get(ExtensionHelper.NETWORK_SERVICE_CAPABILITIES_DETAIL_KEY)) + .getAsJsonObject(); + } catch (Exception e) { + logger.warn("Failed to parse network.service.capabilities JSON: {}", e.getMessage()); + } + } + Map> result = new HashMap<>(); + for (Service service : serviceNames) { + Map capMap = new HashMap<>(); + if (capsObj != null && capsObj.has(service.getName())) { + JsonObject svcCaps = capsObj.getAsJsonObject(service.getName()); + for (Map.Entry entry : svcCaps.entrySet()) { + Capability cap = Capability.getCapability(entry.getKey()); + if (cap != null) { + capMap.put(cap, entry.getValue().getAsString()); + } + } + } + result.put(service, capMap); + } + return result; + } + } + + return new HashMap<>(); + } + + /** + * Sets the boolean service-provided flags on a {@link PhysicalNetworkServiceProviderVO} + * based on a set of service names. + */ + private void applyServicesToNsp(PhysicalNetworkServiceProviderVO nsp, Set services) { + nsp.setSourcenatServiceProvided(services.contains(Service.SourceNat)); + nsp.setStaticnatServiceProvided(services.contains(Service.StaticNat)); + nsp.setPortForwardingServiceProvided(services.contains(Service.PortForwarding)); + nsp.setFirewallServiceProvided(services.contains(Service.Firewall)); + nsp.setGatewayServiceProvided(services.contains(Service.Gateway)); + nsp.setDnsServiceProvided(services.contains(Service.Dns)); + nsp.setDhcpServiceProvided(services.contains(Service.Dhcp)); + nsp.setUserdataServiceProvided(services.contains(Service.UserData)); + nsp.setLbServiceProvided(services.contains(Service.Lb)); + nsp.setVpnServiceProvided(services.contains(Service.Vpn)); + nsp.setSecuritygroupServiceProvided(services.contains(Service.SecurityGroup)); + nsp.setNetworkAclServiceProvided(services.contains(Service.NetworkACL)); + nsp.setCustomActionServiceProvided(services.contains(Service.CustomAction)); + } + + /** + * Propagates a change in the set of network services declared by a NetworkOrchestrator + * extension to every physical network the extension is registered with. A service that is + * still referenced by a network or VPC offering (i.e. the extension is configured as the + * provider for that service) cannot be removed. Otherwise, every physical network's provider + * is updated to add newly declared services and drop services that are no longer declared. + */ + protected void updateNetworkExtensionServicesOnPhysicalNetworks(ExtensionVO extension, + Set oldServices, Set newServices) { + Set removedServices = new HashSet<>(oldServices); + removedServices.removeAll(newServices); + Set addedServices = new HashSet<>(newServices); + addedServices.removeAll(oldServices); + if (removedServices.isEmpty() && addedServices.isEmpty()) { + return; + } + for (Service removedService : removedServices) { + boolean usedByNetworkOffering = CollectionUtils.isNotEmpty( + networkOfferingServiceMapDao.listOfferingIdsByServiceAndProvider(removedService, extension.getName())); + boolean usedByVpcOffering = CollectionUtils.isNotEmpty( + vpcOfferingServiceMapDao.listOfferingIdsByServiceAndProvider(removedService, extension.getName())); + if (usedByNetworkOffering || usedByVpcOffering) { + throw new CloudRuntimeException(String.format( + "Cannot remove service %s from extension '%s' as it is used by a network/VPC offering", + removedService.getName(), extension.getName())); + } + } + List physicalNetworkIds = extensionResourceMapDao.listResourceIdsByExtensionIdAndType( + extension.getId(), ExtensionResourceMap.ResourceType.PhysicalNetwork); + for (Long physicalNetworkId : physicalNetworkIds) { + PhysicalNetworkServiceProviderVO nsp = physicalNetworkServiceProviderDao.findByServiceProvider( + physicalNetworkId, extension.getName()); + if (nsp == null) { + continue; + } + List enabledServices = new ArrayList<>(nsp.getEnabledServices()); + enabledServices.removeAll(removedServices); + for (Service addedService : addedServices) { + if (!enabledServices.contains(addedService)) { + enabledServices.add(addedService); + } + } + nsp.setEnabledServices(enabledServices); + physicalNetworkServiceProviderDao.update(nsp.getId(), nsp); + logger.info("Updated NetworkServiceProvider '{}' on physical network {} with services {}", + extension, physicalNetworkId, enabledServices); + } + } + + /** Keys that are always stored with display=false (sensitive). */ + private static final Set SENSITIVE_DETAIL_KEYS = + Set.of("password", "sshkey"); + @Override @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UNREGISTER, eventDescription = "unregistering extension resource") public Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd) { @@ -942,10 +1350,15 @@ public Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd) { final String resourceType = cmd.getResourceType(); if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { throw new InvalidParameterValueException( - String.format("Currently only [%s] can be used to unregister an extension of type Orchestrator", + String.format("Currently only [%s] can be used to unregister an extension", EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); } - unregisterExtensionWithCluster(resourceId, extensionId); + ExtensionResourceMap.ResourceType resType = EnumUtils.getEnum(ExtensionResourceMap.ResourceType.class, resourceType); + if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(resType)) { + unregisterExtensionWithPhysicalNetwork(resourceId, extensionId); + } else { + unregisterExtensionWithCluster(resourceId, extensionId); + } return extensionDao.findById(extensionId); } @@ -965,6 +1378,45 @@ public void unregisterExtensionWithCluster(Cluster cluster, Long extensionId) { } } + protected void unregisterExtensionWithPhysicalNetwork(String resourceId, Long extensionId) { + PhysicalNetworkVO physicalNetwork = physicalNetworkDao.findByUuid(resourceId); + if (physicalNetwork == null) { + throw new InvalidParameterValueException("Invalid physical network ID specified"); + } + // Find the specific map entry for this extension+physical-network combination + ExtensionResourceMapVO existing = extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType( + extensionId, physicalNetwork.getId(), ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (existing == null) { + return; + } + + ExtensionVO ext = extensionDao.findById(existing.getExtensionId()); + if (ext != null) { + List networksUsingProvider = networkDao.listByPhysicalNetworkAndProvider( + physicalNetwork.getId(), ext.getName()); + if (CollectionUtils.isNotEmpty(networksUsingProvider)) { + throw new CloudRuntimeException(String.format( + "Cannot unregister extension '%s' from physical network %s. " + + "Provider is used by %d existing network service(s)", + ext.getName(), physicalNetwork, networksUsingProvider.size())); + } + } + + extensionResourceMapDao.remove(existing.getId()); + extensionResourceMapDetailsDao.removeDetails(existing.getId()); + + // Also remove the auto-created NSP for this extension + if (ext != null) { + PhysicalNetworkServiceProviderVO nsp = + physicalNetworkServiceProviderDao.findByServiceProvider(physicalNetwork.getId(), ext.getName()); + if (nsp != null) { + physicalNetworkServiceProviderDao.remove(nsp.getId()); + logger.info("Removed NetworkServiceProvider '{}' from physical network {} " + + "on extension unregister", ext, physicalNetwork); + } + } + } + @Override public ExtensionResponse createExtensionResponse(Extension extension, EnumSet viewDetails) { @@ -988,6 +1440,12 @@ public ExtensionResponse createExtensionResponse(Extension extension, Cluster cluster = clusterDao.findById(extensionResourceMapVO.getResourceId()); extensionResourceResponse.setId(cluster.getUuid()); extensionResourceResponse.setName(cluster.getName()); + } else if (ExtensionResourceMap.ResourceType.PhysicalNetwork.equals(extensionResourceMapVO.getResourceType())) { + PhysicalNetworkVO pn = physicalNetworkDao.findById(extensionResourceMapVO.getResourceId()); + if (pn != null) { + extensionResourceResponse.setId(pn.getUuid()); + extensionResourceResponse.setName(pn.getName()); + } } Map details = extensionResourceMapDetailsDao.listDetailsKeyPairs( extensionResourceMapVO.getId(), true); @@ -1143,7 +1601,7 @@ public List listCustomActions(ListCustomActionCmd } if (extensionId == null && resourceType != null && StringUtils.isNotBlank(resourceId)) { - Extension extension = getExtensionFromResource(resourceType, resourceId); + Extension extension = getExtensionWithCustomActionFromResource(resourceType, resourceId); if (extension == null) { logger.error("No extension found for the specified resource [type: {}, id: {}]", resourceTypeStr, resourceId); throw new InvalidParameterValueException("Internal error listing custom actions with specified resource"); @@ -1335,7 +1793,7 @@ protected void updatedCustomActionDetails(long id, Boolean cleanupDetails, Map cmdParameters = cmd.getParameters(); @@ -1360,8 +1818,6 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { logger.error("Failed to run {} as it is not enabled", customActionVO); throw new InvalidParameterValueException(error); } - final String actionName = customActionVO.getName(); - RunCustomActionCommand runCustomActionCommand = new RunCustomActionCommand(actionName); final long extensionId = customActionVO.getExtensionId(); final ExtensionVO extensionVO = extensionDao.findById(extensionId); if (extensionVO == null) { @@ -1392,39 +1848,47 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { logger.error("Specified resource does not exist for running {}", customActionVO); throw new CloudRuntimeException(error); } - Long clusterId = null; - Long hostId = null; - if (entity instanceof Cluster) { - clusterId = ((Cluster)entity).getId(); - List hosts = hostDao.listByClusterAndHypervisorType(clusterId, Hypervisor.HypervisorType.External); - if (CollectionUtils.isEmpty(hosts)) { - logger.error("No hosts found for {} for running {}", entity, customActionVO); - throw new CloudRuntimeException(error); - } - hostId = hosts.get(0).getId(); - } else if (entity instanceof Host) { - Host host = (Host)entity; - if (!Hypervisor.HypervisorType.External.equals(host.getHypervisorType())) { - logger.error("Invalid {} specified as host resource for running {}", entity, customActionVO); - throw new InvalidParameterValueException(error); - } - hostId = host.getId(); - clusterId = host.getClusterId(); - } else if (entity instanceof VirtualMachine) { - VirtualMachine virtualMachine = (VirtualMachine)entity; + if (entity instanceof VirtualMachine) { + VirtualMachine virtualMachine = (VirtualMachine) entity; accountService.checkAccess(caller, null, true, virtualMachine); - if (!Hypervisor.HypervisorType.External.equals(virtualMachine.getHypervisorType())) { - logger.error("Invalid {} specified as VM resource for running {}", entity, customActionVO); - throw new InvalidParameterValueException(error); - } - VirtualMachineProfile vmProfile = new VirtualMachineProfileImpl(virtualMachine); - VirtualMachineTO virtualMachineTO = virtualMachineManager.toVmTO(vmProfile); - runCustomActionCommand.setVmTO(virtualMachineTO); - Pair clusterAndHostId = virtualMachineManager.findClusterAndHostIdForVm(virtualMachine, false); - clusterId = clusterAndHostId.first(); - hostId = clusterAndHostId.second(); + return runVirtualMachineCustomAction(virtualMachine, customActionVO, extensionVO, + actionResourceType, cmdParameters); + } else if (entity instanceof Network) { + // Network custom action: dispatched directly to NetworkCustomActionProvider (no agent) + Network network = (Network) entity; + accountService.checkAccess(caller, null, true, network); + return runNetworkCustomAction(network, customActionVO, extensionVO, actionResourceType, cmdParameters); + } else if (entity instanceof Vpc) { + // VPC custom action: dispatched directly to NetworkCustomActionProvider (no agent) + Vpc vpc = (Vpc) entity; + accountService.checkAccess(caller, null, true, vpc); + return runVpcCustomAction(vpc, customActionVO, extensionVO, actionResourceType, cmdParameters); + } + throw new CloudRuntimeException(String.format("Custom action for resource type %s is not supported", + actionResourceType.name())); + } + + private CustomActionResultResponse runVirtualMachineCustomAction(VirtualMachine virtualMachine, + ExtensionCustomActionVO customActionVO, ExtensionVO extensionVO, + ExtensionCustomAction.ResourceType actionResourceType, + Map cmdParameters) { + + String error = "Internal error running action on virtual machine"; + + if (!Hypervisor.HypervisorType.External.equals(virtualMachine.getHypervisorType())) { + logger.error("Invalid {} specified as VM resource for running {}", virtualMachine, customActionVO); + throw new InvalidParameterValueException(error); } + final String actionName = customActionVO.getName(); + RunCustomActionCommand runCustomActionCommand = new RunCustomActionCommand(actionName); + VirtualMachineProfile vmProfile = new VirtualMachineProfileImpl(virtualMachine); + VirtualMachineTO virtualMachineTO = virtualMachineManager.toVmTO(vmProfile); + runCustomActionCommand.setVmTO(virtualMachineTO); + Pair clusterAndHostId = virtualMachineManager.findClusterAndHostIdForVm(virtualMachine, false); + + Long clusterId = clusterAndHostId.first(); + Long hostId = clusterAndHostId.second(); if (clusterId == null || hostId == null) { logger.error( "Unable to find cluster or host with the specified resource - cluster ID: {}, host ID: {}", @@ -1458,7 +1922,7 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { Map result = new HashMap<>(); response.setSuccess(false); result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, - actionResourceType, entity)); + actionResourceType, virtualMachine)); Map> externalDetails = getExternalAccessDetails(allDetails.first(), hostId, extensionResource); Map vmExternalDetails = null; @@ -1473,7 +1937,7 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { runCustomActionCommand.setWait(customActionVO.getTimeout()); try { logger.info("Running custom action: {} with {} parameters", actionName, - (parameters != null ? parameters.keySet().size() : 0)); + (parameters != null ? parameters.size() : 0)); Answer answer = agentMgr.send(hostId, runCustomActionCommand); if (!(answer instanceof RunCustomActionAnswer)) { logger.error("Unexpected answer [{}] received for {}", answer.getClass().getSimpleName(), @@ -1483,7 +1947,7 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { RunCustomActionAnswer customActionAnswer = (RunCustomActionAnswer) answer; response.setSuccess(answer.getResult()); result.put(ApiConstants.MESSAGE, getActionMessage(answer.getResult(), customActionVO, extensionVO, - actionResourceType, entity)); + actionResourceType, virtualMachine)); result.put(ApiConstants.DETAILS, customActionAnswer.getDetails()); } } catch (AgentUnavailableException e) { @@ -1499,6 +1963,183 @@ public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { return response; } + /** + * Executes a custom action for a Network resource by delegating to an + * available {@link NetworkCustomActionProvider} (e.g. NetworkExtensionElement). + * This path does NOT go through the agent framework. + */ + protected CustomActionResultResponse runNetworkCustomAction(Network network, + ExtensionCustomActionVO customActionVO, ExtensionVO extensionVO, + ExtensionCustomAction.ResourceType actionResourceType, + Map cmdParameters) { + + final String actionName = customActionVO.getName(); + CustomActionResultResponse response = new CustomActionResultResponse(); + response.setId(customActionVO.getUuid()); + response.setName(actionName); + response.setObjectName("customactionresult"); + Map result = new HashMap<>(); + response.setSuccess(false); + result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, actionResourceType, network)); + + // Resolve action parameters + List actionParameters = null; + Pair, Map> allDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customActionVO.getId()); + if (allDetails.second().containsKey(ApiConstants.PARAMETERS)) { + actionParameters = ExtensionCustomAction.Parameter.toListFromJson( + allDetails.second().get(ApiConstants.PARAMETERS)); + } + Map parameters = null; + if (CollectionUtils.isNotEmpty(actionParameters)) { + parameters = ExtensionCustomAction.Parameter.validateParameterValues(actionParameters, cmdParameters); + } + + // Find the provider name for this network + String providerName = networkServiceMapDao.getProviderForServiceInNetwork(network.getId(), Service.CustomAction); + if (StringUtils.isBlank(providerName)) { + logger.error("No network service provider found for network {}", network); + result.put(ApiConstants.DETAILS, "No network service provider found for this network"); + response.setResult(result); + return response; + } + + // Check if provider name matches the extension name + if (!providerName.equals(extensionVO.getName())) { + logger.error("Provider name '{}' for network {} does not match extension name '{}'", + providerName, network, extensionVO); + result.put(ApiConstants.DETAILS, "Network service provider '" + providerName + + "' does not match extension '" + extensionVO.getName() + "'"); + response.setResult(result); + return response; + } + + // Get the network element implementing that provider + NetworkElement element = networkModel.getElementImplementingProvider(providerName); + if (element == null) { + logger.error("No NetworkElement found implementing provider '{}' for network {}", providerName, network); + result.put(ApiConstants.DETAILS, "No network element found for provider: " + providerName); + response.setResult(result); + return response; + } + + // The element must implement NetworkCustomActionProvider + if (!(element instanceof NetworkCustomActionProvider)) { + logger.error("Network element '{}' for provider '{}' does not support custom actions", + element.getClass().getSimpleName(), providerName); + result.put(ApiConstants.DETAILS, "Provider '" + providerName + "' does not support custom actions"); + response.setResult(result); + return response; + } + + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + try { + if (!provider.canHandleCustomAction(network)) { + throw new CloudRuntimeException("Provider '" + providerName + "' cannot handle custom action for this network"); + } + logger.info("Running network custom action '{}' on network {} via {} (provider: {})", + actionName, network, element.getClass().getSimpleName(), providerName); + String output = provider.runCustomAction(network, actionName, parameters); + boolean success = output != null; + response.setSuccess(success); + result.put(ApiConstants.MESSAGE, getActionMessage(success, customActionVO, extensionVO, actionResourceType, network)); + result.put(ApiConstants.DETAILS, success ? output : "Action failed — check management server logs for details"); + } catch (Exception e) { + logger.error("Network custom action '{}' threw exception: {}", actionName, e.getMessage(), e); + result.put(ApiConstants.DETAILS, "Action failed: " + e.getMessage()); + } + response.setResult(result); + return response; + } + + /** + * Executes a custom action for a VPC resource by finding the VPC's + * extension provider and dispatching directly to it (no tier network lookup). + */ + protected CustomActionResultResponse runVpcCustomAction(Vpc vpc, + ExtensionCustomActionVO customActionVO, ExtensionVO extensionVO, + ExtensionCustomAction.ResourceType actionResourceType, + Map cmdParameters) { + + final String actionName = customActionVO.getName(); + CustomActionResultResponse response = new CustomActionResultResponse(); + response.setId(customActionVO.getUuid()); + response.setName(actionName); + response.setObjectName("customactionresult"); + Map result = new HashMap<>(); + response.setSuccess(false); + result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, actionResourceType, vpc)); + + // Resolve action parameters + List actionParameters = null; + Pair, Map> allDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customActionVO.getId()); + if (allDetails.second().containsKey(ApiConstants.PARAMETERS)) { + actionParameters = ExtensionCustomAction.Parameter.toListFromJson( + allDetails.second().get(ApiConstants.PARAMETERS)); + } + Map parameters = null; + if (CollectionUtils.isNotEmpty(actionParameters)) { + parameters = ExtensionCustomAction.Parameter.validateParameterValues(actionParameters, cmdParameters); + } + + // Find the provider name for this VPC + String providerName = vpcServiceMapDao.getProviderForServiceInVpc(vpc.getId(), Service.CustomAction); + if (StringUtils.isBlank(providerName)) { + logger.error("No VPC service provider found for VPC {}", vpc.getId()); + result.put(ApiConstants.DETAILS, "No VPC service provider found for this VPC"); + response.setResult(result); + return response; + } + + // Check if provider name matches the extension name + if (!providerName.equals(extensionVO.getName())) { + logger.error("Provider name '{}' for vpc {} does not match extension name '{}'", + providerName, vpc.getId(), extensionVO.getName()); + result.put(ApiConstants.DETAILS, "Network service provider '" + providerName + + "' does not match extension '" + extensionVO.getName() + "'"); + response.setResult(result); + return response; + } + + // Get the network element implementing that provider + NetworkElement element = networkModel.getElementImplementingProvider(providerName); + if (element == null) { + logger.error("No NetworkElement found implementing provider '{}' for VPC {}", providerName, vpc); + result.put(ApiConstants.DETAILS, "No network element found for provider: " + providerName); + response.setResult(result); + return response; + } + + // The element must implement NetworkCustomActionProvider + if (!(element instanceof NetworkCustomActionProvider)) { + logger.error("Network element '{}' for provider '{}' does not support VPC custom actions", + element.getClass().getSimpleName(), providerName); + result.put(ApiConstants.DETAILS, "Provider '" + providerName + "' does not support custom actions"); + response.setResult(result); + return response; + } + + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + try { + if (!provider.canHandleVpcCustomAction(vpc)) { + throw new CloudRuntimeException("Provider '" + providerName + "' cannot handle custom action for this VPC"); + } + logger.info("Running VPC custom action '{}' on VPC {} via {} (provider: {})", + actionName, vpc.getId(), element.getClass().getSimpleName(), providerName); + String output = provider.runCustomAction(vpc, actionName, parameters); + boolean success = output != null; + response.setSuccess(success); + result.put(ApiConstants.MESSAGE, getActionMessage(success, customActionVO, extensionVO, actionResourceType, vpc)); + result.put(ApiConstants.DETAILS, success ? output : "Action failed — check management server logs for details"); + } catch (Exception e) { + logger.error("VPC custom action '{}' threw exception: {}", actionName, e.getMessage(), e); + result.put(ApiConstants.DETAILS, "Action failed: " + e.getMessage()); + } + response.setResult(result); + return response; + } + @Override public ExtensionCustomActionResponse createCustomActionResponse(ExtensionCustomAction customAction) { ExtensionCustomActionResponse response = new ExtensionCustomActionResponse(customAction.getUuid(), @@ -1608,11 +2249,8 @@ public void updateExtensionResourceMapDetails(long extensionResourceMapId, Map detailsList = new ArrayList<>(); - for (Map.Entry entry : details.entrySet()) { - detailsList.add(new ExtensionResourceMapDetailsVO(extensionResourceMapId, entry.getKey(), - entry.getValue())); - } + List detailsList = + buildExtensionResourceDetailsArray(extensionResourceMapId, details); extensionResourceMapDetailsDao.saveDetails(detailsList); } @@ -1650,6 +2288,11 @@ public Extension getExtension(long id) { return extensionDao.findById(id); } + @Override + public Extension getExtensionByNameAndType(String name, Extension.Type type) { + return extensionDao.findByNameAndType(name, type); + } + @Override public Extension getExtensionForCluster(long clusterId) { Long extensionId = getExtensionIdForCluster(clusterId); @@ -1714,6 +2357,7 @@ public List> getCommands() { cmds.add(UpdateExtensionCmd.class); cmds.add(RegisterExtensionCmd.class); cmds.add(UnregisterExtensionCmd.class); + cmds.add(UpdateRegisteredExtensionCmd.class); return cmds; } @@ -1765,4 +2409,95 @@ protected void runInContext() { } } } + + @Override + public String getExtensionScriptPath(Extension extension) { + if (extension == null) { + return null; + } + return externalProvisioner.getExtensionPath(extension.getRelativePath()); + } + + @Override + public Extension getExtensionForPhysicalNetworkAndProvider(long physicalNetworkId, String providerName) { + if (StringUtils.isBlank(providerName)) { + return null; + } + ExtensionVO ext = extensionDao.findByName(providerName); + if (ext == null) { + return null; + } + + ExtensionResourceMapVO map = extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType( + ext.getId(), physicalNetworkId, ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (map != null) { + return ext; + } + return null; + } + + @Override + public Map getAllResourceMapDetailsForExtensionOnPhysicalNetwork(long physicalNetworkId, long extensionId) { + ExtensionResourceMapVO map = extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType( + extensionId, physicalNetworkId, ExtensionResourceMap.ResourceType.PhysicalNetwork); + if (map == null) { + return new HashMap<>(); + } + + Map details = extensionResourceMapDetailsDao.listDetailsKeyPairs(map.getId()); + return details != null ? details : new HashMap<>(); + } + + @Override + public boolean isNetworkExtensionProvider(String providerName) { + if (StringUtils.isBlank(providerName)) { + return false; + } + return extensionDao.findByNameAndType(providerName, Extension.Type.NetworkOrchestrator) != null; + } + + @Override + public boolean usesNetworkExtensionIsolation(String providerName) { + Extension extension = extensionDao.findByName(providerName); + if (extension == null) { + return false; + } + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + if (MapUtils.isEmpty(extDetails)) { + return false; + } + return NETWORK_EXTENSION_ISOLATION_METHOD.equalsIgnoreCase(extDetails.get(NETWORK_ISOLATION_METHOD_DETAIL_KEY)); + } + + @Override + public List listExtensionsByType(Extension.Type type) { + if (type == null) { + return new ArrayList<>(); + } + List extensions = extensionDao.listByType(type); + if (CollectionUtils.isEmpty(extensions)) { + return new ArrayList<>(); + } + return new ArrayList<>(extensions); + } + + @Override + public Map> getNetworkCapabilitiesForProvider(Long physicalNetworkId, + String providerName) { + if (StringUtils.isBlank(providerName)) { + return new HashMap<>(); + } + Extension extension = null; + if (physicalNetworkId != null) { + extension = getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + } else { + // Search across all physical networks + extension = extensionDao.findByNameAndType(providerName, Extension.Type.NetworkOrchestrator); + } + if (extension == null) { + return new HashMap<>(); + } + Map extDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId()); + return buildCapabilitiesFromDetailKeys(extDetails); + } } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java new file mode 100644 index 000000000000..e98848c01f7d --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java @@ -0,0 +1,2618 @@ +// 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 org.apache.cloudstack.framework.extensions.network; + +import java.io.File; +import java.net.URI; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import com.cloud.agent.api.to.LoadBalancerTO; +import com.cloud.dc.DataCenter; +import com.cloud.dc.Vlan; +import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.VlanDao; +import com.cloud.deploy.DeployDestination; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.host.dao.HostDao; +import com.cloud.network.IpAddressManager; +import com.cloud.network.IpAddress; +import com.cloud.network.Network; +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Provider; +import com.cloud.network.Network.Service; +import com.cloud.network.NetworkModel; +import com.cloud.network.Networks; +import com.cloud.network.dao.NetworkDetailVO; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.PublicIpAddress; +import com.cloud.network.addr.PublicIp; +import com.cloud.network.dao.FirewallRulesDao; +import com.cloud.network.dao.IPAddressDao; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.NetworkDetailsDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.element.AggregatedCommandExecutor; +import com.cloud.network.element.DhcpServiceProvider; +import com.cloud.network.element.DnsServiceProvider; +import com.cloud.network.element.FirewallServiceProvider; +import com.cloud.network.element.IpDeployer; +import com.cloud.network.element.LoadBalancingServiceProvider; +import com.cloud.network.element.NetworkACLServiceProvider; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.element.SourceNatServiceProvider; +import com.cloud.network.element.StaticNatServiceProvider; +import com.cloud.network.element.UserDataServiceProvider; +import com.cloud.network.element.VpcProvider; +import com.cloud.network.vpc.NetworkACLItem; +import com.cloud.network.vpc.PrivateGateway; +import com.cloud.network.vpc.StaticRouteProfile; +import com.cloud.network.vpc.Vpc; +import com.cloud.network.lb.LoadBalancingRule; +import com.cloud.network.rules.FirewallRule; +import com.cloud.network.rules.FirewallRuleVO; +import com.cloud.network.rules.PortForwardingRule; +import com.cloud.network.rules.StaticNat; +import com.cloud.offerings.NetworkOfferingVO; +import com.cloud.offerings.dao.NetworkOfferingDao; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.dao.GuestOSCategoryDao; +import com.cloud.storage.dao.GuestOSDao; +import com.cloud.user.AccountService; +import com.cloud.uservm.UserVm; +import com.cloud.offering.NetworkOffering; +import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.NetUtils; +import com.cloud.vm.Nic; +import com.cloud.vm.NicProfile; +import com.cloud.vm.NicVO; +import com.cloud.vm.ReservationContext; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.resourcedetail.dao.VpcDetailsDao; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Collectors; + + +public class NetworkExtensionElement extends AdapterBase implements + NetworkElement, SourceNatServiceProvider, StaticNatServiceProvider, + PortForwardingServiceProvider, IpDeployer, NetworkCustomActionProvider, + DhcpServiceProvider, DnsServiceProvider, FirewallServiceProvider, + UserDataServiceProvider, LoadBalancingServiceProvider, + VpcProvider, NetworkACLServiceProvider, AggregatedCommandExecutor { + + private static final Map> DEFAULT_CAPABILITIES = new HashMap<>(); + + + /** + * When non-null, restricts all operations to the extension whose name + * matches this provider name. + */ + private String providerName; + + @Inject + private NetworkModel networkModel; + @Inject + private NetworkServiceMapDao ntwkSrvcDao; + @Inject + private ExtensionHelper extensionHelper; + @Inject + private NetworkDetailsDao networkDetailsDao; + @Inject + private IpAddressManager ipAddressManager; + @Inject + private NetworkOrchestrationService networkManager; + @Inject + private AccountService accountService; + @Inject + private PhysicalNetworkDao physicalNetworkDao; + @Inject + private ExtensionDetailsDao extensionDetailsDao; + @Inject + private NetworkDao networkDao; + @Inject + private DataCenterDao dataCenterDao; + @Inject + private VlanDao vlanDao; + @Inject + private GuestOSCategoryDao guestOSCategoryDao; + @Inject + private GuestOSDao guestOSDao; + @Inject + private HostDao hostDao; + @Inject + private VMInstanceDetailsDao vmInstanceDetailsDao; + @Inject + private UserVmDao userVmDao; + @Inject + private NicDao nicDao; + @Inject + private NetworkOfferingDao networkOfferingDao; + @Inject + private ServiceOfferingDao serviceOfferingDao; + @Inject + private FirewallRulesDao firewallRulesDao; + @Inject + private IPAddressDao ipAddressDao; + @Inject + private VpcDao vpcDao; + @Inject + private VpcDetailsDao vpcDetailsDao; + + // ---- Script argument names ---- + + public static final String ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS = "physical-network-extension-details"; + public static final String ARG_NETWORK_EXTENSION_DETAILS = "network-extension-details"; + public static final String ARG_PAYLOAD = "payload"; + public static final String ARG_ACTION_PARAMS = "action-params"; + + public static final int DEFAULT_SCRIPT_TIMEOUT_SECONDS = 60; + + public static final int EXIT_CODE_SUCCESS = 0; + public static final int EXIT_CODE_FAILURE = -1; + + // ---- Script command names ---- + + public static final String CMD_IMPLEMENT_NETWORK = "implement-network"; + public static final String CMD_SHUTDOWN_NETWORK = "shutdown-network"; + public static final String CMD_DESTROY_NETWORK = "destroy-network"; + public static final String CMD_ENSURE_NETWORK_DEVICE = "ensure-network-device"; + public static final String CMD_ASSIGN_IP = "assign-ip"; + public static final String CMD_RELEASE_IP = "release-ip"; + public static final String CMD_ADD_STATIC_NAT = "add-static-nat"; + public static final String CMD_DELETE_STATIC_NAT = "delete-static-nat"; + public static final String CMD_ADD_PORT_FORWARD = "add-port-forward"; + public static final String CMD_DELETE_PORT_FORWARD = "delete-port-forward"; + public static final String CMD_ADD_DHCP_ENTRY = "add-dhcp-entry"; + public static final String CMD_CONFIG_DHCP_SUBNET = "config-dhcp-subnet"; + public static final String CMD_REMOVE_DHCP_SUBNET = "remove-dhcp-subnet"; + public static final String CMD_REMOVE_DHCP_ENTRY = "remove-dhcp-entry"; + public static final String CMD_ADD_DNS_ENTRY = "add-dns-entry"; + public static final String CMD_REMOVE_DNS_ENTRY = "remove-dns-entry"; + public static final String CMD_CONFIG_DNS_SUBNET = "config-dns-subnet"; + public static final String CMD_REMOVE_DNS_SUBNET = "remove-dns-subnet"; + public static final String CMD_SAVE_VM_DATA = "save-vm-data"; + public static final String CMD_SAVE_PASSWORD = "save-password"; + public static final String CMD_SAVE_USERDATA = "save-userdata"; + public static final String CMD_SAVE_SSHKEY = "save-sshkey"; + public static final String CMD_SAVE_HYPERVISOR_HOSTNAME = "save-hypervisor-hostname"; + public static final String CMD_APPLY_LB_RULES = "apply-lb-rules"; + public static final String CMD_APPLY_FW_RULES = "apply-fw-rules"; + public static final String CMD_RESTORE_NETWORK = "restore-network"; + public static final String CMD_IMPLEMENT_VPC = "implement-vpc"; + public static final String CMD_SHUTDOWN_VPC = "shutdown-vpc"; + public static final String CMD_UPDATE_VPC_SOURCE_NAT_IP = "update-vpc-source-nat-ip"; + public static final String CMD_APPLY_NETWORK_ACL = "apply-network-acl"; + public static final String CMD_CUSTOM_ACTION = "custom-action"; + public static final String CMD_PREPARE_NIC = "prepare-nic"; + public static final String CMD_RELEASE_NIC = "release-nic"; + + // ---- Network detail key ---- + + /** + * Key used to persist the per-network JSON blob in {@code network_details}. + * The blob is produced by the network-extension.sh's {@code ensure-network-device} + * command and may contain any fields the script needs (e.g. selected host, + * namespace name, VRF ID, …). + */ + public static final String NETWORK_DETAIL_EXTENSION_DETAILS = "extension.details"; + + /** + * Extension detail key that controls whether {@link #ensureExtensionIp} allocates + * a placeholder NIC/IP. + * + *

Applies to any network whose DHCP/DNS/UserData service is provided by this + * extension without SourceNat or Gateway — i.e. shared networks and + * isolated networks that omit SourceNat. Set the value to {@code "false"} on the + * Extension object (via {@code createExtension} or {@code updateExtension}) when + * the extension handles its own data-plane addressing and does not need CloudStack + * to reserve a real IP from the subnet pool (e.g. OVN, which programs port + * bindings in-fabric). Omitting the detail or setting any value other than + * {@code "false"} preserves the original behaviour (placeholder IP is allocated).

+ */ + public static final String NETWORK_ALLOCATE_EXTENSION_IP = "network.allocate.extension.ip"; + + public String getProviderName() { + return providerName; + } + + /** + * Returns a new {@link NetworkExtensionElement} scoped to {@code providerName}, + * sharing all injected dependencies with this instance. + */ + public NetworkExtensionElement withProviderName(String providerName) { + NetworkExtensionElement copy = new NetworkExtensionElement(); + copy.networkModel = this.networkModel; + copy.ntwkSrvcDao = this.ntwkSrvcDao; + copy.extensionHelper = this.extensionHelper; + copy.networkDetailsDao = this.networkDetailsDao; + copy.ipAddressManager = this.ipAddressManager; + copy.physicalNetworkDao = this.physicalNetworkDao; + copy.extensionDetailsDao = this.extensionDetailsDao; + copy.networkDao = this.networkDao; + copy.dataCenterDao = this.dataCenterDao; + copy.vlanDao = this.vlanDao; + copy.guestOSCategoryDao = this.guestOSCategoryDao; + copy.guestOSDao = this.guestOSDao; + copy.hostDao = this.hostDao; + copy.vmInstanceDetailsDao = this.vmInstanceDetailsDao; + copy.userVmDao = this.userVmDao; + copy.nicDao = this.nicDao; + copy.networkManager = this.networkManager; + copy.networkOfferingDao = this.networkOfferingDao; + copy.serviceOfferingDao = this.serviceOfferingDao; + copy.accountService = this.accountService; + copy.firewallRulesDao = this.firewallRulesDao; + copy.ipAddressDao = this.ipAddressDao; + copy.vpcDao = this.vpcDao; + copy.vpcDetailsDao = this.vpcDetailsDao; + copy.providerName = providerName; + + logger.debug("NetworkExtensionElement initialised with provider name '{}'", providerName); + return copy; + } + + // ---- Capabilities ---- + + @Override + public Map> getCapabilities() { + try { + // If this element is scoped to a provider name, prefer capabilities stored + // in the extension's "network.service.capabilities" detail. The ExtensionHelper + // exposes a helper that loads the Service→Capability map from the DB. + if (StringUtils.isNotBlank(providerName)) { + Map> caps = extensionHelper.getNetworkCapabilitiesForProvider(null, providerName); + if (MapUtils.isNotEmpty(caps)) { + return caps; + } + } + } catch (Exception e) { + logger.warn("Failed to load network service capabilities from extension details for provider '{}': {}", providerName, e.getMessage()); + } + + return DEFAULT_CAPABILITIES; + } + + @Override + public Provider getProvider() { + if (providerName != null) { + return Provider.createTransientProvider(providerName); + } + return Provider.NetworkExtension; + } + + // ---- Extension / provider resolution ---- + + protected Extension resolveExtension(Network network) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + logger.warn("Network {} has no physical network — cannot resolve extension", network); + return null; + } + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + if (ext != null) { + return ext; + } + logger.warn("No extension found for scoped provider '{}' on physical network {}", providerName, physicalNetworkId); + return null; + } + + protected boolean canHandle(Network network, Service service) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + return false; + } + boolean hasExt = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName) != null; + if (!hasExt) { + return false; + } + if (service == null) { + return true; + } + List sp = ntwkSrvcDao.getProvidersForServiceInNetwork(network.getId(), service); + return sp != null && sp.stream() + .anyMatch(p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + return true; + } + + // ---- NetworkElement lifecycle ---- + + @Override + public boolean implement(Network network, NetworkOffering offering, DeployDestination dest, + ReservationContext context) throws ConcurrentOperationException, + ResourceUnavailableException, InsufficientCapacityException { + if (!canHandle(network, null)) { + return false; + } + logger.info("Implementing network extension for network {} (VLAN {})", network, network.getBroadcastUri()); + + // Step 1: Ensure a network device is selected and its details stored. + ensureExtensionDetails(network); + + // Step 2: Create the network on the device. + JsonObject implementPayload = new JsonObject(); + addNetworkToPayload(implementPayload, network); + addExtensionIpToPayload(implementPayload, network); + + Pair result = executeScriptAndReturnOutput(network, CMD_IMPLEMENT_NETWORK, implementPayload); + + if (result.first() != EXIT_CODE_SUCCESS) { + return false; + } + + // Update the network properties from the output + applyNetworkUpdateFromScriptOutput(network, result.second()); + + // Step 3: Configure source NAT for both VPC and non-VPC networks for + // compatibility (other network-element providers may also implement VPC tiers). + // When this is a VPC tier, the script's assign-ip does nothing for source-nat + // because VPC source NAT is managed at the VPC level by implementVpc(). + if (canHandle(network, Service.SourceNat)) { + try { + if (network.getVpcId() == null) { + // Isolated network: apply the network's own source NAT IP. + Account owner = accountService.getAccount(network.getAccountId()); + PublicIpAddress existingIp = networkModel.getSourceNatIpAddressForGuestNetwork(owner, network); + if (existingIp != null) { + applyIps(network, List.of(existingIp), Set.of(Service.SourceNat)); + } + } else { + // VPC tier: apply the VPC-level source NAT IP (script is a no-op for SNAT). + final PublicIpAddress vpcSourceNatIp = getVpcSourceNatIp(network.getVpcId()); + if (vpcSourceNatIp != null) { + applyIps(network, List.of(vpcSourceNatIp), Set.of(Service.SourceNat)); + } + } + } catch (Exception e) { + logger.warn("Failed to configure source NAT IP for network {}: {}", network, e.getMessage()); + } + } + + return true; + } + + @Override + public boolean prepare(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + + if (!canHandle(network, null)) { + return false; + } + + if (!networkModel.isProviderEnabledInPhysicalNetwork(networkModel.getPhysicalNetworkId(network), getProvider().getName())) { + return false; + } + + // Sync nic with network + applyNicUpdateFromNetwork(network, nic.getId()); + + // Build payload for prepare-nic script command + try { + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + if (vm != null) { + payload.addProperty("hostname", safeStr(vm.getHostName())); + } + addExtensionIpToPayload(payload, network); + + logger.debug("Preparing NIC via extension script: network={} nicMac={} nicIp={}", network, nic.getMacAddress(), nic.getIPv4Address()); + + return executeScript(network, CMD_PREPARE_NIC, payload); + } catch (Exception e) { + logger.warn("prepare: failed to prepare NIC for network {}: {}", network, e.getMessage()); + return false; + } + } + + private void applyNicUpdateFromNetwork(Network network, Long nicId) { + if (nicId == null) { + return; + } + NicVO nicVo = nicDao.findById(nicId); + if (nicVo == null) { + return; + } + if (network.getBroadcastUri() != null) { + nicVo.setBroadcastUri(network.getBroadcastUri()); + nicVo.setIsolationUri(network.getBroadcastUri()); + nicDao.update(nicId, nicVo); + } + } + + @Override + public boolean release(Network network, NicProfile nic, VirtualMachineProfile vm, + ReservationContext context) throws ConcurrentOperationException, ResourceUnavailableException { + if (!canHandle(network, null)) { + return true; + } + + try { + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + addExtensionIpToPayload(payload, network); + + logger.debug("Releasing NIC via extension script: network={} nicMac={} nicIp={}", network, nic != null ? nic.getMacAddress() : null, nic != null ? nic.getIPv4Address() : null); + + return executeScript(network, CMD_RELEASE_NIC, payload); + } catch (Exception e) { + logger.warn("release: failed to release NIC for network {}: {}", network, e.getMessage()); + return false; + } + } + + @Override + public boolean shutdown(Network network, ReservationContext context, boolean cleanup) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Shutting down network extension for network {}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + boolean result = executeScript(network, CMD_SHUTDOWN_NETWORK, payload); + if (result) { + // Remove stored per-network extension details (e.g. namespace). For VPC-backed networks + // the namespace is named cs-vpc-, stored in the extension details. Removing the + // stored details ensures the namespace is deleted/forgotten on shutdown. + try { + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } catch (Exception e) { + logger.warn("Failed to remove network extension details for network {}: {}", network, e.getMessage()); + } + } + return result; + } + + @Override + public boolean destroy(Network network, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Destroying network extension for network {}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + // For both isolated and VPC tier networks, use destroy-network. + // For VPC tiers, the script preserves the shared namespace; + // the VPC namespace is removed only when shutdownVpc() calls shutdown-vpc. + boolean result = executeScript(network, CMD_DESTROY_NETWORK, payload); + if (result) { + cleanupPlaceholderNicIp(network, context); + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } + return result; + } + + /** + * Releases placeholder NIC IPs allocated for DHCP/DNS/UserData extension traffic, + * then removes the placeholder NIC record(s) for this network. + */ + protected void cleanupPlaceholderNicIp(Network network, ReservationContext context) { + List placeholderNics = nicDao.listPlaceholderNicsByNetworkIdAndVmType( + network.getId(), VirtualMachine.Type.DomainRouter); + if (CollectionUtils.isEmpty(placeholderNics)) { + return; + } + + long userId = accountService.getSystemUser().getId(); + Account caller = accountService.getSystemAccount(); + if (context != null && context.getAccount() != null) { + caller = context.getAccount(); + } + + for (NicVO placeholderNic : placeholderNics) { + try { + String ip = placeholderNic.getIPv4Address(); + if (StringUtils.isNotBlank(ip)) { + logger.debug("Cleaning up PlaceHolder IP {} on network {}", ip, network); + IPAddressVO ipAddress = ipAddressDao.findByIpAndSourceNetworkId(network.getId(), ip); + if (ipAddress != null) { + if (Network.GuestType.Shared.equals(network.getGuestType())) { + ipAddressManager.disassociatePublicIpAddress(ipAddress, userId, caller); + } else { + ipAddressManager.markIpAsUnavailable(ipAddress.getId()); + ipAddressDao.unassignIpAddress(ipAddress.getId()); + } + } + } + } catch (Exception e) { + logger.warn("Failed to release placeholder IP for network {} and nic {}: {}", + network, placeholderNic, e.getMessage()); + } + + try { + nicDao.remove(placeholderNic.getId()); + } catch (Exception e) { + logger.warn("Failed to remove placeholder nic {} for network {}: {}", + placeholderNic, network, e.getMessage()); + } + } + } + + @Override + public boolean isReady(PhysicalNetworkServiceProvider provider) { + return true; + } + + @Override + public boolean shutdownProviderInstances(PhysicalNetworkServiceProvider provider, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + return true; + } + + @Override + public boolean canEnableIndividualServices() { + return true; + } + + @Override + public boolean verifyServicesCombination(Set services) { + return true; + } + + // ---- ensure-network-device ---- + + /** + * Calls the network-extension.sh script with {@code ensure-network-device} before + * the first network operation. The script verifies the previously selected + * device is reachable (using the {@code hosts} list in the physical-network + * extension details) and performs failover if needed. The returned JSON is + * persisted in {@code network_details} and forwarded to all subsequent calls + * via {@value #ARG_NETWORK_EXTENSION_DETAILS}. + * + *

For VPC tier networks the extension details are inherited from the VPC-level + * details (stored in {@code vpc_details}) so all tiers in the same VPC share + * the same host/namespace binding. The script's {@code ensure-network-device} + * is only called at the VPC level (see {@link #ensureExtensionDetails(Vpc)}).

+ */ + protected void ensureExtensionDetails(Network network) { + if (network.getVpcId() != null) { + Vpc vpc = vpcDao.findById(network.getVpcId()); + ensureExtensionDetails(vpc); + return; + } + + // Isolated network: run ensure-network-device to select / validate the host. + Map stored = networkDetailsDao.listDetailsKeyPairs(network.getId()); + String currentDetails = stored != null + ? stored.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + + logger.info("Ensuring network device for network {} (current={})", network, currentDetails); + + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + JsonObject argsPayload = new JsonObject(); + addNetworkToPayload(argsPayload, network); + argsPayload.addProperty("current_details", currentDetails); + JsonObject payload = buildNetworkScriptPayload(network, argsPayload, extension); + + try { + Pair result = executeScriptWithFilePayload(scriptFile, + CMD_ENSURE_NETWORK_DEVICE, payload, "Network extension"); + String output = result.second() != null ? result.second() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.warn("ensure-network-device exited {} for network {} — keeping current details", + -1, network); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + return; + } + if (output.isEmpty()) { + output = "{}".equals(currentDetails) ? "{}" : currentDetails; + } + if (!output.equals(currentDetails)) { + logger.info("Network device updated for network {}: {}", network, output); + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, output, false); + } else { + logger.debug("Network device unchanged for network {}: {}", network, output); + } + } catch (Exception e) { + logger.warn("Failed ensure-network-device for network {}: {}", network, e.getMessage()); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + } + } + + /* + * If the network supports DHCP/DNS/UserData but not SourceNat/Gateway, + * an additional IP is needed on the external network to host these services. + * This method ensures that IP is allocated and configured on the external network and returns its address. + */ + protected String ensureExtensionIp(Network network) { + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.SourceNat, Service.Gateway)) { + // Gateway or Source NAT will be configured on the external network + return network.getGateway(); + } + + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.Dhcp, Service.Dns, Service.UserData)) { + // Some extensions (e.g. OVN) handle shared-network addressing in the + // data plane and do not need a CloudStack-allocated placeholder IP. + Extension ext = extensionHelper.getExtensionByNameAndType(providerName, Extension.Type.NetworkOrchestrator); + if (ext != null) { + ExtensionDetailsVO allocateDetail = + extensionDetailsDao.findDetail(ext.getId(), NETWORK_ALLOCATE_EXTENSION_IP); + if (allocateDetail != null && "false".equalsIgnoreCase(allocateDetail.getValue())) { + logger.debug("Skipping placeholder IP for network {} — {} detail is false", + network.getId(), NETWORK_ALLOCATE_EXTENSION_IP); + return null; + } + try { + // An extra IP will be allocated and configured on the external network + Nic placeholderNic = networkModel.getPlaceholderNicForRouter(network, null); + if (placeholderNic == null) { + NetworkDetailVO routerIpDetail = networkDetailsDao.findDetail(network.getId(), ApiConstants.ROUTER_IP); + String routerIp = routerIpDetail != null ? routerIpDetail.getValue() : null; + Account account = accountService.getAccount(network.getAccountId()); + String extensionIp = Network.GuestType.Shared.equals(network.getGuestType()) ? + ipAddressManager.assignPublicIpAddress(network.getDataCenterId(), null, account, Vlan.VlanType.DirectAttached, network.getId(), routerIp, false, false).getAddress().toString() : + ipAddressManager.acquireGuestIpAddress(network, routerIp); + logger.debug("Saving placeholder nic with ip4 address {} for the network", extensionIp, network); + networkManager.savePlaceholderNic(network, extensionIp, null, VirtualMachine.Type.DomainRouter); + return extensionIp; + } + return placeholderNic.getIPv4Address(); + } catch (Exception e) { + logger.warn("Failed to acquire extension IP for network {}: {}", network, e.getMessage()); + } + } + } + return null; + } + + // ---- IpDeployer ---- + + @Override + public boolean applyIps(Network network, List ipAddress, Set services) + throws ResourceUnavailableException { + if (CollectionUtils.isEmpty(ipAddress)) { + return true; + } + logger.info("Applying {} IPs for network {}", ipAddress.size(), network); + + for (PublicIpAddress ip : ipAddress) { + boolean isSourceNat = ip.isSourceNat(); + boolean isRevoke = ip.getState() == IpAddress.State.Releasing; + String action = isRevoke ? CMD_RELEASE_IP : CMD_ASSIGN_IP; + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addPublicIpToPayload(payload, ip.getId(), isSourceNat); + + boolean result = executeScript(network, action, payload); + if (!result) { + throw new ResourceUnavailableException( + "Failed to " + action + " for IP " + ip.getAddress().addr(), + Network.class, network.getId()); + } + } + return true; + } + + // ---- StaticNatServiceProvider ---- + + @Override + public boolean applyStaticNats(Network config, List rules) + throws ResourceUnavailableException { + if (CollectionUtils.isEmpty(rules)) { + return true; + } + if (!canHandle(config, Service.StaticNat)) { + return false; + } + logger.info("Applying {} static NAT rules for network {}", rules.size(), config); + for (StaticNat rule : rules) { + String action = rule.isForRevoke() ? CMD_DELETE_STATIC_NAT : CMD_ADD_STATIC_NAT; + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, config); + addPublicIpToPayload(payload, rule.getSourceIpAddressId(), false); + payload.addProperty("private_ip", safeStr(rule.getDestIpAddress())); + boolean result = executeScript(config, action, payload); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for static NAT rule", + Network.class, config.getId()); + } + } + return true; + } + + // ---- PortForwardingServiceProvider ---- + + @Override + public boolean applyPFRules(Network network, List rules) + throws ResourceUnavailableException { + if (CollectionUtils.isEmpty(rules)) { + return true; + } + if (!canHandle(network, Service.PortForwarding)) { + return false; + } + logger.info("Applying {} port forwarding rules for network {}", rules.size(), network); + for (PortForwardingRule rule : rules) { + boolean isRevoke = rule.getState() == FirewallRule.State.Revoke; + String action = isRevoke ? CMD_DELETE_PORT_FORWARD : CMD_ADD_PORT_FORWARD; + String publicPort = PortForwardingServiceProvider.getPublicPortRange(rule); + String privatePort = PortForwardingServiceProvider.getPrivatePFPortRange(rule); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addPublicIpToPayload(payload, rule.getSourceIpAddressId(), false); + payload.addProperty("public_port", safeStr(publicPort)); + payload.addProperty("private_ip", safeStr(rule.getDestinationIpAddress() != null + ? rule.getDestinationIpAddress().addr() : null)); + payload.addProperty("private_port", safeStr(privatePort)); + payload.addProperty("protocol", safeStr(rule.getProtocol())); + boolean result = executeScript(network, action, payload); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for port forwarding rule", + Network.class, network.getId()); + } + } + return true; + } + + // ---- Script execution ---- + + protected boolean executeScript(Network network, String command, JsonObject argsPayload) { + return executeScriptAndReturnOutput(network, command, argsPayload).first() == 0; + } + + protected Pair executeScriptAndReturnOutput(Network network, String command, JsonObject argsPayload) { + ensureExtensionDetails(network); + Extension extension = resolveExtension(network); + JsonObject payload = buildNetworkScriptPayload(network, argsPayload, extension); + return executeScriptWithFilePayload(network, command, payload); + } + + private JsonObject parseJsonOutput(String outputStr) { + if (StringUtils.isBlank(outputStr)) { + return null; + } + try { + JsonElement parsed = JsonParser.parseString(outputStr); + if (!parsed.isJsonObject()) { + logger.debug("Ignoring non-object script output: {}", outputStr); + return null; + } + return parsed.getAsJsonObject(); + } catch (Exception e) { + logger.debug("Ignoring non-JSON script output: {}", outputStr); + return null; + } + } + + private String getJsonString(JsonObject jsonObject, String keyPath) { + if (jsonObject == null || StringUtils.isBlank(keyPath)) { + return null; + } + JsonElement value = jsonObject.has(keyPath) ? jsonObject.get(keyPath) : null; + if (value == null) { + JsonElement current = jsonObject; + String[] parts = keyPath.split("\\."); + for (String part : parts) { + if (current == null || !current.isJsonObject()) { + current = null; + break; + } + JsonObject currentObj = current.getAsJsonObject(); + if (!currentObj.has(part)) { + current = null; + break; + } + current = currentObj.get(part); + } + value = current; + } + if (value == null || value.isJsonNull()) { + return null; + } + return value.getAsString(); + } + + private void applyNetworkUpdateFromScriptOutput(Network network, String outputStr) { + JsonObject outputJson = parseJsonOutput(outputStr); + String networkBroadcastUri = getJsonString(outputJson, "network.broadcast_uri"); + String networkBroadcastDomainType = getJsonString(outputJson, "network.broadcast_domain_type"); + + if (ExtensionHelper.NETWORK_EXTENSION_GURU_NAME.equals(network.getGuruName()) && + (networkBroadcastUri == null || networkBroadcastDomainType == null)) { + throw new CloudRuntimeException(String.format( + "Script output is missing required network properties 'network.broadcast_uri' and 'network.broadcast_domain_type'" + + " for network %s: %s", network, outputStr)); + } + + try { + NetworkVO networkVo = networkDao.findById(network.getId()); + if (networkVo == null) { + return; + } + + boolean changed = false; + if (networkBroadcastDomainType != null) { + Networks.BroadcastDomainType domainType = EnumUtils.getEnumIgnoreCase(Networks.BroadcastDomainType.class, networkBroadcastDomainType); + if (domainType != null) { + networkVo.setBroadcastDomainType(domainType); + changed = true; + } else { + logger.warn("Ignoring unknown broadcast domain type '{}' for network {}", + networkBroadcastDomainType, network); + } + } + + if (networkBroadcastUri != null) { + networkVo.setBroadcastUri(URI.create(networkBroadcastUri)); + changed = true; + } + + if (changed) { + networkDao.update(networkVo.getId(), networkVo); + for (NicVO nicVO : nicDao.listByNetworkId(networkVo.getId())) { + applyNicUpdateFromNetwork(networkVo, nicVO.getId()); + } + } + } catch (Exception e) { + logger.warn("Failed to update network {} from script output: {}", network, e.getMessage()); + } + } + + protected Pair executeScriptWithFilePayload(Network network, String command, JsonObject payload) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + return executeScriptWithFilePayload(scriptFile, command, payload, "Network extension"); + } + + private Pair executeScriptWithFilePayload(File scriptFile, String command, + JsonObject payload, String logPrefix) { + File payloadFile = null; + try { + payloadFile = File.createTempFile("cs-extnet-" + command + "-", ".payload"); + String payloadJson = payload != null ? new Gson().toJson(payload) : "{}"; + logger.debug("Writing payload to payload file {}", payloadFile); + Files.writeString(payloadFile.toPath(), payloadJson, StandardCharsets.UTF_8); + + List cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add(command); + cmdLine.add(payloadFile.getAbsolutePath()); + cmdLine.add(String.valueOf(DEFAULT_SCRIPT_TIMEOUT_SECONDS)); + + logger.debug("Executing {} script: {}", logPrefix, String.join(" ", cmdLine)); + + ProcessBuilder processBuilder = new ProcessBuilder(cmdLine); + processBuilder.redirectErrorStream(true); + Process process = processBuilder.start(); + byte[] output = process.getInputStream().readAllBytes(); + int exitCode = process.waitFor(); + + String outputStr = new String(output).trim(); + if (!outputStr.isEmpty()) { + logger.debug("Script output: {}", outputStr); + } + + if (exitCode != EXIT_CODE_SUCCESS) { + logger.error("{} script {} failed with exit code {}: {}", logPrefix, command, exitCode, outputStr); + return new Pair<>(exitCode, outputStr); + } + + JsonObject outputJson = parseJsonOutput(outputStr); + String status = outputJson != null ? getJsonString(outputJson, "status") : null; + if (StringUtils.isNotBlank(status) && !"success".equalsIgnoreCase(status)) { + logger.error("{} script {} returned non-success status '{}': {}", logPrefix, command, status, outputStr); + return new Pair<>(EXIT_CODE_FAILURE, outputStr); + } + + return new Pair<>(EXIT_CODE_SUCCESS, outputStr); + } catch (Exception e) { + throw new CloudRuntimeException( + String.format("Failed preparing payload file for command %s", command), e); + } finally { + if (payloadFile != null && payloadFile.exists() && !payloadFile.delete()) { + payloadFile.deleteOnExit(); + } + } + } + + private JsonObject buildNetworkScriptPayload(Network network, JsonObject argsPayload, Extension extension) { + JsonObject payload = new JsonObject(); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(network.getPhysicalNetworkId(), extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, buildNetworkExtensionDetailsPayload(network)); + payload.add(ARG_PAYLOAD, argsPayload != null ? argsPayload : new JsonObject()); + return payload; + } + + /** + * Adds all standard network-level fields to the payload. + * Always-present: network_id, vlan, zone_id. + * Conditional (only when non-blank): gateway, cidr, vpc_id, and the two + * IPv6 network fields (network_ip6_gateway, network_ip6_cidr). + */ + private void addNetworkToPayload(JsonObject payload, Network network) { + if (payload == null || network == null) { + return; + } + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("vlan", safeStr(getVlanId(network))); + payload.addProperty("zone_id", String.valueOf(network.getDataCenterId())); + if (network.getGuestType() != null) { + payload.addProperty("guest_type", network.getGuestType().toString().toLowerCase()); + } + if (network.getState() != null) { + payload.addProperty("network_state", network.getState().toString().toLowerCase()); + } + if (StringUtils.isNotBlank(network.getGateway())) { + payload.addProperty("gateway", safeStr(network.getGateway())); + } + if (StringUtils.isNotBlank(network.getCidr())) { + payload.addProperty("cidr", safeStr(network.getCidr())); + } + if (network.getVpcId() != null) { + payload.addProperty("vpc_id", String.valueOf(network.getVpcId())); + } + if (StringUtils.isNotBlank(network.getIp6Gateway())) { + payload.addProperty("network_ip6_gateway", safeStr(network.getIp6Gateway())); + } + if (StringUtils.isNotBlank(network.getIp6Cidr())) { + payload.addProperty("network_ip6_cidr", safeStr(network.getIp6Cidr())); + } + } + + /** + * Adds all standard VPC-level fields to the payload. + * Always-present: vpc_id, zone_id. + * Conditional (only when non-null / non-blank): vpc_state, vpc_cidr. + */ + private void addVpcToPayload(JsonObject payload, Vpc vpc) { + if (payload == null || vpc == null) { + return; + } + payload.addProperty("vpc_id", String.valueOf(vpc.getId())); + payload.addProperty("zone_id", String.valueOf(vpc.getZoneId())); + if (vpc.getState() != null) { + payload.addProperty("vpc_state", vpc.getState().toString().toLowerCase()); + } + if (StringUtils.isNotBlank(vpc.getCidr())) { + payload.addProperty("vpc_cidr", safeStr(vpc.getCidr())); + } + } + + /** + * Adds all available NIC fields to the payload. + * Fields are added only when non-blank / non-null so the JSON stays clean. + * Covers: nic_id, nic_uuid, mac, ip, gateway (IPv4), netmask, default_nic, + * device_id, and the three IPv6 NIC fields. + */ + private void addNicToPayload(JsonObject payload, NicProfile nic) { + if (payload == null || nic == null) { + return; + } + payload.addProperty("nic_id", String.valueOf(nic.getId())); + if (StringUtils.isNotBlank(nic.getUuid())) { + payload.addProperty("nic_uuid", nic.getUuid()); + } + payload.addProperty("mac", safeStr(nic.getMacAddress())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + if (StringUtils.isNotBlank(nic.getIPv4Gateway())) { + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + } + if (StringUtils.isNotBlank(nic.getIPv4Netmask())) { + payload.addProperty("netmask", safeStr(nic.getIPv4Netmask())); + } + payload.addProperty("default_nic", String.valueOf(nic.isDefaultNic())); + if (nic.getDeviceId() != null) { + payload.addProperty("device_id", String.valueOf(nic.getDeviceId())); + } + if (StringUtils.isNotBlank(nic.getIPv6Address())) { + payload.addProperty("ip6_address", safeStr(nic.getIPv6Address())); + } + if (StringUtils.isNotBlank(nic.getIPv6Gateway())) { + payload.addProperty("ip6_gateway", safeStr(nic.getIPv6Gateway())); + } + if (StringUtils.isNotBlank(nic.getIPv6Cidr())) { + payload.addProperty("ip6_cidr", safeStr(nic.getIPv6Cidr())); + } + } + + /** + * Adds the network DNS fields to the payload: {@code dns} (comma-separated + * resolver list) and {@code domain} (network domain suffix). + */ + private void addNetworkDnsToPayload(JsonObject payload, Network network) { + if (payload == null || network == null) { + return; + } + payload.addProperty("dns", safeStr(getNetworkDns(network))); + payload.addProperty("domain", safeStr(network.getNetworkDomain())); + } + + /** + * Resolves the extension IP for {@code network} via {@link #ensureExtensionIp} + * and adds it to the payload as {@code extension_ip}. + */ + private void addExtensionIpToPayload(JsonObject payload, Network network) { + if (payload == null || network == null) { + return; + } + String extensionIp = ensureExtensionIp(network); + if (StringUtils.isNotBlank(extensionIp)) { + payload.addProperty("extension_ip", extensionIp); + } + } + + // ---- Detail helpers ---- + + /** + * Returns all {@code extension_resource_map_details} for the given extension + * on the physical network as a plain map, enriched with physical-network + * metadata (name, kvmnetworklabel, vmwarenetworklabel, xennetworklabel, + * public_kvmnetworklabel) so the wrapper script can derive bridge names and + * interface names without extra lookups. + */ + private Map buildPhysicalNetworkDetailsMap(Long physicalNetworkId, Extension extension) { + Map details = new HashMap<>(); + if (physicalNetworkId == null || extension == null) { + return details; + } + // Start with registered extension_resource_map_details + Map mapDetails = extensionHelper.getAllResourceMapDetailsForExtensionOnPhysicalNetwork( + physicalNetworkId, extension.getId()); + if (mapDetails != null) { + details.putAll(mapDetails); + } + + // Enrich with physical-network record fields + PhysicalNetworkVO pn = physicalNetworkDao.findById(physicalNetworkId); + if (pn != null && pn.getName() != null) { + details.put("physicalnetworkname", pn.getName()); + } + + return details; + } + + /** + * Builds the physical-network extension details as a {@link JsonObject}. + * Includes all {@code extension_resource_map_details} for the extension on the + * physical network, enriched with physical-network metadata fields. + */ + private JsonObject buildPhysicalNetworkExtensionDetailsPayload(Long physicalNetworkId, Extension extension) { + Map map = buildPhysicalNetworkDetailsMap(physicalNetworkId, extension); + JsonObject obj = new JsonObject(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() != null) { + obj.addProperty(entry.getKey(), entry.getValue()); + } + } + return obj; + } + + /** + * Returns the per-network extension-details JSON blob (the value stored under + * {@code NETWORK_DETAIL_EXTENSION_DETAILS} in {@code network_details} or + * {@code vpc_details}) as a {@link JsonObject}. + * Returns an empty object when no blob has been stored yet. + */ + private JsonObject buildNetworkExtensionDetailsPayload(Network network) { + String json; + if (network.getVpcId() != null) { + Map vpcDetails = vpcDetailsDao.listDetailsKeyPairs(network.getVpcId()); + json = vpcDetails != null ? vpcDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } else { + Map networkDetails = networkDetailsDao.listDetailsKeyPairs(network.getId()); + json = networkDetails != null ? networkDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } + return parseJsonObjectOrEmpty(json); + } + + /** + * Returns the VPC-level extension-details JSON blob (stored under + * {@code NETWORK_DETAIL_EXTENSION_DETAILS} in {@code vpc_details}) as a + * {@link JsonObject}. Returns an empty object when no blob has been stored. + */ + private JsonObject buildVpcExtensionDetailsPayload(long vpcId) { + Map vpcDetails = vpcDetailsDao.listDetailsKeyPairs(vpcId); + String json = vpcDetails != null ? vpcDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + return parseJsonObjectOrEmpty(json); + } + + /** + * Builds the custom-action parameters as a {@link JsonObject}. + * Returns an empty object for {@code null} or empty parameter maps. + */ + private JsonObject buildActionParamsPayload(Map parameters) { + JsonObject obj = new JsonObject(); + if (MapUtils.isEmpty(parameters)) { + return obj; + } + for (Map.Entry entry : parameters.entrySet()) { + obj.addProperty(entry.getKey(), + entry.getValue() != null ? entry.getValue().toString() : ""); + } + return obj; + } + + /** + * Parses a JSON string into a {@link JsonObject}. + * Returns an empty {@link JsonObject} when the input is {@code null}, blank, + * or not a valid JSON object. + */ + private JsonObject parseJsonObjectOrEmpty(String json) { + if (StringUtils.isBlank(json)) { + return new JsonObject(); + } + try { + JsonElement element = JsonParser.parseString(json); + return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); + } catch (Exception e) { + throw new CloudRuntimeException("Failed to parse JSON: " + json, e); + } + } + + // ---- Custom action ---- + + @Override + public boolean canHandleCustomAction(Network network) { + return canHandle(network, Service.CustomAction); + } + + /** + * Runs a custom action on the external network device. + * The custom action payload is written to a temporary file and passed to the + * extension script via {@link #executeScriptWithFilePayload(File, String, JsonObject, String)}. + */ + @Override + public String runCustomAction(Network network, String actionName, Map parameters) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + JsonObject payload = buildCustomActionPayload(network, extension, actionName, parameters); + + logger.info("Running custom action '{}' on network {} (extension: {}, params: {} key(s))", + actionName, network, extension != null ? extension.getName() : "unknown", + parameters != null ? parameters.size() : 0); + + try { + Pair result = executeScriptWithFilePayload(scriptFile, CMD_CUSTOM_ACTION, payload, + "Network extension"); + String outputStr = result.second() != null ? result.second().trim() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.error("Custom action '{}' on network {} failed: {}", actionName, network, outputStr); + return null; + } + logger.info("Custom action '{}' on network {} completed successfully", actionName, network); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute custom action '{}' on network {}: {}", actionName, network, e.getMessage()); + throw new CloudRuntimeException(String.format("Failed to execute custom action %s on network %s", actionName, network.getUuid()), e); + } + } + + @Override + public boolean canHandleVpcCustomAction(Vpc vpc) { + return resolveExtensionForVpc(vpc) != null; + } + + /** + * Runs a custom action on the external network device for a VPC. + * The custom action payload is written to a temporary file and passed to the + * extension script via {@link #executeScriptWithFilePayload(File, String, JsonObject, String)}. + */ + @Override + public String runCustomAction(Vpc vpc, String actionName, Map parameters) { + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + throw new CloudRuntimeException("No extension found for VPC " + vpc.getUuid()); + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + + JsonObject payload = buildCustomActionPayload(vpc, physicalNetworkId, extension, actionName, parameters); + + logger.info("Running custom action '{}' on VPC {} (extension: {}, params: {} key(s))", + actionName, vpc, extension != null ? extension.getName() : "unknown", + parameters != null ? parameters.size() : 0); + + try { + Pair result = executeScriptWithFilePayload(scriptFile, CMD_CUSTOM_ACTION, payload, + "VPC extension"); + String outputStr = result.second() != null ? result.second().trim() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.error("VPC custom action '{}' on VPC {} failed: {}", actionName, vpc, outputStr); + return null; + } + logger.info("VPC custom action '{}' on VPC {} completed successfully", actionName, vpc); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute VPC custom action '{}' on VPC {}: {}", actionName, vpc, e.getMessage()); + throw new CloudRuntimeException(String.format("Failed to execute VPC custom action %s on VPC %s ", actionName, vpc.getUuid()), e); + } + } + + private JsonObject buildCustomActionPayload(Network network, Extension extension, String actionName, + Map parameters) { + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + payload.addProperty("action", actionName); + payload.add(ARG_ACTION_PARAMS, buildActionParamsPayload(parameters)); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(network.getPhysicalNetworkId(), extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, + buildNetworkExtensionDetailsPayload(network)); + return payload; + } + + private JsonObject buildCustomActionPayload(Vpc vpc, Long physicalNetworkId, Extension extension, + String actionName, Map parameters) { + JsonObject payload = new JsonObject(); + addVpcToPayload(payload, vpc); + payload.addProperty("action", actionName); + payload.add(ARG_ACTION_PARAMS, buildActionParamsPayload(parameters)); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(physicalNetworkId, extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, + buildVpcExtensionDetailsPayload(vpc.getId())); + return payload; + } + + // ---- Script file resolution ---- + + /** + * Resolves the executable script file from the given extension. + * + *

Lookup order (first match wins):

+ *
    + *
  1. {@code /.sh} — preferred convention, + * e.g. for an extension named {@code network-extension} the script is + * {@code network-extension.sh}.
  2. + *
  3. {@code } itself, if it is a file and is executable.
  4. + *
+ */ + protected File resolveScriptFile(Network network, Extension extension) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + throw new CloudRuntimeException("Network " + network.getUuid() + " has no physical network"); + } + if (extension == null) { + throw new CloudRuntimeException( + "No NetworkOrchestrator extension found for network " + network.getUuid() + + " on physical network " + physicalNetworkId); + } + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not of type NetworkOrchestrator"); + } + if (!Extension.State.Enabled.equals(extension.getState())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not enabled"); + } + if (!extension.isPathReady()) { + throw new CloudRuntimeException("Extension " + extension.getName() + " path is not ready"); + } + + String extensionPath = extensionHelper.getExtensionScriptPath(extension); + if (extensionPath == null) { + throw new CloudRuntimeException("Could not resolve path for extension " + extension.getName()); + } + + File extensionFile = new File(extensionPath); + if (extensionFile.isFile() && extensionFile.canExecute()) { + return extensionFile; + } + + throw new CloudRuntimeException( + "No executable script found in extension path " + extensionPath + " inside the extension directory."); + } + + // ---- Helpers ---- + + private String getVlanId(Network network) { + return network.getBroadcastUri() != null + ? Networks.BroadcastDomainType.getValue(network.getBroadcastUri()) : null; + } + + private String getIpAddress(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + return ip != null ? ip.getAddress().addr() : ""; + } + + /** + * Adds all standard public-IP fields to the payload. + * Makes exactly two DB calls: one for the {@link IpAddress} and one for + * its {@link VlanVO}. All five fields are then derived from those two + * objects in memory — no further DB calls are made. + * Fields: {@code public_ip}, {@code public_vlan}, {@code public_gateway}, + * {@code public_cidr}, {@code source_nat}. + */ + private void addPublicIpToPayload(JsonObject payload, Long ipAddressId, boolean sourceNat) { + if (payload == null || ipAddressId == null) { + return; + } + IpAddress ip = networkModel.getIp(ipAddressId); + if (ip == null || ip.getAddress() == null) { + return; + } + VlanVO vlan = vlanDao.findById(ip.getVlanId()); + payload.addProperty("public_ip", safeStr(ip.getAddress().addr())); + payload.addProperty("public_vlan", vlan != null ? safeStr(vlan.getVlanTag()) : ""); + payload.addProperty("public_gateway", vlan != null ? safeStr(vlan.getVlanGateway()) : ""); + payload.addProperty("public_cidr", vlan != null + ? StringUtils.defaultString(NetUtils.ipAndNetMaskToCidr(ip.getAddress().addr(), vlan.getVlanNetmask())) : ""); + payload.addProperty("source_nat", String.valueOf(sourceNat)); + } + + private String safeStr(String value) { + return value != null ? value : ""; + } + + // ---- DhcpServiceProvider ---- + + private String getNetworkDns(final Network network) { + final DataCenter dc = dataCenterDao.findById(network.getDataCenterId()); + Pair dnsList = networkModel.getNetworkIp4Dns(network, dc); + return dnsList.first() + (dnsList.second() != null ? "," + dnsList.second() : ""); + } + + @Override + public boolean addDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("addDhcpEntry: network={} mac={} ip={} ipv6={}", network, + nic.getMacAddress(), nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("hostname", safeStr(vm.getHostName())); + addNetworkDnsToPayload(payload, network); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_ADD_DHCP_ENTRY, payload); + } + + @Override + public boolean configDhcpSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("configDhcpSupportForSubnet: network={}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + addExtensionIpToPayload(payload, network); + addNicToPayload(payload, nic); + return executeScript(network, CMD_CONFIG_DHCP_SUBNET, payload); + } + + @Override + public boolean removeDhcpSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpSupportForSubnet: network={}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DHCP_SUBNET, payload); + } + + @Override + public boolean setExtraDhcpOptions(Network network, long nicId, Map dhcpOptions) { + return false; + } + + @Override + public boolean removeDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) + throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpEntry: network={} mac={} ip={} ipv6={}", network, + nic.getMacAddress(), nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DHCP_ENTRY, payload); + } + + // ---- DnsServiceProvider ---- + + @Override + public boolean addDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + String hostname = vm.getHostName(); + logger.debug("addDnsEntry: network={} hostname={} ip={} ipv6={}", network, + hostname, nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("hostname", safeStr(hostname)); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_ADD_DNS_ENTRY, payload); + } + + @Override + public boolean removeDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("removeDnsEntry: network={} mac={} ip={} ipv6={}", network, + nic.getMacAddress(), nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + addNicToPayload(payload, nic); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DNS_ENTRY, payload); + } + + @Override + public boolean configDnsSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("configDnsSupportForSubnet: network={}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + addExtensionIpToPayload(payload, network); + addNicToPayload(payload, nic); + return executeScript(network, CMD_CONFIG_DNS_SUBNET, payload); + } + + @Override + public boolean removeDnsSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("removeDnsSupportForSubnet: network={}", network); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DNS_SUBNET, payload); + } + + // ---- UserDataServiceProvider ---- + + @Override + public boolean addPasswordAndUserdata(Network network, NicProfile nic, VirtualMachineProfile profile, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + + VirtualMachine vm = profile.getVirtualMachine(); + + // SSH public key from VM instance details + String sshPublicKey = null; + try { + VMInstanceDetailVO sshKeyDetail = vmInstanceDetailsDao.findDetail(profile.getId(), VmDetailConstants.SSH_PUBLIC_KEY); + if (sshKeyDetail != null) { + sshPublicKey = sshKeyDetail.getValue(); + } + } catch (Exception e) { + logger.debug("Could not fetch SSH public key for VM {}: {}", profile, e.getMessage()); + } + + // Service offering display name + String serviceOfferingName = ""; + try { + serviceOfferingName = profile.getServiceOffering().getDisplayText(); + } catch (Exception e) { + logger.debug("Could not fetch service offering for VM {}: {}", profile, e.getMessage()); + } + + // Is Windows guest? + boolean isWindows = false; + try { + isWindows = guestOSCategoryDao + .findById(guestOSDao.findById(vm.getGuestOSId()).getCategoryId()) + .getName().equalsIgnoreCase("Windows"); + } catch (Exception e) { + logger.debug("Could not determine OS type for VM {}: {}", profile, e.getMessage()); + } + + // Hypervisor hostname – prefer dest host, fall back to current host + String destHostname = null; + try { + if (dest != null && dest.getHost() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname(dest.getHost().getName()); + } else if (vm.getHostId() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname( + hostDao.findById(vm.getHostId()).getName()); + } + } catch (Exception e) { + logger.debug("Could not resolve hypervisor hostname for VM {}: {}", profile, e.getMessage()); + } + + // Password from the VM profile parameter (set by UserVmManager before deployment) + String password = (String) profile.getParameter(VirtualMachineProfile.Param.VmPassword); + + // Use this NIC's IP — the metadata server in each namespace identifies requesters + // by REMOTE_ADDR, which will be the VM's IP on THIS network (not necessarily the + // default NIC IP), so we always key metadata by the NIC's IP on this network. + String nicIpAddress = nic.getIPv4Address(); + + logger.debug("addPasswordAndUserdata: network={} ip={} ipv6={} hasPassword={} hasSshKey={}", + network.getId(), nicIpAddress, nic.getIPv6Address(), + StringUtils.isNotEmpty(password), + StringUtils.isNotEmpty(sshPublicKey)); + + final UserVmVO userVm = userVmDao.findById(vm.getId()); + if (userVm == null) { + throw new CloudRuntimeException("Could not find UserVmVO for VM " + vm.getUuid()); + } + + // Generate the full metadata set (userdata, meta-data/*, password) in one go + List vmData = networkModel.generateVmData( + userVm.getUserData(), + userVm.getUserDataDetails(), + serviceOfferingName, + vm.getDataCenterId(), + profile.getInstanceName(), + profile.getHostName(), + profile.getId(), + profile.getUuid(), + nicIpAddress, + sshPublicKey, + password, + isWindows, + destHostname); + + if (CollectionUtils.isEmpty(vmData)) { + logger.debug("addPasswordAndUserdata: no VM data generated for network={} ip={} ipv6={}", network, nicIpAddress, nic.getIPv6Address()); + return true; + } + + JsonArray vmDataArray = new JsonArray(); + for (String[] entry : vmData) { + String dir = entry[NetworkModel.CONFIGDATA_DIR]; + String file = entry[NetworkModel.CONFIGDATA_FILE]; + String content = entry.length > NetworkModel.CONFIGDATA_CONTENT + ? entry[NetworkModel.CONFIGDATA_CONTENT] : null; + if (content == null) content = ""; + + String contentStr; + if (NetworkModel.USERDATA_DIR.equals(dir) && NetworkModel.USERDATA_FILE.equals(file)) { + try { + contentStr = new String(Base64.getDecoder().decode(content), StandardCharsets.UTF_8); + } catch (Exception e) { + contentStr = content; + } + } else { + contentStr = content; + } + + JsonObject entryObj = new JsonObject(); + entryObj.addProperty("dir", dir); + entryObj.addProperty("file", file); + entryObj.addProperty("content", contentStr); + vmDataArray.add(entryObj); + } + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("ip", safeStr(nicIpAddress)); + addExtensionIpToPayload(payload, network); + payload.add("vm_data", vmDataArray); + + return executeScript(network, CMD_SAVE_VM_DATA, payload); + } + + @Override + public boolean savePassword(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String password = (String) vm.getParameter(VirtualMachineProfile.Param.VmPassword); + if (StringUtils.isEmpty(password)) { + return true; + } + logger.debug("savePassword: network={} ip={} ipv6={}", network, nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("password", password); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_SAVE_PASSWORD, payload); + } + + @Override + public boolean saveUserData(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String userData = null; + if (vm.getVirtualMachine() instanceof UserVm) { + userData = ((UserVm) vm.getVirtualMachine()).getUserData(); + } + if (StringUtils.isEmpty(userData)) { + return true; + } + logger.debug("saveUserData: network={} ip={} ipv6={}", network, nic.getIPv4Address(), nic.getIPv6Address()); + String userDataDecoded; + try { + userDataDecoded = new String(Base64.getDecoder().decode(userData), StandardCharsets.UTF_8); + } catch (Exception e) { + userDataDecoded = userData; + } + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("userdata", userDataDecoded); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_SAVE_USERDATA, payload); + } + + @Override + public boolean saveSSHKey(Network network, NicProfile nic, VirtualMachineProfile vm, + String sshPublicKey) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + if (StringUtils.isEmpty(sshPublicKey)) { + return true; + } + logger.debug("saveSSHKey: network={} ip={} ipv6={}", network, nic.getIPv4Address(), nic.getIPv6Address()); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("sshkey", sshPublicKey); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_SAVE_SSHKEY, payload); + } + + @Override + public boolean saveHypervisorHostname(NicProfile nic, Network network, VirtualMachineProfile vm, + DeployDestination dest) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String hostname = dest != null && dest.getHost() != null ? dest.getHost().getName() : null; + if (StringUtils.isBlank(hostname)) { + return true; + } + logger.debug("saveHypervisorHostname: network={} ip={} ipv6={} host={}", network, + nic.getIPv4Address(), nic.getIPv6Address(), hostname); + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addNicToPayload(payload, nic); + payload.addProperty("hypervisor_hostname", hostname); + addExtensionIpToPayload(payload, network); + return executeScript(network, CMD_SAVE_HYPERVISOR_HOSTNAME, payload); + } + + // ---- LoadBalancingServiceProvider ---- + + @Override + public boolean applyLBRules(Network network, List rules) + throws ResourceUnavailableException { + if (CollectionUtils.isEmpty(rules)) { + return true; + } + if (!canHandle(network, Service.Lb)) { + return false; + } + logger.info("Applying {} LB rules for network {}", rules.size(), network); + + JsonArray lbRulesArray = new JsonArray(); + for (LoadBalancingRule rule : rules) { + boolean revoke = rule.getState() == FirewallRule.State.Revoke; + JsonObject ruleObj = new JsonObject(); + ruleObj.addProperty("id", rule.getId()); + ruleObj.addProperty("name", rule.getName()); + ruleObj.addProperty("publicIp", rule.getSourceIp() != null ? rule.getSourceIp().addr() : ""); + ruleObj.addProperty("publicPort", rule.getSourcePortStart()); + ruleObj.addProperty("privatePort", rule.getDefaultPortStart()); + ruleObj.addProperty("protocol", safeStr(rule.getProtocol())); + ruleObj.addProperty("algorithm", safeStr(rule.getAlgorithm())); + ruleObj.addProperty("revoke", revoke); + JsonArray backendsArray = buildLBRuleBackendArray(rule); + ruleObj.add("backends", backendsArray); + lbRulesArray.add(ruleObj); + } + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + payload.add("lb_rules", lbRulesArray); + boolean result = executeScript(network, CMD_APPLY_LB_RULES, payload); + if (!result) { + throw new ResourceUnavailableException("Failed to apply LB rules for network " + network.getUuid(), + Network.class, network.getId()); + } + return true; + } + + private static JsonArray buildLBRuleBackendArray(LoadBalancingRule rule) { + JsonArray backendsArray = new JsonArray(); + if (rule.getDestinations() != null) { + for (LoadBalancingRule.LbDestination dest : rule.getDestinations()) { + JsonObject destObj = new JsonObject(); + destObj.addProperty("ip", dest.getIpAddress()); + destObj.addProperty("port", dest.getDestinationPortStart()); + destObj.addProperty("revoked", dest.isRevoked()); + backendsArray.add(destObj); + } + } + return backendsArray; + } + + @Override + public boolean validateLBRule(Network network, LoadBalancingRule rule) { + // Delegate validation to the external script; accept by default + return true; + } + + @Override + public List updateHealthChecks(Network network, + List lbrules) { + // Health-check state updates are not implemented via this path + return new ArrayList<>(); + } + + @Override + public boolean handlesOnlyRulesInTransitionState() { + return false; + } + + @Override + public IpDeployer getIpDeployer(Network network) { + // This element itself implements IpDeployer; return this instance. + return this; + } + + @Override + public boolean rollingRestartSupported() { + return false; + } + + /** + * Applies all active firewall rules for a network to the external network device. + * + *

Three categories of rules are handled:

+ *
    + *
  1. Egress rules ({@link FirewallRule.TrafficType#Egress}) — control outbound + * traffic from guest VMs. The network offering's {@code egressDefaultPolicy} flag + * is consulted: + *
      + *
    • {@code true} (ALLOW by default) — each egress rule becomes a DROP rule; + * a catch-all ACCEPT is appended at the end.
    • + *
    • {@code false} (DENY by default) — each egress rule becomes an ACCEPT rule; + * a catch-all DROP is appended at the end.
    • + *
    + *
  2. + *
  3. Ingress rules ({@link FirewallRule.TrafficType#Ingress}) on public IPs + * (static NAT, port-forwarding, LB, …) — control inbound access to a specific + * public IP. The wrapper script uses {@code conntrack --ctorigdst} to match the + * original pre-DNAT destination, so no private-IP lookup is required and all + * DNAT-based services (static-NAT, port-forwarding, LB) are handled uniformly.
  4. + *
  5. Default egress policy — always conveyed via the JSON payload so the + * script can enforce it even when the explicit rule list is empty.
  6. + *
+ * + *

Full-state rebuild semantics: + * {@code applyFWRules} is called with a narrow scope — the firewall manager + * passes only the rules for one public IP ({@code applyIngressFirewallRules}) or only + * the egress rules ({@code applyEgressFirewallRules}) per call. The script, however, + * rebuilds the entire firewall chain from scratch each time it runs. To avoid wiping + * the rules for other IPs on every call, this method ignores the {@code rules} parameter + * and instead queries the database for all active (non-revoked, non-System) + * {@link FirewallRule.Purpose#Firewall} rules for the network.

+ * + *

Script command: {@code apply-fw-rules}

+ */ + @Override + public boolean applyFWRules(Network network, List rules) + throws ResourceUnavailableException { + if (!canHandle(network, Service.Firewall)) { + return false; + } + + // Determine default egress policy from the network offering. + // true = ALLOW (default permissive; explicit rules are deny-rules) + // false = DENY (default restrictive; explicit rules are allow-rules) + NetworkOfferingVO offering = networkOfferingDao.findById(network.getNetworkOfferingId()); + boolean defaultEgressAllow = offering == null || offering.isEgressDefaultPolicy(); + + // Load ALL active (non-revoked) firewall rules for this network from the DB. + // applyFWRules is called in a narrow scope (only one public IP's ingress rules, or + // only egress rules per call), but the script does a full rebuild of the firewall + // chain. Querying the DB ensures every call produces a complete, correct chain. + List allRules = firewallRulesDao.listByNetworkAndPurposeAndNotRevoked( + network.getId(), FirewallRule.Purpose.Firewall); + // Skip System-type rules — the default egress policy is already conveyed by + // "default_egress_allow". System rules are transient (not stored in DB), but + // guard here anyway in case of future changes. + allRules = allRules.stream() + .filter(r -> !FirewallRule.FirewallRuleType.System.equals(r.getType())) + .collect(Collectors.toList()); + + for (FirewallRuleVO r : allRules) { + firewallRulesDao.loadSourceCidrs(r); + firewallRulesDao.loadDestinationCidrs(r); + } + + logger.info("applyFWRules: network={} activeRules={} defaultEgressAllow={}", + network, allRules.size(), defaultEgressAllow); + + JsonObject fwRules = new JsonObject(); + fwRules.addProperty("default_egress_allow", defaultEgressAllow); + fwRules.addProperty("cidr", safeStr(network.getCidr())); + JsonArray rulesArray = new JsonArray(); + for (FirewallRuleVO rule : allRules) { + boolean isEgress = FirewallRule.TrafficType.Egress.equals(rule.getTrafficType()); + JsonObject ruleObj = new JsonObject(); + ruleObj.addProperty("id", rule.getId()); + ruleObj.addProperty("type", isEgress ? "egress" : "ingress"); + ruleObj.addProperty("protocol", safeStr(rule.getProtocol())); + if (rule.getSourcePortStart() != null) ruleObj.addProperty("portStart", rule.getSourcePortStart()); + if (rule.getSourcePortEnd() != null) ruleObj.addProperty("portEnd", rule.getSourcePortEnd()); + if (rule.getIcmpType() != null) ruleObj.addProperty("icmpType", rule.getIcmpType()); + if (rule.getIcmpCode() != null) ruleObj.addProperty("icmpCode", rule.getIcmpCode()); + // For ingress rules include the public IP the rule is associated with. + if (!isEgress) ruleObj.addProperty("publicIp", getIpAddress(rule.getSourceIpAddressId())); + // sourceCidrs: for ingress = allowed external source IPs; + // for egress = allowed VM source IP ranges + JsonArray sourceCidrsArray = new JsonArray(); + List sourceCidrs = rule.getSourceCidrList(); + if (CollectionUtils.isNotEmpty(sourceCidrs)) { + for (String cidr : sourceCidrs) sourceCidrsArray.add(cidr); + } + ruleObj.add("sourceCidrs", sourceCidrsArray); + // destCidrs: optional destination CIDR filter (meaningful for egress rules) + JsonArray destCidrsArray = new JsonArray(); + List destCidrs = rule.getDestinationCidrList(); + if (CollectionUtils.isNotEmpty(destCidrs)) { + for (String cidr : destCidrs) destCidrsArray.add(cidr); + } + ruleObj.add("destCidrs", destCidrsArray); + rulesArray.add(ruleObj); + } + fwRules.add("rules", rulesArray); + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + payload.add("fw_rules", fwRules); + + boolean result = executeScript(network, CMD_APPLY_FW_RULES, payload); + if (!result) { + throw new ResourceUnavailableException( + "Failed to apply firewall rules for network " + network, + Network.class, network.getId()); + } + return true; + } + + // ---- AggregatedCommandExecutor ---- + + /** + * Called at the start of a network-restart cycle (before rules are re-programmed). + * We have nothing to "start" here — the batch restore is driven by + * {@link #completeAggregatedExecution}. + */ + @Override + public boolean prepareAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + if (!canHandle(network, null)) { + return true; + } + logger.debug("prepareAggregatedExecution: network={} dest={}", network, dest); + return true; + } + + /** + * Called after all firewall/NAT/LB rules have been re-applied during a network restart. + * + *

Queries all active User-VM NICs on this network from the database, builds a single + * batch JSON payload containing DHCP/DNS/metadata entries for every VM, and sends it to + * the wrapper script as a single {@code restore-network} call. This avoids N script + * invocations (one per VM) and instead performs the full restore in one shot.

+ */ + @Override + public boolean completeAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + if (!canHandle(network, null)) { + return true; + } + + logger.debug("completeAggregatedExecution: restoring all VM network data for network={} dest={}", network, dest); + + boolean dhcpEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.Dhcp) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.Dhcp, getProvider()); + boolean dnsEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.Dns) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.Dns, getProvider()); + boolean userdataEnabled = networkModel.areServicesSupportedInNetwork(network.getId(), Service.UserData) + && networkModel.isProviderSupportServiceInNetwork(network.getId(), Service.UserData, getProvider()); + + if (!dhcpEnabled && !dnsEnabled && !userdataEnabled) { + logger.debug("completeAggregatedExecution: no DHCP/DNS/UserData service for network={}, skipping", network); + return true; + } + + // Query all active User-VM NICs on this network + List nics = nicDao.listByNetworkIdAndType(network.getId(), VirtualMachine.Type.User); + if (CollectionUtils.isEmpty(nics)) { + logger.debug("completeAggregatedExecution: no user VM NICs on network={}, skipping", network); + return true; + } + + logger.info("completeAggregatedExecution: building batch restore for {} VMs on network={}", + nics.size(), network); + + JsonObject restoreData = buildRestoreNetworkData(network, nics, dhcpEnabled, dnsEnabled, userdataEnabled); + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + addExtensionIpToPayload(payload, network); + addNetworkDnsToPayload(payload, network); + payload.add("restore_data", restoreData); + + return executeScript(network, CMD_RESTORE_NETWORK, payload); + } + + /** + * Called in the {@code finally} block of the network-restart cycle to clean up any + * temporary state created by {@link #prepareAggregatedExecution}. + * Nothing to clean up here. + */ + @Override + public boolean cleanupAggregatedExecution(Network network, DeployDestination dest) + throws ResourceUnavailableException { + return true; + } + + /** + * Builds the base64-encoded JSON payload for {@code restore-network}. + * + *

The JSON structure is:

+ *
+     * {
+     *   "dhcp_enabled": true,
+     *   "dns_enabled":  true,
+     *   "userdata_enabled": true,
+     *   "vms": [
+     *     {
+     *       "ip":          "10.0.0.10",
+     *       "mac":         "02:00:00:00:00:01",
+     *       "hostname":    "vm-1",
+     *       "default_nic": true,
+     *       "vm_data": [
+     *         { "dir": "userdata", "file": "user-data", "content": "" },
+     *         { "dir": "meta-data", "file": "instance-id", "content": "" },
+     *         ...
+     *       ]
+     *     },
+     *     ...
+     *   ]
+     * }
+     * 
+ * + *

Each {@code vm_data} entry has its {@code content} as a plain UTF-8 string, + * matching the encoding used by the per-VM {@code save-vm-data} command.

+ */ + private JsonObject buildRestoreNetworkData(Network network, List nics, + boolean dhcpEnabled, boolean dnsEnabled, boolean userdataEnabled) { + + Map offeringNameCache = new HashMap<>(); + + JsonObject root = new JsonObject(); + root.addProperty("dhcp_enabled", dhcpEnabled); + root.addProperty("dns_enabled", dnsEnabled); + root.addProperty("userdata_enabled", userdataEnabled); + JsonArray vmsArray = new JsonArray(); + + for (NicVO nic : nics) { + if (nic.getState() != Nic.State.Reserved && nic.getState() != Nic.State.Allocated) { + continue; + } + if (nic.getIPv4Address() == null || nic.getMacAddress() == null) { + continue; + } + + long instanceId = nic.getInstanceId(); + + UserVmVO userVm = userVmDao.findById(instanceId); + if (userVm == null) { + continue; + } + + // Per-VM data array (only if UserData service is enabled) + List vmData = null; + if (userdataEnabled) { + try { + // Service offering display text + String offeringName = offeringNameCache.computeIfAbsent(userVm.getServiceOfferingId(), id -> { + try { + ServiceOfferingVO so = serviceOfferingDao.findById(id); + return so != null ? so.getDisplayText() : ""; + } catch (Exception e) { + return ""; + } + }); + + // SSH public key + String sshPublicKey = null; + try { + VMInstanceDetailVO sshKeyDetail = vmInstanceDetailsDao.findDetail(instanceId, VmDetailConstants.SSH_PUBLIC_KEY); + if (sshKeyDetail != null) { + sshPublicKey = sshKeyDetail.getValue(); + } + } catch (Exception e) { + logger.debug("Could not fetch SSH key for VM {}: {}", userVm, e.getMessage()); + } + + // Is Windows? + boolean isWindows = false; + try { + isWindows = guestOSCategoryDao + .findByIdIncludingRemoved(guestOSDao.findByIdIncludingRemoved(userVm.getGuestOSId()).getCategoryId()) + .getName().equalsIgnoreCase("Windows"); + } catch (Exception ignored) { } + + // Hypervisor hostname from current host + String destHostname = null; + try { + if (userVm.getHostId() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname( + hostDao.findById(userVm.getHostId()).getName()); + } + } catch (Exception ignored) { } + + vmData = networkModel.generateVmData( + userVm.getUserData(), + userVm.getUserDataDetails(), + offeringName, + userVm.getDataCenterId(), + userVm.getInstanceName(), + userVm.getHostName(), + userVm.getId(), + userVm.getUuid(), + nic.getIPv4Address(), + sshPublicKey, + null, // password — not re-issued on restore + isWindows, + destHostname); + } catch (Exception e) { + logger.warn("Could not generate vmData for VM {} on network {}: {}", userVm, network, e.getMessage()); + } + } + + JsonObject vmObj = new JsonObject(); + addNetworkDnsToPayload(vmObj, network); + vmObj.addProperty("ip", nic.getIPv4Address()); + vmObj.addProperty("ip6_address", safeStr(nic.getIPv6Address())); + vmObj.addProperty("mac", nic.getMacAddress()); + vmObj.addProperty("nic_uuid", safeStr(nic.getUuid())); + vmObj.addProperty("hostname", safeStr(userVm.getHostName())); + vmObj.addProperty("device_id", String.valueOf(nic.getDeviceId())); + vmObj.addProperty("default_nic", nic.isDefaultNic()); + + JsonArray vmDataArray = new JsonArray(); + if (CollectionUtils.isNotEmpty(vmData)) { + for (String[] entry : vmData) { + String dir = entry[NetworkModel.CONFIGDATA_DIR]; + String file = entry[NetworkModel.CONFIGDATA_FILE]; + String content = entry.length > NetworkModel.CONFIGDATA_CONTENT + ? entry[NetworkModel.CONFIGDATA_CONTENT] : null; + if (content == null) content = ""; + + String contentStr; + if (NetworkModel.USERDATA_DIR.equals(dir) && NetworkModel.USERDATA_FILE.equals(file)) { + try { + contentStr = new String(Base64.getDecoder().decode(content), StandardCharsets.UTF_8); + } catch (Exception e) { + contentStr = content; + } + } else { + contentStr = content; + } + + JsonObject entryObj = new JsonObject(); + entryObj.addProperty("dir", dir); + entryObj.addProperty("file", file); + entryObj.addProperty("content", contentStr); + vmDataArray.add(entryObj); + } + } + vmObj.add("vm_data", vmDataArray); + vmsArray.add(vmObj); + } + + root.add("vms", vmsArray); + return root; + } + + // ---- VpcProvider ---- + + /** + * Finds the extension + physical-network pair for the given VPC by scanning the + * physical networks in the VPC's zone for a registered NetworkOrchestrator extension. + * Returns {@code null} when no suitable extension is found. + */ + protected Pair resolveExtensionForVpc(Vpc vpc) { + List physNetworks; + List networks = networkDao.listByVpc(vpc.getId()); + if (CollectionUtils.isNotEmpty(networks)) { + physNetworks = new ArrayList<>(); + for (NetworkVO network : networks) { + PhysicalNetworkVO pn = physicalNetworkDao.findById(network.getPhysicalNetworkId()); + if (pn != null && !physNetworks.contains(pn)) { + physNetworks.add(pn); + } + } + } else { + physNetworks = physicalNetworkDao.listByZoneAndTrafficType(vpc.getZoneId(), Networks.TrafficType.Guest); + if (CollectionUtils.isEmpty(physNetworks)) { + return null; + } + } + for (PhysicalNetworkVO pn : physNetworks) { + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(pn.getId(), providerName); + if (ext != null) { + return new Pair<>(pn.getId(), ext); + } + } + return null; + } + + /** + * Resolves the script file for a VPC-level operation (no network object required). + */ + protected File resolveScriptFileForVpc(Long physicalNetworkId, Extension extension) { + if (physicalNetworkId == null) { + throw new CloudRuntimeException("No physical network ID for VPC extension"); + } + if (extension == null) { + throw new CloudRuntimeException("No extension found for physical network " + physicalNetworkId); + } + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not of type NetworkOrchestrator"); + } + if (!Extension.State.Enabled.equals(extension.getState())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not enabled"); + } + if (!extension.isPathReady()) { + throw new CloudRuntimeException("Extension " + extension.getName() + " path is not ready"); + } + String extensionPath = extensionHelper.getExtensionScriptPath(extension); + if (extensionPath == null) { + throw new CloudRuntimeException("Could not resolve path for extension " + extension.getName()); + } + File extensionFile = new File(extensionPath); + if (extensionFile.isFile() && extensionFile.canExecute()) { + return extensionFile; + } + throw new CloudRuntimeException( + "No executable script found in extension path " + extensionPath + + ". Expected '" + extension.getName() + ".sh'."); + } + + /** + * Calls {@code ensure-network-device} with VPC-level args (no {@code --network-id}). + * The returned JSON is persisted in {@code vpc_details} under key + * {@value #NETWORK_DETAIL_EXTENSION_DETAILS}. VPC tier networks then inherit + * these details via {@link #ensureExtensionDetails(Network)}. + */ + protected void ensureExtensionDetails(Vpc vpc) { + Map stored = vpcDetailsDao.listDetailsKeyPairs(vpc.getId()); + String currentDetails = stored != null + ? stored.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + + logger.info("Ensuring extension device for VPC {} (current={})", vpc, currentDetails); + + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + logger.warn("ensureExtensionDetails(vpc): no extension found for VPC {} zone {}", + vpc, vpc.getZoneId()); + return; + } + JsonObject argsPayload = new JsonObject(); + addVpcToPayload(argsPayload, vpc); + argsPayload.addProperty("current_details", currentDetails); + + try { + Pair result = executeVpcScriptAndReturnOutput(vpc, CMD_ENSURE_NETWORK_DEVICE, argsPayload); + String output = result.second() != null ? result.second() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.warn("ensure-network-device exited {} for VPC {} — keeping current details", + -1, vpc); + if ("{}".equals(currentDetails)) { + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + return; + } + if (output.isEmpty()) { + output = "{}".equals(currentDetails) ? "{}" : currentDetails; + } + if (!output.equals(currentDetails)) { + logger.info("VPC extension device updated for VPC {}: {}", vpc, output); + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, output, false); + } else { + logger.debug("VPC extension device unchanged for VPC {}: {}", vpc, output); + } + } catch (Exception e) { + logger.warn("Failed ensure-network-device for VPC {}: {}", vpc, e.getMessage()); + if ("{}".equals(currentDetails)) { + vpcDetailsDao.addDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + } + } + + /** + * Executes the extension script for a VPC-level command (no tier network required). + * Uses VPC-level details from {@code vpc_details}. + */ + protected boolean executeVpcScript(Vpc vpc, String command, JsonObject argsPayload) { + return executeVpcScriptAndReturnOutput(vpc, command, argsPayload).first() == EXIT_CODE_SUCCESS; + } + + protected Pair executeVpcScriptAndReturnOutput(Vpc vpc, String command, JsonObject argsPayload) { + Pair physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + logger.warn("executeVpcScript: no extension found for VPC {} zone {}", vpc, vpc.getZoneId()); + return new Pair<>(EXIT_CODE_FAILURE, "No extension found for VPC " + vpc.getUuid()); + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + try { + JsonObject payload = buildVpcScriptPayload(vpc, argsPayload, physicalNetworkId, extension); + return executeScriptWithFilePayload(scriptFile, command, payload, "VPC extension"); + } catch (Exception e) { + logger.error("Failed to execute VPC extension script {}: {}", command, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute VPC extension script: " + command, e); + } + } + + private JsonObject buildVpcScriptPayload(Vpc vpc, JsonObject argsPayload, Long physicalNetworkId, Extension extension) { + JsonObject payload = new JsonObject(); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(physicalNetworkId, extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, buildVpcExtensionDetailsPayload(vpc.getId())); + payload.add(ARG_PAYLOAD, argsPayload != null ? argsPayload : new JsonObject()); + return payload; + } + + protected PublicIpAddress getVpcSourceNatIp(long vpcId) { + final List ips = ipAddressDao.listByAssociatedVpc(vpcId, true); + if (CollectionUtils.isEmpty(ips)) { + return null; + } + IPAddressVO selected = null; + for (final IPAddressVO ip : ips) { + if (ip.getState() != IpAddress.State.Releasing) { + selected = ip; + break; + } + } + if (selected == null) { + selected = ips.get(0); + } + + final VlanVO vlan = vlanDao.findById(selected.getVlanId()); + if (vlan == null) { + logger.warn("No VLAN found for VPC source NAT IP {} (vpc={})", selected.getAddress(), vpcId); + return null; + } + return PublicIp.createFromAddrAndVlan(selected, vlan); + } + + /** + * Implements the VPC by: + *
    + *
  1. Calling {@link #ensureExtensionDetails(Vpc)} to select a host and + * save the VPC-level details (does not use any anchor tier network).
  2. + *
  3. Calling the script's {@code implement-vpc} command to create the VPC + * namespace and VPC-level networking state.
  4. + *
  5. Applying VPC source NAT if a source-NAT IP already exists (the script's + * {@code assign-ip} sets up the public veth + SNAT rule for the VPC CIDR).
  6. + *
+ */ + @Override + public boolean implementVpc(Vpc vpc, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + + // Step 1: Ensure a VPC extension device is selected and details saved at VPC level. + ensureExtensionDetails(vpc); + + // Step 2: Create the VPC namespace (no anchor tier network needed). + JsonObject implPayload = new JsonObject(); + addVpcToPayload(implPayload, vpc); + + // Include source NAT IP if already allocated, so the script can set up the + // VPC-level SNAT rule for the entire VPC CIDR. + final PublicIpAddress sourceNatIp = getVpcSourceNatIp(vpc.getId()); + if (sourceNatIp != null) { + addPublicIpToPayload(implPayload, sourceNatIp.getId(), true); + } + + return executeVpcScript(vpc, CMD_IMPLEMENT_VPC, implPayload); + } + + /** + * Shuts down the VPC by: + *
    + *
  1. Calling {@code destroy-network} for each extension-backed VPC tier (removes + * tier resources but preserves the shared VPC namespace).
  2. + *
  3. Calling {@code shutdown-vpc} to remove the VPC namespace and state after + * all tiers have been cleaned up.
  4. + *
+ */ + @Override + public boolean shutdownVpc(Vpc vpc, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + final List networks = networkModel.listNetworksByVpc(vpc.getId()); + + boolean result = true; + if (networks != null) { + for (final Network network : networks) { + if (!canHandle(network, null)) { + continue; + } + + final JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + + final boolean tierResult = executeScript(network, CMD_DESTROY_NETWORK, payload); + result = result && tierResult; + } + } + + // Remove the VPC namespace and VPC-level details regardless of tier result. + JsonObject vpcPayload = new JsonObject(); + addVpcToPayload(vpcPayload, vpc); + boolean vpcResult = executeVpcScript(vpc, CMD_SHUTDOWN_VPC, vpcPayload); + if (vpcResult) { + try { + vpcDetailsDao.removeDetail(vpc.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } catch (Exception e) { + logger.warn("Failed to remove VPC extension details for VPC {}: {}", vpc, e.getMessage()); + } + } + result = result && vpcResult; + + return result; + } + + @Override + public boolean createPrivateGateway(PrivateGateway gateway) + throws ConcurrentOperationException, ResourceUnavailableException { + throw new UnsupportedOperationException("Private gateways are not supported by the network extension element."); + } + + /** Private gateways are not supported by the network extension element. */ + @Override + public boolean deletePrivateGateway(PrivateGateway gateway) + throws ConcurrentOperationException, ResourceUnavailableException { + throw new UnsupportedOperationException("Private gateways are not supported by the network extension element."); + } + + /** Static routes are not supported by the network extension element. */ + @Override + public boolean applyStaticRoutes(Vpc vpc, List routes) + throws ResourceUnavailableException { + throw new UnsupportedOperationException("Static routes are not supported by the network extension element."); + } + + /** ACL items on private gateways are not supported by the network extension element. */ + @Override + public boolean applyACLItemsToPrivateGw(PrivateGateway gateway, List rules) + throws ResourceUnavailableException { + throw new UnsupportedOperationException("ACL items on private gateways are not supported by the network extension element."); + } + + @Override + public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { + if (vpc == null || address == null || address.getAddress() == null) { + logger.warn("updateVpcSourceNatIp: invalid input (vpc={}, address={})", vpc, address); + return false; + } + + final JsonObject payload = new JsonObject(); + addVpcToPayload(payload, vpc); + addPublicIpToPayload(payload, address.getId(), true); + + final boolean result = executeVpcScript(vpc, CMD_UPDATE_VPC_SOURCE_NAT_IP, payload); + if (!result) { + logger.warn("updateVpcSourceNatIp: failed to update source NAT IP for VPC {} to {}", + vpc, address.getAddress().addr()); + } + return result; + } + + /** + * Applies VPC network ACL rules for a VPC tier network via the script's + * {@code apply-network-acl} command. Rules are serialised as a JSON array + * and passed via a temporary payload file. + * + *

Script command: {@code apply-network-acl}

+ */ + @Override + public boolean applyNetworkACLs(Network config, List rules) + throws ResourceUnavailableException { + if (!canHandle(config, Service.NetworkACL)) { + return true; + } + + // Rebuild the ACL chain from all non-revoked rules. + List activeRules = rules == null ? List.of() : + rules.stream() + .filter(r -> r.getState() != NetworkACLItem.State.Revoke) + .collect(Collectors.toList()); + + logger.info("applyNetworkACLs: network={} activeRules={}", config, activeRules.size()); + + JsonArray aclRules = buildAclRulesArray(activeRules); + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, config); + payload.add("acl_rules", aclRules); + + boolean result = executeScript(config, CMD_APPLY_NETWORK_ACL, payload); + if (!result) { + throw new ResourceUnavailableException( + "Failed to apply network ACL rules for network " + config.getUuid(), + Network.class, config.getId()); + } + return true; + } + + /** + * Re-applies ACL rules for all extension-backed networks in a VPC after a rule reorder. + * Calls {@code apply-network-acl} for each affected network with the full ACL item list. + */ + @Override + public boolean reorderAclRules(Vpc vpc, List networks, + List networkACLItems) { + if (CollectionUtils.isEmpty(networks)) { + return true; + } + + List activeRules = networkACLItems == null ? List.of() : + networkACLItems.stream() + .filter(r -> r.getState() != NetworkACLItem.State.Revoke) + .collect(Collectors.toList()); + + boolean result = true; + for (Network network : networks) { + if (!canHandle(network, Service.NetworkACL)) { + continue; + } + try { + JsonArray aclRules = buildAclRulesArray(activeRules); + + JsonObject payload = new JsonObject(); + addNetworkToPayload(payload, network); + payload.add("acl_rules", aclRules); + + boolean r = executeScript(network, CMD_APPLY_NETWORK_ACL, payload); + result = result && r; + } catch (Exception e) { + logger.warn("reorderAclRules: failed for network {}: {}", network, e.getMessage()); + result = false; + } + } + return result; + } + + /** + * Serialises a list of {@link NetworkACLItem}s to a {@link JsonArray} + * suitable for passing to the {@code apply-network-acl} script command. + * Rules are sorted by their number (priority order). + */ + private JsonArray buildAclRulesArray(List rules) { + JsonArray array = new JsonArray(); + List sorted = rules.stream() + .sorted(java.util.Comparator.comparingInt(NetworkACLItem::getNumber)) + .collect(Collectors.toList()); + for (NetworkACLItem rule : sorted) { + JsonObject ruleObj = buildAclRuleObject(rule); + JsonArray sourceCidrsArray = new JsonArray(); + List sourceCidrs = rule.getSourceCidrList(); + if (CollectionUtils.isNotEmpty(sourceCidrs)) { + for (String cidr : sourceCidrs) sourceCidrsArray.add(cidr); + } + ruleObj.add("sourceCidrs", sourceCidrsArray); + array.add(ruleObj); + } + return array; + } + + private JsonObject buildAclRuleObject(NetworkACLItem rule) { + JsonObject ruleObj = new JsonObject(); + ruleObj.addProperty("number", rule.getNumber()); + ruleObj.addProperty("action", rule.getAction().name().toLowerCase()); + ruleObj.addProperty("trafficType", rule.getTrafficType().name().toLowerCase()); + ruleObj.addProperty("protocol", safeStr(rule.getProtocol())); + if (rule.getSourcePortStart() != null) ruleObj.addProperty("portStart", rule.getSourcePortStart()); + if (rule.getSourcePortEnd() != null) ruleObj.addProperty("portEnd", rule.getSourcePortEnd()); + if (rule.getIcmpType() != null) ruleObj.addProperty("icmpType", rule.getIcmpType()); + if (rule.getIcmpCode() != null) ruleObj.addProperty("icmpCode", rule.getIcmpCode()); + return ruleObj; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md new file mode 100644 index 000000000000..9101a591587e --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/README.md @@ -0,0 +1,1493 @@ + + +# Network Extension Script Protocol + +This document describes the complete interface between Apache CloudStack's +`NetworkExtensionElement` and the external script (Bash, Python, or any +executable) that implements network services for a custom device. + +Any executable that handles the commands listed below can be registered as a +**NetworkOrchestrator extension** and used as the provider for one or more +CloudStack network services (DHCP, DNS, UserData, SourceNat, StaticNat, +PortForwarding, Firewall, Lb, NetworkACL, Gateway). + +The reference implementation is the `network-namespace` extension at +`extensions/network-namespace/`, which uses Linux network namespaces on KVM +hosts. Use it as a working example. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Script Placement Convention](#script-placement-convention) +3. [CloudStack Setup Steps](#cloudstack-setup-steps) +4. [Always-present payload fields](#always-present-payload-fields) +5. [Shared payload fields](#shared-payload-fields) +6. [Command Reference](#command-reference) + - [ensure-network-device](#ensure-network-device) + - [implement-network](#implement-network) + - [shutdown-network](#shutdown-network) + - [destroy-network](#destroy-network) + - [implement-vpc](#implement-vpc) + - [shutdown-vpc](#shutdown-vpc) + - [update-vpc-source-nat-ip](#update-vpc-source-nat-ip) + - [assign-ip / release-ip](#assign-ip--release-ip) + - [add-static-nat / delete-static-nat](#add-static-nat--delete-static-nat) + - [add-port-forward / delete-port-forward](#add-port-forward--delete-port-forward) + - [apply-fw-rules](#apply-fw-rules) + - [apply-network-acl](#apply-network-acl) + - [prepare-nic / release-nic](#prepare-nic--release-nic) + - [add-dhcp-entry / remove-dhcp-entry](#add-dhcp-entry--remove-dhcp-entry) + - [config-dhcp-subnet / remove-dhcp-subnet](#config-dhcp-subnet--remove-dhcp-subnet) + - [set-dhcp-options](#set-dhcp-options) + - [add-dns-entry](#add-dns-entry) + - [config-dns-subnet / remove-dns-subnet](#config-dns-subnet--remove-dns-subnet) + - [save-vm-data](#save-vm-data) + - [save-password](#save-password) + - [save-userdata](#save-userdata) + - [save-sshkey](#save-sshkey) + - [save-hypervisor-hostname](#save-hypervisor-hostname) + - [apply-lb-rules](#apply-lb-rules) + - [restore-network](#restore-network) + - [custom-action](#custom-action) +7. [Service-to-Command Mapping](#service-to-command-mapping) +8. [Capabilities Configuration](#capabilities-configuration) +9. [VPC Networks](#vpc-networks) +10. [Extension IP](#extension-ip) +11. [Exit Codes](#exit-codes) +12. [Minimal Script Skeleton](#minimal-script-skeleton) + +--- + +## Architecture Overview + +``` +CloudStack Management Server + │ + │ exec /.sh + │ payload-file contains JSON for the command invocation + ▼ + Your Script (Bash / Python / Go / …) + │ + │ configures / queries your device: + │ • KVM host over SSH + │ • SDN controller REST API + │ • Hardware appliance CLI + │ • Cloud provider API + ▼ + External Network Device +``` + +CloudStack calls the script synchronously (blocking process execution) on the +**management server** for every network event. The script is responsible for +translating those events into configuration changes on the actual device. + +The script must: + +- **Exit 0** on success. +- **Exit non-zero** on failure (CloudStack will log the error and may retry). +- For `ensure-network-device` only, **print a single-line JSON object** to + stdout (see [ensure-network-device](#ensure-network-device)). + +All other commands must produce no output on stdout (any output is logged at +DEBUG level and ignored). + +--- + +## Script Placement Convention + +CloudStack resolves the executable in this order (first match wins): + +1. **`/.sh`** — preferred convention. + Example: extension named `my-sdn` → script at + `.../my-sdn/my-sdn.sh`. +2. **`` itself**, if it is a regular file and is executable. + +The `` is the `path` field returned by `listExtensions` after +the extension is created. CloudStack sets it to: + +``` +/usr/share/cloudstack-management/extensions// +``` + +> **Tip:** Your script does not have to live on the management server — it can +> be a thin proxy that SSHes into a remote appliance. The +> `network-namespace.sh` entry-point is exactly that: it SSHes into the target +> KVM host and calls the wrapper script there. + +--- + +## CloudStack Setup Steps + +### Step 1 – Create the Extension + +```bash +cmk createExtension \ + name=my-sdn \ + type=NetworkOrchestrator \ + details[0].network.services="SourceNat,StaticNat,PortForwarding,Firewall,Lb,Dhcp,Dns,UserData,CustomAction" \ + details[1].network.service.capabilities="$(cat my-sdn-capabilities.json)" \ + details[2].network.isolation.method="NetworkExtension" +``` + +`network.service.capabilities` is a JSON object — see +[Capabilities Configuration](#capabilities-configuration). + +### Step 2 – Deploy the Script + +Copy your executable to the path reported by `listExtensions`: + +```bash +SCRIPT_PATH=$(cmk listExtensions name=my-sdn | jq -r '.[0].path') +# e.g. /usr/share/cloudstack-management/extensions/my-sdn/ +mkdir -p "${SCRIPT_PATH}" +cp my-sdn.sh "${SCRIPT_PATH}/my-sdn.sh" +chmod 755 "${SCRIPT_PATH}/my-sdn.sh" +``` + +If you have multiple management servers, deploy the script to **every** one. + +### Step 3 – Register the Extension to a Physical Network + +```bash +PHYS_ID=$(cmk listPhysicalNetworks | jq -r '.[0].id') +EXT_ID=$(cmk listExtensions name=my-sdn | jq -r '.[0].id') + +cmk registerExtension \ + id=${EXT_ID} \ + resourcetype=PhysicalNetwork \ + resourceid=${PHYS_ID} \ + details[0].hosts="192.168.1.10,192.168.1.11" \ + details[1].username="root" \ + details[2].password="s3cr3t" +``` + +To update the registration details later, use `updateRegisteredExtension`: + +```bash +cmk updateRegisteredExtension \ + extensionid=${EXT_ID} \ + resourcetype=PhysicalNetwork \ + resourceid=${PHYS_ID} \ + details[0].hosts="192.168.1.10,192.168.1.11" \ + details[1].username="root" \ + details[2].password="s3cr3t" +``` + +Any key/value pairs you pass here are stored with the physical-network +registration as extension metadata. The `custom-action` path embeds them +directly into the payload file under `physical-network-extension-details`. +The schema is entirely yours — CloudStack treats it as opaque. + +> **`network.isolation.method=NetworkExtension`** must be set as an Extension +> detail (via `createExtension` or `updateExtension`). +> This is required whenever your `implement-network` handler outputs JSON that +> updates the network's broadcast domain type. +> Without it the script output is accepted but silently ignored — the network +> record in the CloudStack database is not updated. + +### Step 4 – Enable the Network Service Provider + +```bash +NSP_ID=$(cmk listNetworkServiceProviders physicalnetworkid=${PHYS_ID} \ + name=my-sdn | jq -r '.[0].id') +cmk updateNetworkServiceProvider id=${NSP_ID} state=Enabled +``` + +### Step 5 – Create a Network Offering + +```bash +cmk createNetworkOffering \ + name="My-SDN-Offering" \ + displaytext="My SDN network offering" \ + guestiptype=Isolated \ + traffictype=GUEST \ + supportedservices="Dhcp,Dns,UserData,SourceNat,StaticNat,PortForwarding,Firewall,Lb" \ + serviceProviderList[0].service=Dhcp serviceProviderList[0].provider=my-sdn \ + serviceProviderList[1].service=Dns serviceProviderList[1].provider=my-sdn \ + serviceProviderList[2].service=UserData serviceProviderList[2].provider=my-sdn \ + serviceProviderList[3].service=SourceNat serviceProviderList[3].provider=my-sdn \ + serviceProviderList[4].service=StaticNat serviceProviderList[4].provider=my-sdn \ + serviceProviderList[5].service=PortForwarding serviceProviderList[5].provider=my-sdn \ + serviceProviderList[6].service=Firewall serviceProviderList[6].provider=my-sdn \ + serviceProviderList[7].service=Lb serviceProviderList[7].provider=my-sdn \ + serviceCapabilityList[0].service=SourceNat \ + serviceCapabilityList[0].capabilitytype=SupportedSourceNatTypes \ + serviceCapabilityList[0].capabilityvalue=peraccount +cmk updateNetworkOffering id= state=Enabled +``` + +--- + +## Always-present payload fields + +For all standard network / VPC commands, CloudStack now executes the script as: + +```text +/.sh +``` + +`payload-file` contains a JSON object with this envelope: + +```json +{ + "physical-network-extension-details": {}, + "network-extension-details": {}, + "payload": {} +} +``` + +| Field | Value | +|---|---| +| `physical-network-extension-details` | Physical-network extension metadata registered on the physical network, enriched with `physicalnetworkname`. | +| `network-extension-details` | Additional network or VPC details stored in CloudStack and forwarded as a JSON object. | +| `payload` | Command-specific JSON object for the command being executed. | + +`timeout-seconds` is currently `60`. + +> **Important:** `custom-action` is still the exception in shape. It uses its +> own top-level payload structure and does **not** wrap command-specific fields +> under a nested `payload` object. Use top-level `action-params` for +> command-specific parameters. + +--- + +## Shared payload fields + +The following names appear repeatedly inside the nested `payload` object. + +### Network-level fields (added by `addNetworkToPayload`) + +| Field | Description | +|---|---| +| `network_id` | CloudStack numeric network ID. | +| `vlan` | Guest VLAN tag (for example `100`). Extracted from the broadcast URI. May be empty for flat networks. | +| `zone_id` | CloudStack zone ID. | +| `guest_type` | Guest network type: `"isolated"`, `"shared"`, or `"l2"`. Scripts should use this to skip NAT/firewall operations that are not applicable to Shared or L2 networks. | +| `network_state` | Guest network state: `"allocated"`, `"setup"`, `"implementing"`, `"implemented"`, `"shutdown"` or `"destroy"`. | +| `gateway` | Guest network gateway (for example `10.0.0.1`). Omitted when blank. | +| `cidr` | Guest network CIDR (for example `10.0.0.0/24`). Omitted when blank. | +| `vpc_id` | CloudStack numeric VPC ID. Present for VPC tier networks and VPC-scoped commands. | +| `network_ip6_gateway` | Guest network IPv6 gateway, when the network has IPv6 configured. | +| `network_ip6_cidr` | Guest network IPv6 CIDR, when the network has IPv6 configured. | + +### NIC-level fields (added by `addNicToPayload`) + +| Field | Description | +|---|---| +| `nic_id` | CloudStack numeric NIC ID. | +| `nic_uuid` | NIC UUID — matches `external_ids:iface-id` written by the KVM agent for OVN port binding. | +| `mac` | VM NIC MAC address. | +| `ip` | VM NIC IPv4 address. | +| `gateway` | VM NIC IPv4 gateway (NIC-level; equals the network gateway for normal guest networks). | +| `netmask` | VM NIC IPv4 netmask (for example `255.255.255.0`). | +| `default_nic` | Stringified boolean — `"false"` for secondary NICs. | +| `device_id` | NIC device index in the VM (slot number). | +| `ip6_address` | VM NIC IPv6 address, when the NIC has IPv6 configured. | +| `ip6_gateway` | VM NIC IPv6 gateway, when available. | +| `ip6_cidr` | VM NIC IPv6 CIDR, when available. | + +### Public-IP fields (added by `addPublicIpToPayload`) + +| Field | Description | +|---|---| +| `public_ip` | A public IP address. | +| `public_vlan` | VLAN tag of the public IP segment. | +| `public_gateway` | Gateway of the public IP segment. | +| `public_cidr` | CIDR of the public IP (for example `203.0.113.0/24`). | +| `source_nat` | Stringified boolean (`"true"` / `"false"`) indicating whether the public IP is the source-NAT IP. | +| `private_ip` | A VM's private guest-network IP address (NAT target). | + +### DNS / extension-IP fields + +| Field | Description | +|---|---| +| `extension_ip` | The IP the extension device uses on the guest side. Equals the gateway when SourceNat/Gateway is provided; otherwise it is a dedicated IP from the guest subnet. | +| `dns` | Comma-separated DNS server list. | +| `domain` | Network domain suffix. | + +--- + +## Command Reference + +### `ensure-network-device` + +**Called:** Before every network operation, automatically by +`NetworkExtensionElement`. + +**Purpose:** Select (or re-validate) the device/host that will handle this +network. Perform failover to another host if the current one is unreachable. +The returned JSON is stored in `network_details` under key `extension.details` and +passed back to later `ensure-network-device` calls as `payload.current_details`. + +**Payload file shape:** + +```json +{ + "physical-network-extension-details": {}, + "network-extension-details": {}, + "payload": { + "network_id": "42", + "vlan": "100", + "zone_id": "1", + "vpc_id": "7", + "current_details": "{}" + } +} +``` + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. Omitted for VPC-level calls. | +| `vlan` | Guest VLAN. Present only for network-level calls. | +| `zone_id` | CloudStack zone ID. | +| `vpc_id` | VPC ID for VPC-level calls, and also present for VPC tier networks. | +| `current_details` | Previously stored `extension.details` JSON string (`{}` on first call). | + +**Stdout:** A single-line JSON object. CloudStack stores this verbatim. +You can put any fields your script needs (host selection, device ID, segment +ID, namespace name, etc.). + +```json +{"host":"192.168.1.10","device_id":"vrf-42","namespace":"cs-net-42"} +``` + +**Exit 0:** JSON written to stdout is persisted. +**Exit non-zero:** Existing details are kept unchanged; a warning is logged. + +--- + +### `implement-network` + +**Called:** When a network is implemented (first VM deployed, or network +restart). + +**Purpose:** Create / bring up the network segment on the device: create the +virtual segment (VRF, namespace, VLAN, …), attach the guest interface, and +configure the gateway. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `extension_ip` | Device IP on the guest network. | +| `vpc_id` | Present for VPC tier networks. | + +> **IPv6 note:** For network-scoped commands that already include `gateway`/`cidr`, +> CloudStack now also includes `network_ip6_gateway` and `network_ip6_cidr` +> when the guest network has IPv6 configured. + +**Stdout (required when `network.isolation.method=NetworkExtension`):** + +When the Extension detail `network.isolation.method=NetworkExtension` is set, +CloudStack selects `NetworkExtensionGuestNetworkGuru` and applies the JSON object +printed to stdout back to the network record. The following two fields **must** +be present in the output so that CloudStack stores the correct broadcast type and +URI — without them the KVM agent (`OvsVifDriver`) will not set +`external_ids:iface-id` on the OVS tap port and OVN port-binding will fail: + +| Output key | Required value | Description | +|---|---|---| +| `network.broadcast_domain_type` | `"Lswitch"` (OVN) or appropriate type | Sets `BroadcastDomainType` on the network record. | +| `network.broadcast_uri` | e.g. `"ovn://cs-net-"` | Sets the broadcast URI used by the hypervisor agent. | + +Example stdout: + +```json +{"network.broadcast_domain_type": "Lswitch", "network.broadcast_uri": "ovn://cs-net-42"} +``` + +> **Note:** These fields are only consumed when the Extension detail +> `network.isolation.method=NetworkExtension` is set. Without it, the script +> output is accepted but the network record is **not** updated (the update is +> silently skipped). + +--- + +### `shutdown-network` + +**Called:** When a network is shut down (e.g., all VMs removed, before +deletion). + +**Purpose:** Tear down the network segment; release resources. The +per-network `extension.details` blob is removed from CloudStack after a successful +return. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `destroy-network` + +**Called:** When a network is permanently deleted. + +**Purpose:** Same as `shutdown-network`, but called at hard-delete time. The +placeholder NIC IP (if any) and the `extension.details` blob are cleaned up +automatically by CloudStack after a successful return. + +**Payload fields (`payload` object):** Identical to `shutdown-network`. + +--- + +### `implement-vpc` + +**Called:** When a VPC is implemented (before any tier is set up). + +**Purpose:** Create the VPC-level networking state on the device (e.g., a +shared namespace or VRF that all tiers will attach to). If a source-NAT IP +is already allocated for the VPC, its details are also included so the script +can set up the VPC-level SNAT rule at this stage. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `vpc_id` | VPC ID. | +| `vpc_cidr` | VPC supernet CIDR. | +| `public_ip` | Source-NAT IP, when already allocated. | +| `public_vlan` | VLAN of the source-NAT IP, when present. | +| `public_gateway` | Gateway of the source-NAT IP segment, when present. | +| `public_cidr` | CIDR of the source-NAT IP, when present. | +| `source_nat` | `"true"` when the public IP fields are present. | + +--- + +### `shutdown-vpc` + +**Called:** During `shutdownVpc` after all tier networks have been destroyed. + +**Purpose:** Remove the VPC-level namespace / VRF and all associated state. +The `extension.details` blob is removed from CloudStack after a successful return. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `vpc_id` | VPC ID. | + +--- + +### `update-vpc-source-nat-ip` + +**Called:** When the VPC source-NAT IP changes +(`updateVpcSourceNatIp` API). + +**Purpose:** Update the VPC-level SNAT rule to point to the new public IP. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `vpc_id` | VPC ID. | +| `vpc_cidr` | VPC supernet CIDR. | +| `public_ip` | New source-NAT IP. | +| `public_vlan` | VLAN of the new source-NAT IP. | +| `public_gateway` | Gateway of the new source-NAT IP segment. | +| `public_cidr` | CIDR of the new source-NAT IP. | +| `source_nat` | Always `"true"`. | + +--- + +### `assign-ip` / `release-ip` + +**Called:** When a public IP is associated with or disassociated from a +network (source NAT, static NAT, PF, LB allocation). + +**Purpose:** +- `assign-ip` — attach the public IP to the device; add the necessary routing + entry so the device can receive traffic for this IP. +- `release-ip` — detach the public IP; remove routing. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN. | +| `public_ip` | The public IP being assigned or released. | +| `source_nat` | `"true"` if this is the source NAT IP. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `public_gateway` | Gateway of the public IP segment. | +| `public_cidr` | CIDR of the public IP. | +| `public_vlan` | Public VLAN tag. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `add-static-nat` / `delete-static-nat` + +**Called:** When a static NAT rule is created or deleted +(`enableStaticNat` / `disableStaticNat` API). + +**Purpose:** Configure a 1:1 bidirectional NAT mapping between a public IP +and a VM private IP. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `public_ip` | Public IP. | +| `public_cidr` | Public IP CIDR. | +| `public_vlan` | Public VLAN tag. | +| `private_ip` | VM private IP (DNAT destination). | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `add-port-forward` / `delete-port-forward` + +**Called:** When a port forwarding rule is created or deleted +(`createPortForwardingRule` / `deletePortForwardingRule` API). + +**Purpose:** Configure a DNAT rule from `public-ip:public-port` to +`private-ip:private-port`. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `public_ip` | Public IP. | +| `public_cidr` | Public IP CIDR. | +| `public_vlan` | Public VLAN tag. | +| `public_port` | Port range on the public IP, for example `22` or `8080-8090`. | +| `private_ip` | VM private IP. | +| `private_port` | Destination port range on the VM. | +| `protocol` | Protocol such as `tcp` or `udp`. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `apply-fw-rules` + +**Called:** When any firewall rule is created, deleted, or updated for the +network (`createFirewallRule`, `deleteFirewallRule`, `updateEgressFirewallRule` +APIs, and during network restart). + +**Purpose:** Rebuild the entire firewall policy for the network from scratch. +CloudStack calls this with a *narrow* scope (one IP's ingress rules or egress +rules per call), but the `fw_rules` payload field always contains **all** active +rules for the network, so a full rebuild is always safe. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `fw_rules` | JSON object containing the firewall payload shown below. | +| `vpc_id` | Present for VPC tier networks. | + +**`fw_rules` JSON:** + +```json +{ + "default_egress_allow": true, + "cidr": "10.0.0.0/24", + "rules": [ + { + "id": 1, + "type": "ingress", + "protocol": "tcp", + "portStart": 22, + "portEnd": 22, + "publicIp": "203.0.113.5", + "sourceCidrs": ["0.0.0.0/0"], + "destCidrs": [] + }, + { + "id": 2, + "type": "egress", + "protocol": "icmp", + "icmpType": -1, + "icmpCode": -1, + "sourceCidrs": [], + "destCidrs": [] + } + ] +} +``` + +| Field | Description | +|---|---| +| `default_egress_allow` | `true` = permissive egress by default (explicit rules are deny rules); `false` = restrictive (explicit rules are allow rules). | +| `cidr` | Guest network CIDR. | +| `rules[].type` | `"ingress"` or `"egress"`. | +| `rules[].protocol` | `"tcp"`, `"udp"`, `"icmp"`, `"all"`. | +| `rules[].portStart` / `portEnd` | TCP/UDP port range (absent for ICMP/all). | +| `rules[].icmpType` / `icmpCode` | ICMP type/code (`-1` = any; absent for TCP/UDP). | +| `rules[].publicIp` | For ingress: the public IP the rule applies to. | +| `rules[].sourceCidrs` | Allowed source IP ranges (ingress: external; egress: VM). | +| `rules[].destCidrs` | Allowed destination IP ranges (egress only). | + +--- + +### `apply-network-acl` + +**Called:** When a VPC network ACL is applied to a tier network +(`createNetworkACLList`, `replaceNetworkACLList`, `updateNetworkACLItem`, +`moveNetworkAclItem` APIs, and during network restart). + +**Purpose:** Rebuild the entire ACL policy for the VPC tier from scratch. +Rules are applied in ascending `number` order. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `acl_rules` | JSON array of ACL rules shown below. | +| `vpc_id` | VPC ID. Always present for VPC tiers. | + +**`acl_rules` JSON:** + +```json +[ + { + "number": 10, + "action": "allow", + "trafficType": "ingress", + "protocol": "tcp", + "portStart": 22, + "portEnd": 22, + "sourceCidrs": ["0.0.0.0/0"] + }, + { + "number": 20, + "action": "deny", + "trafficType": "egress", + "protocol": "all", + "sourceCidrs": [] + } +] +``` + +| Field | Description | +|---|---| +| `number` | Rule priority (lower number = higher priority). | +| `action` | `"allow"` or `"deny"`. | +| `trafficType` | `"ingress"` or `"egress"`. | +| `protocol` | `"tcp"`, `"udp"`, `"icmp"`, `"all"`. | +| `portStart` / `portEnd` | TCP/UDP port range (absent for ICMP/all). | +| `icmpType` / `icmpCode` | ICMP type/code (absent for TCP/UDP). | +| `sourceCidrs` | Source CIDR filter list. | + +--- + +### `prepare-nic` / `release-nic` + +**Called:** On every NIC attach (`prepare`) and detach (`release`) regardless +of which services the extension provides. + +**Purpose:** +- `prepare-nic` — set up per-NIC state before the VM boots: create the port + binding on the device (OVN `Logical_Switch_Port`, dnsmasq entry, …). +- `release-nic` — tear down per-NIC state after the VM is destroyed: remove the + port binding and associated metadata. + +These commands fire for **all** NICs on extension-managed networks, not just +those belonging to DHCP/DNS-enabled offerings. + +**`prepare-nic` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `mac` | VM NIC MAC address. | +| `ip` | VM NIC IPv4 address. | +| `nic_ip6_address` | NIC IPv6 address, when configured. | +| `nic_ip6_gateway` | NIC IPv6 gateway, when available. | +| `nic_ip6_cidr` | NIC IPv6 CIDR, when available. | +| `nic_uuid` | NIC UUID — matches `external_ids:iface-id` written by the KVM agent for OVN port binding. | +| `default_nic` | Stringified boolean — `"false"` for secondary NICs. | +| `hostname` | VM hostname. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `extension_ip` | Extension IP. | +| `vpc_id` | Present for VPC tier networks. | + +**`release-nic` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `mac` | VM NIC MAC address. | +| `ip` | VM NIC IPv4 address. | +| `nic_ip6_address` | NIC IPv6 address, when configured. | +| `nic_ip6_gateway` | NIC IPv6 gateway, when available. | +| `nic_ip6_cidr` | NIC IPv6 CIDR, when available. | +| `nic_uuid` | NIC UUID. | +| `extension_ip` | Extension IP. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `add-dhcp-entry` / `remove-dhcp-entry` + +**Called:** When a VM NIC is reserved (`add`) or released (`remove`) on a +network whose DHCP service is provided by this extension. + +**Purpose:** Add or remove a static DHCP lease for the VM. + +> **IPv6 note:** For NIC-scoped commands (`add/remove-dhcp-entry`, +> `add-dns-entry`, `save-vm-data`, `save-password`, `save-userdata`, +> `save-sshkey`, `save-hypervisor-hostname`), CloudStack includes +> `nic_ip6_address`, `nic_ip6_gateway`, and `nic_ip6_cidr` when the NIC +> has IPv6 information. + +**`add-dhcp-entry` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `mac` | VM NIC MAC address, for example `02:00:00:00:00:01`. | +| `ip` | VM assigned IP. | +| `hostname` | VM hostname. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `dns` | Comma-separated DNS server list. | +| `default_nic` | Stringified boolean indicating whether this NIC is the default NIC. | +| `domain` | Network domain suffix. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +**`remove-dhcp-entry` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `mac` | VM NIC MAC address. | +| `ip` | VM assigned IP. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `config-dhcp-subnet` / `remove-dhcp-subnet` + +**Called:** When a shared-network subnet is configured or removed. + +**Purpose:** Configure the DHCP scope (pool, gateway, DNS) for a subnet +without tying it to a specific VM. + +**`config-dhcp-subnet` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `dns` | Comma-separated DNS server list. | +| `vlan` | Guest VLAN tag. | +| `domain` | Network domain suffix. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +**`remove-dhcp-subnet` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `extension_ip` | Extension IP. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `set-dhcp-options` + +**Called:** When extra DHCP options are set on a NIC +(`updateNicExtraDhcpOption` API). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `nic_id` | CloudStack NIC ID. | +| `options` | Compact JSON string such as `{"15":"example.com","119":"search.example.com"}`. | +| `extension_ip` | Extension IP. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `add-dns-entry` + +**Called:** When a VM NIC is reserved on a network whose DNS service is +provided by this extension. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `hostname` | VM hostname. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `config-dns-subnet` / `remove-dns-subnet` + +**Called:** When a DNS scope is configured or removed for a subnet. + +**`config-dns-subnet` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `dns` | Comma-separated DNS server list. | +| `vlan` | Guest VLAN tag. | +| `domain` | Network domain suffix. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +**`remove-dns-subnet` payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `extension_ip` | Extension IP. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `save-vm-data` + +**Called:** When a VM is deployed or updated with user data, SSH keys, or +password on a network whose UserData service is provided by this extension. + +**Purpose:** Store the complete cloud-init metadata set (user-data, +meta-data/*, password) for the VM so the metadata HTTP server can serve it. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `gateway` | Gateway of the VM NIC on this network. | +| `extension_ip` | Extension IP. | +| `vm_data` | JSON array shown below. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +**`vm_data` JSON:** + +```json +[ + {"dir":"userdata", "file":"user-data", "content":""}, + {"dir":"meta-data", "file":"instance-id", "content":""}, + {"dir":"meta-data", "file":"local-hostname", "content":""}, + {"dir":"meta-data", "file":"public-keys/0/openssh-key", "content":""}, + {"dir":"password", "file":"vm_password", "content":""} +] +``` + +Each `content` field is a plain UTF-8 string. Write it directly to disk. + +Your metadata HTTP server should serve each entry at: +`http:///latest//` + +--- + +### `save-password` + +**Called:** When a password reset is requested for a VM +(`resetPasswordForVirtualMachine` API). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `gateway` | Gateway of the VM NIC. | +| `password` | Plain-text new password. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `save-userdata` + +**Called:** When a VM's user data is updated +(`updateVirtualMachine` with `userdata`). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `gateway` | Gateway of the VM NIC. | +| `userdata` | User-data as plain text. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `save-sshkey` + +**Called:** When an SSH public key is reset for a VM +(`resetSSHKeyForVirtualMachine` API). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `gateway` | Gateway of the VM NIC. | +| `sshkey` | SSH public key (plain text). | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `save-hypervisor-hostname` + +**Called:** When a VM is deployed and UserData service is active. + +**Purpose:** Store the hypervisor hostname in the metadata so VMs can identify +which host they run on (cloud-init `availability-zone` / host detection). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `ip` | VM IP. | +| `gateway` | Gateway of the VM NIC. | +| `hypervisor_hostname` | Hypervisor node hostname. | +| `extension_ip` | Extension IP. | +| `nic_uuid` | NIC UUID, when available. | +| `vpc_id` | Present for VPC tier networks. | + +--- + +### `apply-lb-rules` + +**Called:** When a load balancer rule is created, deleted, or its members +change (`createLoadBalancerRule`, `deleteLoadBalancerRule`, +`assignToLoadBalancerRule`, `removeFromLoadBalancerRule` APIs). + +**Purpose:** Configure the load balancer on the device: create/update/delete +virtual server → backend pool mappings. + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `vlan` | Guest VLAN tag. | +| `lb_rules` | JSON array of LB rules shown below. | +| `vpc_id` | Present for VPC tier networks. | + +**Decoded `lb_rules` JSON:** + +```json +[ + { + "id": 1, + "name": "lb-web", + "publicIp": "203.0.113.5", + "publicPort": 80, + "privatePort": 8080, + "protocol": "tcp", + "algorithm": "roundrobin", + "revoke": false, + "backends": [ + {"ip": "10.0.0.10", "port": 8080, "revoked": false}, + {"ip": "10.0.0.11", "port": 8080, "revoked": true} + ] + } +] +``` + +| Field | Description | +|---|---| +| `revoke` | `true` → delete this rule; `false` → create/update. | +| `backends[].revoked` | `true` → this backend has been removed from the rule. | +| `algorithm` | `roundrobin`, `leastconn`, or `source`. | +| `protocol` | `tcp`, `udp`, or `tcp-proxy`. | + +--- + +### `restore-network` + +**Called:** During a `restartNetwork(cleanup=true)` or `restartVPC(cleanup=true)` +operation, after all rules (firewall/NAT/LB) have been re-applied. + +**Purpose:** Batch-restore all DHCP leases, DNS entries, and metadata for +every VM currently on the network in a single call (instead of N per-VM +calls). + +**Payload fields (`payload` object):** + +| Field | Description | +|---|---| +| `network_id` | Network ID. | +| `gateway` | Guest network gateway. | +| `cidr` | Guest network CIDR. | +| `vlan` | Guest VLAN tag. | +| `extension_ip` | Extension IP. | +| `dns` | Comma-separated DNS server list. | +| `domain` | Network domain suffix. | +| `restore_data` | JSON restore payload shown below. | +| `vpc_id` | Present for VPC tier networks. | + +**`restore_data` JSON:** + +```json +{ + "dhcp_enabled": true, + "dns_enabled": true, + "userdata_enabled": true, + "vms": [ + { + "ip": "10.0.0.10", + "mac": "02:00:00:00:00:01", + "hostname": "vm-1", + "default_nic": true, + "vm_data": [ + {"dir": "userdata", "file": "user-data", "content": ""}, + {"dir": "meta-data", "file": "instance-id", "content": ""}, + {"dir": "meta-data", "file": "local-hostname","content": ""} + ] + } + ] +} +``` + +Each `vm_data[].content` is a plain UTF-8 string (same encoding as in `save-vm-data`). + +--- + +### `custom-action` + +**Called:** Via the `runNetworkCustomAction` API. + +**Purpose:** Allows operators to trigger ad-hoc operations on the device +without defining new CloudStack API calls. + +CloudStack writes the full custom-action request to a temporary JSON payload +file and passes that file directly to the script. Unlike the other commands, +`custom-action` does **not** use the nested `{ "payload": ... }` envelope; +command-specific inputs are provided in top-level `action-params`. + +It still includes the same top-level extension detail objects used elsewhere, +including `physical-network-extension-details` and `network-extension-details`. + +**Top-level payload keys (network-level):** + +| Key | Description | +|---|---| +| `network_id` | The CloudStack network ID. | +| `vpc_id` | Present when the network belongs to a VPC. | +| `action` | The action name passed by the operator. | +| `action-params` | JSON object with arbitrary key/value parameters. | +| `physical-network-extension-details` | Physical-network extension details JSON. | +| `network-extension-details` | Stored `extension.details` JSON for the network. | + +**Top-level payload keys (VPC-level):** + +| Key | Description | +|---|---| +| `vpc_id` | The CloudStack VPC ID. | +| `action` | The action name passed by the operator. | +| `action-params` | JSON object with arbitrary key/value parameters. | +| `physical-network-extension-details` | Physical-network extension details JSON. | +| `network-extension-details` | Stored `extension.details` JSON for the VPC. | + +Hook scripts should parse the payload file directly. + +**Stdout:** Returned verbatim to the API caller. + +--- + +## Service-to-Command Mapping + +| CloudStack Network Service | Commands triggered | +|---|---| +| **SourceNat / Gateway** | `assign-ip`, `release-ip` | +| **StaticNat** | `add-static-nat`, `delete-static-nat` | +| **PortForwarding** | `add-port-forward`, `delete-port-forward` | +| **Firewall** | `apply-fw-rules` | +| **Lb** | `apply-lb-rules` | +| **NetworkACL** | `apply-network-acl` | +| **Dhcp** | `add-dhcp-entry`, `remove-dhcp-entry`, `config-dhcp-subnet`, `remove-dhcp-subnet`, `set-dhcp-options` | +| **Dns** | `add-dns-entry`, `config-dns-subnet`, `remove-dns-subnet` | +| **UserData** | `save-vm-data`, `save-password`, `save-userdata`, `save-sshkey`, `save-hypervisor-hostname` | +| *(NIC lifecycle — all)* | `prepare-nic`, `release-nic` | +| *(network lifecycle — all)* | `ensure-network-device`, `implement-network`, `shutdown-network`, `destroy-network`, `restore-network` | +| *(VPC lifecycle)* | `ensure-network-device`, `implement-vpc`, `shutdown-vpc`, `update-vpc-source-nat-ip` | +| *(operator)* | `custom-action` | + +Your script only needs to implement the commands for the services it declares +in `network.services`. All other commands can safely be no-ops (exit 0). + +--- + +## Capabilities Configuration + +When creating the extension, set `network.service.capabilities` to a JSON +object keyed by service name. The values are capability name → value string +maps. + +```json +{ + "SourceNat": { + "SupportedSourceNatTypes": "peraccount", + "RedundantRouter": "false" + }, + "StaticNat": { + "Supported": "true" + }, + "PortForwarding": { + "SupportedProtocols": "tcp,udp" + }, + "Firewall": { + "TrafficStatistics": "per public ip", + "SupportedProtocols": "tcp,udp,icmp", + "SupportedEgressProtocols":"tcp,udp,icmp,all", + "SupportedTrafficDirection":"ingress,egress", + "MultipleIps": "true" + }, + "Lb": { + "SupportedLBAlgorithms": "roundrobin,leastconn,source", + "SupportedLBIsolation": "dedicated", + "SupportedProtocols": "tcp,udp,tcp-proxy", + "SupportedStickinessMethods": "lbcookie,appsession", + "LbSchemes": "Public", + "SslTermination": "false", + "VmAutoScaling": "false" + }, + "Dhcp": { + "DhcpAccrossMultipleSubnets": "true" + }, + "Dns": { + "AllowDnsSuffixModification": "true", + "ExternalDns": "true" + }, + "Gateway": { + "RedundantRouter": "false" + }, + "NetworkACL": { + "SupportedProtocols": "tcp,udp,icmp" + }, + "UserData": { + "Supported": "true" + } +} +``` + +Only declare services and capabilities your implementation actually supports. + +--- + +## VPC Networks + +For networks that belong to a VPC, `vpc_id` is added to the nested command +payload. Use it to share state across all tiers of the same VPC (e.g., a +single VRF or namespace per VPC instead of per tier). + +In `ensure-network-device`, use `vpc_id` (when present) as the hash key for +host selection so all tiers of a VPC always land on the same device. + +VPC lifecycle commands (`implement-vpc`, `shutdown-vpc`, +`update-vpc-source-nat-ip`) are invoked at the VPC level with no +`network_id` — only `vpc_id`. Tier-level commands such as +`implement-network` and `destroy-network` still receive both +`network_id` and `vpc_id`. + +To use this extension as a VPC provider: + +1. Create the NetworkOffering with `useVpc=on`. +2. Create a VpcOffering with the extension as provider. + +--- + +## Extension IP + +`extension_ip` is the IP the device presents on the guest network side: + +- **With SourceNat or Gateway service:** equals `gateway` (the device is the + gateway; no separate IP needed). +- **Without SourceNat/Gateway** (Dhcp/Dns/UserData only, e.g. a shared network + helper): CloudStack allocates a dedicated IP from the guest subnet and passes + it. The device must listen on this IP for DHCP, DNS, and metadata (port 80) + requests. + +### Opting out of the placeholder IP + +Some extensions (e.g. OVN) manage their own data-plane addressing and do not +need CloudStack to reserve a real IP for networks that provide DHCP/DNS/UserData +without SourceNat or Gateway. This covers **both** shared networks and isolated +networks that omit SourceNat. Set the Extension detail +**`network.allocate.extension.ip=false`** when creating or updating the Extension +to suppress the allocation: + +```bash +cmk createExtension \ + name=my-sdn \ + ... \ + details[N].network.allocate.extension.ip=false +``` + +When this detail is `false`, `ensureExtensionIp` returns `null` for any network +backed by this extension that reaches the placeholder-IP branch — i.e. networks +with DHCP/DNS/UserData but without SourceNat/Gateway. `extension_ip` will not +be set in command payloads, and no IP is consumed from the subnet pool. + +Omitting the detail (or setting any value other than `false`) preserves the +original behaviour — a placeholder NIC is allocated and `extension_ip` is +included in every command payload. + +--- + +## Exit Codes + +| Exit code | Meaning | +|---|---| +| `0` | Success. | +| Any non-zero | Failure. CloudStack logs the exit code and script output, and treats the operation as failed. | + +For SSH-proxy scripts you may use sub-codes for diagnostics (they are logged +but not interpreted differently by CloudStack): + +| Suggested code | Suggested meaning | +|---|---| +| `1` | Usage / configuration error. | +| `2` | SSH connection / authentication failure. | +| `3` | Remote script returned non-zero. | + +--- + +## Minimal Script Skeleton + +The following Bash skeleton handles all commands and can be used as a starting +point. Replace each `TODO` block with your device's API calls. + +```bash +#!/bin/bash +# my-sdn.sh — CloudStack NetworkOrchestrator extension entry-point +set -euo pipefail + +COMMAND="${1:-}" +PAYLOAD_FILE="${2:-}" +TIMEOUT_SECONDS="${3:-60}" + +if [[ -z "${COMMAND}" || -z "${PAYLOAD_FILE}" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +root_field() { + python3 - "$PAYLOAD_FILE" "$1" <<'PY' +import json, sys +with open(sys.argv[1], encoding='utf-8') as fh: + data = json.load(fh) +value = data.get(sys.argv[2], "") +if isinstance(value, (dict, list)): + print(json.dumps(value, separators=(",", ":"))) +elif value is None: + print("") +else: + print(value) +PY +} + +payload_field() { + python3 - "$PAYLOAD_FILE" "$1" <<'PY' +import json, sys +with open(sys.argv[1], encoding='utf-8') as fh: + data = json.load(fh) +value = data.get('payload', {}).get(sys.argv[2], "") +if isinstance(value, (dict, list)): + print(json.dumps(value, separators=(",", ":"))) +elif value is None: + print("") +else: + print(value) +PY +} + +case "${COMMAND}" in + + ensure-network-device) + # TODO: check that the device is reachable; select/validate host + # Print per-network JSON to stdout (stored by CloudStack) + printf '{"device":"%s"}\n' "$(payload_field network_id)" + ;; + + implement-network) + # TODO: create virtual segment (VRF / VLAN / namespace / …) + # TODO: configure gateway IP $(payload_field extension_ip) on $(payload_field cidr) + ;; + + shutdown-network|destroy-network) + # TODO: tear down the virtual segment + ;; + + implement-vpc) + # TODO: create VPC-level namespace / VRF for vpc=$(payload_field vpc_id) + # Optional source-NAT IP: $(payload_field public_ip) + ;; + + shutdown-vpc) + # TODO: remove VPC namespace / VRF for vpc=$(payload_field vpc_id) + ;; + + update-vpc-source-nat-ip) + # TODO: update VPC SNAT rule to use new public IP $(payload_field public_ip) + ;; + + assign-ip) + # TODO: attach public IP $(payload_field public_ip) to the device + ;; + + release-ip) + # TODO: remove public IP $(payload_field public_ip) from the device + ;; + + add-static-nat) + # TODO: DNAT $(payload_field public_ip) → $(payload_field private_ip) + ;; + + delete-static-nat) + # TODO: remove DNAT for $(payload_field public_ip) + ;; + + add-port-forward) + # TODO: DNAT $(payload_field public_ip):$(payload_field public_port) → $(payload_field private_ip):$(payload_field private_port) + ;; + + delete-port-forward) + # TODO: remove the port-forwarding DNAT rule + ;; + + apply-fw-rules) + FW_JSON=$(payload_field fw_rules) + # TODO: parse $FW_JSON and apply to device + ;; + + apply-network-acl) + ACL_JSON=$(payload_field acl_rules) + # TODO: parse $ACL_JSON and apply to VPC tier + ;; + + prepare-nic) + # TODO: create port binding mac=$(payload_field mac) ip=$(payload_field ip) nic_uuid=$(payload_field nic_uuid) + ;; + + release-nic) + # TODO: remove port binding mac=$(payload_field mac) ip=$(payload_field ip) + ;; + + add-dhcp-entry) + # TODO: add static lease mac=$(payload_field mac) ip=$(payload_field ip) + ;; + + remove-dhcp-entry) + # TODO: remove static lease for mac=$(payload_field mac) + ;; + + config-dhcp-subnet|remove-dhcp-subnet) ;; + + set-dhcp-options) ;; + + add-dns-entry) + # TODO: add A record hostname=$(payload_field hostname) ip=$(payload_field ip) + ;; + + config-dns-subnet|remove-dns-subnet) ;; + + save-vm-data) + VM_DATA_JSON=$(payload_field vm_data) + # TODO: iterate entries and write to metadata store + ;; + + save-password|save-userdata|save-sshkey|save-hypervisor-hostname) ;; + + apply-lb-rules) + LB_JSON=$(payload_field lb_rules) + # TODO: parse $LB_JSON and configure load balancer + ;; + + restore-network) + RESTORE_JSON=$(payload_field restore_data) + # TODO: iterate vms and restore leases / DNS / metadata + ;; + + custom-action) + ACTION_NAME=$(root_field action) + ACTION_PARAMS=$(root_field action-params) + # TODO: handle $ACTION_NAME with params $ACTION_PARAMS + echo "custom action ${ACTION_NAME} not implemented" + exit 1 + ;; + + *) + echo "Unknown command: ${COMMAND}" >&2 + exit 1 + ;; +esac + +exit 0 +``` + +For a full production implementation see https://github.com/apache/cloudstack-extensions/tree/network-namespace/Network-Namespace: +- `network-namespace.sh` — management-server entry-point (SSH proxy). +- `network-namespace-wrapper.sh` — KVM-host wrapper that implements all commands using Linux network namespaces. diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java index 535a0f703958..546f33b2a1d4 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java @@ -40,7 +40,7 @@ public class ExtensionDetailsVO implements ResourceDetail { @Column(name = "name", nullable = false, length = 255) private String name; - @Column(name = "value", nullable = false, length = 255) + @Column(name = "value", nullable = false, length = 4096) private String value; @Column(name = "display") diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java index 5cb6f7b85114..c2b23f1eee04 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java @@ -40,7 +40,7 @@ public class ExtensionResourceMapDetailsVO implements ResourceDetail { @Column(name = "name", nullable = false, length = 255) private String name; - @Column(name = "value", nullable = false, length = 255) + @Column(name = "value", nullable = false, length = 4096) private String value; @Column(name = "display") diff --git a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml index 9d44d8ff7f3d..ee5e98630140 100644 --- a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml +++ b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml @@ -33,4 +33,7 @@ + + + diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java index 1ca601293a37..9ae051af00f4 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.framework.extensions.api; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.util.Arrays; @@ -89,4 +90,21 @@ public void testGetDetailsWithInvalidValueThrowsException() { setPrivateField("details", detailsList); cmd.getDetails(); } + + // ----------------------------------------------------------------------- + // Tests for type getter + // ----------------------------------------------------------------------- + + @Test + public void testGetTypeReturnsValueWhenSet() { + setPrivateField("type", "NetworkOrchestrator"); + assertEquals("NetworkOrchestrator", cmd.getType()); + } + + @Test + public void testGetTypeReturnsNullWhenUnset() { + setPrivateField("type", null); + assertNull(cmd.getType()); + } + } diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java new file mode 100644 index 000000000000..023820237f47 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateRegisteredExtensionCmdTest.java @@ -0,0 +1,160 @@ +// 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 org.apache.cloudstack.framework.extensions.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.Map; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.user.Account; + +public class UpdateRegisteredExtensionCmdTest { + + private UpdateRegisteredExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new UpdateRegisteredExtensionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void extensionIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "extensionId", null); + assertNull(cmd.getExtensionId()); + } + + @Test + public void extensionIdReturnsValueWhenSet() { + Long extensionId = 42L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getExtensionId()); + } + + @Test + public void cleanupDetailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", null); + assertNull(cmd.isCleanupDetails()); + } + + @Test + public void cleanupDetailsReturnsTrueWhenSet() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", true); + assertTrue(cmd.isCleanupDetails()); + } + + @Test + public void cleanupDetailsReturnsFalseWhenSetToFalse() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", false); + assertFalse(cmd.isCleanupDetails()); + } + + @Test + public void getEntityOwnerIdReturnsSystemAccountId() { + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void getApiResourceTypeReturnsExtension() { + assertEquals(ApiCommandResourceType.Extension, cmd.getApiResourceType()); + } + + @Test + public void getApiResourceIdReturnsExtensionId() { + Long extensionId = 99L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getApiResourceId()); + } + + @Test + public void resourceIdReturnsValueWhenSet() { + String resourceId = "resource-123"; + ReflectionTestUtils.setField(cmd, "resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "PhysicalNetwork"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void detailsReturnsEmptyMapWhenUnset() { + ReflectionTestUtils.setField(cmd, "details", null); + Map details = cmd.getDetails(); + assertNotNull(details); + assertTrue(details.isEmpty()); + } + + @Test + public void executeSetsExtensionResponseWhenManagerSucceeds() { + Extension extension = mock(Extension.class); + ExtensionResponse response = mock(ExtensionResponse.class); + when(extensionsManager.updateRegisteredExtensionWithResource(cmd)).thenReturn(extension); + when(extensionsManager.createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all))) + .thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).updateRegisteredExtensionWithResource(cmd); + verify(extensionsManager).createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all)); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.updateRegisteredExtensionWithResource(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update registered extension")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals("Failed to update registered extension", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java index 76a0175e7576..f4d5099b78b0 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java @@ -83,4 +83,52 @@ public void listResourceIdsByExtensionIdAndTypeReturnsCorrectIds() { when(dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(expectedIds); assertEquals(expectedIds, dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)); } + + // ----------------------------------------------------------------------- + // Tests for new methods: findResourceByExtensionIdAndResourceIdAndType, listResourceIdsByType + // ----------------------------------------------------------------------- + + @Test + public void findResourceByExtensionIdAndResourceIdAndTypeReturnsNullWhenNoMatch() { + when(dao.findResourceByExtensionIdAndResourceIdAndType(999L, 77L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(null); + assertNull(dao.findResourceByExtensionIdAndResourceIdAndType(999L, 77L, ExtensionResourceMap.ResourceType.PhysicalNetwork)); + } + + @Test + public void findResourceByExtensionIdAndResourceIdAndTypeReturnsMatchingEntry() { + ExtensionResourceMapVO map1 = new ExtensionResourceMapVO(); + map1.setExtensionId(42L); + map1.setResourceId(7L); + map1.setResourceType(ExtensionResourceMap.ResourceType.PhysicalNetwork); + ExtensionResourceMapVO expected = map1; + when(dao.findResourceByExtensionIdAndResourceIdAndType(42L, 7L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(expected); + ExtensionResourceMapVO result = dao.findResourceByExtensionIdAndResourceIdAndType(42L, 7L, ExtensionResourceMap.ResourceType.PhysicalNetwork); + assertEquals(expected, result); + } + + @Test + public void findResourceByExtensionIdAndResourceIdAndTypeDifferentiatesResourceTypes() { + ExtensionResourceMapVO clusterMap = new ExtensionResourceMapVO(); + clusterMap.setResourceType(ExtensionResourceMap.ResourceType.Cluster); + when(dao.findResourceByExtensionIdAndResourceIdAndType(10L, 55L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(clusterMap); + when(dao.findResourceByExtensionIdAndResourceIdAndType(10L, 55L, ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(null); + + assertEquals(clusterMap, dao.findResourceByExtensionIdAndResourceIdAndType(10L, 55L, ExtensionResourceMap.ResourceType.Cluster)); + assertNull(dao.findResourceByExtensionIdAndResourceIdAndType(10L, 55L, ExtensionResourceMap.ResourceType.PhysicalNetwork)); + } + + @Test + public void listResourceIdsByTypeReturnsEmptyListWhenNoMatch() { + when(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(List.of()); + assertTrue(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork).isEmpty()); + } + + @Test + public void listResourceIdsByTypeReturnsMatchingIds() { + List expectedIds = List.of(5L, 10L, 15L); + when(dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(expectedIds); + List result = dao.listResourceIdsByType(ExtensionResourceMap.ResourceType.PhysicalNetwork); + assertEquals(3, result.size()); + assertEquals(expectedIds, result); + } } diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java index ff3fce06b006..fa8c97742697 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -37,8 +37,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; import java.io.File; import java.security.InvalidParameterException; @@ -62,6 +64,7 @@ import org.apache.cloudstack.extension.CustomActionResultResponse; import org.apache.cloudstack.extension.Extension; import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.extension.ExtensionHelper; import org.apache.cloudstack.extension.ExtensionResourceMap; import org.apache.cloudstack.framework.extensions.api.AddCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; @@ -74,6 +77,7 @@ import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateRegisteredExtensionCmd; import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; import org.apache.cloudstack.framework.extensions.command.GetExtensionPathChecksumCommand; @@ -87,6 +91,7 @@ import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapDetailsVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; import org.apache.cloudstack.utils.identity.ManagementServerNode; @@ -94,6 +99,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; @@ -122,6 +128,21 @@ import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.ExternalProvisioner; import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Network; +import com.cloud.network.NetworkModel; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; +import com.cloud.network.dao.PhysicalNetworkServiceProviderVO; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.element.NetworkElement; +import com.cloud.network.vpc.Vpc; +import com.cloud.network.vpc.dao.VpcOfferingServiceMapDao; +import com.cloud.network.vpc.dao.VpcServiceMapDao; +import com.cloud.offerings.dao.NetworkOfferingServiceMapDao; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; import com.cloud.org.Cluster; import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; @@ -136,7 +157,6 @@ import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VmDetailConstants; -import com.cloud.vm.dao.VMInstanceDao; @RunWith(MockitoJUnitRunner.class) public class ExtensionsManagerImplTest { @@ -168,8 +188,6 @@ public class ExtensionsManagerImplTest { @Mock private ExtensionCustomActionDetailsDao extensionCustomActionDetailsDao; @Mock - private VMInstanceDao vmInstanceDao; - @Mock private VirtualMachineManager virtualMachineManager; @Mock private EntityManager entityManager; @@ -185,6 +203,25 @@ public class ExtensionsManagerImplTest { private RoleService roleService; @Mock private AccountService accountService; + @Mock + private PhysicalNetworkDao physicalNetworkDao; + @Mock + private NetworkDao networkDao; + @Mock + private NetworkServiceMapDao networkServiceMapDao; + @Mock + private VpcServiceMapDao vpcServiceMapDao; + @Mock + private NetworkModel networkModel; + + @Mock + private PhysicalNetworkServiceProviderDao physicalNetworkServiceProviderDao; + + @Mock + private NetworkOfferingServiceMapDao networkOfferingServiceMapDao; + + @Mock + private VpcOfferingServiceMapDao vpcOfferingServiceMapDao; @Before public void setUp() { @@ -287,17 +324,9 @@ public void unregisterExtensionWithClusterThrowsIfClusterNotFound() { @Test public void getExtensionFromResourceReturnsNullIfEntityNotFound() { when(entityManager.findByUuid(any(), anyString())).thenReturn(null); - assertNull(extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "uuid")); + assertNull(extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "uuid")); } - @Test - public void getActionMessageReturnsDefaultOnBlank() { - ExtensionCustomAction action = mock(ExtensionCustomAction.class); - Extension ext = mock(Extension.class); - when(action.getSuccessMessage()).thenReturn(null); - String msg = extensionsManager.getActionMessage(true, action, ext, ExtensionCustomAction.ResourceType.VirtualMachine, null); - assertTrue(msg.contains("Successfully completed")); - } @Test public void getActionMessageReturnsDefaultMessageForSuccessWithoutCustomMessage() { @@ -343,14 +372,6 @@ public void getActionMessageReturnsCustomFailureMessage() { assertEquals("Custom failure message", result); } - @Test - public void getActionMessageHandlesNullActionMessage() { - ExtensionCustomAction action = mock(ExtensionCustomAction.class); - when(action.getSuccessMessage()).thenReturn(null); - Extension extension = mock(Extension.class); - String result = extensionsManager.getActionMessage(true, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); - assertTrue(result.contains("Successfully completed")); - } @Test public void getFilteredExternalDetailsReturnsFilteredMap() { @@ -381,26 +402,6 @@ public void sendExtensionPathNotReadyAlertDoesNotCallsAlertManager() { anyLong(), anyLong(), anyString(), anyString()); } - @Test - public void updateExtensionPathReadyUpdatesWhenStateDiffers() { - Extension ext = mock(Extension.class); - when(ext.getId()).thenReturn(1L); - when(ext.isPathReady()).thenReturn(false); - ExtensionVO vo = mock(ExtensionVO.class); - when(extensionDao.createForUpdate(1L)).thenReturn(vo); - when(extensionDao.update(1L, vo)).thenReturn(true); - extensionsManager.updateExtensionPathReady(ext, true); - verify(extensionDao).update(1L, vo); - } - - @Test - public void disableExtensionUpdatesState() { - ExtensionVO vo = mock(ExtensionVO.class); - when(extensionDao.createForUpdate(1L)).thenReturn(vo); - when(extensionDao.update(1L, vo)).thenReturn(true); - extensionsManager.disableExtension(1L); - verify(extensionDao).update(1L, vo); - } @Test public void getExtensionFromResourceReturnsExtensionForValidResource() { @@ -413,7 +414,7 @@ public void getExtensionFromResourceReturnsExtensionForValidResource() { ExtensionVO extension = mock(ExtensionVO.class); when(extensionDao.findById(100L)).thenReturn(extension); - Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); assertEquals(extension, result); } @@ -422,7 +423,7 @@ public void getExtensionFromResourceReturnsExtensionForValidResource() { public void getExtensionFromResourceReturnsNullForInvalidResourceUuid() { when(entityManager.findByUuid(eq(VirtualMachine.class), eq("invalid-uuid"))).thenReturn(null); - Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "invalid-uuid"); + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "invalid-uuid"); assertNull(result); } @@ -433,7 +434,7 @@ public void getExtensionFromResourceReturnsNullForMissingClusterMapping() { when(entityManager.findByUuid(eq(VirtualMachine.class), eq("vm-uuid"))).thenReturn(vm); when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(null, null)); - Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); assertNull(result); } @@ -445,7 +446,7 @@ public void getExtensionFromResourceReturnsNullForMissingExtensionMapping() { when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(1L, 1L)); when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(null); - Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); assertNull(result); } @@ -558,41 +559,26 @@ public void getExtensionsPathReturnsProvisionerPath() { assertEquals("/tmp/extensions", extensionsManager.getExtensionsPath()); } - @Test - public void getExtensionIdForClusterReturnsNullIfNoMap() { - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); - assertNull(extensionsManager.getExtensionIdForCluster(1L)); - } @Test - public void getExtensionIdForClusterReturnsIdIfMapExists() { - ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); - when(map.getExtensionId()).thenReturn(5L); - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); - assertEquals(Long.valueOf(5L), extensionsManager.getExtensionIdForCluster(1L)); - } - - @Test - public void getExtensionReturnsExtension() { - ExtensionVO ext = mock(ExtensionVO.class); - when(extensionDao.findById(1L)).thenReturn(ext); - assertEquals(ext, extensionsManager.getExtension(1L)); - } - - @Test - public void getExtensionForClusterReturnsNullIfNoId() { - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); - assertNull(extensionsManager.getExtensionForCluster(1L)); + public void checkExtensionPathSyncUpdatesReadyWhenStateDiffers() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.isPathReady()).thenReturn(false); + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + verify(extensionsManager).updateExtensionPathReady(ext, false); } @Test - public void getExtensionForClusterReturnsExtensionIfIdExists() { - ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); - when(map.getExtensionId()).thenReturn(5L); - when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); - ExtensionVO ext = mock(ExtensionVO.class); - when(extensionDao.findById(5L)).thenReturn(ext); - assertEquals(ext, extensionsManager.getExtensionForCluster(1L)); + public void checkExtensionPathSyncUpdatesReadyWhenStateUnchanged() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.isPathReady()).thenReturn(true); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + verify(extensionsManager, times(1)).updateExtensionPathReady(any(), anyBoolean()); } @Test @@ -950,6 +936,105 @@ public void testUpdateExtension_InvalidState() { extensionsManager.updateExtension(cmd); } + @Test(expected = CloudRuntimeException.class) + public void testUpdateExtension_RemovingUsedNetworkServiceThrows() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(6L); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + Map newDetails = new HashMap<>(); + newDetails.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat"); + when(cmd.getDetails()).thenReturn(newDetails); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(6L); + when(ext.getName()).thenReturn("MyExt"); + when(ext.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extensionDao.findById(6L)).thenReturn(ext); + + Map oldDetailsMap = new HashMap<>(); + oldDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat,StaticNat"); + Map updatedDetailsMap = new HashMap<>(); + updatedDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat"); + when(extensionDetailsDao.listDetailsKeyPairs(6L)).thenReturn(oldDetailsMap).thenReturn(updatedDetailsMap); + + when(networkOfferingServiceMapDao.listOfferingIdsByServiceAndProvider(Network.Service.StaticNat, "MyExt")) + .thenReturn(Collections.singletonList(1L)); + + extensionsManager.updateExtension(cmd); + } + + @Test(expected = CloudRuntimeException.class) + public void testUpdateExtension_RemovingServiceUsedByVpcOfferingThrows() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(8L); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + Map newDetails = new HashMap<>(); + newDetails.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat"); + when(cmd.getDetails()).thenReturn(newDetails); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(8L); + when(ext.getName()).thenReturn("MyExt"); + when(ext.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extensionDao.findById(8L)).thenReturn(ext); + + Map oldDetailsMap = new HashMap<>(); + oldDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat,StaticNat"); + Map updatedDetailsMap = new HashMap<>(); + updatedDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat"); + when(extensionDetailsDao.listDetailsKeyPairs(8L)).thenReturn(oldDetailsMap).thenReturn(updatedDetailsMap); + + when(networkOfferingServiceMapDao.listOfferingIdsByServiceAndProvider(Network.Service.StaticNat, "MyExt")) + .thenReturn(Collections.emptyList()); + when(vpcOfferingServiceMapDao.listOfferingIdsByServiceAndProvider(Network.Service.StaticNat, "MyExt")) + .thenReturn(Collections.singletonList(1L)); + + extensionsManager.updateExtension(cmd); + } + + @Test + public void testUpdateExtension_UpdatesPhysicalNetworkServicesWhenNotInUse() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(7L); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + Map newDetails = new HashMap<>(); + newDetails.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat,StaticNat"); + when(cmd.getDetails()).thenReturn(newDetails); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(7L); + when(ext.getName()).thenReturn("MyExt"); + when(ext.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extensionDao.findById(7L)).thenReturn(ext); + + Map oldDetailsMap = new HashMap<>(); + oldDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat"); + Map updatedDetailsMap = new HashMap<>(); + updatedDetailsMap.put(ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, "SourceNat,StaticNat"); + when(extensionDetailsDao.listDetailsKeyPairs(7L)).thenReturn(oldDetailsMap).thenReturn(updatedDetailsMap); + + when(extensionResourceMapDao.listResourceIdsByExtensionIdAndType(7L, ExtensionResourceMap.ResourceType.PhysicalNetwork)) + .thenReturn(Collections.singletonList(100L)); + PhysicalNetworkServiceProviderVO nsp = mock(PhysicalNetworkServiceProviderVO.class); + when(nsp.getId()).thenReturn(500L); + when(nsp.getEnabledServices()).thenReturn(new ArrayList<>(Collections.singletonList(Network.Service.SourceNat))); + when(physicalNetworkServiceProviderDao.findByServiceProvider(100L, "MyExt")).thenReturn(nsp); + + extensionsManager.updateExtension(cmd); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(nsp).setEnabledServices(captor.capture()); + assertTrue(captor.getValue().contains(Network.Service.SourceNat)); + assertTrue(captor.getValue().contains(Network.Service.StaticNat)); + verify(physicalNetworkServiceProviderDao).update(500L, nsp); + } + @Test public void updateExtensionsDetails_SavesDetails_WhenDetailsProvided() { long extensionId = 10L; @@ -1024,13 +1109,6 @@ public void testDeleteExtension_Success() { verify(extensionDao).remove(1L); } - @Test - public void testRegisterExtensionWithResource_InvalidResourceType() { - RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); - when(cmd.getResourceType()).thenReturn("InvalidType"); - - assertThrows(InvalidParameterValueException.class, () -> extensionsManager.registerExtensionWithResource(cmd)); - } @Test public void registerExtensionWithResourceRegistersSuccessfullyForValidResourceType() { @@ -1063,8 +1141,6 @@ public void registerExtensionWithResourceThrowsForMissingExtension() { RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.Cluster.name()); when(cmd.getResourceId()).thenReturn(UUID.randomUUID().toString()); - ClusterVO clusterVO = mock(ClusterVO.class); - when(clusterDao.findByUuid(anyString())).thenReturn(clusterVO); extensionsManager.registerExtensionWithResource(cmd); } @@ -1139,6 +1215,154 @@ public void unregisterExtensionWithClusterHandlesMissingMappingGracefully() { verify(extensionResourceMapDao, never()).remove(anyLong()); } + @Test + public void unregisterExtensionWithResourceThrowsWhenProviderUsedByExistingNetworks() { + UnregisterExtensionCmd cmd = mock(UnregisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getExtensionId()).thenReturn(1L); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(1L, 42L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(existing); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getName()).thenReturn("extnet-provider"); + when(extensionDao.findById(1L)).thenReturn(extension); + + NetworkVO network = mock(NetworkVO.class); + when(networkDao.listByPhysicalNetworkAndProvider(42L, "extnet-provider")).thenReturn(List.of(network)); + + assertThrows(CloudRuntimeException.class, () -> extensionsManager.unregisterExtensionWithResource(cmd)); + verify(extensionResourceMapDao, never()).remove(anyLong()); + } + + @Test + public void updateRegisteredExtensionWithResourceUpdatesDetailsForExistingMapping() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "hosts", "10.10.10.10")); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(1L, 42L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(existing); + + Extension result = extensionsManager.updateRegisteredExtensionWithResource(cmd); + + assertEquals(extension, result); + verify(extensionResourceMapDetailsDao, never()).removeDetails(anyLong()); + verify(extensionResourceMapDetailsDao).saveDetails(any()); + } + + @Test + public void updateRegisteredExtensionWithResourceCleanupDetailsFirstThenSaveRequested() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "password", "secret")); + when(cmd.isCleanupDetails()).thenReturn(true); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(1L, 42L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(existing); + + extensionsManager.updateRegisteredExtensionWithResource(cmd); + + verify(extensionResourceMapDetailsDao).removeDetails(100L); + verify(extensionResourceMapDetailsDao, never()).saveDetails(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void updateRegisteredExtensionWithResourceStoresSensitiveDetailsWithDisplayFalse() { + UpdateRegisteredExtensionCmd cmd = mock(UpdateRegisteredExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("physnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(Map.of("username", "root", "password", "newSecret")); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physicalNetwork = mock(PhysicalNetworkVO.class); + when(physicalNetwork.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("physnet-uuid")).thenReturn(physicalNetwork); + + ExtensionResourceMapVO existing = mock(ExtensionResourceMapVO.class); + when(existing.getId()).thenReturn(100L); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(1L, 42L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(existing); + extensionsManager.updateRegisteredExtensionWithResource(cmd); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(extensionResourceMapDetailsDao).saveDetails(captor.capture()); + verify(extensionResourceMapDetailsDao, never()).removeDetails(anyLong()); + List savedDetails = captor.getValue(); + + ExtensionResourceMapDetailsVO passwordDetail = savedDetails.stream() + .filter(detail -> "password".equals(detail.getName())) + .findFirst() + .orElse(null); + assertNotNull(passwordDetail); + assertFalse(passwordDetail.isDisplay()); + assertEquals("newSecret", passwordDetail.getValue()); + } + + @Test + @SuppressWarnings("unchecked") + public void registerExtensionWithClusterStoresSensitiveDetailsWithDisplayFalse() { + Cluster cluster = mock(Cluster.class); + when(cluster.getId()).thenReturn(12L); + when(cluster.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(5L); + + ExtensionResourceMapVO persistedMap = mock(ExtensionResourceMapVO.class); + when(persistedMap.getId()).thenReturn(120L); + when(extensionResourceMapDao.persist(any())).thenReturn(persistedMap); + + extensionsManager.registerExtensionWithCluster(cluster, extension, + Map.of("username", "admin", "password", "s3cr3t")); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(extensionResourceMapDetailsDao).saveDetails(captor.capture()); + List savedDetails = captor.getValue(); + + ExtensionResourceMapDetailsVO passwordDetail = savedDetails.stream() + .filter(detail -> "password".equals(detail.getName())) + .findFirst() + .orElse(null); + assertNotNull(passwordDetail); + assertFalse(passwordDetail.isDisplay()); + } + @Test public void testCreateExtensionResponse_BasicFields() { Extension extension = mock(Extension.class); @@ -1708,6 +1932,238 @@ public void runCustomAction_CheckAccessThrowsException() throws Exception { } } + @Test + public void runNetworkCustomAction_NoProviderFound_ReturnsFailureResponse() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(11L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(10L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(10L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + + CustomActionResultResponse response = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Network, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertEquals("No network service provider found for this network", response.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runNetworkCustomAction_ProviderElementMissing_ReturnsFailureResponse() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(12L); + when(networkServiceMapDao.getProviderForServiceInNetwork(12L, Network.Service.CustomAction)).thenReturn("ExtProvider"); + when(networkModel.getElementImplementingProvider("ExtProvider")).thenReturn(null); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(11L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(11L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("ExtProvider"); + + CustomActionResultResponse response = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Network, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertEquals("No network element found for provider: ExtProvider", response.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runNetworkCustomAction_ProviderCannotHandle_ReturnsFailureResponse() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(13L); + when(networkServiceMapDao.getProviderForServiceInNetwork(13L, Network.Service.CustomAction)).thenReturn("ExtProvider"); + + NetworkElement element = mock(NetworkElement.class, withSettings().extraInterfaces(NetworkCustomActionProvider.class)); + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + when(networkModel.getElementImplementingProvider("ExtProvider")).thenReturn(element); + when(provider.canHandleCustomAction(network)).thenReturn(false); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(12L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(12L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("ExtProvider"); + + CustomActionResultResponse response = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Network, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertTrue(response.getResult().get(ApiConstants.DETAILS).contains("cannot handle custom action")); + } + + @Test + public void runNetworkCustomAction_ProviderDoesNotImplementCustomAction_ReturnsFailureResponse() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(131L); + when(networkServiceMapDao.getProviderForServiceInNetwork(131L, Network.Service.CustomAction)).thenReturn("ExtProvider"); + + NetworkElement element = mock(NetworkElement.class); + when(networkModel.getElementImplementingProvider("ExtProvider")).thenReturn(element); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(121L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(121L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("ExtProvider"); + + CustomActionResultResponse response = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Network, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertTrue(response.getResult().get(ApiConstants.DETAILS).contains("does not support custom actions")); + } + + @Test + public void runNetworkCustomAction_SuccessfulExecution_ReturnsSuccessResponse() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(14L); + when(networkServiceMapDao.getProviderForServiceInNetwork(14L, Network.Service.CustomAction)).thenReturn("ExtProvider"); + + NetworkElement element = mock(NetworkElement.class, withSettings().extraInterfaces(NetworkCustomActionProvider.class)); + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + when(networkModel.getElementImplementingProvider("ExtProvider")).thenReturn(element); + when(provider.canHandleCustomAction(network)).thenReturn(true); + when(provider.runCustomAction(eq(network), eq("dump-config"), any())).thenReturn("dump-output"); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(13L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(13L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("ExtProvider"); + + CustomActionResultResponse response = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Network, Collections.emptyMap()); + + assertTrue(response.getSuccess()); + assertEquals("dump-output", response.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runVpcCustomAction_ProviderNotCustomActionProvider_ReturnsFailureResponse() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(21L); + when(vpcServiceMapDao.getProviderForServiceInVpc(21L, Network.Service.CustomAction)).thenReturn("VpcProvider"); + + NetworkElement element = mock(NetworkElement.class); + when(networkModel.getElementImplementingProvider("VpcProvider")).thenReturn(element); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(20L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(20L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("VpcProvider"); + + CustomActionResultResponse response = extensionsManager.runVpcCustomAction( + vpc, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Vpc, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertTrue(response.getResult().get(ApiConstants.DETAILS).contains("does not support custom actions")); + } + + @Test + public void runVpcCustomAction_NoProviderFound_ReturnsFailureResponse() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(211L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(201L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(201L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + + CustomActionResultResponse response = extensionsManager.runVpcCustomAction( + vpc, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Vpc, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertEquals("No VPC service provider found for this VPC", response.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runVpcCustomAction_ProviderCannotHandleVpc_ReturnsFailureResponse() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(212L); + when(vpcServiceMapDao.getProviderForServiceInVpc(212L, Network.Service.CustomAction)).thenReturn("VpcProvider"); + + NetworkElement element = mock(NetworkElement.class, withSettings().extraInterfaces(NetworkCustomActionProvider.class)); + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + when(networkModel.getElementImplementingProvider("VpcProvider")).thenReturn(element); + when(provider.canHandleVpcCustomAction(vpc)).thenReturn(false); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(202L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(202L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("VpcProvider"); + + CustomActionResultResponse response = extensionsManager.runVpcCustomAction( + vpc, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Vpc, Collections.emptyMap()); + + assertFalse(response.getSuccess()); + assertTrue(response.getResult().get(ApiConstants.DETAILS).contains("cannot handle custom action")); + } + + @Test + public void runVpcCustomAction_SuccessfulExecution_ReturnsSuccessResponse() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(22L); + when(vpcServiceMapDao.getProviderForServiceInVpc(22L, Network.Service.CustomAction)).thenReturn("VpcProvider"); + + NetworkElement element = mock(NetworkElement.class, withSettings().extraInterfaces(NetworkCustomActionProvider.class)); + NetworkCustomActionProvider provider = (NetworkCustomActionProvider) element; + when(networkModel.getElementImplementingProvider("VpcProvider")).thenReturn(element); + when(provider.canHandleVpcCustomAction(vpc)).thenReturn(true); + when(provider.runCustomAction(eq(vpc), eq("dump-config"), any())).thenReturn("vpc-dump-output"); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getId()).thenReturn(21L); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(21L)) + .thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("VpcProvider"); + + CustomActionResultResponse response = extensionsManager.runVpcCustomAction( + vpc, actionVO, extensionVO, ExtensionCustomAction.ResourceType.Vpc, Collections.emptyMap()); + + assertTrue(response.getSuccess()); + assertEquals("vpc-dump-output", response.getResult().get(ApiConstants.DETAILS)); + } + @Test public void createCustomActionResponse_SetsBasicFields() { ExtensionCustomAction action = mock(ExtensionCustomAction.class); @@ -2218,4 +2674,392 @@ public void addInbuiltExtensionReservedResourceDetailsAddedDetails() { assertEquals(reservedResourceDetails.size(), entry.getValue().size()); assertTrue(reservedResourceDetails.containsAll(entry.getValue())); } + + // ----------------------------------------------------------------------- + // Tests for network custom action behavior + // ----------------------------------------------------------------------- + + + // Helper: a mock object that is both a NetworkElement and a NetworkCustomActionProvider + interface MockNetworkElement extends NetworkElement, NetworkCustomActionProvider {} + + @Test + public void runNetworkCustomActionSucceeds() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("reboot-device"); + when(actionVO.getId()).thenReturn(1L); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("my-extnet"); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(1L)).thenReturn(details); + + // networkServiceMapDao returns provider name for SourceNat + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn("my-extnet"); + + // element implements both NetworkElement and NetworkCustomActionProvider + MockNetworkElement element = mock(MockNetworkElement.class); + when(element.canHandleCustomAction(eq(network))).thenReturn(true); + when(element.runCustomAction(eq(network), eq("reboot-device"), any())).thenReturn("OK: bridge bounced"); + when(networkModel.getElementImplementingProvider("my-extnet")).thenReturn(element); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertTrue(resp.isSuccess()); + assertEquals("OK: bridge bounced", resp.getResult().get(ApiConstants.DETAILS)); + } + + @Test + public void runNetworkCustomActionFailsWhenNoProvider() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("dump-config"); + when(actionVO.getId()).thenReturn(2L); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(2L)).thenReturn(details); + + // No provider found for any service + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn(null); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertFalse(resp.isSuccess()); + assertTrue(resp.getResult().get(ApiConstants.DETAILS).contains("No network service provider")); + } + + // ----------------------------------------------------------------------- + // Tests for getExtensionFromResource with Network resource type + // ----------------------------------------------------------------------- + + @Test + public void getExtensionFromResourceReturnsExtensionForNetworkWithProviderMatch() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(10L); + when(network.getPhysicalNetworkId()).thenReturn(5L); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + String providerName ="my-ext-provider"; + when(networkServiceMapDao.getProviderForServiceInNetwork(10L, Network.Service.CustomAction)).thenReturn(providerName); + + ExtensionVO ext = mock(ExtensionVO.class); + doReturn(ext).when(extensionsManager).getExtensionForPhysicalNetworkAndProvider(5L, "my-ext-provider"); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertEquals(ext, result); + } + + @Test + public void getExtensionFromResourceFallsBackToFirstMappingForNetwork() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(10L); + when(network.getPhysicalNetworkId()).thenReturn(5L); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + when(networkServiceMapDao.getProviderForServiceInNetwork(10L, Network.Service.CustomAction)).thenReturn(null); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertNull(result); + } + + @Test + public void getExtensionFromResourceReturnsNullForNetworkWithNullPhysicalNetworkId() { + Network network = mock(Network.class); + when(network.getPhysicalNetworkId()).thenReturn(null); + when(entityManager.findByUuid(eq(Network.class), eq("net-uuid"))).thenReturn(network); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Network, "net-uuid"); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for getExtensionFromResource with Vpc resource type + // ----------------------------------------------------------------------- + + @Test + public void getExtensionFromResourceReturnsExtensionForVpcWithProviderMatch() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(20L); + when(entityManager.findByUuid(eq(Vpc.class), eq("vpc-uuid"))).thenReturn(vpc); + + when(vpcServiceMapDao.getProviderForServiceInVpc(20L, Network.Service.CustomAction)).thenReturn("my-vpc-provider"); + + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findByNameAndType("my-vpc-provider", Extension.Type.NetworkOrchestrator)).thenReturn(ext); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Vpc, "vpc-uuid"); + + assertEquals(ext, result); + } + + @Test + public void getExtensionFromResourceReturnsNullForVpcWithoutProvider() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(20L); + when(entityManager.findByUuid(eq(Vpc.class), eq("vpc-uuid"))).thenReturn(vpc); + + when(vpcServiceMapDao.getProviderForServiceInVpc(20L, Network.Service.CustomAction)).thenReturn(null); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Vpc, "vpc-uuid"); + + assertNull(result); + verify(extensionDao, never()).findByName(anyString()); + } + + @Test + public void getExtensionFromResourceReturnsNullForVpcWhenProviderExtensionNotFound() { + Vpc vpc = mock(Vpc.class); + when(vpc.getId()).thenReturn(20L); + when(entityManager.findByUuid(eq(Vpc.class), eq("vpc-uuid"))).thenReturn(vpc); + + when(vpcServiceMapDao.getProviderForServiceInVpc(20L, Network.Service.CustomAction)).thenReturn("missing-provider"); + when(extensionDao.findByNameAndType("missing-provider", Extension.Type.NetworkOrchestrator)).thenReturn(null); + + Extension result = extensionsManager.getExtensionWithCustomActionFromResource(ExtensionCustomAction.ResourceType.Vpc, "vpc-uuid"); + + assertNull(result); + } + + + // ----------------------------------------------------------------------- + // Tests for registerExtensionWithPhysicalNetwork + // ----------------------------------------------------------------------- + + @Test + public void registerExtensionWithPhysicalNetworkSucceeds() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("pnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getDetails()).thenReturn(null); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getType()).thenReturn(Extension.Type.NetworkOrchestrator); + when(extension.getName()).thenReturn("my-ext"); + when(extension.getId()).thenReturn(1L); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physNet = mock(PhysicalNetworkVO.class); + when(physNet.getId()).thenReturn(42L); + when(physicalNetworkDao.findByUuid("pnet-uuid")).thenReturn(physNet); + + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(1L, 42L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(null); + when(extensionDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap()); + + ExtensionResourceMapVO savedMap = mock(ExtensionResourceMapVO.class); + when(savedMap.getExtensionId()).thenReturn(1L); + when(extensionResourceMapDao.persist(any())).thenReturn(savedMap); + + when(physicalNetworkServiceProviderDao.findByServiceProvider(42L, "my-ext")).thenReturn(null); + + Extension result = extensionsManager.registerExtensionWithResource(cmd); + assertEquals(extension, result); + } + + @Test(expected = InvalidParameterValueException.class) + public void registerExtensionWithPhysicalNetworkFailsForNonNetworkOrchestratorType() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.PhysicalNetwork.name()); + when(cmd.getResourceId()).thenReturn("pnet-uuid"); + when(cmd.getExtensionId()).thenReturn(1L); + + ExtensionVO extension = mock(ExtensionVO.class); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getName()).thenReturn("orch-ext"); + when(extensionDao.findById(1L)).thenReturn(extension); + + PhysicalNetworkVO physNet = mock(PhysicalNetworkVO.class); + when(physicalNetworkDao.findByUuid("pnet-uuid")).thenReturn(physNet); + + extensionsManager.registerExtensionWithResource(cmd); + } + + + // ----------------------------------------------------------------------- + // Tests for getExtensionForPhysicalNetworkAndProvider + // ----------------------------------------------------------------------- + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsMatchingExtension() { + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(10L); + when(extensionDao.findByName("myext")).thenReturn(ext); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(10L, 5L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(mock(ExtensionResourceMapVO.class)); + + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, "myext"); + assertEquals(ext, result); + } + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsNullWhenNameDoesNotMatch() { + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(10L); + when(extensionDao.findByName("myext")).thenReturn(ext); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(10L, 5L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(null); + + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, "myext"); + assertNull(result); + } + + @Test + public void getExtensionForPhysicalNetworkAndProviderReturnsNullForNullProviderName() { + Extension result = extensionsManager.getExtensionForPhysicalNetworkAndProvider(5L, null); + assertNull(result); + } + + // ----------------------------------------------------------------------- + // Tests for getAllResourceMapDetailsForExtensionOnPhysicalNetwork + // ----------------------------------------------------------------------- + + @Test + public void getAllResourceMapDetailsForExtensionOnPhysicalNetworkReturnsDetails() { + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getId()).thenReturn(100L); + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(10L, 5L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(mapVO); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(100L)) + .thenReturn(Map.of("host", "192.168.1.1")); + + Map result = extensionsManager.getAllResourceMapDetailsForExtensionOnPhysicalNetwork(5L, 10L); + assertEquals("192.168.1.1", result.get("host")); + } + + @Test + public void getAllResourceMapDetailsForExtensionOnPhysicalNetworkReturnsEmptyWhenNoMapping() { + when(extensionResourceMapDao.findResourceByExtensionIdAndResourceIdAndType(10L, 5L, + ExtensionResourceMap.ResourceType.PhysicalNetwork)).thenReturn(null); + + Map result = extensionsManager.getAllResourceMapDetailsForExtensionOnPhysicalNetwork(5L, 10L); + assertTrue(result.isEmpty()); + } + + // ----------------------------------------------------------------------- + // Tests for isNetworkExtensionProvider + // ----------------------------------------------------------------------- + + @Test + public void isNetworkExtensionProviderReturnsTrueWhenProviderMatchesExtension() { + when(extensionDao.findByNameAndType("my-ext", Extension.Type.NetworkOrchestrator)).thenReturn(mock(ExtensionVO.class)); + + assertTrue(extensionsManager.isNetworkExtensionProvider("my-ext")); + } + + @Test + public void isNetworkExtensionProviderReturnsFalseWhenNoMatch() { + when(extensionDao.findByNameAndType("unknown", Extension.Type.NetworkOrchestrator)).thenReturn(null); + assertFalse(extensionsManager.isNetworkExtensionProvider("unknown")); + } + + @Test + public void isNetworkExtensionProviderReturnsFalseForNullProvider() { + assertFalse(extensionsManager.isNetworkExtensionProvider(null)); + } + + // ----------------------------------------------------------------------- + // Tests for listExtensionsByType + // ----------------------------------------------------------------------- + + @Test + public void listExtensionsByTypeReturnsExtensionsForType() { + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(List.of(ext)); + + List result = extensionsManager.listExtensionsByType(Extension.Type.NetworkOrchestrator); + assertEquals(1, result.size()); + assertEquals(ext, result.get(0)); + } + + @Test + public void listExtensionsByTypeReturnsEmptyForNullType() { + List result = extensionsManager.listExtensionsByType(null); + assertTrue(result.isEmpty()); + } + + @Test + public void listExtensionsByTypeReturnsEmptyWhenNoExtensions() { + when(extensionDao.listByType(Extension.Type.NetworkOrchestrator)).thenReturn(Collections.emptyList()); + List result = extensionsManager.listExtensionsByType(Extension.Type.NetworkOrchestrator); + assertTrue(result.isEmpty()); + } + + // ----------------------------------------------------------------------- + // Tests for getNetworkCapabilitiesForProvider + // ----------------------------------------------------------------------- + + @Test + public void getNetworkCapabilitiesForProviderReturnsCapabilitiesFromExtensionDetails() { + long physNetId = 10L; + String providerName = "my-ext"; + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getId()).thenReturn(5L); + doReturn(ext).when(extensionsManager).getExtensionForPhysicalNetworkAndProvider(physNetId, providerName); + + when(extensionDetailsDao.listDetailsKeyPairs(5L)).thenReturn(Map.of( + ExtensionHelper.NETWORK_SERVICES_DETAIL_KEY, + "SourceNat,StaticNat")); + + Map> result = + extensionsManager.getNetworkCapabilitiesForProvider(physNetId, providerName); + assertNotNull(result); + assertTrue(result.containsKey(Network.Service.SourceNat)); + } + + @Test + public void getNetworkCapabilitiesForProviderReturnsEmptyMapForNullProvider() { + Map> result = + extensionsManager.getNetworkCapabilitiesForProvider(10L, null); + assertTrue(result.isEmpty()); + } + + @Test + public void runNetworkCustomActionFailsWhenProviderReturnsNull() { + Network network = mock(Network.class); + when(network.getId()).thenReturn(5L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(actionVO.getUuid()).thenReturn("action-uuid"); + when(actionVO.getName()).thenReturn("unknown-action"); + when(actionVO.getId()).thenReturn(3L); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getName()).thenReturn("my-extnet"); + + Pair, Map> details = new Pair<>(new HashMap<>(), new HashMap<>()); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(3L)).thenReturn(details); + + // networkServiceMapDao returns provider name + when(networkServiceMapDao.getProviderForServiceInNetwork(eq(5L), any())).thenReturn("my-extnet"); + + // element implements both NetworkElement and NetworkCustomActionProvider but action returns null + MockNetworkElement element = mock(MockNetworkElement.class); + when(element.canHandleCustomAction(eq(network))).thenReturn(true); + when(element.runCustomAction(eq(network), eq("unknown-action"), any())).thenReturn(null); + when(networkModel.getElementImplementingProvider("my-extnet")).thenReturn(element); + + CustomActionResultResponse resp = extensionsManager.runNetworkCustomAction( + network, actionVO, extensionVO, + ExtensionCustomAction.ResourceType.Network, new HashMap<>()); + + assertFalse(resp.isSuccess()); + assertTrue(resp.getResult().get(ApiConstants.DETAILS).contains("Action failed")); + } + } diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java index 7672b9dc6f97..be0953581dd7 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/AsyncJobManagerImpl.java @@ -31,6 +31,8 @@ import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.inject.Inject; import javax.naming.ConfigurationException; @@ -116,6 +118,8 @@ import org.apache.logging.log4j.ThreadContext; public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, ClusterManagerListener, Configurable { + private static final Pattern PASSWORD_FIELD_PATTERN = Pattern.compile("\\\"password\\\":\\\"([^\\\"]*)\\\"+"); + // Advanced public static final ConfigKey JobExpireMinutes = new ConfigKey("Advanced", Long.class, "job.expire.minutes", "1440", "Time (in minutes) for async-jobs to be kept in system", true, ConfigKey.Scope.Global); @@ -184,6 +188,7 @@ public class AsyncJobManagerImpl extends ManagerBase implements AsyncJobManager, private volatile long _executionRunNumber = 1; private final ScheduledExecutorService _heartbeatScheduler = Executors.newScheduledThreadPool(1, new NamedThreadFactory("AsyncJobMgr-Heartbeat")); + private final ExecutorService _eventBusPublisher = Executors.newSingleThreadExecutor(new NamedThreadFactory("AsyncJobMgr-EventBus")); private ExecutorService _apiJobExecutor; private ExecutorService _workerJobExecutor; @@ -554,22 +559,26 @@ public AsyncJob queryJob(final long jobId, final boolean updatePollTime) { } public String obfuscatePassword(String result, boolean hidePassword) { - if (hidePassword) { - String pattern = "\"password\":"; - if (result != null) { - if (result.contains(pattern)) { - String[] resp = result.split(pattern); - String psswd = resp[1].toString().split(",")[0]; - if (psswd.endsWith("}")) { - psswd = psswd.substring(0, psswd.length() - 1); - result = resp[0] + pattern + psswd.replace(psswd.substring(2, psswd.length() - 1), "*****") + "}," + resp[1].split(",", 2)[1]; - } else { - result = resp[0] + pattern + psswd.replace(psswd.substring(2, psswd.length() - 1), "*****") + "," + resp[1].split(",", 2)[1]; - } - } - } + if (!hidePassword || StringUtils.isBlank(result)) { + return result; + } + + Matcher matcher = PASSWORD_FIELD_PATTERN.matcher(result); + StringBuilder obfuscatedResult = new StringBuilder(); + while (matcher.find()) { + String password = matcher.group(1); + String replacement = "\"password\":\"" + obfuscatePasswordValue(password) + "\""; + matcher.appendReplacement(obfuscatedResult, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(obfuscatedResult); + return obfuscatedResult.toString(); + } + + private String obfuscatePasswordValue(String password) { + if (StringUtils.isEmpty(password)) { + return password; } - return result; + return password.charAt(0) + "*****"; } private void scheduleExecution(final AsyncJobVO job) { @@ -1378,6 +1387,7 @@ public boolean start() { @Override public boolean stop() { _heartbeatScheduler.shutdown(); + _eventBusPublisher.shutdown(); _apiJobExecutor.shutdown(); _workerJobExecutor.shutdown(); return true; @@ -1397,8 +1407,26 @@ protected AsyncJobManagerImpl() { } private void publishOnEventBus(AsyncJob job, String jobEvent) { - _messageBus.publish(null, AsyncJob.Topics.JOB_EVENT_PUBLISH, PublishScope.LOCAL, - new Pair(job, jobEvent)); + try { + _eventBusPublisher.submit(new ManagedContextRunnable() { + @Override + protected void runInContext() { + publishJobEvent(job, jobEvent); + } + }); + } catch (RejectedExecutionException e) { + logger.warn("Failed to publish async job event, event bus publisher is shut down", e); + } + } + + private void publishJobEvent(AsyncJob job, String jobEvent) { + try { + _messageBus.publish(null, AsyncJob.Topics.JOB_EVENT_PUBLISH, PublishScope.LOCAL, + new Pair<>(job, jobEvent)); + } catch (Throwable t) { + logger.warn("Failed to publish async job event on message bus. jobId={}, jobEvent={}", + job != null ? job.getId() : null, jobEvent, t); + } } @Override diff --git a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/VmWorkJobVO.java b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/VmWorkJobVO.java index 41eaac598bf3..050fe4e5215c 100644 --- a/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/VmWorkJobVO.java +++ b/framework/jobs/src/main/java/org/apache/cloudstack/framework/jobs/impl/VmWorkJobVO.java @@ -69,6 +69,14 @@ public VmWorkJobVO(String related) { setRelated(related); } + public VmWorkJobVO(String related, long userId, long accountId, String cmd, Long instanceId, VirtualMachine.Type vmType, Step step) { + super(null, userId, accountId, cmd, null, instanceId, null, null); + setRelated(related); + this.vmType = vmType; + this.step = step; + this.vmInstanceId = instanceId; + } + public Step getStep() { return step; } diff --git a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/AsyncJobManagerTest.java b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/AsyncJobManagerTest.java index 7130873e4eed..f3cd37188456 100644 --- a/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/AsyncJobManagerTest.java +++ b/framework/jobs/src/test/java/org/apache/cloudstack/framework/jobs/AsyncJobManagerTest.java @@ -17,12 +17,15 @@ package org.apache.cloudstack.framework.jobs; import org.apache.cloudstack.framework.jobs.impl.AsyncJobManagerImpl; +import org.apache.commons.lang3.StringUtils; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; +import com.cloud.utils.HumanReadableJson; + @RunWith (MockitoJUnitRunner.class) public class AsyncJobManagerTest { @@ -37,6 +40,12 @@ public class AsyncJobManagerTest { String inputNoBraces = "\"password\":\"password\"\",\"action\":\"OFF\""; String expectedNoBraces = "\"password\":\"p*****\",\"action\":\"OFF\""; + String realUserVmResponseWithPasswordInput = "{\"id\":\"f75b0990-5801-4b78-bcb0-58a503afa49c\",\"name\":\"pw-vm\"," + + "\"displayname\":\"pw-vm\",\"account\":\"admin\",\"password\":\"67wSK5\",\"instancename\":\"i-2-17-VM\"," + + "\"details\":{\"password\":\"3WTVryPJZJwMZGcJJ+OOYf84+uixk/1FraomPG9N6/Uvng\\u003d\\u003d\"," + + "\"Message.ReservedCapacityFreed.Flag\":\"true\",\"rootDiskController\":\"osdefault\"}," + + "\"arch\":\"x86_64\",\"jobid\":\"c13865d3-61ec-4269-979a-3d799181d5fe\",\"jobstatus\":0}"; + @Test public void obfuscatePasswordTest() { String result = asyncJobManager.obfuscatePassword(input, true); @@ -79,4 +88,15 @@ public void obfuscatePasswordTestHidePasswordNoPassword() { Assert.assertEquals(noPassword, result); } + @Test + public void obfuscatePasswordTestHidePasswordRealInput() { + String result = asyncJobManager.obfuscatePassword(realUserVmResponseWithPasswordInput, true); + + Assert.assertNotNull(result); + Assert.assertFalse(result.contains("\"password\":\"3WTVryPJZJwMZGcJJ+OOYf84+uixk\"")); + String jsonObject = HumanReadableJson.getHumanReadableBytesJson(result); + Assert.assertTrue(StringUtils.isNotEmpty(jsonObject)); + Assert.assertTrue(jsonObject.contains("\"password\":\"3*****\"")); + } + } diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDao.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDao.java index da36bc0b98c5..faf5ce363393 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDao.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDao.java @@ -25,7 +25,7 @@ public interface QuotaCreditsDao extends GenericDao { - List findCredits(Long accountId, Long domainId, Date startDate, Date endDate, boolean recursive); + List findCredits(Long accountId, List domainIds, Date startDate, Date endDate); QuotaCreditsVO saveCredits(QuotaCreditsVO credits); diff --git a/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDaoImpl.java b/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDaoImpl.java index ce51177d0aec..ceb901ca30fd 100644 --- a/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDaoImpl.java +++ b/framework/quota/src/main/java/org/apache/cloudstack/quota/dao/QuotaCreditsDaoImpl.java @@ -21,7 +21,6 @@ import javax.inject.Inject; -import com.cloud.domain.dao.DomainDao; import com.cloud.utils.db.Filter; import com.cloud.utils.db.SearchBuilder; import org.apache.cloudstack.quota.vo.QuotaBalanceVO; @@ -39,8 +38,6 @@ @Component public class QuotaCreditsDaoImpl extends GenericDaoBase implements QuotaCreditsDao { - @Inject - DomainDao domainDao; @Inject QuotaBalanceDao quotaBalanceDao; @@ -50,19 +47,18 @@ public QuotaCreditsDaoImpl() { quotaCreditsVoSearch = createSearchBuilder(); quotaCreditsVoSearch.and("updatedOn", quotaCreditsVoSearch.entity().getUpdatedOn(), SearchCriteria.Op.BETWEEN); quotaCreditsVoSearch.and("accountId", quotaCreditsVoSearch.entity().getAccountId(), SearchCriteria.Op.EQ); - quotaCreditsVoSearch.and("domainId", quotaCreditsVoSearch.entity().getDomainId(), SearchCriteria.Op.IN); + quotaCreditsVoSearch.and("domainIds", quotaCreditsVoSearch.entity().getDomainId(), SearchCriteria.Op.IN); quotaCreditsVoSearch.done(); } @Override - public List findCredits(Long accountId, Long domainId, Date startDate, Date endDate, boolean recursive) { + public List findCredits(Long accountId, List domainIds, Date startDate, Date endDate) { SearchCriteria sc = quotaCreditsVoSearch.create(); Filter filter = new Filter(QuotaCreditsVO.class, "updatedOn", true, 0L, Long.MAX_VALUE); sc.setParametersIfNotNull("accountId", accountId); - if (domainId != null) { - List domainIds = recursive ? domainDao.getDomainAndChildrenIds(domainId) : List.of(domainId); - sc.setParameters("domainId", domainIds.toArray()); + if (domainIds != null) { + sc.setParameters("domainIds", domainIds.toArray()); } if (ObjectUtils.allNotNull(startDate, endDate)) { diff --git a/packaging/systemd/cloudstack-management.default b/packaging/systemd/cloudstack-management.default index a41338beda68..dbb7fa7d4bc5 100644 --- a/packaging/systemd/cloudstack-management.default +++ b/packaging/systemd/cloudstack-management.default @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -JAVA_OPTS="-Djava.security.properties=/etc/cloudstack/management/java.security.ciphers -Djava.awt.headless=true -Xmx2G -XX:+UseParallelGC -XX:MaxGCPauseMillis=500 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/cloudstack/management/ -XX:ErrorFile=/var/log/cloudstack/management/cloudstack-management.err --add-opens=java.base/java.lang=ALL-UNNAMED --add-exports=java.base/sun.security.x509=ALL-UNNAMED" +JAVA_OPTS="-Djava.security.properties=/etc/cloudstack/management/java.security.ciphers -Djava.awt.headless=true -Xmx2G -XX:+UseParallelGC -XX:MaxGCPauseMillis=500 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/cloudstack/management/ -XX:ErrorFile=/var/log/cloudstack/management/cloudstack-management.err --add-opens=java.base/java.lang=ALL-UNNAMED --add-exports=java.base/sun.security.x509=ALL-UNNAMED -Djava.io.tmpdir=/var/tmp" CLASSPATH="/usr/share/cloudstack-management/lib/*:/etc/cloudstack/management:/usr/share/cloudstack-common:/usr/share/cloudstack-management/setup:/usr/share/cloudstack-management:/usr/share/cloudstack-mysql-ha/lib/*" diff --git a/plugins/backup/dummy/src/main/java/org/apache/cloudstack/backup/DummyBackupProvider.java b/plugins/backup/dummy/src/main/java/org/apache/cloudstack/backup/DummyBackupProvider.java index b228a9f8ce05..00bb353788f9 100644 --- a/plugins/backup/dummy/src/main/java/org/apache/cloudstack/backup/DummyBackupProvider.java +++ b/plugins/backup/dummy/src/main/java/org/apache/cloudstack/backup/DummyBackupProvider.java @@ -90,13 +90,14 @@ public boolean assignVMToBackupOffering(VirtualMachine vm, BackupOffering backup } @Override - public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup) { + public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { logger.debug("Restoring vm {} from backup {} on the Dummy Backup Provider", vm, backup); return true; } @Override - public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, Pair vmNameAndState) { + public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore) { final VolumeVO volume = volumeDao.findByUuid(backupVolumeInfo.getUuid()); final StoragePoolHostVO dataStore = storagePoolHostDao.findByUuid(dataStoreUuid); final DiskOffering diskOffering = diskOfferingDao.findByUuid(backupVolumeInfo.getDiskOfferingId()); @@ -153,7 +154,7 @@ public boolean willDeleteBackupsOnOfferingRemoval() { } @Override - public Pair takeBackup(VirtualMachine vm, Boolean quiesceVM) { + public Pair takeBackup(VirtualMachine vm, Boolean quiesceVM, boolean isolated) { logger.debug("Starting backup for VM {} on Dummy provider", vm); BackupVO backup = new BackupVO(); @@ -204,7 +205,7 @@ public void syncBackupStorageStats(Long zoneId) { } @Override - public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid) { + public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickrestore) { return new Pair<>(true, null); } } diff --git a/plugins/backup/kboss/pom.xml b/plugins/backup/kboss/pom.xml new file mode 100644 index 000000000000..eb8cbd03efa1 --- /dev/null +++ b/plugins/backup/kboss/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + cloud-plugin-backup-kvm-backup-on-secondary-storage + Apache CloudStack Plugin - KVM Backup On Secondary Storage + + cloudstack-plugins + org.apache.cloudstack + 4.23.0.0-SNAPSHOT + ../../pom.xml + + + + org.apache.cloudstack + cloud-plugin-hypervisor-kvm + ${project.version} + + + org.apache.cloudstack + cloud-engine-components-api + ${project.version} + compile + + + org.apache.cloudstack + cloud-engine-orchestration + ${project.version} + compile + + + diff --git a/plugins/backup/kboss/src/main/java/org/apache/cloudstack/backup/KbossBackupProvider.java b/plugins/backup/kboss/src/main/java/org/apache/cloudstack/backup/KbossBackupProvider.java new file mode 100644 index 000000000000..596dc62b7206 --- /dev/null +++ b/plugins/backup/kboss/src/main/java/org/apache/cloudstack/backup/KbossBackupProvider.java @@ -0,0 +1,2921 @@ +// 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 org.apache.cloudstack.backup; + +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.BACKUP_HASH; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.CURRENT; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.END_OF_CHAIN; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.IMAGE_STORE_ID; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.ISOLATED; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.PARENT_ID; +import static org.apache.cloudstack.backup.dao.BackupDetailsDao.SCREENSHOT_PATH; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import org.apache.cloudstack.alert.AlertService; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.command.user.vm.DestroyVMCmd; +import org.apache.cloudstack.backup.dao.BackupDao; +import org.apache.cloudstack.backup.dao.BackupDetailsDao; +import org.apache.cloudstack.backup.dao.BackupOfferingDao; +import org.apache.cloudstack.backup.dao.BackupOfferingDetailsDao; +import org.apache.cloudstack.backup.dao.InternalBackupDataStoreDao; +import org.apache.cloudstack.backup.dao.InternalBackupJoinDao; +import org.apache.cloudstack.backup.dao.InternalBackupServiceJobDao; +import org.apache.cloudstack.backup.dao.InternalBackupStoragePoolDao; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.engine.orchestration.service.VolumeOrchestrationService; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.framework.jobs.AsyncJob; +import org.apache.cloudstack.framework.jobs.AsyncJobExecutionContext; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.Outcome; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; +import org.apache.cloudstack.framework.jobs.impl.OutcomeImpl; +import org.apache.cloudstack.framework.jobs.impl.VmWorkJobVO; +import org.apache.cloudstack.jobs.JobInfo; +import org.apache.cloudstack.secstorage.heuristics.HeuristicType; +import org.apache.cloudstack.storage.command.BackupDeleteAnswer; +import org.apache.cloudstack.storage.command.DeleteCommand; +import org.apache.cloudstack.storage.datastore.db.ImageStoreDao; +import org.apache.cloudstack.storage.datastore.db.ImageStoreVO; +import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; +import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; +import org.apache.cloudstack.storage.heuristics.HeuristicRuleHelper; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.storage.to.KbossTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.storage.vmsnapshot.VMSnapshotHelper; +import org.apache.cloudstack.storage.volume.VolumeObject; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.storage.MergeDiskOnlyVmSnapshotCommand; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.DataTO; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.agent.manager.Commands; +import com.cloud.alert.AlertManager; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.resource.ResourceState; +import com.cloud.storage.DataStoreRole; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.Storage; +import com.cloud.storage.Volume; +import com.cloud.storage.VolumeApiService; +import com.cloud.storage.VolumeApiServiceImpl; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.DiskOfferingDao; +import com.cloud.storage.dao.VolumeDao; +import com.cloud.uservm.UserVm; +import com.cloud.utils.DateUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.Predicate; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; +import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.exception.BackupException; +import com.cloud.utils.exception.BackupProviderException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.NoTransitionException; +import com.cloud.vm.NicVO; +import com.cloud.vm.UserVmManager; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineManagerImpl; +import com.cloud.vm.VirtualMachineProfileImpl; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.VmWork; +import com.cloud.vm.VmWorkConstants; +import com.cloud.vm.VmWorkDeleteBackup; +import com.cloud.vm.VmWorkRestoreBackup; +import com.cloud.vm.VmWorkRestoreVolumeBackupAndAttach; +import com.cloud.vm.VmWorkSerializer; +import com.cloud.vm.VmWorkTakeBackup; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.snapshot.VMSnapshot; +import com.cloud.vm.snapshot.VMSnapshotDetailsVO; +import com.cloud.vm.snapshot.VMSnapshotVO; +import com.cloud.vm.snapshot.dao.VMSnapshotDao; +import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; + +public class KbossBackupProvider extends AdapterBase implements InternalBackupProvider, Configurable { + protected ConfigKey backupChainSize = new ConfigKey<>("Advanced", Integer.class, "backup.chain.size", "8", "Determines the max size of a backup chain." + + " Currently only used by the KBOSS provider. If cloud admins set it to 1 , all the backups will be full backups. With values lower than 1, the backup chain will be " + + "unlimited, unless it is stopped by another process. Please note that unlimited backup chains have a higher chance of getting corrupted, as new backups will be" + + " dependant on all of the older ones.", true, ConfigKey.Scope.Zone); + + protected ConfigKey backupTimeout = new ConfigKey<>("Advanced", Integer.class, "kboss.timeout", "43200", "Timeout, in seconds, to execute KBOSS commands. After the " + + "command times out, the Management Server will still wait for another kboss.timeout seconds to receive a response from the Agent.", true, ConfigKey.Scope.Zone); + + @Inject + private AsyncJobManager jobManager; + @Inject + private EntityManager entityManager; + + @Inject + private VirtualMachineManager virtualMachineManager; + + @Inject + private UserVmDao userVmDao; + + @Inject + private VMInstanceDetailsDao vmInstanceDetailsDao; + + @Inject + private VMSnapshotHelper vmSnapshotHelper; + + @Inject + private SnapshotDataStoreDao snapshotDataStoreDao; + + @Inject + private VMSnapshotDao vmSnapshotDao; + + @Inject + private VMSnapshotDetailsDao vmSnapshotDetailsDao; + + @Inject + private BackupDao backupDao; + + @Inject + private InternalBackupJoinDao internalBackupJoinDao; + + @Inject + private BackupDetailsDao backupDetailDao; + + @Inject + private InternalBackupStoragePoolDao internalBackupStoragePoolDao; + + @Inject + private InternalBackupDataStoreDao internalBackupDataStoreDao; + + @Inject + private BackupOfferingDao backupOfferingDao; + + @Inject + private BackupOfferingDetailsDao backupOfferingDetailsDao; + + @Inject + private HeuristicRuleHelper heuristicRuleHelper; + + @Inject + private DataStoreManager dataStoreManager; + + @Inject + private AgentManager agentManager; + + @Inject + private EndPointSelector endPointSelector; + + @Inject + private VolumeDao volumeDao; + + @Inject + private ImageStoreDao imageStoreDao; + + @Inject + private VolumeApiService volumeApiService; + + @Inject + private PrimaryDataStoreDao storagePoolDao; + + @Inject + private HostDao hostDao; + + @Inject + private UserVmManager userVmManager; + + @Inject + private VolumeOrchestrationService volumeOrchestrationService; + + @Inject + private VolumeDataFactory volumeDataFactory; + @Inject + private InternalBackupServiceJobDao internalBackupServiceJobDao; + + @Inject + private BackupManager backupManager; + + @Inject + private DiskOfferingDao diskOfferingDao; + + @Inject + private HypervisorGuruManager hypervisorGuruManager; + + @Inject + private NicDao nicDao; + + @Inject + private AlertManager alertManager; + + protected final List validChildStatesToRemoveBackup = List.of(Backup.Status.Expunged, Backup.Status.Error, Backup.Status.Failed); + + private final List supportedStoragePoolTypes = List.of(Storage.StoragePoolType.Filesystem, Storage.StoragePoolType.NetworkFilesystem, + Storage.StoragePoolType.SharedMountPoint); + + private final List allowedBackupStatesToRemove = List.of(Backup.Status.BackedUp, Backup.Status.Failed, Backup.Status.Error); + + private final List allowedBackupStatesToCompress = List.of(Backup.Status.BackedUp, Backup.Status.Restoring); + + private final List allowedBackupStatesToValidate = List.of(Backup.Status.BackedUp, Backup.Status.Restoring); + + private final List allowedVmStates = Arrays.asList(VirtualMachine.State.Running, VirtualMachine.State.Stopped); + + @Override + public String getName() { + return "kboss"; + } + + @Override + public String getDescription() { + return "KVM Backup on Secondary Storage"; + } + + @Override + public List listBackupOfferings(Long zoneId) { + return List.of(); + } + + @Override + public boolean isValidProviderOffering(Long zoneId, String uuid) { + return true; + } + + @Override + public boolean assignVMToBackupOffering(VirtualMachine vm, BackupOffering backupOffering) { + logger.debug("Assigning VM [{}] to KBOSS backup offering with name:[{}], uuid: [{}].", vm.getUuid(), backupOffering.getName(), backupOffering.getUuid()); + if (!Hypervisor.HypervisorType.KVM.equals(vm.getHypervisorType())) { + logger.error("KVM Backup on Secondary Storage provider is only supported for KVM."); + return false; + } + + for (VMSnapshotVO vmSnapshotVO : vmSnapshotDao.findByVmAndByType(vm.getId(), VMSnapshot.Type.Disk)) { + List vmSnapshotDetails = vmSnapshotDetailsDao.listDetails(vmSnapshotVO.getId()); + if (!vmSnapshotDetails.stream().allMatch(vmSnapshotDetailsVO -> vmSnapshotDetailsVO.getName().equals(VolumeApiServiceImpl.KVM_FILE_BASED_STORAGE_SNAPSHOT))) { + logger.error("KBOSS is only supported with disk-only VM snapshots using [{}] strategy. Found a disk-only VM snapshot using another strategy for the VM.", + VolumeApiServiceImpl.KVM_FILE_BASED_STORAGE_SNAPSHOT); + logger.debug("Found VM snapshot details [{}].", () -> vmSnapshotDetails.stream().map(VMSnapshotDetailsVO::getName).collect(Collectors.toList())); + return false; + } + } + + return CollectionUtils.isEmpty(vmSnapshotDao.findByVmAndByType(vm.getId(), VMSnapshot.Type.DiskAndMemory)); + } + + @Override + public boolean removeVMFromBackupOffering(VirtualMachine vm) { + logger.info("Removing VM [{}] from KBOSS backup offering.", vm.getUuid()); + + validateVmState(vm, "remove backup offering", VirtualMachine.State.Expunging, VirtualMachine.State.Destroyed); + if (endBackupChain(vm)) { + return true; + } + UserVmVO vmVO = userVmDao.findById(vm.getId()); + logger.error("Failed to merge deltas for VM [{}] during backup offering removal process. Changing its state to [{}].", vm, VirtualMachine.State.BackupError); + vmInstanceDetailsDao.addDetail(vm.getId(), VmDetailConstants.LAST_KNOWN_STATE, vmVO.getState().name(), false); + vmVO.setState(VirtualMachine.State.BackupError); + userVmDao.update(vmVO.getId(), vmVO); + + return false; + } + + @Override + public boolean willDeleteBackupsOnOfferingRemoval() { + return false; + } + + @Override + public Pair takeBackup(VirtualMachine vm, Boolean quiesceVm, boolean isolated) { + logger.debug("Queueing backup on VM [{}].", vm.getUuid()); + Outcome outcome = createBackupThroughJobQueue(vm, ObjectUtils.defaultIfNull(quiesceVm, false), isolated); + + try { + outcome.get(); + } catch (InterruptedException | ExecutionException e) { + throw new CloudRuntimeException(String.format("Unable to retrieve result from job takeBackup due to [%s]. VM [%s].", e.getMessage(), vm.getUuid()), e); + } + + Object jobResult = jobManager.unmarshallResultObject(outcome.getJob()); + + if (jobResult instanceof BackupProviderException) { + throw (BackupProviderException) jobResult; + } else if (jobResult instanceof Throwable) { + throw new CloudRuntimeException(String.format("Exception while taking KBOSS backup for VM [%s]. Check the logs for more information.", vm.getUuid())); + } + + Pair result = (Pair)jobResult; + Pair returnValue = new Pair<>(result.first(), null); + if (result.first()) { + returnValue.second(backupDao.findById(result.second())); + } + return returnValue; + } + + @Override + public Pair orchestrateTakeBackup(Backup backup, boolean quiesceVm, boolean isolated) { + BackupVO backupVO = (BackupVO) backup; + long vmId = backup.getVmId(); + VirtualMachine userVm = virtualMachineManager.findById(vmId); + Long hostId = vmSnapshotHelper.pickRunningHost(vmId); + HostVO hostVO = hostDao.findById(hostId); + + if (hostVO.getStatus() != Status.Up || hostVO.getResourceState() != ResourceState.Enabled) { + backupVO.setStatus(Backup.Status.Failed); + backupDao.update(backupVO.getId(), backupVO); + + logger.error("No available host found to create backup [{}] of VM [{}]. Setting the backup as Failed.", backupVO.getUuid(), userVm.getUuid()); + return new Pair<>(Boolean.FALSE, backup.getId()); + } + + List volumeTOs; + try { + validateVmState(userVm, "take backup"); + volumeTOs = vmSnapshotHelper.getVolumeTOList(userVm.getId()); + validateStorages(volumeTOs, userVm.getUuid()); + } catch (Exception e) { + backupVO.setStatus(Backup.Status.Failed); + backupDao.update(backupVO.getId(), backupVO); + throw e; + } + + logger.info("Starting VM backup process for VM [{}].", userVm.getUuid()); + + BackupOfferingVO backupOfferingVO = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); + + backupVO.setDate(new Date()); + List backupChain = getBackupJoinParents(backupVO, true); + InternalBackupJoinVO parentBackup = null; + if (isolated) { + setBackupAsIsolated(backupVO); + } else { + parentBackup = getParentAndSetEndOfChain(backupVO, backupChain, backupOfferingVO); + } + InternalBackupJoinVO newBackupJoin = internalBackupJoinDao.findById(backup.getId()); + boolean fullBackup = parentBackup == null; + List parentBackupDeltasOnPrimary = new ArrayList<>(); + List parentBackupDeltasOnSecondary = new ArrayList<>(); + List chainImageStoreUrls = null; + List kbossTOs = new ArrayList<>(); + HashMap volumeUuidToDeltaPrimaryRef = new HashMap<>(); + HashMap volumeUuidToDeltaSecondaryRef = new HashMap<>(); + + if (!fullBackup) { + parentBackupDeltasOnPrimary = internalBackupStoragePoolDao.listByBackupId(parentBackup.getId()); + parentBackupDeltasOnSecondary = internalBackupDataStoreDao.listByBackupId(parentBackup.getId()); + + chainImageStoreUrls = getChainImageStoreUrls(backupChain); + } + + boolean runningVm = userVm.getState() == VirtualMachine.State.Running; + transitVmState(userVm, VirtualMachine.Event.BackupRequested, hostId); + updateBackupStatusToBackingUp(volumeTOs, backupVO); + + DataStore imageStore = getImageStoreForBackup(userVm.getDataCenterId(), backupVO); + createBasicBackupDetails(imageStore.getId(), fullBackup ? 0L : parentBackup.getId(), backupVO); + + List succeedingVmSnapshotList = getSucceedingVmSnapshotList(parentBackup); + VMSnapshotVO succeedingVmSnapshot = succeedingVmSnapshotList.isEmpty() ? null : succeedingVmSnapshotList.get(0); + + Map> volumeIdToSnapshotDataStoreList = mapVolumesToVmSnapshotReferences(volumeTOs, succeedingVmSnapshotList); + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + KbossTO kbossTO = new KbossTO(volumeObjectTO, volumeIdToSnapshotDataStoreList.getOrDefault(volumeObjectTO.getId(), new ArrayList<>())); + kbossTOs.add(kbossTO); + createDeltaReferences(fullBackup, !succeedingVmSnapshotList.isEmpty(), runningVm, backup, parentBackupDeltasOnSecondary, + parentBackupDeltasOnPrimary, volumeUuidToDeltaPrimaryRef, volumeUuidToDeltaSecondaryRef, succeedingVmSnapshot, kbossTO); + } + + TakeKbossBackupCommand command = new TakeKbossBackupCommand(quiesceVm, runningVm, newBackupJoin.getEndOfChain(), userVm.getInstanceName(), imageStore.getUri(), + chainImageStoreUrls, kbossTOs, isolated); + + Answer answer = sendBackupCommand(hostId, command); + + if (answer == null || !answer.getResult()) { + processBackupFailure(answer, userVm, hostId, runningVm, backupVO); + return new Pair<>(Boolean.FALSE, null); + } + + processBackupSuccess(runningVm, volumeTOs, volumeUuidToDeltaPrimaryRef, volumeUuidToDeltaSecondaryRef, (TakeKbossBackupAnswer)answer, parentBackupDeltasOnPrimary, + succeedingVmSnapshotList, backupVO, fullBackup, userVm, hostId, newBackupJoin.getEndOfChain(), isolated); + + if (!isolated) { + updateCurrentBackup(newBackupJoin); + } + + if (offeringSupportsCompression(newBackupJoin)) { + compressBackupAsync(newBackupJoin, backup.getZoneId(), userVm.getAccountId()); + } else { + validateBackupAsyncIfHasOfferingSupport(newBackupJoin, backup.getZoneId(), userVm.getAccountId()); + } + return new Pair<>(Boolean.TRUE, backupVO.getId()); + } + + @Override + public boolean deleteBackup(Backup backup, boolean forced) { + logger.debug("Queueing backup [{}] deletion.", backup.getUuid()); + Outcome outcome = deleteBackupThroughJobQueue(backup, forced); + + try { + outcome.get(); + } catch (InterruptedException | ExecutionException e) { + throw new CloudRuntimeException(String.format("Unable to retrieve result from job deleteBackup due to [%s]. Backup [%s].", e.getMessage(), backup.getUuid()), e); + } + + Object jobResult = jobManager.unmarshallResultObject(outcome.getJob()); + + if (jobResult instanceof Throwable) { + if (jobResult instanceof BackupProviderException) { + throw (BackupProviderException) jobResult; + } + throw new CloudRuntimeException(String.format("Exception while deleting KBOSS backup [%s]. Check the logs for more information.", backup.getUuid())); + } + + return BooleanUtils.isTrue((Boolean) jobResult); + } + + @Override + public Boolean orchestrateDeleteBackup(Backup backup, boolean forced) { + BackupVO backupVO = (BackupVO) backup; + + VirtualMachine virtualMachine = virtualMachineManager.findById(backup.getVmId()); + + if (virtualMachine != null) { + validateVmState(virtualMachine, "delete backup", VirtualMachine.State.Destroyed); + } + + logger.info("Starting delete process for backup [{}].", backupVO); + + if (!validateBackupStateForRemoval(backupVO.getId())) { + return false; + } + + checkErrorBackup(backupVO, virtualMachine); + if (deleteFailedBackup(backupVO)) { + return true; + } + + InternalBackupJoinVO childBackup = internalBackupJoinDao.findByParentId(backup.getId()); + + if (childBackup != null && !validChildStatesToRemoveBackup.contains(childBackup.getStatus())) { + logger.debug("Backup [{}] has children that are not in one of the following states [{}]; will mark it as removed on the database but the files will not be deleted " + + "from secondary storage until the children are also expunged.", backup.getUuid(), validChildStatesToRemoveBackup); + backupVO.setStatus(Backup.Status.Removed); + backupDao.update(backupVO.getId(), backupVO); + return true; + } + + InternalBackupJoinVO backupJoinVO = internalBackupJoinDao.findById(backup.getId()); + if (backupJoinVO.getCurrent()) { + if (!mergeCurrentBackupDeltas(backupJoinVO)) { + return false; + } + InternalBackupJoinVO parent = internalBackupJoinDao.findById(backupJoinVO.getParentId()); + if (parent != null && parent.getStatus() == Backup.Status.BackedUp) { + backupDetailDao.persist(new BackupDetailVO(parent.getId(), END_OF_CHAIN, Boolean.TRUE.toString(), false)); + } + } + + Commands deleteCommands = new Commands(Command.OnError.Continue); + + DataStore dataStore = addBackupDeltasToDeleteCommand(backup.getId(), deleteCommands); + Pair, InternalBackupJoinVO> backupParentsToBeRemovedAndLastAliveBackup = getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(backupVO, + deleteCommands); + + EndPoint endPoint = endPointSelector.select(dataStore); + if (endPoint == null) { + logger.error("Unable to find SSVM to delete backup [{}]. Check if SSVM is up for the zone.", backup); + throw new CloudRuntimeException(String.format("Unable to delete backup [%s]. Please check the logs.", backup.getUuid())); + } + Answer[] deleteAnswers; + try { + deleteAnswers = sendBackupCommands(endPoint.getId(), deleteCommands); + } catch (AgentUnavailableException | OperationTimedoutException e) { + throw new CloudRuntimeException(e); + } + + List removedBackupIds = backupParentsToBeRemovedAndLastAliveBackup.first().stream().map(InternalBackupJoinVO::getId).collect(Collectors.toList()); + removedBackupIds.add(backup.getId()); + + boolean isFailedSetEmpty = processRemoveBackupFailures(forced, deleteAnswers, removedBackupIds, backupJoinVO); + + processRemovedBackups(removedBackupIds); + + if (backupParentsToBeRemovedAndLastAliveBackup.second() != null) { + backupDetailDao.persist(new BackupDetailVO(backupParentsToBeRemovedAndLastAliveBackup.second().getId(), END_OF_CHAIN, Boolean.TRUE.toString(), false)); + } + + return isFailedSetEmpty; + } + + @Override + public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { + logger.debug("Queueing backup [{}] restore for VM [{}].", backup.getUuid(), vm.getUuid()); + validateQuickRestore(backup, quickRestore); + + Outcome outcome = restoreVMFromBackupThroughJobQueue(vm, backup, quickRestore, hostId); + + try { + outcome.get(); + } catch (InterruptedException | ExecutionException e) { + throw new CloudRuntimeException(String.format("Unable to retrieve result from job restoreVMFromBackup due to [%s]. Backup [%s].", e.getMessage(), backup.getUuid()), e); + } finally { + BackupVO backupVO = backupDao.findById(backup.getId()); + backupVO.setStatus(Backup.Status.BackedUp); + backupDao.update(backupVO.getId(), backupVO); + } + + Object jobResult = jobManager.unmarshallResultObject(outcome.getJob()); + + handleRestoreException(backup, vm, jobResult); + + return BooleanUtils.isTrue((Boolean) jobResult); + } + + @Override + public Boolean orchestrateRestoreVMFromBackup(Backup backup, VirtualMachine vm, boolean quickRestore, Long hostId, boolean sameVmAsBackup) { + logger.info("Starting restore backup process for VM [{}] and backup [{}].", vm.getUuid(), backup); + validateNoVmSnapshots(vm); + validateQuickRestore(backup, quickRestore); + long backupId = backup.getId(); + Pair isValidStateAndBackupVo = validateCompressionStateForRestoreAndGetBackup(backupId); + + if (!isValidStateAndBackupVo.first()) { + return false; + } + + InternalBackupJoinVO backupJoinVO = internalBackupJoinDao.findById(backupId); + InternalBackupJoinVO currentBackup = sameVmAsBackup ? internalBackupJoinDao.findCurrent(vm.getId()) : null; + List deltasOnPrimary = new ArrayList<>(); + if (currentBackup != null) { + deltasOnPrimary = internalBackupStoragePoolDao.listByBackupId(currentBackup.getId()); + } + List deltasOnSecondary = internalBackupDataStoreDao.listByBackupId(backupId); + List volumeTOs = vmSnapshotHelper.getVolumeTOList(vm.getId()); + + Set deltasToRemove = new HashSet<>(); + + List backupsWithoutVolumes = sameVmAsBackup ? getBackupsWithoutVolumes(deltasOnSecondary, volumeTOs) : List.of(); + + HostVO host; + try { + host = getHostToRestore(vm, quickRestore, hostId); + } catch (AgentUnavailableException e) { + throw new CloudRuntimeException(e); + } + + BackupVO backupVO = isValidStateAndBackupVo.second(); + List volumeInfos = backupVO.getBackedUpVolumes(); + if (sameVmAsBackup) { + createAndAttachVolumes(volumeInfos, backupsWithoutVolumes, vm, host); + // Get new volume references + volumeTOs = vmSnapshotHelper.getVolumeTOList(vm.getId()); + } + + Set> backupAndVolumePairs = generateBackupAndVolumePairsToRestore(deltasOnSecondary, volumeTOs, backupJoinVO, sameVmAsBackup); + + List deltasToBeMerged = List.of(); + if (sameVmAsBackup) { + List volumesNotPartOfTheBackup = getVolumesThatAreNotPartOfTheBackup(volumeTOs, deltasOnSecondary); + deltasToBeMerged = populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(deltasOnPrimary, deltasToRemove, volumeTOs, volumesNotPartOfTheBackup, + vm.getUuid()); + } + Set secondaryStorageUrls = getParentSecondaryStorageUrls(backupVO); + + Commands commands = new Commands(Command.OnError.Stop); + commands.addCommand(new RestoreKbossBackupCommand(deltasToRemove, backupAndVolumePairs, secondaryStorageUrls, quickRestore)); + commands.addCommand(new MergeDiskOnlyVmSnapshotCommand(deltasToBeMerged, vm.getState().equals(VirtualMachine.State.Running), vm.getInstanceName())); + + Answer[] answers; + + try { + answers = sendBackupCommands(host.getId(), commands); + } catch (OperationTimedoutException | AgentUnavailableException e) { + throw new CloudRuntimeException(e); + } + + if (answers == null) { + logger.error("Failed to restore backup [{}] due to no answer from host.", backup); + return false; + } + + if (!processRestoreAnswers(vm, answers, quickRestore)) { + return false; + } + + updateVolumePathsAndSizeIfNeeded(vm, volumeTOs, volumeInfos, deltasToBeMerged, sameVmAsBackup); + + if (currentBackup != null) { + internalBackupStoragePoolDao.expungeByBackupId(currentBackup.getId()); + setEndOfChainAndRemoveCurrentForBackup(currentBackup); + } + + if (quickRestore) { + List volumesToConsolidate = getVolumesToConsolidate(vm, deltasOnSecondary, volumeTOs, host.getId(), sameVmAsBackup); + return finalizeQuickRestore(vm, volumesToConsolidate, host.getId()); + } + + return true; + } + + @Override + public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore) { + logger.debug("Queueing backup [{}] volume [{}] restore for VM [{}].", backup.getUuid(), backupVolumeInfo, vm.getUuid()); + validateQuickRestore(backup, quickRestore); + Outcome outcome = restoreBackedUpVolumeThroughJobQueue(vm, backup, backupVolumeInfo, hostIp, quickRestore); + + try { + outcome.get(); + } catch (InterruptedException | ExecutionException e) { + throw new CloudRuntimeException(String.format("Unable to retrieve result from job restoreBackedUpVolume due to [%s]. Backup [%s].", e.getMessage(), backup.getUuid()), e); + } finally { + BackupVO backupVO = backupDao.findById(backup.getId()); + backupVO.setStatus(Backup.Status.BackedUp); + backupDao.update(backupVO.getId(), backupVO); + } + + Object jobResult = jobManager.unmarshallResultObject(outcome.getJob()); + + handleRestoreException(backup, vm, jobResult); + + if (!(jobResult instanceof Pair)) { + throw new CloudRuntimeException(String.format("Unexpected answer from restoreBackupVolume job. Got [%s].", jobResult)); + } + return (Pair) jobResult; + } + + @Override + public Pair orchestrateRestoreBackedUpVolume(Backup backup, VirtualMachine vm, Backup.VolumeInfo backupVolumeInfo, String hostIp, boolean quickRestore) { + BackupVO backupVO = (BackupVO) backup; + Pair isValidStateAndBackupVo = validateCompressionStateForRestoreAndGetBackup(backup.getId()); + + if (!isValidStateAndBackupVo.first()) { + return new Pair<>(false, null); + } + + VolumeVO backedUpVolume = volumeDao.findByUuidIncludingRemoved(backupVolumeInfo.getUuid()); + HostVO hostVo = hostDao.findByIp(hostIp); + VolumeInfo volumeInfo = duplicateAndCreateVolume(vm, hostVo, backupVolumeInfo); + + VolumeObjectTO volumeObjectTO = (VolumeObjectTO) volumeInfo.getTO(); + InternalBackupDataStoreVO deltaOnSecondary = internalBackupDataStoreDao.findByBackupIdAndVolumeId(backup.getId(), backedUpVolume.getId()); + InternalBackupJoinVO internalBackupJoinVO = internalBackupJoinDao.findById(backup.getId()); + Pair backupAndVolumePair = generateBackupAndVolumePairForSingleNewVolume(deltaOnSecondary, volumeObjectTO, internalBackupJoinVO); + Set secondaryStorageUrls = getParentSecondaryStorageUrls(backupVO); + + RestoreKbossBackupCommand cmd = new RestoreKbossBackupCommand(Set.of(), Set.of(backupAndVolumePair), secondaryStorageUrls, quickRestore); + + Answer answer = sendBackupCommand(hostVo.getId(), cmd); + + if (!processRestoreAnswers(vm, new Answer[] {answer}, quickRestore)) { + throw new CloudRuntimeException("Bad answer from agent"); + } + + VolumeVO newVolume = (VolumeVO)volumeInfo.getVolume(); + volumeDao.update(newVolume.getId(), newVolume); + + Volume attachedVolume = volumeApiService.attachVolumeToVM(vm.getId(), newVolume.getId(), null, false, false); + + if (quickRestore) { + ArrayList volumeToConsolidate = new ArrayList<>(); + volumeToConsolidate.add(volumeDataFactory.getVolume(attachedVolume.getId())); + return new Pair<>(finalizeQuickRestore(vm, volumeToConsolidate, hostVo.getId()), attachedVolume.getUuid()); + } + + return new Pair<>(true, attachedVolume.getUuid()); + } + + @Override + public boolean startBackupCompression(long backupId, long hostId) { + Pair validCompressAndBackupVO = validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + + if (!validCompressAndBackupVO.first()) { + return false; + } + + InternalBackupJoinVO backup = internalBackupJoinDao.findById(backupId); + InternalBackupJoinVO parentBackup = internalBackupJoinDao.findById(backup.getParentId()); + + List backupDeltas = internalBackupDataStoreDao.listByBackupId(backupId); + List parentBackupDeltas = parentBackup != null ? internalBackupDataStoreDao.listByBackupId(backup.getParentId()) : List.of(); + + DataStoreTO imageStoreTo = dataStoreManager.getDataStore(backup.getImageStoreId(), DataStoreRole.Image).getTO(); + DataStoreTO parentStoreTo = parentBackup != null ? dataStoreManager.getDataStore(parentBackup.getImageStoreId(), DataStoreRole.Image).getTO() : null; + + List deltasToCompressAndParents = new ArrayList<>(); + for (InternalBackupDataStoreVO delta : backupDeltas) { + BackupDeltaTO backupDeltaTO = new BackupDeltaTO(imageStoreTo, Hypervisor.HypervisorType.KVM, delta.getBackupPath()); + InternalBackupDataStoreVO parentDataStore = parentBackupDeltas.stream().filter(parent -> parent.getVolumeId() == delta.getVolumeId()).findFirst().orElse(null); + BackupDeltaTO parentDeltaTO = parentDataStore != null ? new BackupDeltaTO(parentStoreTo, Hypervisor.HypervisorType.KVM, parentDataStore.getBackupPath()) : null; + deltasToCompressAndParents.add(new DeltaMergeTreeTO(null, parentDeltaTO, backupDeltaTO, null)); + } + + HostVO hostVO = hostDao.findById(hostId); + BackupVO backupVO = validCompressAndBackupVO.second(); + + long minFreeStorage = Math.round(backupVO.getSize() * backupCompressionMinimumFreeStorage.valueIn(hostVO.getDataCenterId())); + + BackupOfferingVO backupOfferingVO = backupOfferingDao.findByIdIncludingRemoved(backupVO.getBackupOfferingId()); + BackupOfferingDetailsVO detail = backupOfferingDetailsDao.findDetail(backupOfferingVO.getId(), ApiConstants.COMPRESSION_LIBRARY); + List backupChain = getBackupJoinParents(backupVO, true); + List chainImageStoreUrls = getChainImageStoreUrls(backupChain); + CompressBackupCommand cmd = new CompressBackupCommand(deltasToCompressAndParents, chainImageStoreUrls, minFreeStorage, detail == null ? null : + Backup.CompressionLibrary.valueOf(detail.getValue()), backupCompressionCoroutines.valueIn(hostVO.getClusterId()), + backupCompressionRateLimit.valueIn(hostVO.getClusterId())); + cmd.setWait(backupCompressionTimeout.valueIn(hostVO.getClusterId())); + Answer answer = agentManager.easySend(hostId, cmd); + + if (answer == null || !answer.getResult()) { + logger.error("Failed to compress backup [{}] due to {}.", backup.getUuid(), answer == null ? "no answer" : answer.getDetails()); + backupVO.setCompressionStatus(Backup.CompressionStatus.CompressionError); + backupDao.update(backupId, backupVO); + return false; + } + + logger.info("Successfully completed the first step of the backup compression process for backup [{}]. Will launch a new compression job to finalize the compression.", + backup.getUuid()); + + internalBackupServiceJobDao.persist(new InternalBackupServiceJobVO(backupVO.getId(), backupVO.getZoneId(), backupVO.getVmId(), backupVO.getAccountId(), + InternalBackupServiceJobType.FinalizeCompression)); + + return true; + } + + @Override + public boolean finalizeBackupCompression(long backupId, long hostId) { + Pair shouldContinueProcessAndBackupVo = validateBackupStateForFinalizeCompression(backupId); + if (!shouldContinueProcessAndBackupVo.first()) { + return false; + } + BackupVO backupVO = shouldContinueProcessAndBackupVo.second(); + + List deltaTOs = getBackupDeltaTOList(backupId); + + FinalizeBackupCompressionCommand cmd = new FinalizeBackupCompressionCommand(backupVO.getStatus() != Backup.Status.BackedUp, deltaTOs); + HostVO hostVO = hostDao.findById(hostId); + cmd.setWait(backupCompressionTimeout.valueIn(hostVO.getClusterId())); + Answer answer = agentManager.easySend(hostId, cmd); + + if (answer == null || !answer.getResult()) { + logger.error("Failed to finish compression of backup [{}] due to {}.", backupVO.getUuid(), answer == null ? "no answer" : answer.getDetails()); + backupVO.setCompressionStatus(Backup.CompressionStatus.CompressionError); + backupDao.update(backupId, backupVO); + return false; + } + + if (cmd.isCleanup()) { + logger.info("Successfully cleaned up backup compression of backup [{}].", backupVO); + return true; + } + + backupVO.setCompressionStatus(Backup.CompressionStatus.Compressed); + backupVO.setUncompressedSize(backupVO.getSize()); + backupVO.setSize(Long.parseLong(answer.getDetails())); + backupDao.update(backupVO.getId(), backupVO); + + logger.info("Finalized compression for backup [{}], old size was [{}], compressed size is [{}].", backupVO.getUuid(), backupVO.getUncompressedSize(), backupVO.getSize()); + + validateBackupAsyncIfHasOfferingSupport(internalBackupJoinDao.findById(backupId), backupVO.getZoneId(), backupVO.getAccountId()); + return true; + } + + @Override + public boolean validateBackup(long backupId, long hostId) { + if (!validateBackupStateForValidation(backupId)) { + return false; + } + BackupVO backupVO = backupDao.findById(backupId); + backupVO.setValidationStatus(Backup.ValidationStatus.Validating); + backupDao.update(backupId, backupVO); + BackupDetailVO hashDetail = backupDetailDao.findDetail(backupId, BACKUP_HASH); + if (hashDetail != null) { + return validateWithHash(backupId, backupVO, hashDetail); + } else { + return validateWithValidationVm(backupId, hostId, backupVO); + } + } + + @Override + public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickRestore) { + Pair shouldRestoreAndOldStatus = validateBackupStateForRestoreBackupToVM(backup.getId()); + if (!shouldRestoreAndOldStatus.first()) { + return new Pair<>(false, "Backup is not in the right state."); + } + + boolean result = false; + try { + result = orchestrateRestoreVMFromBackup(backup, vm, quickRestore, null, false); + } catch (Exception exception) { + handleRestoreException(backup, vm, exception); + } finally { + Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback) transactionStatus -> { + BackupVO backupVO = backupDao.findById(backup.getId()); + backupVO.setStatus(shouldRestoreAndOldStatus.second()); + backupDao.update(backupVO.getId(), backupVO); + return true; + }); + } + + return new Pair<>(result, null); + } + + @Override + public boolean finishBackupChain(VirtualMachine virtualMachine) { + UserVmVO userVmVO = userVmDao.findById(virtualMachine.getId()); + if (allowedVmStates.contains(userVmVO.getState())) { + return endBackupChain(userVmVO); + } + if (userVmVO.getState() != VirtualMachine.State.BackupError) { + logger.error("VM [{}] is not in the right state to finish backup chain. It can only be in states [Running, Stopped and BackupError].", userVmVO.getUuid()); + + return false; + } + return normalizeBackupErrorAndFinishChain(userVmVO); + } + + @Override + public void syncBackupMetrics(Long zoneId) { + } + + @Override + public Backup createNewBackupEntryForRestorePoint(Backup.RestorePoint rp, VirtualMachine vm) { + return null; + } + + @Override + public Pair getBackupStorageStats(Long zoneId) { + return new Pair<>(0L, 0L); + } + + @Override + public void syncBackupStorageStats(Long zoneId) { + } + + @Override + public boolean supportsInstanceFromBackup() { + return true; + } + + @Override + public boolean supportsMemoryVmSnapshot() { + return false; + } + + @Override + public void prepareVolumeForDetach(Volume volume, VirtualMachine virtualMachine) { + logger.info("Preparing volume [{}] for detach.", volume.getUuid()); + mergeCurrentDeltaIntoVolume(volume, virtualMachine, "detach", virtualMachine.getState().equals(VirtualMachine.State.Running)); + } + + @Override + public void prepareVolumeForMigration(Volume volume, VirtualMachine vm) { + if (VirtualMachine.State.Migrating.equals(vm.getState())) { + logger.info("Preparing volume [{}] for live migration.", volume.getUuid()); + mergeCurrentDeltaIntoVolume(volume, vm, "live migration", true); + } + } + + @Override + public void updateVolumeId(VirtualMachine virtualMachine, long oldVolumeId, long newVolumeId) { + internalBackupDataStoreDao.updateVolumeId(oldVolumeId, newVolumeId); + } + + @Override + public void prepareVmForSnapshotRevert(VMSnapshot vmSnapshot, VirtualMachine virtualMachine) { + InternalBackupJoinVO currentBackup = internalBackupJoinDao.findCurrent(virtualMachine.getId()); + + if (currentBackup == null) { + logger.debug("There is no current backup delta, the VM [{}] is already prepared for VM snapshot revert.", virtualMachine.getUuid()); + return; + } + if (currentBackup.getDate().before(vmSnapshot.getCreated())) { + logger.debug("The current backup delta was taken before [{}] the VM snapshot being reverted [{}], no need to prepare the VM.", currentBackup.getDate(), + vmSnapshot.getCreated()); + + return; + } + + logger.debug("Preparing VM [{}] for VM snapshot reversion.", virtualMachine.getUuid()); + + List volumeObjectTOs = vmSnapshotHelper.getVolumeTOList(virtualMachine.getId()); + VMSnapshotVO vmSnapshotSucceedingCurrentBackup = getSucceedingVmSnapshot(currentBackup); + + List deltaMergeTreeTOList = new ArrayList<>(); + Commands commands = new Commands(Command.OnError.Stop); + List deletedDeltas = new ArrayList<>(); + + createDeleteCommandsAndMergeTrees(volumeObjectTOs, commands, deletedDeltas, vmSnapshotSucceedingCurrentBackup, deltaMergeTreeTOList); + + if (!deltaMergeTreeTOList.isEmpty()) { + commands.addCommand(new MergeDiskOnlyVmSnapshotCommand(deltaMergeTreeTOList, false, virtualMachine.getInstanceName())); + } + + Long hostId = vmSnapshotHelper.pickRunningHost(virtualMachine.getId()); + + Answer[] answers; + try { + answers = sendBackupCommands(hostId, commands); + } catch (AgentUnavailableException | OperationTimedoutException e) { + throw new CloudRuntimeException(e); + } + + if (answers == null || Arrays.stream(answers).anyMatch(answer -> !answer.getResult())) { + logger.error("Error while trying to prepare VM [{}] for VM snapshot reversion. Got [{}] as answers from host.", virtualMachine.getUuid(), + answers != null ? Arrays.stream(answers).filter(answer -> !answer.getResult()).map(Answer::getDetails) : null); + throw new CloudRuntimeException(String.format("Unable to prepare VM [%s] for VM snapshot reversion.", virtualMachine.getUuid())); + } + + List snapRefsSucceedingCurrentBackup = new ArrayList<>(); + if (vmSnapshotSucceedingCurrentBackup != null) { + snapRefsSucceedingCurrentBackup = vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(vmSnapshotSucceedingCurrentBackup.getId()); + } + + updateReferencesAfterPrepareForSnapshotRevert(deltaMergeTreeTOList, snapRefsSucceedingCurrentBackup, deletedDeltas, currentBackup); + } + + /** + * Get the secondary storage URLs of the backups that are backing files of this VM. This is only useful for Validation VMs currently, which are created with backing files on + * the secondary storage. + * */ + @Override + public Set getSecondaryStorageUrls(UserVm userVm) { + VMInstanceDetailVO detailVO = vmInstanceDetailsDao.findDetail(userVm.getId(), ApiConstants.BACKUP_ID); + if (detailVO == null) { + return Set.of(); + } + BackupVO backupVO = backupDao.findByUuid(detailVO.getValue()); + Set secondaryStorageUrls = getParentSecondaryStorageUrls(backupVO); + InternalBackupJoinVO internalBackupJoinVO = internalBackupJoinDao.findById(backupVO.getId()); + secondaryStorageUrls.add(imageStoreDao.findById(internalBackupJoinVO.getImageStoreId()).getUrl()); + return secondaryStorageUrls; + } + + @Override + public Boolean crossZoneInstanceCreationEnabled(BackupOffering backupOffering) { + return false; + } + + @Override + public List listRestorePoints(VirtualMachine vm) { + return null; + } + + @Override + public String getConfigComponentName() { + return BackupService.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] {backupChainSize, backupTimeout, backupCompressionTimeout, backupCompressionMinimumFreeStorage, backupCompressionRateLimit, + backupCompressionCoroutines}; + } + + protected Outcome createBackupThroughJobQueue(VirtualMachine vm, boolean quiesceVm, boolean isolated) { + final CallContext context = CallContext.current(); + long userId = context.getCallingUser().getId(); + long accountId = context.getCallingAccount().getAccountId(); + long vmId = vm.getId(); + + BackupVO backup = new BackupVO(String.format("%s-%s", vm.getHostName(), DateUtil.getDateInSystemTimeZone()), vmId, vm.getBackupOfferingId(), accountId, + vm.getDomainId(), vm.getDataCenterId(), 0, Backup.Status.Queued, null, + Backup.CompressionStatus.Uncompressed, Backup.ValidationStatus.NotValidated); + + VmWorkJobVO workJob = new VmWorkJobVO(AsyncJobExecutionContext.getOriginJobId(), userId, accountId, VmWorkTakeBackup.class.getName(), vmId, VirtualMachine.Type.Instance, + VmWorkJobVO.Step.Starting); + VmWorkTakeBackup workInfo = new VmWorkTakeBackup(userId, accountId, vmId, backupDao.persist(backup).getId(), VM_WORK_JOB_HANDLER, quiesceVm, isolated); + + workJob.setDispatcher(VmWorkConstants.VM_WORK_JOB_DISPATCHER); + workJob.setCmdInfo(VmWorkSerializer.serialize(workInfo)); + + jobManager.submitAsyncJob(workJob, VmWorkConstants.VM_WORK_QUEUE, vmId); + AsyncJobExecutionContext.getCurrentExecutionContext().joinJob(workJob.getId()); + + return new OutcomeImpl<>(Pair.class, workJob, VirtualMachineManagerImpl.VmJobCheckInterval.value(), new Predicate() { + @Override + public boolean checkCondition() { + AsyncJobVO jobVo = entityManager.findById(AsyncJobVO.class, workJob.getId()); + return jobVo == null || jobVo.getStatus() != JobInfo.Status.IN_PROGRESS; + } + }, AsyncJob.Topics.JOB_STATE); + } + + protected Outcome deleteBackupThroughJobQueue(Backup backup, boolean forced) { + final CallContext context = CallContext.current(); + long userId = context.getCallingUser().getId(); + long accountId = context.getCallingAccount().getAccountId(); + VirtualMachine userVm = userVmDao.findByIdIncludingRemoved(backup.getVmId()); + long vmId = userVm.getId(); + + VmWorkJobVO workJob = new VmWorkJobVO(AsyncJobExecutionContext.getOriginJobId(), userId, accountId, VmWorkDeleteBackup.class.getName(), vmId, VirtualMachine.Type.Instance, + VmWorkJobVO.Step.Starting); + VmWorkDeleteBackup workInfo = new VmWorkDeleteBackup(userId, accountId, vmId, VM_WORK_JOB_HANDLER, backup.getId(), forced); + + return submitWorkJob(workJob, workInfo, vmId); + } + + protected Outcome restoreVMFromBackupThroughJobQueue(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { + final CallContext context = CallContext.current(); + long userId = context.getCallingUser().getId(); + long accountId = context.getCallingAccount().getAccountId(); + long vmId = vm.getId(); + + VmWorkJobVO workJob = new VmWorkJobVO(AsyncJobExecutionContext.getOriginJobId(), userId, accountId, VmWorkRestoreBackup.class.getName(), vmId, VirtualMachine.Type.Instance, + VmWorkJobVO.Step.Starting); + VmWorkRestoreBackup workInfo = new VmWorkRestoreBackup(userId, accountId, vmId, VM_WORK_JOB_HANDLER, backup.getId(), quickRestore, hostId); + + return submitWorkJob(workJob, workInfo, vmId); + } + + protected Outcome restoreBackedUpVolumeThroughJobQueue(VirtualMachine vm, Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, boolean quickRestore) { + final CallContext context = CallContext.current(); + long userId = context.getCallingUser().getId(); + long accountId = context.getCallingAccount().getAccountId(); + long vmId = vm.getId(); + + VmWorkJobVO workJob = new VmWorkJobVO(AsyncJobExecutionContext.getOriginJobId(), userId, accountId, VmWorkRestoreVolumeBackupAndAttach.class.getName(), vmId, + VirtualMachine.Type.Instance, VmWorkJobVO.Step.Starting); + VmWorkRestoreVolumeBackupAndAttach workInfo = new VmWorkRestoreVolumeBackupAndAttach(userId, accountId, vmId, VM_WORK_JOB_HANDLER, backup.getId(), + backupVolumeInfo, hostIp, quickRestore); + + return submitWorkJob(workJob, workInfo, vmId); + } + + protected OutcomeImpl submitWorkJob(VmWorkJobVO workJob, VmWork workInfo, long vmId) { + workJob.setDispatcher(VmWorkConstants.VM_WORK_JOB_DISPATCHER); + workJob.setCmdInfo(VmWorkSerializer.serialize(workInfo)); + + jobManager.submitAsyncJob(workJob, VmWorkConstants.VM_WORK_QUEUE, vmId); + AsyncJobExecutionContext.getCurrentExecutionContext().joinJob(workJob.getId()); + + return new OutcomeImpl<>(Boolean.class, workJob, VirtualMachineManagerImpl.VmJobCheckInterval.value(), new Predicate() { + @Override + public boolean checkCondition() { + AsyncJobVO jobVo = entityManager.findById(AsyncJobVO.class, workJob.getId()); + return jobVo == null || jobVo.getStatus() != JobInfo.Status.IN_PROGRESS; + } + }, AsyncJob.Topics.JOB_STATE); + } + + protected void validateBackupAsyncIfHasOfferingSupport(InternalBackupJoinVO backupJoinVO, long zoneId, long accountId) { + if (!offeringSupportsValidation(backupJoinVO)) { + return; + } + + logger.info("Queuing backup validation job for backup [{}].", backupJoinVO.getUuid()); + internalBackupServiceJobDao.persist(new InternalBackupServiceJobVO(backupJoinVO.getId(), zoneId, backupJoinVO.getVmId(), accountId, InternalBackupServiceJobType.BackupValidation)); + } + + protected void compressBackupAsync(InternalBackupJoinVO backupJoinVO, long zoneId, long accountId) { + logger.info("Queuing backup compression job for backup [{}].", backupJoinVO.getUuid()); + internalBackupServiceJobDao.persist(new InternalBackupServiceJobVO(backupJoinVO.getId(), zoneId, backupJoinVO.getVmId(), accountId, InternalBackupServiceJobType.StartCompression)); + } + + protected boolean finalizeQuickRestore(VirtualMachine vm, List volumesToConsolidate, long hostId) { + logger.info("Finalizing quick restore for VM [{}].", vm.getUuid()); + + UserVmVO userVmVO = userVmDao.findById(vm.getId()); + if (userVmVO.getState() == VirtualMachine.State.Stopped) { + try { + logger.info("Starting VM [{}] as part of the quick restore process.", vm.getName()); + userVmManager.startVirtualMachine(userVmVO.getId(), hostId, new HashMap<>(), null, true); + } catch (Exception e) { + logger.error("Caught [{}] while trying to quick restore VM [{}]. Throwing BackupException.", e, vm); + throw new BackupException(String.format("Exception while trying to start VM [%s] as part of the quick restore process.", userVmVO.getUuid()), e, false); + } + } + + return consolidateVolumes(vm, hostId, volumesToConsolidate); + } + + protected boolean validateWithHash(long backupId, BackupVO backupVO, BackupDetailVO hashDetail) { + List backupDeltaTOList = getBackupDeltaTOList(backupId); + TakeBackupHashCommand hashCommand = new TakeBackupHashCommand(backupDeltaTOList, backupVO.getUuid()); + List hosts = hostDao.listAllHostsUpByZoneAndHypervisor(backupVO.getZoneId(), Hypervisor.HypervisorType.KVM); + String message; + if (CollectionUtils.isEmpty(hosts)) { + message = String.format("No Up and Enabled host found in zone [%s]. Cannot validate backup [%s]. Will try again later.", backupVO.getZoneId(), backupVO.getUuid()); + logger.error(message); + setBackupUnableToValidateAndSendAlert(backupVO, message); + return false; + } + Collections.shuffle(hosts); + Answer answer = sendBackupCommand(hosts.get(0).getId(), hashCommand); + if (!answer.getResult()) { + message = String.format("Unable to get hash of backup [%s] due to [%s]. Will try again later.", backupVO.getUuid(), answer.getDetails()); + logger.warn(message); + setBackupUnableToValidateAndSendAlert(backupVO, message); + return false; + } + + if (!hashDetail.getValue().equals(answer.getDetails())) { + message = String.format("Current xxHash128 of backup [%s] is different from previous validated hash. This backup has changed and might be corrupt." + + "The old hash is [%s]; the new hash is [%s].", backupVO.getUuid(), hashDetail.getValue(), answer.getDetails()); + logger.error(message); + setBackupAsInvalidAndSendAlert(backupVO, message); + return false; + } + + logger.info("xxHash128 of backup [{}] is the same as when it was validated. This backup is still valid.", backupVO.getUuid()); + backupVO.setValidationStatus(Backup.ValidationStatus.Valid); + backupDao.update(backupId, backupVO); + return true; + } + + protected boolean validateWithValidationVm(long backupId, long hostId, BackupVO backupVO) { + boolean startedVm = false; + UserVmVO validationVm = null; + List volumeVOs = List.of(); + try { + validationVm = allocateValidationVm(backupId, backupVO); + if (validationVm == null) { + return false; + } + + HostVO hostVo = hostDao.findById(hostId); + List volumeToList = new ArrayList<>(); + volumeVOs = volumeDao.findByInstance(validationVm.getId()); + createValidationVolumesOnPrimaryStorage(volumeVOs, validationVm, backupVO, hostVo, volumeToList); + + List backupDeltas = internalBackupDataStoreDao.listByBackupId(backupId); + InternalBackupJoinVO backupJoinVO = internalBackupJoinDao.findById(backupId); + Set> backupDeltaAndVolumePairs = generateBackupAndVolumePairsToRestore(backupDeltas, volumeToList, backupJoinVO, false); + if (!prepareForValidation(hostId, backupDeltaAndVolumePairs, backupVO, validationVm)) { + return false; + } + + userVmManager.startVirtualMachine(validationVm, null); + startedVm = true; + //refresh info + validationVm = userVmDao.findById(validationVm.getId()); + hostVo = hostDao.findById(validationVm.getHostId()); + + HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(validationVm.getHypervisorType()); + VirtualMachineProfileImpl profile = new VirtualMachineProfileImpl(validationVm); + VirtualMachineTO vmTO = hvGuru.implement(profile); + + if (!validateBackup(backupId, vmTO, backupDeltaAndVolumePairs, backupVO, validationVm, hostVo)) { + endBackupChainIfConfigured(backupVO); + return false; + } + calculateAndSaveHash(backupDeltaAndVolumePairs, backupVO, hostVo.getId()); + return true; + } catch (Exception ex) { + logger.error("Encountered an exception during the validation process of backup [{}]. Will cleanup now.", backupVO.getUuid(), ex); + setBackupUnableToValidateAndSendAlert(backupVO, "Failed to validate due to unexpected exception: " + ex.getMessage()); + return false; + } finally { + cleanupValidation(startedVm, validationVm, backupVO, volumeVOs); + } + } + + /** + * If backupValidationEndChainOnFail is true for the account, and the backup being validated is part of the current chain, we end the current chain. + * */ + protected void endBackupChainIfConfigured(BackupVO backupVO) { + if (!getValidationEndChainOnFail(backupVO)) { + return; + } + VirtualMachine vm = userVmDao.findByIdIncludingRemoved(backupVO.getVmId()); + validateVmState(vm, "end backup chain", VirtualMachine.State.Expunging, VirtualMachine.State.Destroyed); + List backupChildren = getBackupJoinChildren(backupVO); + + // Get updated record + InternalBackupJoinVO backupJoinVO = internalBackupJoinDao.findById(backupVO.getId()); + if (backupJoinVO.getCurrent() || (!backupChildren.isEmpty() && backupChildren.get(backupChildren.size() - 1).getCurrent())) { + logger.info("As [{}] is true, we are ending the backup chain for VM [{}]. The next backup will be a full backup.", + BackupValidationServiceJobController.backupValidationEndChainOnFail.toString()); + endBackupChain(vm); + } + } + + /** + * This method was created to facilitate testing + * */ + protected Boolean getValidationEndChainOnFail(BackupVO backupVO) { + return BackupValidationServiceJobController.backupValidationEndChainOnFail.valueIn(backupVO.getAccountId()); + } + + protected boolean normalizeBackupErrorAndFinishChain(UserVmVO userVmVO) { + VMInstanceDetailVO detail = vmInstanceDetailsDao.findDetail(userVmVO.getId(), VmDetailConstants.LAST_KNOWN_STATE); + boolean runningVM = detail == null || VirtualMachine.State.valueOf(detail.getValue()) == VirtualMachine.State.Running; + + BackupVO backupVO = backupDao.findLatestByStatusAndVmId(Backup.Status.Error, userVmVO.getId()); + InternalBackupJoinVO internalBackupJoinVO = internalBackupJoinDao.findById(backupVO.getId()); + ImageStoreVO imageStoreVO = imageStoreDao.findById(internalBackupJoinVO.getImageStoreId()); + + List kbossTOS = new ArrayList<>(); + List deltasOnPrimary = internalBackupStoragePoolDao.listByBackupId(internalBackupJoinVO.getId()); + InternalBackupJoinVO parent = internalBackupJoinDao.findById(internalBackupJoinVO.getParentId()); + + // There is a possibility that the cleanup step of the backup creation was executed, and thus we would have to merge with the old parent's parent + List parentDeltasOnPrimary = new ArrayList<>(); + if (parent != null) { + parentDeltasOnPrimary = internalBackupStoragePoolDao.listByBackupId(parent.getId()); + } + + List deltasOnSecondary = internalBackupDataStoreDao.listByBackupId(internalBackupJoinVO.getId()); + configureKbossTosForCleanup(userVmVO, deltasOnPrimary, deltasOnSecondary, runningVM, parentDeltasOnPrimary, kbossTOS); + CleanupKbossBackupErrorCommand command = new CleanupKbossBackupErrorCommand(runningVM, userVmVO.getInstanceName(), imageStoreVO.getUrl(), kbossTOS); + + long hostId = userVmVO.getHostId() != null ? userVmVO.getHostId() : vmSnapshotHelper.pickRunningHost(userVmVO.getId()); + Answer answer = sendBackupCommand(hostId, command); + if (answer == null || !answer.getResult()) { + logger.error("Unable to finish backup chain for VM [{}]. The host [{}] logs will have more information on why this happened.", userVmVO.getUuid(), hostId); + return false; + } + + boolean chainAlreadyEnded = processCleanupBackupErrorAnswer(userVmVO, answer); + + if (!chainAlreadyEnded) { + return endBackupChain(userVmVO); + } + InternalBackupJoinVO current = internalBackupJoinDao.findCurrent(userVmVO.getId()); + internalBackupStoragePoolDao.expungeByBackupId(current.getId()); + setEndOfChainAndRemoveCurrentForBackup(current); + + return true; + } + + protected boolean processCleanupBackupErrorAnswer(UserVmVO userVmVO, Answer answer) { + boolean runningVM; + CleanupKbossBackupErrorAnswer cleanAnswer = (CleanupKbossBackupErrorAnswer) answer; + logger.info("Successfully finished chain for VM [{}] and normalizing the BackupError state. Cleaning up metadata.", userVmVO.getUuid()); + + boolean chainAlreadyEnded = false; + for (VolumeObjectTO volumeObjectTO : cleanAnswer.getVolumeObjectTos()) { + VolumeVO volumeVO = volumeDao.findById(volumeObjectTO.getId()); + if (!volumeObjectTO.getPath().equals(volumeVO.getPath())) { + volumeVO.setPath(volumeObjectTO.getPath()); + volumeDao.update(volumeVO.getId(), volumeVO); + chainAlreadyEnded = true; + } + } + + runningVM = cleanAnswer.isVmRunning(); + userVmVO.setState(runningVM ? VirtualMachine.State.Running : VirtualMachine.State.Stopped); + userVmDao.update(userVmVO.getId(), userVmVO); + vmInstanceDetailsDao.removeDetail(userVmVO.getId(), VmDetailConstants.LAST_KNOWN_STATE); + return chainAlreadyEnded; + } + + protected void calculateAndSaveHash(Set> backupDeltaAndVolumePairs, BackupVO backupVO, long hostId) { + TakeBackupHashCommand cmd = new TakeBackupHashCommand(backupDeltaAndVolumePairs.stream().map(Pair::first).collect(Collectors.toList()), backupVO.getUuid()); + Answer answer = sendBackupCommand(hostId, cmd); + + if (answer.getResult() && answer.getDetails() != null) { + logger.debug("Got xxHash128 [{}] of backup [{}].", answer.getDetails(), backupVO.getUuid()); + backupDetailDao.addDetail(backupVO.getId(), BackupDetailsDao.BACKUP_HASH, answer.getDetails(), false); + return; + } + logger.warn("Unable to get hash of backup [{}] due to [{}].", backupVO.getUuid(), answer.getDetails()); + } + + + /** + * Return a list of BackupDeltaTO of the given backup. + * */ + protected List getBackupDeltaTOList(long backupId) { + InternalBackupJoinVO backupJoinVO = internalBackupJoinDao.findById(backupId); + DataStoreTO imageStoreTo = dataStoreManager.getDataStore(backupJoinVO.getImageStoreId(), DataStoreRole.Image).getTO(); + List deltas = internalBackupDataStoreDao.listByBackupId(backupId); + + return deltas.stream() + .map(delta -> new BackupDeltaTO(imageStoreTo, Hypervisor.HypervisorType.KVM, delta.getBackupPath())) + .collect(Collectors.toList()); + } + + protected void cleanupValidation(boolean startedVm, UserVmVO validationVm, BackupVO backupVO, List volumeVOs) { + if (validationVm == null) { + return; + } + if (startedVm) { + userVmManager.stopVirtualMachine(validationVm.getId(), true); + } + DestroyVMCmd destroyVMCmd = new DestroyVMCmd(validationVm.getId(), true); + StringBuilder errorMessage = new StringBuilder("Cleanup failed due to:"); + boolean sendMail = false; + try { + userVmManager.destroyVm(destroyVMCmd, false); + } catch (Exception e) { + errorMessage.append("\nGot an unexpected exception while trying to destroy validation VM."); + sendMail = true; + logger.error("Got an error while trying to cleanup validation of backup [{}].", backupVO.getUuid(), e); + } + for (VolumeVO volume : volumeVOs) { + if (volume.getVolumeType() == Volume.Type.ROOT) { + continue; + } + Volume vol = volumeApiService.destroyVolume(volume.getId(), CallContext.current().getCallingAccount(), true, true, null); + if (vol == null) { + sendMail = true; + errorMessage.append(String.format("\nWe were unable to destroy volume [%s].", volume.getUuid())); + } + } + + if (startedVm) { + CleanupKbossValidationCommand cleanupKbossValidationCommand = new CleanupKbossValidationCommand(validationVm.getName(), getSecondaryStorageUrls(validationVm)); + Answer answer = agentManager.easySend(validationVm.getHostId(), cleanupKbossValidationCommand); + if (answer == null || !answer.getResult()) { + logger.error("Failed to cleanup post validation of backup [{}]. Got answer [{}]", backupVO.getUuid(), answer == null ? null : answer.getDetails()); + HostVO host = hostDao.findById(validationVm.getHostId()); + sendMail = true; + errorMessage.append(String.format("\nFailed to cleanup secondary storage mount at host [%s].", host != null ? host.getUuid() : "null")); + } + } + + if (sendMail) { + sendCleanupFailedEmail(backupVO, errorMessage.toString()); + } + } + + protected boolean validateBackup(long backupId, VirtualMachineTO vmTO, Set> backupDeltaAndVolumePairs, BackupVO backupVO, UserVmVO validationVm, + HostVO hostVo) { + Answer answer; + ValidateKbossVmCommand validateKbossVmCommand = new ValidateKbossVmCommand(vmTO, backupDeltaAndVolumePairs.stream().findFirst().get().first()); + configureValidationSteps(validateKbossVmCommand, backupVO); + answer = agentManager.easySend(validationVm.getHostId(), validateKbossVmCommand); + + boolean result = processValidationAnswer(answer, backupVO, validationVm, hostVo, validateKbossVmCommand); + if (result) { + backupVO.setValidationStatus(Backup.ValidationStatus.Valid); + backupDao.update(backupId, backupVO); + } + return result; + } + + protected boolean prepareForValidation(long hostId, Set> backupDeltaAndVolumePairs, BackupVO backupVO, UserVmVO validationVm) { + PrepareValidationCommand prepareCommand = new PrepareValidationCommand(new ArrayList<>(backupDeltaAndVolumePairs), getParentSecondaryStorageUrls(backupVO)); + + Answer answer = agentManager.easySend(hostId, prepareCommand); + + if (answer == null || !answer.getResult()) { + String msg = String.format("Failed to prepare dummy VM [%s] for validation of %s. %s", validationVm.getName(), backupVO.getUuid(), answer != null ? + "Details: "+ answer.getDetails(): ""); + logger.error(msg); + setBackupUnableToValidateAndSendAlert(backupVO, msg); + return false; + } + return true; + } + + protected void createValidationVolumesOnPrimaryStorage(List volumeVOs, UserVmVO validationVm, BackupVO backupVO, HostVO hostVo, List volumeToList) throws NoTransitionException { + for (VolumeVO volume : volumeVOs) { + logger.debug("Creating validation volume [{}] for validation VM [{}].", volume.getUuid(), validationVm.getUuid()); + VolumeInfo volumeInfo = volumeDataFactory.getVolume(volume.getId()); + volumeInfo = volumeOrchestrationService.createVolumeOnPrimaryStorage(validationVm, volumeInfo, Hypervisor.HypervisorType.KVM, null, hostVo.getClusterId(), + hostVo.getPodId()); + validateCorrectStorageType(backupVO, volume, volumeInfo); + volumeToList.add((VolumeObjectTO)volumeInfo.getTO()); + } + } + + protected UserVmVO allocateValidationVm(long backupId, BackupVO backupVO) { + UserVmVO validationVm; + try { + validationVm = (UserVmVO) userVmManager.allocateVMForValidation(backupId, Hypervisor.HypervisorType.KVM); + NicVO nic = nicDao.findDefaultNicForVM(validationVm.getId()); + virtualMachineManager.updateVmNic(validationVm, nic, false); + validationVm.setDataCenterId(backupVO.getZoneId()); + } catch (InsufficientCapacityException | ResourceAllocationException | ResourceUnavailableException e) { + String msg = String.format("Unable to allocate dummy VM to validate %s due to %s.", backupVO.getUuid(), e.getMessage()); + logger.error(msg, e); + setBackupUnableToValidateAndSendAlert(backupVO, msg); + return null; + } + return validationVm; + } + + protected List getVolumesToConsolidate(VirtualMachine vm, List deltasOnSecondary, List volumeObjectTOS, long hostId, + boolean sameVmAsBackup) { + List volumesToConsolidate = new ArrayList<>(); + + transitVmState(vm, VirtualMachine.Event.RestoringSuccess, hostId); + for (VolumeObjectTO volume : volumeObjectTOS) { + VolumeInfo volumeInfo = volumeDataFactory.getVolume(volume.getVolumeId()); + transitVolumeState(volumeInfo.getVolume(), Volume.Event.RestoreSucceeded); + + if (!sameVmAsBackup || deltasOnSecondary.stream().anyMatch(delta -> delta.getVolumeId() == volume.getVolumeId())) { + volumesToConsolidate.add(volumeInfo); + } + } + return volumesToConsolidate; + } + + protected boolean consolidateVolumes(VirtualMachine vm, long hostId, List volumesToConsolidate) { + for (VolumeInfo volumeInfo : volumesToConsolidate) { + transitVolumeState(volumeInfo.getVolume(), Volume.Event.ConsolidationRequested); + } + + VMInstanceDetailVO uuids = vmInstanceDetailsDao.findDetail(vm.getId(), VmDetailConstants.LINKED_VOLUMES_SECONDARY_STORAGE_UUIDS); + List secondaryStorageUuids = uuids != null ? List.of(uuids.getValue().split(",")) : List.of(); + ConsolidateVolumesCommand cmd = new ConsolidateVolumesCommand(volumesToConsolidate, secondaryStorageUuids, vm.getInstanceName()); + Answer answer = sendBackupCommand(hostId, cmd); + + String logError = String.format("Failed to consolidate volumes [%s] of VM [%s]. Answer details: [%s].", + volumesToConsolidate, vm.getName(), answer != null ? answer.getDetails() : "null"); + if (!(answer instanceof ConsolidateVolumesAnswer)) { + logger.error(logError); + throw new BackupException(logError, false); + } + ConsolidateVolumesAnswer cAnswer = (ConsolidateVolumesAnswer)answer; + processConsolidateAnswer(cAnswer, volumesToConsolidate, vm); + + logger.info("Volume consolidation answer: [{}].", cAnswer.getResult()); + return cAnswer.getResult(); + } + + /** + * Validates the Backup status:
+ * - If it is Error and The VM is in BackupError, will throw an exception;
+ * - If it is in Error but the VM is not in BackupError, will set the backup as Failed so that it may be removed with {@code deleteFailedBackup(BackupVO backupVO)};
+ * - If it is not in Error, does nothing. + * */ + protected void checkErrorBackup(BackupVO backupVO, VirtualMachine virtualMachine) { + if (backupVO.getStatus() != Backup.Status.Error) { + return; + } + if (virtualMachine != null && virtualMachine.getState() == VirtualMachine.State.BackupError) { + logger.error("Unable to delete backup [{}] as it is in Error state and the associated VM [{}] is in BackupError state. You must read the backup creation logs," + + " normalize the VM's volumes in the hypervisor/storage and update the VM state in the database before trying to delete the backup. Try again when the VM is not " + + "in this state.", backupVO, virtualMachine.getUuid()); + throw new InvalidParameterValueException(String.format("Unable to delete backup [%s]. Please check the logs.", backupVO.getUuid())); + } + logger.debug("Assuming VM and storage are normalized and setting backup [{}] as failed so its metadata is deleted."); + backupVO.setStatus(Backup.Status.Failed); + } + + /** + * Deletes a Failed backup metadata and sets the backup as Expunged. + * */ + protected boolean deleteFailedBackup(BackupVO backupVO) { + if (backupVO.getStatus() != Backup.Status.Failed) { + return false; + } + long backupId = backupVO.getId(); + + backupVO.setStatus(Backup.Status.Expunged); + backupDao.update(backupId, backupVO); + internalBackupStoragePoolDao.expungeByBackupId(backupId); + internalBackupDataStoreDao.expungeByBackupId(backupId); + backupDetailDao.removeDetails(backupId); + return true; + } + + /** + * Merges the current delta on primary storage, if any, into the given volume. If the backup has no more deltas on primary storage, will set the backup as end_of_chain. + * */ + protected void mergeCurrentDeltaIntoVolume(Volume volume, VirtualMachine virtualMachine, String operation, boolean isVmRunning) { + InternalBackupStoragePoolVO delta = internalBackupStoragePoolDao.findOneByVolumeId(volume.getId()); + if (delta == null) { + logger.debug("Volume [{}] has no deltas to merge, doing nothing.", volume.getUuid()); + return; + } + InternalBackupJoinVO internalBackupJoinVO = internalBackupJoinDao.findById(delta.getBackupId()); + VMSnapshotVO succeedingVmSnapshotVO = getSucceedingVmSnapshot(internalBackupJoinVO); + + DataStore store = dataStoreManager.getDataStore(volume.getPoolId(), DataStoreRole.Primary); + VolumeObject volumeObject = VolumeObject.getVolumeObject(store, (VolumeVO)volume); + + DeltaMergeTreeTO deltaMergeTreeTO = createDeltaMergeTree(succeedingVmSnapshotVO == null, isVmRunning, delta, (VolumeObjectTO)volumeObject.getTO(), succeedingVmSnapshotVO); + MergeDiskOnlyVmSnapshotCommand cmd = new MergeDiskOnlyVmSnapshotCommand(List.of(deltaMergeTreeTO), isVmRunning, virtualMachine.getInstanceName()); + + Answer answer = sendBackupCommand(vmSnapshotHelper.pickRunningHost(virtualMachine.getId()), cmd); + + if (answer == null || !answer.getResult()) { + logger.error("Error while trying to prepare volume [{}] for {}. Got [{}] as answer from host.", volume.getUuid(), operation, answer != null ? answer.getDetails() : null); + throw new CloudRuntimeException(String.format("Unable to prepare volume [%s] for [%s].", volume.getUuid(), operation)); + } + + if (succeedingVmSnapshotVO == null) { + VolumeVO volumeVO = volumeDao.findById(volumeObject.getId()); + volumeVO.setPath(deltaMergeTreeTO.getParent().getPath()); + volumeDao.update(volumeVO.getId(), volumeVO); + } + + expungeOldDeltasAndUpdateVmSnapshotIfNeeded(List.of(delta), succeedingVmSnapshotVO); + + List backupDeltas = internalBackupStoragePoolDao.listByBackupId(delta.getBackupId()); + if (backupDeltas.isEmpty()) { + logger.debug("Backup [{}] has no more deltas on primary storage due to prepare volume [{}] for {} operation. Will set it as end of chain and not current.", + internalBackupJoinVO.getUuid(), volume.getUuid(), operation); + setEndOfChainAndRemoveCurrentForBackup(internalBackupJoinVO); + } + } + + /** + * Creates the necessary delta references on both primary and secondary storage. Also maps the volume to the parent delta backup and create the delta merge tree. + * */ + protected void createDeltaReferences(boolean fullBackup, boolean hasVmSnapshotSucceedingLastBackup, boolean runningVm, Backup backup, + List parentBackupDeltasOnSecondary, List parentBackupDeltasOnPrimary, + HashMap volumeUuidToDeltaPrimaryRef, HashMap volumeUuidToDeltaSecondaryRef, + VMSnapshotVO succeedingVmSnapshot, KbossTO kbossTO) { + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + logger.debug("Creating delta references for backup [{}] of volume [{}].", backup.getUuid(), volumeObjectTO.getUuid()); + + String filename = UUID.randomUUID().toString(); + String relativePathOnSecondary = String.format("%s%s%s%s%s%s%s", "backups", File.separator, volumeObjectTO.getAccountId(), File.separator, volumeObjectTO.getId(), + File.separator, filename); + kbossTO.setDeltaPathOnPrimary(filename); + kbossTO.setDeltaPathOnSecondary(relativePathOnSecondary); + + InternalBackupDataStoreVO deltaSecondaryRef = new InternalBackupDataStoreVO(backup.getId(), volumeObjectTO.getVolumeId(), volumeObjectTO.getDeviceId(), relativePathOnSecondary); + if (!fullBackup) { + InternalBackupStoragePoolVO parentDeltaOnPrimary = createDeltaMergeTreeForVolume(false, runningVm, parentBackupDeltasOnPrimary, succeedingVmSnapshot, kbossTO); + findAndSetParentBackupPath(parentBackupDeltasOnSecondary, parentDeltaOnPrimary, kbossTO); + } + + InternalBackupDataStoreVO referenceOnSecondary = internalBackupDataStoreDao.persist(deltaSecondaryRef); + logger.trace("Created reference [{}] for backup [{}] of volume [{}].", referenceOnSecondary, backup, volumeObjectTO); + volumeUuidToDeltaSecondaryRef.put(volumeObjectTO.getUuid(), referenceOnSecondary); + + InternalBackupStoragePoolVO deltaPrimaryRef = new InternalBackupStoragePoolVO(backup.getId(), volumeObjectTO.getPoolId(), volumeObjectTO.getVolumeId(), filename, + volumeObjectTO.getPath()); + + if (kbossTO.getDeltaMergeTreeTO() != null && !hasVmSnapshotSucceedingLastBackup) { + deltaPrimaryRef.setBackupDeltaParentPath(kbossTO.getDeltaMergeTreeTO().getParent().getPath()); + } else if (hasVmSnapshotSucceedingLastBackup) { + deltaPrimaryRef.setBackupDeltaParentPath(volumeObjectTO.getPath()); + } + + InternalBackupStoragePoolVO referenceOnPrimary = internalBackupStoragePoolDao.persist(deltaPrimaryRef); + logger.trace("Created reference [{}] for backup [{}] of volume [{}].", referenceOnPrimary, backup, volumeObjectTO); + volumeUuidToDeltaPrimaryRef.put(volumeObjectTO.getUuid(), referenceOnPrimary); + } + + protected HostVO getHostToRestore(VirtualMachine vm, boolean quickRestore, Long hostId) throws AgentUnavailableException { + HostVO host; + if (quickRestore) { + if (hostId == null) { + hostId = vm.getLastHostId(); + } + if (hostId == null) { + logger.error("Cannot quick restore if the VM has no last host and no hostId was informed. You may try to start it in an available host and stop it before quick" + + " restoring. Otherwise, use the normal restore."); + throw new AgentUnavailableException(String.format("No host found to quick restore VM [%s]. Please check the logs.", vm.getUuid()), -1); + } + host = hostDao.findByIdIncludingRemoved(hostId); + if (host.getStatus() != Status.Up || host.isInMaintenanceStates() || host.getResourceState() != ResourceState.Enabled) { + logger.error("Cannot quick restore if the VM's last host is in maintenance, not Up, or disabled. You may try to start it in an available host and stop it before quick" + + " restoring. Otherwise, use the normal restore."); + throw new AgentUnavailableException(String.format("No host found to quick restore VM [%s]. Please check the logs.", vm.getUuid()), -1); + } + } else { + hostId = vmSnapshotHelper.pickRunningHost(vm.getId()); + host = hostDao.findByIdIncludingRemoved(hostId); + } + return host; + } + + /** + * Returns ordered list of disk-only VM snapshots taken after the last backup. The list is ordered from oldest to newest. + * */ + protected List getSucceedingVmSnapshotList(InternalBackupJoinVO backup) { + List vmSnapshotVOs = new ArrayList<>(); + if (backup == null) { + return vmSnapshotVOs; + } + + VMSnapshotVO currentSnapshotVO = vmSnapshotDao.findCurrentSnapshotByVmId(backup.getVmId()); + if (currentSnapshotVO == null || currentSnapshotVO.getCreated().before(backup.getDate())) { + return vmSnapshotVOs; + } + vmSnapshotVOs.add(0, currentSnapshotVO); + + while (currentSnapshotVO.getParent() != null && currentSnapshotVO.getParent() != 0) { + VMSnapshotVO parentSnap = vmSnapshotDao.findById(currentSnapshotVO.getParent()); + if (parentSnap.getCreated().before(backup.getDate())){ + break; + } + currentSnapshotVO = parentSnap; + vmSnapshotVOs.add(0, currentSnapshotVO); + } + + logger.debug("Found the following VM snapshots that succeed the backup [{}]: [{}].", backup.getUuid(), vmSnapshotVOs); + + return vmSnapshotVOs; + } + + /** + * Returns the disk-only VM snapshot taken after the last backup, if any. + * */ + protected VMSnapshotVO getSucceedingVmSnapshot(InternalBackupJoinVO backup) { + List snaps = getSucceedingVmSnapshotList(backup); + if (snaps.isEmpty()) { + return null; + } + return snaps.get(0); + } + + /** + * Given a VM snapshot, returns a map of volume id to list of snapshot references of the children of the VM snapshot. + * */ + protected Map> gatherSnapshotReferencesOfChildrenSnapshot(List volumeObjectTOs, VMSnapshot vmSnapshotVO) { + Map> volumeToSnapshotRefs = new HashMap<>(); + if (vmSnapshotVO == null) { + return volumeToSnapshotRefs; + } + + List snapshotChildren = vmSnapshotDao.listByParent(vmSnapshotVO.getId()); + if (CollectionUtils.isEmpty(snapshotChildren)) { + return volumeToSnapshotRefs; + } + + List snapshotDataStoreVOS = new ArrayList<>(); + snapshotChildren.stream() + .map(snapshotVo -> vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(snapshotVo.getId())) + .forEach(snapshotDataStoreVOS::addAll); + mapVolumesToSnapshotReferences(volumeObjectTOs, snapshotDataStoreVOS, volumeToSnapshotRefs); + if (logger.isDebugEnabled()) { + StringBuilder log = new StringBuilder(String.format("Found the following snapshot references that succeed the VM snapshot [%s].", vmSnapshotVO.getUuid())); + for (VolumeObjectTO volumeObjectTO : volumeObjectTOs) { + log.append(String.format(" Volume [%s]; Snapshot references [%s].", volumeObjectTO.getUuid(), volumeToSnapshotRefs.get(volumeObjectTO.getId()))); + } + logger.debug(log.toString()); + } + + return volumeToSnapshotRefs; + } + + /** + * Given a list of volumes and VM snapshots, maps the volumes to the snapshot references of the VM snapshots. + * */ + protected Map> mapVolumesToVmSnapshotReferences(List volumeObjectTOs, List vmSnapshotVOList) { + Map> volumeToSnapshotRefs = new HashMap<>(); + if (vmSnapshotVOList.isEmpty()) { + logger.trace("No VM snapshot to map to any volume, returning."); + return volumeToSnapshotRefs; + } + + ArrayList allRefs = new ArrayList<>(); + for (VMSnapshotVO vmSnapshotVO : vmSnapshotVOList) { + allRefs.addAll(vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(vmSnapshotVO.getId())); + } + mapVolumesToSnapshotReferences(volumeObjectTOs, allRefs, volumeToSnapshotRefs); + logger.trace("Given volume objects [{}] and VM snapshots [{}], created the following map [{}].", volumeObjectTOs, vmSnapshotVOList, volumeToSnapshotRefs); + return volumeToSnapshotRefs; + } + + protected void mapVolumesToSnapshotReferences(List volumeObjectTOs, List snapshotDataStoreVOS, Map> volumeToSnapshotRefs) { + for (VolumeObjectTO volumeObjectTO : volumeObjectTOs) { + List associatedSnapshots = snapshotDataStoreVOS.stream() + .filter(snapRef -> Objects.equals(snapRef.getVolumeId(), volumeObjectTO.getVolumeId())) + .collect(Collectors.toList()); + volumeToSnapshotRefs.put(volumeObjectTO.getId(), associatedSnapshots); + } + } + + /** + * Updates the necessary references on the database. Also calculates the backup's physical size. + * */ + protected long updateDeltaReferencesAndCalculateBackupPhysicalSize(VolumeObjectTO volumeObjectTO, HashMap volumeUuidToDeltaPrimaryRef, + HashMap volumeUuidToDeltaSecondaryRef, TakeKbossBackupAnswer answer, long physicalBackupSize, boolean endChain, boolean isolated, + BackupVO backupVO) { + String volumeUuid = volumeObjectTO.getUuid(); + InternalBackupStoragePoolVO deltaPrimaryRef = volumeUuidToDeltaPrimaryRef.get(volumeUuid); + if (endChain || isolated) { + logger.trace("Since backup [{}] is [{}]. We will delete the delta reference on primary at [{}] as it does not exist anymore.", backupVO.getUuid(), endChain ? + "end of chain" : "isolated", deltaPrimaryRef.getBackupDeltaPath()); + internalBackupStoragePoolDao.expunge(deltaPrimaryRef.getId()); + } + + InternalBackupDataStoreVO deltaSecondaryRef = volumeUuidToDeltaSecondaryRef.get(volumeUuid); + + String newVolumePath = answer.getMapVolumeUuidToNewVolumePath().get(volumeUuid); + + VolumeVO volumeVO = volumeDao.findById(volumeObjectTO.getId()); + volumeVO.setPath(newVolumePath); + logger.trace("Updating volume [{}] path to [{}].", volumeVO.getUuid(), newVolumePath); + volumeDao.update(volumeVO.getId(), volumeVO); + + Pair deltaPathOnSecondaryAndSize = answer.getMapVolumeUuidToDeltaPathOnSecondaryAndSize().get(volumeUuid); + logger.trace("Updating delta reference on secondary [{}] path to [{}].", deltaSecondaryRef, deltaPathOnSecondaryAndSize.first()); + deltaSecondaryRef.setBackupPath(deltaPathOnSecondaryAndSize.first()); + internalBackupDataStoreDao.update(deltaSecondaryRef.getId(), deltaSecondaryRef); + + physicalBackupSize += deltaPathOnSecondaryAndSize.second(); + return physicalBackupSize; + } + + /** + * Expunge the old backup deltas and if there were disk-only VM snapshot deltas after the last backup, update their paths. + * */ + protected void expungeOldDeltasAndUpdateVmSnapshotIfNeeded(List oldDeltasOnPrimary, VMSnapshot vmSnapshot) { + List snapshotRefs = vmSnapshot == null ? List.of() : vmSnapshotHelper.getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(vmSnapshot.getId()); + for (InternalBackupStoragePoolVO oldBackupDelta : oldDeltasOnPrimary) { + logger.trace("Expunging old backup delta [{}].", oldBackupDelta); + internalBackupStoragePoolDao.expunge(oldBackupDelta.getId()); + SnapshotDataStoreVO snapshotDataStoreVO = snapshotRefs.stream().filter(ref -> ref.getVolumeId() == oldBackupDelta.getVolumeId()).findFirst().orElse(null); + if (snapshotDataStoreVO == null) { + continue; + } + snapshotDataStoreVO.setInstallPath(oldBackupDelta.getBackupDeltaParentPath()); + logger.debug("Updating snapshot delta [{}] path to [{}].", snapshotDataStoreVO.getId(), oldBackupDelta.getBackupDeltaParentPath()); + snapshotDataStoreDao.update(snapshotDataStoreVO.getId(), snapshotDataStoreVO); + } + } + + /** + * Create a {@link DeltaMergeTreeTO} for the volume if it has a delta on primary and add it to the list. + * + * @return the delta on primary of the volume. Null if no delta. + * */ + protected InternalBackupStoragePoolVO createDeltaMergeTreeForVolume(boolean childIsVolume, boolean runningVm, List deltasOnPrimary, VMSnapshotVO succeedingVmSnapshot, + KbossTO kbossTO) { + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + + InternalBackupStoragePoolVO deltaOnPrimary = deltasOnPrimary.stream() + .filter(delta -> delta.getVolumeId() == volumeObjectTO.getVolumeId()) + .findFirst() + .orElse(null); + if (deltaOnPrimary == null) { + logger.debug("Volume [{}] has no delta on primary storage.", volumeObjectTO); + return null; + } + + logger.debug("Volume [{}] has a backup delta on primary storage [{}].", volumeObjectTO.getUuid(), deltaOnPrimary); + + kbossTO.setDeltaMergeTreeTO(createDeltaMergeTree(childIsVolume, runningVm, deltaOnPrimary, volumeObjectTO, succeedingVmSnapshot)); + return deltaOnPrimary; + } + + protected DeltaMergeTreeTO createDeltaMergeTree(boolean childIsVolume, boolean runningVm, InternalBackupStoragePoolVO deltaOnPrimary, + VolumeObjectTO volumeObjectTO, VMSnapshotVO succeedingVmSnapshot) { + DataStore store = dataStoreManager.getDataStore(deltaOnPrimary.getStoragePoolId(), DataStoreRole.Primary); + DataTO deltaChild; + if (childIsVolume) { + deltaChild = volumeObjectTO; + } else { + deltaChild = new BackupDeltaTO(store.getTO(), Hypervisor.HypervisorType.KVM, deltaOnPrimary.getBackupDeltaPath()); + } + + BackupDeltaTO deltaParent = new BackupDeltaTO(store.getTO(), Hypervisor.HypervisorType.KVM, deltaOnPrimary.getBackupDeltaParentPath()); + + List succeedingDeltaPaths = new ArrayList<>(); + if (succeedingVmSnapshot != null) { + succeedingDeltaPaths = gatherSnapshotReferencesOfChildrenSnapshot(List.of(volumeObjectTO), succeedingVmSnapshot).getOrDefault(volumeObjectTO.getVolumeId(), List.of()) + .stream().map(SnapshotDataStoreVO::getInstallPath).collect(Collectors.toList()); + + if (!childIsVolume && !runningVm && succeedingDeltaPaths.isEmpty()) { + succeedingDeltaPaths = List.of(volumeObjectTO.getPath()); + logger.debug("Since the last backup delta of volume [{}] is succeeded by a snapshot and the delta created by this snapshot is also the volume, it will have to be" + + " rebased. Setting it as the grand-child.", volumeObjectTO.getUuid()); + } + } + + List deltaGrandchildren = succeedingDeltaPaths.stream() + .map(deltaPath -> new BackupDeltaTO(store.getTO(), Hypervisor.HypervisorType.KVM, deltaPath)) + .collect(Collectors.toList()); + + DeltaMergeTreeTO deltaMergeTreeTO = new DeltaMergeTreeTO(volumeObjectTO, deltaParent, deltaChild, deltaGrandchildren); + + logger.debug("Mapped the following delta merge tree for volume [{}]: [{}].", volumeObjectTO.getUuid(), deltaMergeTreeTO); + return deltaMergeTreeTO; + } + + /** + * Sets on the {@code kbossTO} the backupParentOnSecondary path based on the list of InternalBackupDataStoreVO. + * + * @param parentBackupDeltasOnSecondary + * List of deltas on secondary; + * @param parentDeltaOnPrimary + * @param kbossTO + * KbossTO to be configured; + */ + protected void findAndSetParentBackupPath(List parentBackupDeltasOnSecondary, InternalBackupStoragePoolVO parentDeltaOnPrimary, KbossTO kbossTO) { + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + if (parentDeltaOnPrimary == null) { + logger.debug("Volume [{}] has no parent on primary, thus its backup cannot be incremental.", volumeObjectTO); + return; + } + + InternalBackupDataStoreVO parentOnSecondary = parentBackupDeltasOnSecondary.stream() + .filter(backupDataStoreVo -> volumeObjectTO.getVolumeId() == backupDataStoreVo.getVolumeId()) + .findFirst() + .orElse(null); + + if (parentOnSecondary == null) { + return; + } + + logger.debug("Volume [{}] already has a backup [{}].", volumeObjectTO.getUuid(), parentOnSecondary.getBackupId()); + + kbossTO.setPathBackupParentOnSecondary(parentOnSecondary.getBackupPath()); + } + + /** + * Verify if the data center has heuristic rules for allocating backups; if there is then returns the {@link DataStore} returned by the JS script. + * Otherwise, returns a {@link DataStore} with free capacity. + */ + protected DataStore getImageStoreForBackup(Long dataCenterId, BackupVO backupVO) { + DataStore imageStore = heuristicRuleHelper.getImageStoreIfThereIsHeuristicRule(dataCenterId, HeuristicType.BACKUP, backupVO); + + if (imageStore == null) { + imageStore = dataStoreManager.getImageStoreWithFreeCapacity(dataCenterId); + } + + if (imageStore == null) { + backupVO.setStatus(Backup.Status.Failed); + backupDao.update(backupVO.getId(), backupVO); + throw new CloudRuntimeException(String.format("Unable to find secondary storage for backup [%s].", backupVO)); + } + + logger.debug("Backup [{}] will use secondary storage [{}].", backupVO.getUuid(), imageStore.getUuid()); + return imageStore; + } + + protected void setBackupAsIsolated(BackupVO backup) { + logger.debug("Setting backup [{}] as isolated.", backup.getUuid()); + backupDetailDao.persist(new BackupDetailVO(backup.getId(), ISOLATED, Boolean.TRUE.toString(), true)); + } + + /** + * Gets the parent for newBackup. Will set the newBackup as the end of chain if needed.
+ * - If no backups are found, returns null.
+ * - If the last backup was the end of the chain, returns null.
+ * + * @param newBackup the new backup being created. + * @param backupChain newBackup's ancestors. + * */ + protected InternalBackupJoinVO getParentAndSetEndOfChain(BackupVO newBackup, List backupChain, BackupOfferingVO offering) { + int chainSize = getChainSizeForBackup(offering, newBackup.getZoneId()); + if (CollectionUtils.isEmpty(backupChain)) { + setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(chainSize, chainSize, newBackup.getId(), newBackup.getUuid()); + return null; + } + + int remainingChainSize = chainSize - backupChain.size(); + setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(remainingChainSize, chainSize, newBackup.getId(), newBackup.getUuid()); + + InternalBackupJoinVO parent = backupChain.get(0); + return parent.getStatus().equals(Backup.Status.BackedUp) ? parent : null; + } + + /** + * For every restore point, maps a volume to it. + * @throws CloudRuntimeException If cannot map restore point to any volume. + * */ + protected Set> generateBackupAndVolumePairsToRestore(List backupDeltas, List volumeTOs, + InternalBackupJoinVO backupJoinVO, boolean sameVmAsBackup) { + Set> backupAndVolumePairs = new HashSet<>(); + DataStore dataStore = dataStoreManager.getDataStore(backupJoinVO.getImageStoreId(), DataStoreRole.Image); + for (InternalBackupDataStoreVO backupDataStoreVO : backupDeltas) { + VolumeObjectTO volumeObjectTO = volumeTOs.stream() + .filter(volumeTO -> sameVmAsBackup ? volumeTO.getVolumeId() == backupDataStoreVO.getVolumeId() : volumeTO.getDeviceId() == backupDataStoreVO.getDeviceId()) + .findFirst() + .orElse(null); + + if (volumeObjectTO == null) { + logger.error("All backups should have a corresponding volume at this point, however, backup delta [{}] does not.", backupDataStoreVO.getId()); + throw new CloudRuntimeException("Error while restoring backup. Please check the logs."); + } + + backupAndVolumePairs.add(new Pair<>(new BackupDeltaTO(dataStore.getTO(), Hypervisor.HypervisorType.KVM, backupDataStoreVO.getBackupPath()), volumeObjectTO)); + } + logger.debug("Generated the following list of pairs of backup deltas and volumes: [{}].", backupAndVolumePairs); + return backupAndVolumePairs; + } + + protected Pair generateBackupAndVolumePairForSingleNewVolume(InternalBackupDataStoreVO backupDeltaVo, VolumeObjectTO volumeTO, + InternalBackupJoinVO backupJoinVO) { + DataStore dataStore = dataStoreManager.getDataStore(backupJoinVO.getImageStoreId(), DataStoreRole.Image); + Pair backupAndVolumePair = new Pair<>(new BackupDeltaTO(dataStore.getTO(), Hypervisor.HypervisorType.KVM, backupDeltaVo.getBackupPath()), volumeTO); + + logger.debug("Paired volume [{}] with backup delta [{}].", volumeTO, backupAndVolumePair.first()); + return backupAndVolumePair; + } + + /** + * For every volume, maps deltas that should be deleted, if there are any. If a volume has a delta but is not part of backup being restored, it will be mapped to be merged. + * + * @return List of deltas to be merged. + * */ + protected List populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(List deltasOnPrimary, Set deltasToRemove, List volumeTOs, + List volumesNotPartOfTheBackupBeingRestored, String vmUuid) { + List deltasToBeMerged = new ArrayList<>(); + for (InternalBackupStoragePoolVO deltaOnPrimary : deltasOnPrimary) { + Optional optional = volumeTOs.stream().filter(volumeTO -> volumeTO.getVolumeId() == deltaOnPrimary.getVolumeId()).findFirst(); + if (optional.isEmpty()) { + logger.error("Failed to find volume that matches delta [{}] with path [{}]. Please check for inconsistencies on the database or if there are leftover" + + " deltas on storage.", deltaOnPrimary.getId(), deltaOnPrimary.getBackupDeltaPath()); + throw new CloudRuntimeException(String.format("Failed to restore VM [%s]. Please check the logs.", vmUuid)); + } + VolumeObjectTO volumeObjectTO = optional.get(); + + if (volumesNotPartOfTheBackupBeingRestored.contains(volumeObjectTO)) { + deltasToBeMerged.add(createDeltaMergeTree(true, false, deltaOnPrimary, volumeObjectTO, null)); + continue; + } + + DataStore dataStore = dataStoreManager.getDataStore(deltaOnPrimary.getStoragePoolId(), DataStoreRole.Primary); + BackupDeltaTO backupDeltaTO = new BackupDeltaTO(dataStore.getTO(), Hypervisor.HypervisorType.KVM, deltaOnPrimary.getBackupDeltaPath()); + logger.debug("Mapped the following backup delta on primary to be removed since the volume [{}] is not part of the backup being restored [{}].", + volumeObjectTO.getUuid(), backupDeltaTO); + deltasToRemove.add(backupDeltaTO); + volumeObjectTO.setPath(deltaOnPrimary.getBackupDeltaParentPath()); + } + if (!deltasToBeMerged.isEmpty()) { + logger.debug("The following deltaMergeTrees [{}] were created to merge volumes [{}] that have no backups.", deltasToBeMerged, volumesNotPartOfTheBackupBeingRestored); + } + return deltasToBeMerged; + } + + protected void updateVolumePathsAndSizeIfNeeded(VirtualMachine vm, List volumeTOs, List volumeInfos, + List deltaMergeTreeTOList, boolean sameVmAsBackup) { + List volumeVOs = volumeDao.findByInstance(vm.getId()); + + for (VolumeVO volumeVO : volumeVOs) { + VolumeObjectTO volumeTO = volumeTOs.stream().filter(volumeObjectTO -> volumeObjectTO.getVolumeId() == volumeVO.getId()).findFirst().get(); + + String log = "Volume [%s] path was updated as part of the backup restore process. New path: [%s]."; + DeltaMergeTreeTO deltaMergeTreeTO = deltaMergeTreeTOList.stream().filter(delta -> delta.getChild().getId() == volumeTO.getId()).findFirst().orElse(null); + if (!volumeVO.getPath().equals(volumeTO.getPath())) { + volumeVO.setPath(volumeTO.getPath()); + logger.debug(() -> String.format(log, volumeVO.getUuid(), volumeVO.getPath())); + } else if (deltaMergeTreeTO != null) { + volumeVO.setPath(deltaMergeTreeTO.getParent().getPath()); + logger.debug(() -> String.format(log, volumeVO.getUuid(), volumeVO.getPath())); + } + + Backup.VolumeInfo volumeInfo = volumeInfos.stream() + .filter(info -> sameVmAsBackup ? volumeVO.getUuid().equals(info.getUuid()) : volumeVO.getDeviceId().equals(info.getDeviceId())) + .findFirst().orElse(null); + if (volumeInfo != null && !Objects.equals(volumeInfo.getSize(), volumeVO.getSize())) { + logger.debug("Volume [{}] size was restored as part of the backup restore process. Old size is [{}] new size is [{}].", volumeVO.getUuid(), + volumeVO.getSize(), volumeInfo.getSize()); + volumeVO.setSize(volumeInfo.getSize()); + } + + volumeDao.update(volumeVO.getId(), volumeVO); + } + } + + protected void createAndAttachVolumes(List volumeInfos, List backupDeltas, VirtualMachine vm, HostVO host) { + logger.info("Found the following backup deltas that have no volume correspondence [{}]. Will create new volumes and attach them to VM [{}].", backupDeltas.stream() + .map(InternalBackupDataStoreVO::getId).collect(Collectors.toList()), vm.getUuid()); + for (InternalBackupDataStoreVO delta : backupDeltas) { + VolumeVO volumeVO = volumeDao.findByIdIncludingRemoved(delta.getVolumeId()); + Backup.VolumeInfo backupVolumeInfo = volumeInfos.stream().filter(info -> volumeVO.getUuid().equals(info.getUuid())).findFirst().orElseThrow(); + VolumeInfo volumeInfo = duplicateAndCreateVolume(vm, host, backupVolumeInfo); + Volume volume = volumeApiService.attachVolumeToVM(vm.getId(), volumeInfo.getId(), null, false, true); + transitVolumeState(volume, Volume.Event.RestoreRequested); + delta.setVolumeId(volume.getId()); + } + } + + protected VolumeInfo duplicateAndCreateVolume(VirtualMachine vm, HostVO hostVo, Backup.VolumeInfo backupVolumeInfo) { + VolumeVO newVolume = duplicateVolume(backupVolumeInfo); + VolumeInfo volumeInfo = volumeDataFactory.getVolume(newVolume.getId()); + + try { + volumeInfo = volumeOrchestrationService.createVolumeOnPrimaryStorage(vm, volumeInfo, Hypervisor.HypervisorType.KVM, null, hostVo.getClusterId(), hostVo.getPodId()); + validateCorrectStorageType(null, newVolume, volumeInfo); + } catch (NoTransitionException ex) { + logger.error("Exception while creating volume to restore.", ex); + throw new CloudRuntimeException(ex); + } + + return volumeInfo; + } + + protected VolumeVO duplicateVolume(Backup.VolumeInfo backupVolumeInfo) { + VolumeVO volumeVO = volumeDao.findByUuidIncludingRemoved(backupVolumeInfo.getUuid()); + VolumeVO duplicateVO = new VolumeVO(volumeVO); + DiskOfferingVO diskOfferingVO = diskOfferingDao.findByUuidIncludingRemoved(backupVolumeInfo.getDiskOfferingId()); + duplicateVO.setDiskOfferingId(diskOfferingVO.getId()); + duplicateVO.setSize(backupVolumeInfo.getSize()); + duplicateVO.setMinIops(backupVolumeInfo.getMinIops()); + duplicateVO.setMaxIops(backupVolumeInfo.getMaxIops()); + duplicateVO.setAttached(null); + duplicateVO.setVolumeType(Volume.Type.DATADISK); + duplicateVO.setInstanceId(null); + duplicateVO.setPoolId(null); + duplicateVO.setPath(null); + return volumeDao.persist(duplicateVO); + } + + protected List getBackupsWithoutVolumes(List backups, List volumes) { + List deltasOnSecondaryWithNoVolumes = new ArrayList<>(); + for (InternalBackupDataStoreVO backup : backups) { + VolumeObjectTO volumeObjectTO = volumes.stream().filter(volumeTO -> volumeTO.getVolumeId() == backup.getVolumeId()) + .findFirst() + .orElse(null); + + if (volumeObjectTO == null) { + deltasOnSecondaryWithNoVolumes.add(backup); + } + } + return deltasOnSecondaryWithNoVolumes; + } + + protected List getVolumesThatAreNotPartOfTheBackup(List volumeObjectTOS, List deltasOnSecondary) { + List volumesWithNoBackups = new ArrayList<>(); + for (VolumeObjectTO volume : volumeObjectTOS) { + if (deltasOnSecondary.stream().noneMatch(delta -> delta.getVolumeId() == volume.getVolumeId())) { + volumesWithNoBackups.add(volume); + } + } + logger.debug("Found the following volumes that are not part of the backup being restored [{}].", volumesWithNoBackups); + return volumesWithNoBackups; + } + + protected void processBackupSuccess(boolean runningVm, List volumeTOs, HashMap volumeUuidToDeltaPrimaryRef, + HashMap volumeUuidToDeltaSecondaryRef, TakeKbossBackupAnswer answer, List parentBackupDeltasOnPrimary, + List succeedingVmSnapshots, BackupVO backupVO, boolean fullBackup, VirtualMachine userVm, Long hostId, boolean endChain, boolean isolated) { + long physicalBackupSize = 0; + logger.debug("Processing backup [{}] success.", backupVO.getUuid()); + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + physicalBackupSize = updateDeltaReferencesAndCalculateBackupPhysicalSize(volumeObjectTO, volumeUuidToDeltaPrimaryRef, volumeUuidToDeltaSecondaryRef, answer, + physicalBackupSize, endChain, isolated, backupVO); + } + + expungeOldDeltasAndUpdateVmSnapshotIfNeeded(parentBackupDeltasOnPrimary, succeedingVmSnapshots.isEmpty() ? null : succeedingVmSnapshots.get(0)); + + backupVO.setSize(physicalBackupSize); + backupVO.setStatus(Backup.Status.BackedUp); + backupVO.setBackedUpVolumes(backupManager.createVolumeInfoFromVolumes(new ArrayList<>(volumeDao.findByInstance(userVm.getId())))); + backupDao.loadDetails(backupVO); + backupVO.getDetails().putAll(backupManager.getBackupDetailsFromVM(userVm)); + backupVO.setType(fullBackup ? "FULL" : "INCREMENTAL"); + backupDao.update(backupVO.getId(), backupVO); + + transitVmState(userVm, runningVm ? VirtualMachine.Event.BackupSucceededRunning : VirtualMachine.Event.BackupSucceededStopped, hostId); + } + + protected void processBackupFailure(Answer answer, VirtualMachine vm, long hostId, boolean runningVm, BackupVO backupVO) { + if (answer instanceof TakeKbossBackupAnswer && ((TakeKbossBackupAnswer) answer).isVmConsistent()) { + logger.info("Backup [{}] of VM [{}] failed. However, the VM is still consistent, so we will roll back its state.", backupVO.getUuid(), vm.getUuid()); + backupVO.setStatus(Backup.Status.Failed); + + transitVmState(vm, runningVm ? VirtualMachine.Event.OperationFailedToRunning : VirtualMachine.Event.OperationFailedToStopped, hostId); + } else { + logger.info("Backup [{}] of VM [{}] ended in error. We are not sure if the VM is consistent; thus, we will set it as BackupError.", backupVO.getUuid(), vm.getUuid()); + transitVmState(vm, VirtualMachine.Event.OperationFailedToError, hostId); + vmInstanceDetailsDao.addDetail(vm.getId(), VmDetailConstants.LAST_KNOWN_STATE, runningVm ? VirtualMachine.State.Running.name() : VirtualMachine.State.Stopped.name(), false); + backupVO.setStatus(Backup.Status.Error); + } + + backupDao.update(backupVO.getId(), backupVO); + } + + protected void processRemovedBackups(List removedBackupIds) { + for (Long removedBackupId : removedBackupIds) { + BackupVO removedBackupVO = backupDao.findByIdIncludingRemoved(removedBackupId); + removedBackupVO.setStatus(Backup.Status.Expunged); + backupDao.update(removedBackupId, removedBackupVO); + internalBackupDataStoreDao.expungeByBackupId(removedBackupId); + backupDetailDao.removeDetailsExcept(removedBackupId, END_OF_CHAIN); + } + } + + /** + * For every backup, except for the one which the command was issued, will set them as Expunged regardless and hope operators will look + * at the logs. For the current one, if forced=false, will set it as error, otherwise, will set it as Expunged as well. + * */ + protected boolean processRemoveBackupFailures(boolean forced, Answer[] deleteAnswers, List removedBackupIds, InternalBackupJoinVO backupJoinVO) { + List failures = Arrays.stream(deleteAnswers).filter(answer -> !answer.getResult()).collect(Collectors.toList()); + Set failedToRemoveBackupIdSet = new HashSet<>(); + if (CollectionUtils.isNotEmpty(failures)) { + StringBuilder failureStringBuilder = new StringBuilder("Encountered the following failures during backup removal, all will be marked as Expunged and need to be" + + " manually deleted from storage. "); + for (Answer answer : failures) { + failedToRemoveBackupIdSet.add(((BackupDeleteAnswer)answer).getBackupId()); + failureStringBuilder.append(answer.getDetails()); + } + logger.error(failureStringBuilder.toString()); + } + + removedBackupIds.removeAll(failedToRemoveBackupIdSet); + + boolean result = failedToRemoveBackupIdSet.isEmpty(); + if (!forced && failedToRemoveBackupIdSet.remove(backupJoinVO.getId())) { + BackupVO failedVO = backupDao.findByIdIncludingRemoved(backupJoinVO.getId()); + logger.info("Since backup delete command was not forced, will not set the main backup [{}] as Expunged, will set it as error instead.", failedVO.getUuid()); + failedVO.setStatus(Backup.Status.Error); + backupDao.update(failedVO.getId(), failedVO); + } + + for (Long failedToRemove : failedToRemoveBackupIdSet) { + BackupVO failedVO = backupDao.findByIdIncludingRemoved(failedToRemove); + failedVO.setStatus(Backup.Status.Expunged); + logger.error("Setting backup [{}] as expunged, even though there was an error when deleting it from storage. Please look at the logs and check if it was deleted from" + + " storage.", failedVO.getUuid()); + backupDao.update(failedToRemove, failedVO); + } + + return result; + } + + protected void processConsolidateAnswer(ConsolidateVolumesAnswer cAnswer, List volumesToConsolidate, VirtualMachine vm) { + for (VolumeObjectTO volumeObjectTO : cAnswer.getSuccessfullyConsolidatedVolumes()) { + VolumeInfo volumeInfo = volumesToConsolidate.stream().filter(vol -> vol.getId() == volumeObjectTO.getVolumeId()).findFirst().orElseThrow(); + transitVolumeState(volumeInfo.getVolume(), Volume.Event.OperationSucceeded); + volumesToConsolidate.remove(volumeInfo); + } + volumesToConsolidate.forEach(volumeInfo -> transitVolumeState(volumeInfo, Volume.Event.OperationFailed)); + if (cAnswer.getResult()) { + vmInstanceDetailsDao.removeDetail(vm.getId(), VmDetailConstants.LINKED_VOLUMES_SECONDARY_STORAGE_UUIDS); + } else { + throw new BackupException(String.format("Failed to consolidate all volumes necessary of VM [%s]. Missing volumes are [%s].", vm.getUuid(), volumesToConsolidate), false); + } + } + + protected boolean processRestoreAnswers(VirtualMachine vm, Answer[] answers, boolean quickRestore) { + boolean cmdSucceeded = true; + for (Answer answer : answers) { + if (answer == null || !answer.getResult()) { + cmdSucceeded = false; + logger.error("Failed to restore backup due to: [{}].", answer == null ? "null answer" : answer.getDetails()); + } + if (answer instanceof RestoreKbossBackupAnswer && quickRestore) { + RestoreKbossBackupAnswer restoreAnswer = (RestoreKbossBackupAnswer) answer; + vmInstanceDetailsDao.addDetail(vm.getId(), VmDetailConstants.LINKED_VOLUMES_SECONDARY_STORAGE_UUIDS, StringUtils.join(restoreAnswer.getSecondaryStorageUuids(), ","), false); + } + } + return cmdSucceeded; + } + + protected boolean processValidationAnswer(Answer answer, BackupVO backupVO, UserVmVO validationVm, HostVO hostVo, ValidateKbossVmCommand validateKbossVmCommand) { + if (answer == null) { + String msg = String.format("Backup [%s] was validated using dummy VM [%s]. The backup was deemed invalid due to: Null answer from host [%s]", backupVO.getUuid(), + validationVm.getName(), hostVo.getName()); + logger.error(msg); + setBackupAsInvalidAndSendAlert(backupVO, msg); + return false; + } + if (!answer.getResult()) { + String msg = String.format("Backup [%s] was validated using dummy VM [%s]. The backup was deemed invalid due to: %s", backupVO.getUuid(), + validationVm.getName(), answer.getDetails()); + logger.error(msg); + setBackupAsInvalidAndSendAlert(backupVO, msg); + return false; + } + if (!(answer instanceof ValidateKbossVmAnswer)) { + return false; + } + ValidateKbossVmAnswer validateKbossVmAnswer = (ValidateKbossVmAnswer)answer; + boolean result = true; + String msg = String.format("Backup [%s] was validated using dummy VM [%s]. The backup was deemed invalid due to: ", backupVO.getUuid(), validationVm.getName()); + if (validateKbossVmCommand.isWaitForBoot() && !validateKbossVmAnswer.isBootValidated()) { + result = false; + msg += "\n - The VM did not boot within the expected time."; + } + if (validateKbossVmCommand.isExecuteScript() && validateKbossVmAnswer.getScriptResult() != null) { + result = false; + msg += "\n - The script did not output the expected output. Captured output: " + validateKbossVmAnswer.getScriptResult(); + } + if (validateKbossVmCommand.isTakeScreenshot() && validateKbossVmAnswer.getScreenshotPath() == null) { + result = false; + msg += "\n - We were unable to take a screenshot of the VM."; + } else if (validateKbossVmCommand.isTakeScreenshot()) { + logger.debug("Saving validation screenshot path [{}] to the backup details of backup [{}].", validateKbossVmAnswer.getScreenshotPath(), backupVO.getUuid()); + backupDetailDao.addDetail(backupVO.getId(), SCREENSHOT_PATH, validateKbossVmAnswer.getScreenshotPath(), false); + } + if (!result) { + setBackupAsInvalidAndSendAlert(backupVO, msg); + } + + return result; + } + + protected void handleBackupExceptionInRestore(VirtualMachine vm, BackupException jobResult) { + if (!jobResult.isVmConsistent()) { + UserVmVO vmVO = userVmDao.findById(vm.getId()); + vmVO.setState(VirtualMachine.State.RestoreError); + userVmDao.update(vmVO.getId(), vmVO); + for (VolumeVO vol : volumeDao.findByInstance(vmVO.getId())) { + vol.setState(Volume.State.RestoreError); + volumeDao.update(vol.getId(), vol); + } + } + } + + protected void handleRestoreException(Backup backup, VirtualMachine vm, Object jobResult) { + if (!(jobResult instanceof Throwable)) { + return; + } + if (jobResult instanceof BackupException) { + handleBackupExceptionInRestore(vm, (BackupException)jobResult); + } else if (jobResult instanceof BackupProviderException) { + throw (BackupProviderException) jobResult; + } + throw new CloudRuntimeException(String.format("Exception while restoring KVM internal incremental backup [%s]. Check the logs for more information.", backup.getUuid()), + ((Throwable)jobResult).getCause()); + } + + protected boolean endBackupChain(VirtualMachine vm) { + InternalBackupJoinVO current = internalBackupJoinDao.findCurrent(vm.getId()); + if (current == null) { + logger.debug("There is no current active chain, no need to do anything."); + return true; + } + + if (mergeCurrentBackupDeltas(current)) { + setEndOfChainAndRemoveCurrentForBackup(current); + return true; + } + return false; + } + + /** + * Merges the backup deltas related to the passed {@code InternalBackupJoinVO}. + * + * @return true if the merge was successful and false otherwise. + * */ + protected boolean mergeCurrentBackupDeltas(InternalBackupJoinVO backupJoinVO) { + VirtualMachine userVm = userVmDao.findById(backupJoinVO.getVmId()); + VMSnapshotVO succeedingVmSnapshot = getSucceedingVmSnapshot(backupJoinVO); + MergeDiskOnlyVmSnapshotCommand cmd = buildMergeDiskOnlyVmSnapshotCommandForCurrentBackup(backupJoinVO, userVm, succeedingVmSnapshot); + Long hostId = vmSnapshotHelper.pickRunningHost(backupJoinVO.getVmId()); + + Answer answer = sendBackupCommand(hostId, cmd); + if (answer == null || !answer.getResult()) { + logger.error("Failed to remove backup [{}]. Tried to merge the current deltas to cleanup the VM but failed due to [{}].", + backupJoinVO.getUuid(), answer != null ? answer.getDetails() : "no answer"); + return false; + } + + expungeOldDeltasAndUpdateVmSnapshotIfNeeded(internalBackupStoragePoolDao.listByBackupId(backupJoinVO.getId()), succeedingVmSnapshot); + + if (succeedingVmSnapshot != null) { + return true; + } + + for (DeltaMergeTreeTO deltaMergeTreeTO : cmd.getDeltaMergeTreeToList()) { + VolumeVO volumeVO = volumeDao.findById(deltaMergeTreeTO.getVolumeObjectTO().getVolumeId()); + volumeVO.setPath(deltaMergeTreeTO.getParent().getPath()); + logger.debug("Updating volume [{}] path to [{}] as part of the backup delete cleanup process.", volumeVO.getUuid(), volumeVO.getPath()); + volumeDao.update(volumeVO.getId(), volumeVO); + } + + return true; + } + + protected void createDeleteCommandsAndMergeTrees(List volumeObjectTOs, Commands commands, List deletedDeltas, + VMSnapshotVO vmSnapshotSucceedingCurrentBackup, List deltaMergeTreeTOList) { + for (VolumeObjectTO volumeObjectTO : volumeObjectTOs) { + InternalBackupStoragePoolVO delta = internalBackupStoragePoolDao.findOneByVolumeId(volumeObjectTO.getVolumeId()); + if (delta == null) { + continue; + } + if (delta.getBackupDeltaPath().equals(volumeObjectTO.getPath())) { + commands.addCommand(new DeleteCommand(new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, delta.getBackupDeltaParentPath()))); + deletedDeltas.add(delta); + logger.debug("Volume [{}] has a backup delta that will be deleted as part of the preparation to revert a VM snapshot.", volumeObjectTO.getUuid()); + } else { + deltaMergeTreeTOList.add(createDeltaMergeTree(false, false, delta, volumeObjectTO, vmSnapshotSucceedingCurrentBackup)); + } + } + } + + /*** + * Gets the list of parents that should be expunged. Will also create delete commands for them and add them to the list deleteCommands object. + * + * @param backupVO backup being expunged + * @param deleteCommands Commands object that will be appended with the delete commands for the parent backups. + * @return A pair which contains the list of backups that will be expunged, and the reference to the last backup of the chain that is still alive, if it exists. + */ + protected Pair, InternalBackupJoinVO> getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(BackupVO backupVO, Commands deleteCommands) { + logger.debug("Searching for removed parents of [{}] that should be expunged.", backupVO); + List backupParents = getBackupJoinParents(backupVO, true); + List backupParentsToBeExpunged = null; + InternalBackupJoinVO lastAliveBackup = null; + for (int i = 0; i < backupParents.size(); i++) { + InternalBackupJoinVO backupParent = backupParents.get(i); + if (Backup.Status.Removed.equals(backupParent.getStatus())) { + addBackupDeltasToDeleteCommand(backupParent.getId(), deleteCommands); + } else { + backupParentsToBeExpunged = backupParents.subList(0, i); + lastAliveBackup = backupParents.get(i); + break; + } + } + if (backupParentsToBeExpunged == null) { + backupParentsToBeExpunged = backupParents; + } + logger.debug("Found [{}] removed parents of [{}] that should be expunged: [{}].", backupParentsToBeExpunged.size(), backupVO, backupParentsToBeExpunged); + return new Pair<>(backupParentsToBeExpunged, lastAliveBackup); + } + + protected MergeDiskOnlyVmSnapshotCommand buildMergeDiskOnlyVmSnapshotCommandForCurrentBackup(InternalBackupJoinVO backupJoinVO, VirtualMachine userVm, VMSnapshotVO vmSnapshot) { + List deltaMergeTreeTOs = new ArrayList<>(); + + List volumeTOs = vmSnapshotHelper.getVolumeTOList(backupJoinVO.getVmId()); + Map> volumeIdToSnapshotDataStoreList = gatherSnapshotReferencesOfChildrenSnapshot(volumeTOs, vmSnapshot); + List deltasOnPrimary = internalBackupStoragePoolDao.listByBackupId(backupJoinVO.getId()); + + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + KbossTO kbossTO = new KbossTO(volumeObjectTO, volumeIdToSnapshotDataStoreList.getOrDefault(volumeObjectTO.getId(), new ArrayList<>())); + createDeltaMergeTreeForVolume(vmSnapshot == null, userVm.getState() == VirtualMachine.State.Running, deltasOnPrimary, vmSnapshot, kbossTO); + if (kbossTO.getDeltaMergeTreeTO() != null) { + deltaMergeTreeTOs.add(kbossTO.getDeltaMergeTreeTO()); + } else { + logger.debug("Volume [{}] does not have any deltas to merge as part of the backup delete process.", volumeObjectTO.getUuid()); + } + } + + return new MergeDiskOnlyVmSnapshotCommand(deltaMergeTreeTOs, userVm.getState().equals(VirtualMachine.State.Running), userVm.getInstanceName()); + } + + protected DataStore addBackupDeltasToDeleteCommand(long backupId, Commands deleteCommands) { + InternalBackupJoinVO internalBackupJoinVO = internalBackupJoinDao.findById(backupId); + List internalBackupDataStoreVOS = internalBackupDataStoreDao.listByBackupId(backupId); + DataStore dataStore = dataStoreManager.getDataStore(internalBackupJoinVO.getImageStoreId(), DataStoreRole.Image); + DataStoreTO dataStoreTO = dataStore.getTO(); + BackupDetailVO screenshotPath = backupDetailDao.findDetail(backupId, SCREENSHOT_PATH); + for (InternalBackupDataStoreVO internalBackupDataStoreVO : internalBackupDataStoreVOS) { + BackupDeltaTO backupDeltaTO = new BackupDeltaTO(dataStoreTO, Hypervisor.HypervisorType.KVM, internalBackupDataStoreVO.getBackupPath()); + backupDeltaTO.setId(backupId); + if (screenshotPath != null) { + backupDeltaTO.setScreenshotPath(screenshotPath.getValue()); + screenshotPath = null; + } + DeleteCommand deleteCommand = new DeleteCommand(backupDeltaTO); + deleteCommands.addCommand(deleteCommand); + } + return dataStore; + } + + protected Set getParentSecondaryStorageUrls(BackupVO backupVO) { + List parentBackups = getBackupJoinParents(backupVO, true); + Set secondaryStorageIds = parentBackups.stream().map(InternalBackupJoinVO::getImageStoreId).collect(Collectors.toSet()); + return secondaryStorageIds.stream().map(id -> imageStoreDao.findById(id).getUrl()).collect(Collectors.toSet()); + } + + protected List getChainImageStoreUrls(List backupChain) { + List chainImageStoreUrls; + LinkedHashSet imageStoreIdSet = backupChain.stream().map(InternalBackupJoinVO::getImageStoreId).collect(Collectors.toCollection(LinkedHashSet::new)); + chainImageStoreUrls = imageStoreIdSet.stream().map(id -> imageStoreDao.findById(id).getUrl()).collect(Collectors.toList()); + return chainImageStoreUrls; + } + + /** + * Gets the list of backup parents of a given BackupVO. + * @param backupVO the backup in question. + * @param includeRemoved whether to include removed (but not expunged) parents or not. + * @return list of parents, or an empty list if no parents found. + * */ + protected List getBackupJoinParents(BackupVO backupVO, boolean includeRemoved) { + List ancestorBackups; + + if (includeRemoved) { + ancestorBackups = internalBackupJoinDao.listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(backupVO.getVmId(), backupVO.getDate()); + } else { + ancestorBackups = internalBackupJoinDao.listByBackedUpAndVmIdAndDateBeforeOrAfterOrderBy(backupVO.getVmId(), backupVO.getDate(), true, false); + } + + for (int i = 0; i < ancestorBackups.size(); i++) { + if (ancestorBackups.get(i).getEndOfChain()) { + return ancestorBackups.subList(0, i); + } + } + + logger.debug("Found the following backup chain ancestors of backup [{}]: [{}].", backupVO, ancestorBackups); + return ancestorBackups; + } + + protected int getChainSizeForBackup(BackupOfferingVO offering, long zoneId) { + BackupOfferingDetailsVO detailsVO = backupOfferingDetailsDao.findDetail(offering.getId(), ApiConstants.BACKUP_CHAIN_SIZE); + if (detailsVO != null) { + return Integer.parseInt(detailsVO.getValue()); + } + return backupChainSize.valueIn(zoneId); + } + + /** + * Gets the list of backup children of a given backupVO. In ascending created order. + * + * @return list of children, or and empty list if no children found. + * */ + protected List getBackupJoinChildren(BackupVO backupVO) { + List children = internalBackupJoinDao.listByBackedUpAndVmIdAndDateBeforeOrAfterOrderBy(backupVO.getVmId(), backupVO.getDate(), false, true); + + long parentId = backupVO.getId(); + for (int i = 0; i < children.size(); i++) { + if (children.get(i).getParentId() != parentId) { + return children.subList(0, i); + } + parentId = children.get(i).getId(); + } + + return children; + } + + /** + * Creates a detail for the given BackupVO if the remaining chain size is one or less and the value of backupChainSize is greater than 0. + * */ + protected void setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(int remainingChainSize, int chainSize, long backupId, String backupUuid) { + if (remainingChainSize <= 1 && chainSize > 0) { + logger.debug("Setting backup [{}] as end of chain.", backupUuid); + backupDetailDao.persist(new BackupDetailVO(backupId, END_OF_CHAIN, Boolean.TRUE.toString(), true)); + } + } + + protected void setBackupVirtualSize(List volumeTOs, BackupVO backupVO) { + long virtualSize = 0; + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + virtualSize += volumeObjectTO.getSize(); + } + + backupVO.setProtectedSize(virtualSize); + } + + protected void updateBackupStatusToBackingUp(List volumeTOs, BackupVO backupVO) { + setBackupVirtualSize(volumeTOs, backupVO); + backupVO.setStatus(Backup.Status.BackingUp); + backupDao.update(backupVO.getId(), backupVO); + } + + /** + * Retrieves the current backup and removes the CURRENT detail. If the informed backup is not the end of chain, sets it as the new CURRENT + * */ + protected void updateCurrentBackup(InternalBackupJoinVO backup) { + InternalBackupJoinVO current = internalBackupJoinDao.findCurrent(backup.getVmId()); + + if (current != null) { + backupDetailDao.removeDetail(current.getId(), CURRENT); + } + + if (!backup.getEndOfChain()) { + backupDetailDao.persist(new BackupDetailVO(backup.getId(), CURRENT, Boolean.TRUE.toString(), true)); + } + } + + /** + * Given a backup, removes the CURRENT detail, and if the snapshot is not set as END_OF_CHAIN, sets it as END_OF_CHAIN. + * */ + protected void setEndOfChainAndRemoveCurrentForBackup(InternalBackupJoinVO currentBackup) { + backupDetailDao.removeDetail(currentBackup.getId(), CURRENT); + if (!currentBackup.getEndOfChain()) { + backupDetailDao.persist(new BackupDetailVO(currentBackup.getId(), END_OF_CHAIN, Boolean.TRUE.toString(), true)); + } + } + + protected void setBackupUnableToValidateAndSendAlert(BackupVO backupVO, String msg) { + backupVO.setValidationStatus(Backup.ValidationStatus.UnableToValidate); + backupDao.update(backupVO.getId(), backupVO); + alertManager.sendAlert(AlertService.AlertType.ALERT_TYPE_BACKUP_VALIDATION_UNABLE_TO_VALIDATE, backupVO.getZoneId(), null, String.format("Unable to validate backup [%s]", + backupVO.getName()), msg); + } + + protected void sendCleanupFailedEmail(BackupVO backupVO, String msg) { + alertManager.sendAlert(AlertService.AlertType.ALERT_TYPE_BACKUP_VALIDATION_CLEANUP_FAILED, backupVO.getZoneId(), null, String.format("Cleanup of validation of backup " + + "[%s] failed", + backupVO.getName()), msg); + } + + protected void setBackupAsInvalidAndSendAlert(BackupVO backupVO, String msg) { + backupVO.setValidationStatus(Backup.ValidationStatus.NotValid); + backupDao.update(backupVO.getId(), backupVO); + alertManager.sendAlert(AlertService.AlertType.ALERT_TYPE_BACKUP_VALIDATION_FAILED, backupVO.getZoneId(), null, String.format("Backup [%s] is not valid", + backupVO.getName()), msg); + } + + protected void configureKbossTosForCleanup(UserVmVO userVmVO, List deltasOnPrimary, List deltasOnSecondary, boolean runningVM, + List parentDeltasOnPrimary, List kbossTOS) { + for (VolumeObjectTO volumeObjectTO : vmSnapshotHelper.getVolumeTOList(userVmVO.getId())) { + InternalBackupStoragePoolVO deltaOnPrimary = deltasOnPrimary.stream() + .filter(delta -> delta.getVolumeId() == volumeObjectTO.getVolumeId()).findFirst().orElseThrow(); + volumeObjectTO.setPath(deltaOnPrimary.getBackupDeltaPath()); + + InternalBackupDataStoreVO deltaOnSecondary = + deltasOnSecondary.stream().filter(delta -> delta.getVolumeId() == volumeObjectTO.getVolumeId()).findFirst().orElseThrow(); + KbossTO kbossTO = new KbossTO(volumeObjectTO, deltaOnPrimary.getBackupDeltaParentPath(), deltaOnSecondary.getBackupPath()); + + parentDeltasOnPrimary.stream() + .filter(delta -> delta.getVolumeId() == volumeObjectTO.getVolumeId()).findFirst() + .ifPresent(parentDelta -> kbossTO.setParentDeltaPathOnPrimary(parentDelta.getBackupDeltaParentPath())); + kbossTOS.add(kbossTO); + } + } + + protected void configureValidationSteps(ValidateKbossVmCommand cmd, BackupVO backup) { + BackupOfferingVO offeringVO = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); + BackupOfferingDetailsVO detailsVO = backupOfferingDetailsDao.findDetail(offeringVO.getId(), ApiConstants.VALIDATION_STEPS); + String validationSteps = detailsVO.getValue(); + for (String step : validationSteps.split(",")) { + Backup.ValidationSteps enumStep = Backup.ValidationSteps.valueOf(step); + switch (enumStep) { + case screenshot: + cmd.setTakeScreenshot(true); + VMInstanceDetailVO screenshotWait = vmInstanceDetailsDao.findDetail(backup.getVmId(), VmDetailConstants.VALIDATION_SCREENSHOT_WAIT); + cmd.setScreenshotWait(screenshotWait != null ? Integer.valueOf(screenshotWait.getValue()) : + BackupValidationServiceJobController.backupValidationScreenshotDefaultWait.valueIn(backup.getAccountId())); + break; + case wait_for_boot: + cmd.setWaitForBoot(true); + VMInstanceDetailVO bootTimeout = vmInstanceDetailsDao.findDetail(backup.getVmId(), VmDetailConstants.VALIDATION_BOOT_TIMEOUT); + cmd.setBootTimeout(bootTimeout != null ? Integer.valueOf(bootTimeout.getValue()) : + BackupValidationServiceJobController.backupValidationBootDefaultTimeout.valueIn(backup.getAccountId())); + break; + case execute_command: + configureValidationScript(cmd, backup); + break; + } + } + } + + protected void configureValidationScript(ValidateKbossVmCommand cmd, BackupVO backupVO) { + long vmId = backupVO.getVmId(); + VMInstanceDetailVO script = vmInstanceDetailsDao.findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND); + if (script == null) { + logger.warn("Execute command step was configured but no script given. Ignoring this step for backup [{}].", backupVO.getUuid()); + return; + } + cmd.setExecuteScript(true); + cmd.setScriptToExecute(script.getValue()); + VMInstanceDetailVO scriptArguments = vmInstanceDetailsDao.findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND_ARGUMENTS); + cmd.setScriptArguments(scriptArguments != null ? scriptArguments.getValue() : null); + VMInstanceDetailVO scriptExpectedResult = vmInstanceDetailsDao.findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND_EXPECTED_RESULT); + cmd.setExpectedResult(scriptExpectedResult != null ? scriptExpectedResult.getValue() : "0"); + VMInstanceDetailVO scriptTimeout = vmInstanceDetailsDao.findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND_TIMEOUT); + cmd.setScriptTimeout(scriptTimeout != null ? Integer.valueOf(scriptTimeout.getValue()) : + BackupValidationServiceJobController.backupValidationScriptDefaultTimeout.valueIn(backupVO.getAccountId())); + } + + protected void createBasicBackupDetails(Long imageStoreId, Long parentId, BackupVO backupVO) { + backupDetailDao.persist(new BackupDetailVO(backupVO.getId(), IMAGE_STORE_ID, imageStoreId.toString(), false)); + backupDetailDao.persist(new BackupDetailVO(backupVO.getId(), PARENT_ID, parentId.toString(), false)); + } + + protected void updateReferencesAfterPrepareForSnapshotRevert(List deltaMergeTreeTOList, List snapRefsSucceedingCurrentBackup, + List deletedDeltas, InternalBackupJoinVO backupVO) { + for (DeltaMergeTreeTO deltaMergeTreeTO : deltaMergeTreeTOList) { + SnapshotDataStoreVO snapshotRef = snapRefsSucceedingCurrentBackup.stream() + .filter(ref -> Objects.equals(ref.getVolumeId(), deltaMergeTreeTO.getVolumeObjectTO().getVolumeId())) + .findFirst() + .orElse(null); + if (snapshotRef != null) { + snapshotRef.setInstallPath(deltaMergeTreeTO.getParent().getPath()); + logger.debug("Updating snapshot reference [{}] path to [{}] as part of the preparation to restore a VM snapshot.", snapshotRef.getId(), snapshotRef.getInstallPath()); + snapshotDataStoreDao.update(snapshotRef.getId(), snapshotRef); + } + internalBackupStoragePoolDao.expungeByVolumeId(deltaMergeTreeTO.getVolumeObjectTO().getVolumeId()); + } + + for (InternalBackupStoragePoolVO delta : deletedDeltas) { + internalBackupStoragePoolDao.expungeByVolumeId(delta.getVolumeId()); + } + + setEndOfChainAndRemoveCurrentForBackup(backupVO); + } + + protected Answer sendBackupCommand(long hostId, Command cmd) { + cmd.setWait(backupTimeout.value()); + return agentManager.easySend(hostId, cmd); + } + + protected Answer[] sendBackupCommands(Long hostId, Commands cmds) throws OperationTimedoutException, AgentUnavailableException { + for (Command cmd : cmds) { + cmd.setWait(backupTimeout.value()); + } + return agentManager.send(hostId, cmds); + } + + protected void validateCorrectStorageType(BackupVO backupVO, VolumeVO volume, VolumeInfo volumeInfo) { + StoragePoolVO storagePoolVO = storagePoolDao.findById(volumeInfo.getDataStore().getId()); + if (!supportedStoragePoolTypes.contains(storagePoolVO.getPoolType())) { + logger.error("Error while trying to create volume [{}]. It was created in a storage that is not supported. Make sure that the disk offerings of VMs with backup " + + "offerings can only be allocated to file-based storages ({}).", backupVO, volume, supportedStoragePoolTypes); + throw new CloudRuntimeException(String.format("Unable to create volume [%s] due to a failure to allocate the volume. Please check the logs.", backupVO.getUuid())); + } + } + + protected void validateQuickRestore(Backup backup, boolean quickRestore) { + BackupOfferingVO backupOfferingVO = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); + BackupOfferingDetailsVO detail = backupOfferingDetailsDao.findDetail(backupOfferingVO.getId(), ApiConstants.ALLOW_QUICK_RESTORE); + if (quickRestore && (detail == null || !Boolean.parseBoolean(detail.getValue()))) { + throw new BackupProviderException(String.format("Unable to quick restore backup [%s] using offering [%s] as the offering does not support quick restoration.", + backup.getUuid(), backupOfferingVO.getUuid())); + } + } + + protected boolean offeringSupportsValidation(InternalBackupJoinVO backup) { + BackupOfferingVO backupOfferingVO = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); + BackupOfferingDetailsVO detail = backupOfferingDetailsDao.findDetail(backupOfferingVO.getId(), ApiConstants.VALIDATE); + if (detail == null || !Boolean.parseBoolean(detail.getValue())) { + logger.debug("Backup [{}] will not be validated as offering [{}] does not support it.", backup, backupOfferingVO.getUuid()); + return false; + } + return true; + } + + protected boolean offeringSupportsCompression(InternalBackupJoinVO backup) { + BackupOfferingVO backupOfferingVO = backupOfferingDao.findByIdIncludingRemoved(backup.getBackupOfferingId()); + BackupOfferingDetailsVO detail = backupOfferingDetailsDao.findDetail(backupOfferingVO.getId(), ApiConstants.COMPRESS); + if (detail == null || !Boolean.parseBoolean(detail.getValue())) { + logger.debug("Backup [{}] will not be compressed as offering [{}] does not support it.", backup, backupOfferingVO.getUuid()); + return false; + } + return true; + } + + protected void validateVmState(VirtualMachine vm, String operation, VirtualMachine.State... additionalStates) { + List allowedStates = new ArrayList<>(this.allowedVmStates); + allowedStates.addAll(Arrays.asList(additionalStates)); + if (!allowedStates.contains(vm.getState())) { + throw new BackupProviderException(String.format("VM [%s] is not in the right state to %s. It must be in one of these states: %s", vm.getUuid(), operation, + allowedStates)); + } + } + + protected Pair validateCompressionStateForRestoreAndGetBackup(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback>) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to get lock on backup [{}]. Cannot restore it.", backupId); + return new Pair<>(false, null); + } + + if (backupVO.getCompressionStatus() == Backup.CompressionStatus.FinalizingCompression) { + logger.error("We cannot restore backups that are finalizing the compression process. Please wait for the process to end and try again later.", + allowedBackupStatesToCompress, backupVO.getStatus()); + return new Pair<>(false, null); + } + backupVO.setStatus(Backup.Status.Restoring); + backupDao.update(backupId, backupVO); + return new Pair<>(true, backupVO); + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + /** + * Validates that the backup is in a valid state. This is synchronized with the backup compression check. We get a new backup reference to make sure the compression has not + * changed the backup compression state. + * */ + protected boolean validateBackupStateForRemoval(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to acquire lock for backup [{}]. Cannot remove it.", backupId); + return false; + } + + if (!allowedBackupStatesToRemove.contains(backupVO.getStatus())) { + logger.error("Backup [{}] is not in a state allowed to be removed. Current state is [{}]; allowed states are [{}]", backupVO, backupVO.getStatus(), + allowedBackupStatesToRemove); + return false; + } + + if (Backup.CompressionStatus.Compressing.equals(backupVO.getCompressionStatus())) { + logger.error("Backup [{}] is being compressed, we cannot delete it. Please wait for the compress process to end and try again later.", backupVO.getUuid()); + return false; + } + + if (Backup.ValidationStatus.Validating.equals(backupVO.getValidationStatus())) { + logger.error("Backup [{}] is being validated, we cannot delete it. Please wait for the validation process to end and try again later.", backupVO.getUuid()); + return false; + } + return true; + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + /** + * Validates that the backup is in a valid state to start the compression. This is synchronized with the backup removal check. We get a new backup reference to make sure the + * delete process has not changed the backup state. + * */ + protected Pair validateBackupStateForStartCompressionAndUpdateCompressionStatus(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback>) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to get lock on backup [{}]. Will abort the start of the compression process. We might try again later.", backupId); + return new Pair<>(false, null); + } + + if (!allowedBackupStatesToCompress.contains(backupVO.getStatus())) { + logger.error("We can only compress backups that are on states [{}]. Current backup state is [{}].", allowedBackupStatesToCompress, backupVO.getStatus()); + return new Pair<>(false, null); + } + + logger.info("Compressing backup [{}].", backupVO.getUuid()); + backupVO.setCompressionStatus(Backup.CompressionStatus.Compressing); + backupDao.update(backupVO.getId(), backupVO); + return new Pair<>(true, backupVO); + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + /** + * Validates that the backup is in a valid state to finalize the compression. This is synchronized with the backup restore check. We get a new backup reference to make sure + * the restore process has not changed the backup state. + * */ + protected Pair validateBackupStateForFinalizeCompression(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback>) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to get lock on backup [{}]. Will abort the finalize compression process. We might try again later.", backupId); + return new Pair<>(false, null); + } + + List children = getBackupJoinChildren(backupVO); + if (Backup.Status.Restoring == backupVO.getStatus() || children.stream().anyMatch(backup -> backup.getStatus() == Backup.Status.Restoring)) { + logger.warn( + "Backup [{}] not in right state to finish compression. We can only finish compression process if backup is in [{}] state and no children are being " + "restored. Will try again later", + backupVO, Backup.Status.BackedUp); + return new Pair<>(false, null); + } + + if (Backup.Status.BackedUp == backupVO.getStatus()) { + logger.info("Backup [{}] is in the right state to finish compression. Will start the process.", backupVO.getUuid()); + backupVO.setCompressionStatus(Backup.CompressionStatus.FinalizingCompression); + backupDao.update(backupId, backupVO); + } else { + logger.warn("Backup [{}] is in [{}] state. Aborting compression and cleaning up compressed data. We can only finish compression process if backup is in [{}] " + + "state.", backupVO.getUuid(), backupVO.getStatus(), Backup.Status.BackedUp); + backupVO.setCompressionStatus(Backup.CompressionStatus.CompressionError); + backupDao.update(backupId, backupVO); + } + return new Pair<>(true, backupVO); + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + protected Pair validateBackupStateForRestoreBackupToVM(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback>) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to get lock on backup [{}]. Cannot create VM from this backup right now.", backupId); + return new Pair<>(false, null); + } + + if (Backup.Status.BackedUp == backupVO.getStatus() || Backup.Status.Restoring == backupVO.getStatus()) { + logger.debug("Backup [{}] is in the right state to create VM from it. Will start the process.", backupVO.getUuid()); + Backup.Status oldStatus = backupVO.getStatus(); + backupVO.setStatus(Backup.Status.Restoring); + backupDao.update(backupId, backupVO); + return new Pair<>(true, oldStatus); + } else { + logger.warn("Backup [{}] is in [{}] state. Aborting VM creation from backup. We can only create VM from backup if backup is in [{}] state.", + backupVO.getUuid(), backupVO.getStatus(), List.of(Backup.Status.BackedUp, Backup.Status.Restoring)); + return new Pair<>(false, null); + } + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + /** + * Validates that the backup is in a valid state to validate. This is synchronized with the backup removal check. We get a new backup reference to make sure the removal process + * has not changed the backup state. + * */ + protected boolean validateBackupStateForValidation(long backupId) { + return Transaction.execute(TransactionLegacy.CLOUD_DB, (TransactionCallback) result -> { + BackupVO backupVO = null; + try { + backupVO = lockBackup(backupId); + if (backupVO == null) { + logger.warn("Unable to acquire lock for backup [{}]. Cannot validate it. It might have been removed.", backupId); + return false; + } + + if (!allowedBackupStatesToValidate.contains(backupVO.getStatus())) { + logger.error("Backup [{}] is not in a state allowed to be validated. Current state is [{}]; allowed states are [{}]", backupVO, backupVO.getStatus(), + allowedBackupStatesToValidate); + return false; + } + return true; + } finally { + if (backupVO != null) { + releaseBackup(backupId); + } + } + }); + } + + protected void validateStorages(List volumeTOs, String vmUuid) { + for (VolumeObjectTO volumeObjectTO : volumeTOs) { + StoragePoolVO storagePoolVO = storagePoolDao.findById(volumeObjectTO.getPoolId()); + if (!supportedStoragePoolTypes.contains(storagePoolVO.getPoolType())) { + logger.error("Only able to take backups of VMs with volumes in the following storage types [{}]. Throwing an exception.", supportedStoragePoolTypes); + throw new BackupProviderException(String.format("Unable to take backup of VM [%s], please check the logs.", vmUuid)); + } + } + } + + protected void validateNoVmSnapshots(VirtualMachine vm) { + List vmSnapshotVOs = vmSnapshotDao.findByVm(vm.getId()); + if (!vmSnapshotVOs.isEmpty()) { + throw new BackupProviderException(String.format("Restoring VM [%s] would remove the current VM snapshots it has. Please remove the VM snapshots [%s] before" + + " restoring the backup.", vm.getUuid(), vmSnapshotVOs.stream().map(VMSnapshotVO::getUuid).collect(Collectors.toList()))); + } + } + + protected BackupVO lockBackup(long backupId) { + return backupDao.acquireInLockTable(backupId, 300); + } + + protected void releaseBackup(long backupId) { + backupDao.releaseFromLockTable(backupId); + } + + protected void transitVmState(VirtualMachine vm, VirtualMachine.Event event, long hostId) { + try { + virtualMachineManager.stateTransitTo(vm, event, hostId); + } catch (NoTransitionException e) { + String msg = String.format("Failed to change VM [%s] state with event [%s].", vm.getUuid(), event.toString()); + logger.error(msg, e); + throw new CloudRuntimeException(msg, e); + } + } + + protected void transitVolumeState(Volume volume, Volume.Event event) { + try { + volumeApiService.stateTransitTo(volume, event); + } catch (NoTransitionException e) { + throw new CloudRuntimeException(e); + } + } +} diff --git a/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/module.properties b/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/module.properties new file mode 100644 index 000000000000..33c86662ed69 --- /dev/null +++ b/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/module.properties @@ -0,0 +1,18 @@ +# 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. +name=kboss +parent=backup diff --git a/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/spring-backup-kboss-context.xml b/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/spring-backup-kboss-context.xml new file mode 100644 index 000000000000..da6600e6a7d2 --- /dev/null +++ b/plugins/backup/kboss/src/main/resources/META-INF/cloudstack/kboss/spring-backup-kboss-context.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/plugins/backup/kboss/src/test/java/org/apache/cloudstack/backup/KbossBackupProviderTest.java b/plugins/backup/kboss/src/test/java/org/apache/cloudstack/backup/KbossBackupProviderTest.java new file mode 100644 index 000000000000..d16e0bd948fc --- /dev/null +++ b/plugins/backup/kboss/src/test/java/org/apache/cloudstack/backup/KbossBackupProviderTest.java @@ -0,0 +1,3115 @@ +// 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 org.apache.cloudstack.backup; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.command.user.vm.DestroyVMCmd; +import org.apache.cloudstack.backup.dao.BackupDao; +import org.apache.cloudstack.backup.dao.BackupDetailsDao; +import org.apache.cloudstack.backup.dao.BackupOfferingDao; +import org.apache.cloudstack.backup.dao.BackupOfferingDetailsDao; +import org.apache.cloudstack.backup.dao.InternalBackupDataStoreDao; +import org.apache.cloudstack.backup.dao.InternalBackupJoinDao; +import org.apache.cloudstack.backup.dao.InternalBackupServiceJobDao; +import org.apache.cloudstack.backup.dao.InternalBackupStoragePoolDao; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPoint; +import org.apache.cloudstack.engine.subsystem.api.storage.EndPointSelector; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; +import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.secstorage.heuristics.HeuristicType; +import org.apache.cloudstack.storage.command.BackupDeleteAnswer; +import org.apache.cloudstack.storage.datastore.db.ImageStoreDao; +import org.apache.cloudstack.storage.datastore.db.ImageStoreVO; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreDao; +import org.apache.cloudstack.storage.datastore.db.SnapshotDataStoreVO; +import org.apache.cloudstack.storage.heuristics.HeuristicRuleHelper; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.storage.to.KbossTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.storage.vmsnapshot.VMSnapshotHelper; +import org.apache.cloudstack.storage.volume.VolumeObject; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.agent.manager.Commands; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.resource.ResourceState; +import com.cloud.storage.DataStoreRole; +import com.cloud.storage.Volume; +import com.cloud.storage.VolumeApiService; +import com.cloud.storage.VolumeApiServiceImpl; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.VolumeDao; +import com.cloud.utils.DateUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; +import com.cloud.utils.exception.BackupProviderException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.NoTransitionException; +import com.cloud.vm.UserVmManager; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.snapshot.VMSnapshot; +import com.cloud.vm.snapshot.VMSnapshotDetailsVO; +import com.cloud.vm.snapshot.VMSnapshotVO; +import com.cloud.vm.snapshot.dao.VMSnapshotDao; +import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; + +@RunWith(MockitoJUnitRunner.class) +public class KbossBackupProviderTest { + + @Mock + private VirtualMachine virtualMachineMock; + + @Mock + private BackupOfferingVO backupOfferingMock; + + @Mock + private VolumeDao volumeDaoMock; + + @Mock + private VolumeVO volumeVoMock; + + @Mock + private SnapshotDataStoreDao snapshotDataStoreDaoMock; + + @Mock + private SnapshotDataStoreVO snapshotDataStoreVoMock; + + @Mock + private VMSnapshotDao vmSnapshotDaoMock; + + @Mock + private VMSnapshotVO vmSnapshotVoMock; + + @Mock + private VMSnapshotDetailsDao vmSnapshotDetailsDaoMock; + + @Mock + private VMSnapshotDetailsVO vmSnapshotDetailsVoMock; + + @Mock + private InternalBackupJoinDao internalBackupJoinDaoMock; + + @Mock + private InternalBackupJoinVO internalBackupJoinVoMock; + + @Mock + private InternalBackupDataStoreDao internalBackupDataStoreDaoMock; + + @Mock + private InternalBackupDataStoreVO internalBackupDataStoreVoMock; + + @Mock + private InternalBackupStoragePoolDao internalBackupStoragePoolDaoMock; + + @Mock + private BackupVO backupVoMock; + + @Mock + private BackupDetailsDao backupDetailDaoMock; + + @Mock + private BackupDetailVO backupDetailVoMock; + + @Mock + private ConfigKey backupChainSize; + + @Mock + private DataStoreManager dataStoreManagerMock; + + @Mock + private DataStore dataStoreMock; + + @Mock + private HeuristicRuleHelper heuristicRuleHelperMock; + + @Mock + private VMSnapshotHelper vmSnapshotHelperMock; + + @Mock + private BackupDao backupDaoMock; + + @Mock + private VolumeObjectTO volumeObjectToMock; + + @Mock + private VirtualMachineManager virtualMachineManagerMock; + + @Mock + private HostDao hostDaoMock; + + @Mock + private HostVO hostVOMock; + + @Mock + private UserVmDao userVmDaoMock; + + @Mock + private VMInstanceDetailsDao vmInstanceDetailsDaoMock; + + @Mock + private VMInstanceDetailVO vmInstanceDetailVoMock; + + @Mock + private UserVmVO userVmVOMock; + + @Mock + private BackupOfferingDao backupOfferingDaoMock; + + @Mock + private AgentManager agentManagerMock; + + @Mock + private TakeKbossBackupAnswer takeKbossBackupAnswerMock; + + @Mock + private EndPointSelector endPointSelectorMock; + + @Mock + private EndPoint endPointMock; + + @Mock + private Backup.VolumeInfo backupVolumeInfoMock; + + @Mock + private VolumeInfo volumeInfoMock; + + @Mock + private VolumeApiService volumeApiServiceMock; + + @Mock + private VolumeDataFactory volumeDataFactoryMock; + + @Mock + private BackupOfferingDetailsDao backupOfferingDetailsDaoMock; + + @Mock + private BackupOfferingDetailsVO backupOfferingDetailsVoMock; + + @Mock + private Answer answerMock; + + @Mock + private InternalBackupServiceJobDao internalBackupServiceJobDaoMock; + + @Mock + private UserVmManager userVmManagerMock; + + @Mock + private HypervisorGuruManager hypervisorGuruManagerMock; + @Mock + private HypervisorGuru hypervisorGuruMock; + @Mock + private VirtualMachineTO virtualMachineToMock; + @Mock + private ImageStoreDao imageStoreDaoMock; + + @Mock + private VolumeObject volumeObjectMock; + + @Mock + private InternalBackupStoragePoolVO internalBackupStoragePoolVoMock; + + @Mock + private BackupDeltaTO backupDeltaToMock; + + @Mock + private DeltaMergeTreeTO deltaMergeTreeToMock; + + @Spy + @InjectMocks + private KbossBackupProvider kbossBackupProviderSpy; + + private long vmId = 319832; + private long volumeId = 41; + + Long backupId = 312L; + + @Before + public void setup() { + doReturn(vmId).when(virtualMachineMock).getId(); + doReturn(vmId).when(backupVoMock).getVmId(); + doReturn(vmId).when(internalBackupJoinVoMock).getVmId(); + doReturn(vmId).when(userVmVOMock).getId(); + doReturn(backupId).when(backupVoMock).getId(); + } + + + @Test + public void assignVMToBackupOfferingTestNotKvm() { + doReturn(Hypervisor.HypervisorType.Any).when(virtualMachineMock).getHypervisorType(); + boolean result = kbossBackupProviderSpy.assignVMToBackupOffering(virtualMachineMock, backupOfferingMock); + assertFalse(result); + } + + @Test + public void assignVMToBackupOfferingTestKvmWithUnsupportedDiskOnlyVmSnapshot() { + doReturn(Hypervisor.HypervisorType.KVM).when(virtualMachineMock).getHypervisorType(); + doReturn(List.of(vmSnapshotVoMock)).when(vmSnapshotDaoMock).findByVmAndByType(vmId, VMSnapshot.Type.Disk); + long vmSnapId = 921; + doReturn(vmSnapId).when(vmSnapshotVoMock).getId(); + doReturn(List.of(vmSnapshotDetailsVoMock)).when(vmSnapshotDetailsDaoMock).listDetails(vmSnapId); + doReturn("Anything").when(vmSnapshotDetailsVoMock).getName(); + + boolean result = kbossBackupProviderSpy.assignVMToBackupOffering(virtualMachineMock, backupOfferingMock); + assertFalse(result); + } + + @Test + public void assignVMToBackupOfferingTestKvmWithSupportedDiskOnlyVmSnapshotAndDiskAndMemoryVmSnapshot() { + doReturn(Hypervisor.HypervisorType.KVM).when(virtualMachineMock).getHypervisorType(); + doReturn(List.of(vmSnapshotVoMock)).when(vmSnapshotDaoMock).findByVmAndByType(vmId, VMSnapshot.Type.Disk); + long vmSnapId = 921; + doReturn(vmSnapId).when(vmSnapshotVoMock).getId(); + doReturn(List.of(vmSnapshotDetailsVoMock)).when(vmSnapshotDetailsDaoMock).listDetails(vmSnapId); + doReturn(VolumeApiServiceImpl.KVM_FILE_BASED_STORAGE_SNAPSHOT).when(vmSnapshotDetailsVoMock).getName(); + doReturn(List.of(vmSnapshotVoMock)).when(vmSnapshotDaoMock).findByVmAndByType(vmId, VMSnapshot.Type.DiskAndMemory); + + boolean result = kbossBackupProviderSpy.assignVMToBackupOffering(virtualMachineMock, backupOfferingMock); + assertFalse(result); + } + + + @Test + public void assignVMToBackupOfferingTestKvmWithSupportedDiskOnlyVmSnapshotAndNoDiskAndMemoryVmSnapshot() { + doReturn(Hypervisor.HypervisorType.KVM).when(virtualMachineMock).getHypervisorType(); + doReturn(List.of(vmSnapshotVoMock)).when(vmSnapshotDaoMock).findByVmAndByType(vmId, VMSnapshot.Type.Disk); + long vmSnapId = 921; + doReturn(vmSnapId).when(vmSnapshotVoMock).getId(); + doReturn(List.of(vmSnapshotDetailsVoMock)).when(vmSnapshotDetailsDaoMock).listDetails(vmSnapId); + doReturn(VolumeApiServiceImpl.KVM_FILE_BASED_STORAGE_SNAPSHOT).when(vmSnapshotDetailsVoMock).getName(); + + boolean result = kbossBackupProviderSpy.assignVMToBackupOffering(virtualMachineMock, backupOfferingMock); + assertTrue(result); + } + + @Test + public void removeVMFromBackupOfferingTestNoActiveChain() { + doReturn(VirtualMachine.State.Running).when(virtualMachineMock).getState(); + + boolean result = kbossBackupProviderSpy.removeVMFromBackupOffering(virtualMachineMock); + + verify(kbossBackupProviderSpy, Mockito.never()).mergeCurrentBackupDeltas(any()); + assertTrue(result); + } + + @Test + public void removeVMFromBackupOfferingTestWithActiveChain() { + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(true).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(any()); + doReturn(VirtualMachine.State.Stopped).when(virtualMachineMock).getState(); + + boolean result = kbossBackupProviderSpy.removeVMFromBackupOffering(virtualMachineMock); + + verify(kbossBackupProviderSpy, Mockito.times(1)).mergeCurrentBackupDeltas(any()); + assertTrue(result); + } + + @Test + public void removeVMFromBackupOfferingTestFailedToEndChain() { + doReturn(VirtualMachine.State.Stopped).when(userVmVOMock).getState(); + doReturn(false).when(kbossBackupProviderSpy).endBackupChain(any()); + doReturn(userVmVOMock).when(userVmDaoMock).findById(any()); + doNothing().when(vmInstanceDetailsDaoMock).addDetail(Mockito.anyLong(), any(), any(), Mockito.anyBoolean()); + + boolean result = kbossBackupProviderSpy.removeVMFromBackupOffering(userVmVOMock); + + verify(vmInstanceDetailsDaoMock, Mockito.times(1)).addDetail(Mockito.anyLong(), any(), any(), Mockito.anyBoolean()); + verify(userVmDaoMock, Mockito.times(1)).update(Mockito.anyLong(), any()); + assertFalse(result); + } + + @Test + public void getBackupJoinParentsTestIncludeRemovedEmptyList() { + Date date = DateUtil.now(); + doReturn(date).when(backupVoMock).getDate(); + doReturn(new ArrayList<>()).when(internalBackupJoinDaoMock).listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(vmId, date); + + List result = kbossBackupProviderSpy.getBackupJoinParents(backupVoMock, true); + + assertTrue(result.isEmpty()); + } + + @Test + public void getBackupJoinParentsTestIncludeRemovedAncestorIsEndOfChain() { + Date date = DateUtil.now(); + doReturn(date).when(backupVoMock).getDate(); + doReturn(true).when(internalBackupJoinVoMock).getEndOfChain(); + doReturn(List.of(internalBackupJoinVoMock)).when(internalBackupJoinDaoMock).listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(vmId, date); + + List result = kbossBackupProviderSpy.getBackupJoinParents(backupVoMock, true); + + assertTrue(result.isEmpty()); + } + + @Test + public void getBackupJoinParentsTestIncludeRemovedAncestorMultipleAncestors() { + Date date = DateUtil.now(); + doReturn(date).when(backupVoMock).getDate(); + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock1).getEndOfChain(); + InternalBackupJoinVO internalBackupJoinVoMock2 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock2).getEndOfChain(); + doReturn(true).when(internalBackupJoinVoMock).getEndOfChain(); + doReturn(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2, internalBackupJoinVoMock)).when(internalBackupJoinDaoMock).listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(vmId, date); + + List result = kbossBackupProviderSpy.getBackupJoinParents(backupVoMock, true); + + assertEquals(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2), result); + } + + @Test + public void getBackupJoinParentsTestIncludeRemovedAncestorMultipleAncestorsNoEndOfChain() { + Date date = DateUtil.now(); + doReturn(date).when(backupVoMock).getDate(); + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock1).getEndOfChain(); + InternalBackupJoinVO internalBackupJoinVoMock2 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock2).getEndOfChain(); + doReturn(false).when(internalBackupJoinVoMock).getEndOfChain(); + doReturn(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2, internalBackupJoinVoMock)).when(internalBackupJoinDaoMock).listIncludingRemovedByVmIdAndBeforeDateOrderByCreatedDesc(vmId, date); + + List result = kbossBackupProviderSpy.getBackupJoinParents(backupVoMock, true); + + assertEquals(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2, internalBackupJoinVoMock), result); + } + + @Test + public void getBackupJoinParentsTestNoRemovedAncestorMultipleAncestorsNoEndOfChain() { + Date date = DateUtil.now(); + doReturn(date).when(backupVoMock).getDate(); + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock1).getEndOfChain(); + InternalBackupJoinVO internalBackupJoinVoMock2 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(false).when(internalBackupJoinVoMock2).getEndOfChain(); + doReturn(false).when(internalBackupJoinVoMock).getEndOfChain(); + doReturn(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2, internalBackupJoinVoMock)).when(internalBackupJoinDaoMock).listByBackedUpAndVmIdAndDateBeforeOrAfterOrderBy(vmId, date, true, + false); + + List result = kbossBackupProviderSpy.getBackupJoinParents(backupVoMock, false); + + assertEquals(List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2, internalBackupJoinVoMock), result); + } + + @Test + public void setEndOfChainTrueIfRemainingChainSizeIsOneTestChainSizeLowerThanOneAndConfigIsZero() { + int chainSize = 0; + kbossBackupProviderSpy.setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(-1, chainSize, 1, "uuid"); + + verify(backupDetailDaoMock, Mockito.never()).persist(any()); + } + + @Test + public void setEndOfChainTrueIfRemainingChainSizeIsOneTestChainSizeLowerThanOneAndConfigBiggerThanZero() { + int chainSize = 1; + kbossBackupProviderSpy.setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(-1, chainSize, 1, "uuid"); + + verify(backupDetailDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void setEndOfChainTrueIfRemainingChainSizeIsOneTestChainSizeBiggerThanOne() { + kbossBackupProviderSpy.setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(2, 0, 1, "uuid"); + + verify(backupDetailDaoMock, Mockito.never()).persist(any()); + } + + @Test + public void setEndOfChainTrueIfRemainingChainSizeIsOneTestChainSizeIsOne() { + int chainSize = 2; + kbossBackupProviderSpy.setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(1, chainSize, 1, "uuid"); + + verify(backupDetailDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void getParentAndSetEndOfChainTestBackupChainIsEmpty() { + int chainSize = 2; + doReturn(chainSize).when(kbossBackupProviderSpy).getChainSizeForBackup(any(), Mockito.anyLong()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + + InternalBackupJoinVO result = kbossBackupProviderSpy.getParentAndSetEndOfChain(backupVoMock, List.of(), null); + + assertNull(result); + verify(kbossBackupProviderSpy, Mockito.times(1)).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), + any()); + } + + @Test + public void getParentAndSetEndOfChainTestBackupChainIsBiggerThanChainSize() { + int chainSize = 2; + doReturn(chainSize).when(kbossBackupProviderSpy).getChainSizeForBackup(any(), Mockito.anyLong()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock1).getStatus(); + InternalBackupJoinVO internalBackupJoinVoMock2 = Mockito.mock(InternalBackupJoinVO.class); + InternalBackupJoinVO result = kbossBackupProviderSpy.getParentAndSetEndOfChain(backupVoMock, List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2), null); + + assertEquals(internalBackupJoinVoMock1, result); + verify(kbossBackupProviderSpy, Mockito.times(1)).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + } + + @Test + public void getParentAndSetEndOfChainTestBackupChainIsSmallerThanChainSize() { + int chainSize = 3; + doReturn(chainSize).when(kbossBackupProviderSpy).getChainSizeForBackup(any(), Mockito.anyLong()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock1).getStatus(); + InternalBackupJoinVO internalBackupJoinVoMock2 = Mockito.mock(InternalBackupJoinVO.class); + InternalBackupJoinVO result = kbossBackupProviderSpy.getParentAndSetEndOfChain(backupVoMock, List.of(internalBackupJoinVoMock1, internalBackupJoinVoMock2), null); + + assertEquals(internalBackupJoinVoMock1, result); + verify(kbossBackupProviderSpy, Mockito.times(1)).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + } + + @Test + public void getParentAndSetEndOfChainTestBackupChainIsNotEmptyParentIsRemoved() { + int chainSize = 2; + doReturn(chainSize).when(kbossBackupProviderSpy).getChainSizeForBackup(any(), Mockito.anyLong()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + + InternalBackupJoinVO internalBackupJoinVoMock1 = Mockito.mock(InternalBackupJoinVO.class); + doReturn(Backup.Status.Removed).when(internalBackupJoinVoMock1).getStatus(); + InternalBackupJoinVO result = kbossBackupProviderSpy.getParentAndSetEndOfChain(backupVoMock, List.of(internalBackupJoinVoMock1), null); + + assertNull(result); + verify(kbossBackupProviderSpy, Mockito.times(1)).setEndOfChainTrueIfRemainingChainSizeIsOneOrLess(Mockito.anyInt(), Mockito.anyInt(), Mockito.anyLong(), any()); + } + + @Test + public void getImageStoreForBackupTestNoHeuristic() { + long zoneId = 2; + doReturn(null).when(heuristicRuleHelperMock).getImageStoreIfThereIsHeuristicRule(zoneId, HeuristicType.BACKUP, backupVoMock); + doReturn(dataStoreMock).when(dataStoreManagerMock).getImageStoreWithFreeCapacity(zoneId); + + DataStore result = kbossBackupProviderSpy.getImageStoreForBackup(zoneId, backupVoMock); + + assertEquals(dataStoreMock, result); + } + + @Test + public void getImageStoreForBackupTestWithHeuristic() { + long zoneId = 2; + doReturn(dataStoreMock).when(heuristicRuleHelperMock).getImageStoreIfThereIsHeuristicRule(zoneId, HeuristicType.BACKUP, backupVoMock); + + DataStore result = kbossBackupProviderSpy.getImageStoreForBackup(zoneId, backupVoMock); + + assertEquals(dataStoreMock, result); + verify(dataStoreManagerMock, Mockito.never()).getImageStoreWithFreeCapacity(Mockito.anyLong()); + } + + @Test (expected = CloudRuntimeException.class) + public void getImageStoreForBackupTestNoStorageFound() { + kbossBackupProviderSpy.getImageStoreForBackup(0L, backupVoMock); + } + + @Test + public void getSucceedingVmSnapshotListTestBackupIsNull() { + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(null); + + assertTrue(result.isEmpty()); + } + + @Test + public void getSucceedingVmSnapshotListTestNoCurrentSnapshotVo() { + doReturn(null).when(vmSnapshotDaoMock).findCurrentSnapshotByVmId(vmId); + + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(internalBackupJoinVoMock); + + assertTrue(result.isEmpty()); + } + + @Test + public void getSucceedingVmSnapshotListTestCurrentCreatedBeforeBackup() { + doReturn(vmSnapshotVoMock).when(vmSnapshotDaoMock).findCurrentSnapshotByVmId(vmId); + Date before = DateUtil.now(); + before.setTime(before.getTime()-10000); + Date now = DateUtil.now(); + doReturn(before).when(vmSnapshotVoMock).getCreated(); + doReturn(now).when(internalBackupJoinVoMock).getDate(); + + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(internalBackupJoinVoMock); + + assertTrue(result.isEmpty()); + } + + @Test + public void getSucceedingVmSnapshotListTestCurrentVmSnapshotHasNoParent() { + doReturn(vmSnapshotVoMock).when(vmSnapshotDaoMock).findCurrentSnapshotByVmId(vmId); + Date before = DateUtil.now(); + before.setTime(before.getTime()-10000); + Date now = DateUtil.now(); + doReturn(now).when(vmSnapshotVoMock).getCreated(); + doReturn(before).when(internalBackupJoinVoMock).getDate(); + + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(internalBackupJoinVoMock); + + assertEquals(1, result.size()); + assertEquals(vmSnapshotVoMock, result.get(0)); + } + + @Test + public void getSucceedingVmSnapshotListTestCurrentVmSnapshotHasParentsCreatedAfter() { + doReturn(vmSnapshotVoMock).when(vmSnapshotDaoMock).findCurrentSnapshotByVmId(vmId); + Date before = DateUtil.now(); + before.setTime(before.getTime()-10000); + Date now = DateUtil.now(); + doReturn(now).when(vmSnapshotVoMock).getCreated(); + doReturn(before).when(internalBackupJoinVoMock).getDate(); + long snapParentId = 909; + doReturn(snapParentId).when(vmSnapshotVoMock).getParent(); + VMSnapshotVO vmSnapshotVoMock1 = Mockito.mock(VMSnapshotVO.class); + doReturn(now).when(vmSnapshotVoMock1).getCreated(); + doReturn(vmSnapshotVoMock1).when(vmSnapshotDaoMock).findById(snapParentId); + + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(internalBackupJoinVoMock); + + assertEquals(List.of(vmSnapshotVoMock1, vmSnapshotVoMock), result); + } + + + @Test + public void getSucceedingVmSnapshotListTestCurrentVmSnapshotHasParentsCreatedBefore() { + doReturn(vmSnapshotVoMock).when(vmSnapshotDaoMock).findCurrentSnapshotByVmId(vmId); + Date before = DateUtil.now(); + before.setTime(before.getTime() - 10000); + Date now = DateUtil.now(); + doReturn(now).when(vmSnapshotVoMock).getCreated(); + doReturn(before).when(internalBackupJoinVoMock).getDate(); + long snapParentId = 909; + doReturn(snapParentId).when(vmSnapshotVoMock).getParent(); + VMSnapshotVO vmSnapshotVoMock1 = Mockito.mock(VMSnapshotVO.class); + Date evenBefore = new Date(before.getTime() - 10000); + doReturn(evenBefore).when(vmSnapshotVoMock1).getCreated(); + doReturn(vmSnapshotVoMock1).when(vmSnapshotDaoMock).findById(snapParentId); + + List result = kbossBackupProviderSpy.getSucceedingVmSnapshotList(internalBackupJoinVoMock); + + assertEquals(List.of(vmSnapshotVoMock), result); + } + + @Test + public void mapVolumesToVmSnapshotReferencesTestVmSnapshotVOListIsEmpty() { + kbossBackupProviderSpy.mapVolumesToVmSnapshotReferences(List.of(), List.of()); + + verify(vmSnapshotHelperMock, Mockito.never()).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(1); + } + + @Test + public void mapVolumesToVmSnapshotReferencesTestVmSnapshotVOListHasTwoElements() { + VMSnapshotVO vmSnapshotVoMock1 = Mockito.mock(VMSnapshotVO.class); + doReturn(1L).when(vmSnapshotVoMock).getId(); + doReturn(2L).when(vmSnapshotVoMock1).getId(); + doNothing().when(kbossBackupProviderSpy).mapVolumesToSnapshotReferences(Mockito.anyList(), Mockito.anyList(), anyMap()); + + kbossBackupProviderSpy.mapVolumesToVmSnapshotReferences(List.of(), List.of(vmSnapshotVoMock, vmSnapshotVoMock1)); + + verify(vmSnapshotHelperMock, times(1)).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(1); + verify(vmSnapshotHelperMock, times(1)).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(2); + verify(kbossBackupProviderSpy, times(1)).mapVolumesToSnapshotReferences(Mockito.anyList(), Mockito.anyList(), anyMap()); + } + + @Test + public void createDeltaReferencesTestFullBackupEndOfChain() { + doReturn(internalBackupDataStoreVoMock).when(internalBackupDataStoreDaoMock).persist(any()); + + kbossBackupProviderSpy.createDeltaReferences(true, + true, true, backupVoMock, List.of(), List.of(), new HashMap<>(), new HashMap<>(), null, new KbossTO(volumeObjectToMock, List.of())); + + verify(internalBackupDataStoreDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void createDeltaReferencesTestIsolatedBackup() { + doReturn(internalBackupDataStoreVoMock).when(internalBackupDataStoreDaoMock).persist(any()); + + kbossBackupProviderSpy.createDeltaReferences(true, + true, true, backupVoMock, List.of(), List.of(), new HashMap<>(), new HashMap<>(), null, new KbossTO(volumeObjectToMock, List.of())); + + verify(internalBackupDataStoreDaoMock, Mockito.times(1)).persist(any()); + verify(kbossBackupProviderSpy, Mockito.times(0)).findAndSetParentBackupPath(any(), any(), any()); + verify(kbossBackupProviderSpy, Mockito.times(0)).findAndSetParentBackupPath(any(), any(), any()); + verify(internalBackupStoragePoolDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void createDeltaReferencesTestNotFullBackupEndOfChain() { + doReturn(internalBackupDataStoreVoMock).when(internalBackupDataStoreDaoMock).persist(any()); + KbossTO kbossTO = new KbossTO(volumeObjectToMock, List.of()); + doReturn(null).when(kbossBackupProviderSpy).createDeltaMergeTreeForVolume(false, true, List.of(), null, kbossTO); + doNothing().when(kbossBackupProviderSpy).findAndSetParentBackupPath(List.of(), null, kbossTO); + + kbossBackupProviderSpy.createDeltaReferences(false, true, true, backupVoMock, List.of(), List.of(), new HashMap<>(), new HashMap<>(), null, kbossTO); + + verify(internalBackupDataStoreDaoMock, Mockito.times(1)).persist(any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).findAndSetParentBackupPath(List.of(), null, kbossTO); + } + + @Test + public void createDeltaReferencesTestFullBackupNotEndOfChainDoesNotHaveVmSnapshotSucceedingLastBackup() { + doReturn(internalBackupDataStoreVoMock).when(internalBackupDataStoreDaoMock).persist(any()); + + kbossBackupProviderSpy.createDeltaReferences(true, + false, true, backupVoMock, List.of(), List.of(), new HashMap<>(), new HashMap<>(), null, new KbossTO(volumeObjectToMock, List.of())); + + verify(internalBackupDataStoreDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void orchestrateTakeBackupTestHostIsDownReturnFalse() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Down); + + Pair result = kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, false); + assertFalse(result.first()); + } + + @Test + public void orchestrateTakeBackupTestHostIsDisconnectedReturnFalse() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Disconnected); + + Pair result = kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, false); + assertFalse(result.first()); + } + + @Test (expected = BackupProviderException.class) + public void orchestrateTakeBackupTestInitialValidationThrowException() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Up); + Mockito.when(hostVOMock.getResourceState()).thenReturn(ResourceState.Enabled); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any()); + Mockito.doThrow(new BackupProviderException("tst")).when(kbossBackupProviderSpy).validateStorages(any(), any()); + + kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, false); + assertEquals(Backup.Status.Failed, backupVoMock.getStatus()); + Mockito.verify(backupDaoMock, Mockito.times(1)).update(Mockito.anyLong(), any()); + } + + @Test + public void orchestrateTakeBackupTestIsolatedBackupFailed() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Up); + Mockito.when(hostVOMock.getResourceState()).thenReturn(ResourceState.Enabled); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any()); + + VolumeObjectTO vol1 = Mockito.mock(VolumeObjectTO.class); + VolumeObjectTO vol2 = Mockito.mock(VolumeObjectTO.class); + doReturn(List.of(vol1, vol2)).when(vmSnapshotHelperMock).getVolumeTOList(Mockito.anyLong()); + + doNothing().when(kbossBackupProviderSpy).validateStorages(any(), any()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(any()); + doReturn(dataStoreMock).when(kbossBackupProviderSpy).getImageStoreForBackup(any(), any()); + + Pair result = kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, true); + assertFalse(result.first()); + assertNull(result.second()); + verify(kbossBackupProviderSpy, Mockito.times(1)).setBackupAsIsolated(backupVoMock); + verify(kbossBackupProviderSpy, Mockito.times(2)).createDeltaReferences(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), any(), any(), any(), any(), any(), any(), any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).processBackupFailure(any(), any(), Mockito.anyLong(), Mockito.anyBoolean(), any()); + } + + @Test + public void orchestrateTakeBackupTestIsolatedBackupSuccessWithCompression() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Up); + Mockito.when(hostVOMock.getResourceState()).thenReturn(ResourceState.Enabled); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any()); + + VolumeObjectTO vol1 = Mockito.mock(VolumeObjectTO.class); + VolumeObjectTO vol2 = Mockito.mock(VolumeObjectTO.class); + doReturn(List.of(vol1, vol2)).when(vmSnapshotHelperMock).getVolumeTOList(Mockito.anyLong()); + + doNothing().when(kbossBackupProviderSpy).validateStorages(any(), any()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(any()); + doReturn(dataStoreMock).when(kbossBackupProviderSpy).getImageStoreForBackup(any(), any()); + doReturn(takeKbossBackupAnswerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(takeKbossBackupAnswerMock).getResult(); + doNothing().when(kbossBackupProviderSpy).processBackupSuccess(anyBoolean(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any(), + anyLong(), anyBoolean(), anyBoolean()); + doReturn(true).when(kbossBackupProviderSpy).offeringSupportsCompression(internalBackupJoinVoMock); + doNothing().when(kbossBackupProviderSpy).compressBackupAsync(internalBackupJoinVoMock, 0, 0); + + Pair result = kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, true); + assertTrue(result.first()); + assertEquals(backupId, result.second()); + verify(kbossBackupProviderSpy, Mockito.times(1)).setBackupAsIsolated(backupVoMock); + verify(kbossBackupProviderSpy, Mockito.times(2)).createDeltaReferences(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), any(), any(), any(), any(), any(), any(), any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).processBackupSuccess(anyBoolean(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any(), + anyLong(), anyBoolean(), anyBoolean()); + verify(kbossBackupProviderSpy, Mockito.times(1)).compressBackupAsync(internalBackupJoinVoMock, 0, 0); + } + + @Test + public void orchestrateTakeBackupTestBackupSuccessWithValidation() { + Mockito.when(virtualMachineManagerMock.findById(Mockito.anyLong())).thenReturn(virtualMachineMock); + Mockito.when(vmSnapshotHelperMock.pickRunningHost(Mockito.anyLong())).thenReturn(1L); + Mockito.when(hostDaoMock.findById(Mockito.anyLong())).thenReturn(hostVOMock); + Mockito.when(hostVOMock.getStatus()).thenReturn(Status.Up); + Mockito.when(hostVOMock.getResourceState()).thenReturn(ResourceState.Enabled); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any()); + + VolumeObjectTO vol1 = Mockito.mock(VolumeObjectTO.class); + VolumeObjectTO vol2 = Mockito.mock(VolumeObjectTO.class); + doReturn(List.of(vol1, vol2)).when(vmSnapshotHelperMock).getVolumeTOList(Mockito.anyLong()); + + doNothing().when(kbossBackupProviderSpy).validateStorages(any(), any()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(any()); + InternalBackupJoinVO parentMock = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentMock).when(kbossBackupProviderSpy).getParentAndSetEndOfChain(any(), any(), any()); + doReturn(dataStoreMock).when(kbossBackupProviderSpy).getImageStoreForBackup(any(), any()); + doReturn(takeKbossBackupAnswerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(takeKbossBackupAnswerMock).getResult(); + doNothing().when(kbossBackupProviderSpy).processBackupSuccess(anyBoolean(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any(), + anyLong(), anyBoolean(), anyBoolean()); + doReturn(false).when(kbossBackupProviderSpy).offeringSupportsCompression(internalBackupJoinVoMock); + doNothing().when(kbossBackupProviderSpy).validateBackupAsyncIfHasOfferingSupport(any(), anyLong(), anyLong()); + + Pair result = kbossBackupProviderSpy.orchestrateTakeBackup(backupVoMock, false, false); + assertTrue(result.first()); + assertEquals(backupId, result.second()); + verify(internalBackupStoragePoolDaoMock).listByBackupId(0); + verify(internalBackupDataStoreDaoMock).listByBackupId(0); + verify(kbossBackupProviderSpy, Mockito.times(2)).createDeltaReferences(Mockito.anyBoolean(), Mockito.anyBoolean(), Mockito.anyBoolean(), any(), any(), any(), any(), any(), any(), any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).processBackupSuccess(anyBoolean(), any(), any(), any(), any(), any(), any(), any(), anyBoolean(), any(), + anyLong(), anyBoolean(), anyBoolean()); + verify(kbossBackupProviderSpy, Mockito.times(1)).validateBackupAsyncIfHasOfferingSupport(internalBackupJoinVoMock, 0, 0); + } + + @Test + public void orchestrateDeleteBackupTestBackupStateIsNotOk() { + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertFalse(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + } + + @Test + public void orchestrateDeleteBackupTestDeleteFailedBackup() throws OperationTimedoutException, AgentUnavailableException { + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(true).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertTrue(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + verify(kbossBackupProviderSpy, never()).sendBackupCommands(anyLong(), any()); + } + + @Test + public void orchestrateDeleteBackupTestDeleteBackupWithLiveChildren() throws OperationTimedoutException, AgentUnavailableException { + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findByParentId(anyLong()); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock).getStatus(); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertTrue(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(backupVoMock).setStatus(Backup.Status.Removed); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy, never()).sendBackupCommands(anyLong(), any()); + } + + @Test + public void orchestrateDeleteBackupTestDeleteCurrentBackupWithNoChildrenFailedToMerge() throws OperationTimedoutException, AgentUnavailableException { + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(false).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(internalBackupJoinVoMock); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertFalse(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(kbossBackupProviderSpy, never()).sendBackupCommands(anyLong(), any()); + verify(kbossBackupProviderSpy).mergeCurrentBackupDeltas(any()); + } + + @Test (expected = CloudRuntimeException.class) + public void orchestrateDeleteBackupTestDeleteCurrentBackupWithNoChildrenWithParentNoEndPoint() throws OperationTimedoutException, AgentUnavailableException { + long parentBackupId = 12; + doReturn(parentBackupId).when(internalBackupJoinVoMock).getParentId(); + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(true).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(internalBackupJoinVoMock); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentBackupId); + doReturn(Backup.Status.BackedUp).when(parentVo).getStatus(); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + doReturn(null).when(kbossBackupProviderSpy).getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(any(), any()); + doReturn(null).when(endPointSelectorMock).select((DataStore)null); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertFalse(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(backupDetailDaoMock).persist(any()); + verify(kbossBackupProviderSpy, never()).sendBackupCommands(anyLong(), any()); + } + + @Test (expected = CloudRuntimeException.class) + public void orchestrateDeleteBackupTestDeleteCurrentBackupWithNoChildrenWithParentTimedoutException() throws OperationTimedoutException, AgentUnavailableException { + long parentBackupId = 12; + doReturn(parentBackupId).when(internalBackupJoinVoMock).getParentId(); + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(true).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(internalBackupJoinVoMock); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentBackupId); + doReturn(Backup.Status.BackedUp).when(parentVo).getStatus(); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + doReturn(null).when(kbossBackupProviderSpy).getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(any(), any()); + doReturn(endPointMock).when(endPointSelectorMock).select((DataStore)null); + doThrow(OperationTimedoutException.class).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertFalse(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(backupDetailDaoMock).persist(any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).sendBackupCommands(anyLong(), any()); + } + + @Test + public void orchestrateDeleteBackupTestDeleteCurrentBackupWithNoChildrenWithParentFailedSetNotEmpty() throws OperationTimedoutException, AgentUnavailableException { + long parentBackupId = 12; + doReturn(parentBackupId).when(internalBackupJoinVoMock).getParentId(); + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(true).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(internalBackupJoinVoMock); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentBackupId); + doReturn(Backup.Status.BackedUp).when(parentVo).getStatus(); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + doReturn(new Pair<>(List.of(), parentVo)).when(kbossBackupProviderSpy).getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(any(), any()); + doReturn(endPointMock).when(endPointSelectorMock).select((DataStore)null); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + doReturn(false).when(kbossBackupProviderSpy).processRemoveBackupFailures(anyBoolean(), any(), any(), any()); + doNothing().when(kbossBackupProviderSpy).processRemovedBackups(any()); + + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertFalse(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(backupDetailDaoMock, Mockito.times(2)).persist(any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).sendBackupCommands(anyLong(), any()); + } + + @Test + public void orchestrateDeleteBackupTestDeleteCurrentBackupWithNoChildrenWithParentFailedSetEmpty() throws OperationTimedoutException, AgentUnavailableException { + long parentBackupId = 12; + doReturn(parentBackupId).when(internalBackupJoinVoMock).getParentId(); + doReturn(virtualMachineMock).when(virtualMachineManagerMock).findById(vmId); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForRemoval(backupId); + doReturn(false).when(kbossBackupProviderSpy).deleteFailedBackup(backupVoMock); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(true).when(kbossBackupProviderSpy).mergeCurrentBackupDeltas(internalBackupJoinVoMock); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentBackupId); + doReturn(Backup.Status.BackedUp).when(parentVo).getStatus(); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + doReturn(new Pair<>(List.of(), parentVo)).when(kbossBackupProviderSpy).getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(any(), any()); + doReturn(endPointMock).when(endPointSelectorMock).select((DataStore)null); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + doReturn(true).when(kbossBackupProviderSpy).processRemoveBackupFailures(anyBoolean(), any(), any(), any()); + doNothing().when(kbossBackupProviderSpy).processRemovedBackups(any()); + + + Boolean result = kbossBackupProviderSpy.orchestrateDeleteBackup(backupVoMock, false); + + assertTrue(result); + verify(kbossBackupProviderSpy).validateVmState(any(), any(), any()); + verify(backupDetailDaoMock, Mockito.times(2)).persist(any()); + verify(kbossBackupProviderSpy, Mockito.times(1)).sendBackupCommands(anyLong(), any()); + } + + @Test + public void orchestrateRestoreVMFromBackupTestInvalidState() { + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, false); + doReturn(new Pair<>(false, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + + boolean result = kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, false, null, false); + + assertFalse(result); + } + + @Test (expected = CloudRuntimeException.class) + public void orchestrateRestoreVMFromBackupTestCurrentBackupNoHostToRestore() throws AgentUnavailableException { // fixxxxxxx + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, false); + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + long currentBackupId = 39; + doThrow(AgentUnavailableException.class).when(kbossBackupProviderSpy).getHostToRestore(virtualMachineMock, false, null); + + kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, false, null, false); + + verify(internalBackupStoragePoolDaoMock).listByBackupId(currentBackupId); + } + + @Test (expected = CloudRuntimeException.class) + public void orchestrateRestoreVMFromBackupTestSameVmCurrentBackupTimeOut() throws AgentUnavailableException, OperationTimedoutException { + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, false); + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + long currentBackupId = 39; + InternalBackupJoinVO currentBackup = Mockito.mock(InternalBackupJoinVO.class); + doReturn(currentBackupId).when(currentBackup).getId(); + doReturn(currentBackup).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(hostVOMock).when(kbossBackupProviderSpy).getHostToRestore(virtualMachineMock, false, null); + doNothing().when(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), anyBoolean()); + doReturn(List.of()).when(kbossBackupProviderSpy).getVolumesThatAreNotPartOfTheBackup(any(), any()); + doReturn(List.of()).when(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + doReturn(VirtualMachine.State.Stopped).when(virtualMachineMock).getState(); + doThrow(OperationTimedoutException.class).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, false, null, true); + + verify(internalBackupStoragePoolDaoMock).listByBackupId(currentBackupId); + verify(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + verify(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + } + + @Test + public void orchestrateRestoreVMFromBackupTestSameVmCurrentBackupNullAnswers() throws AgentUnavailableException, OperationTimedoutException { + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, false); + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + long currentBackupId = 39; + InternalBackupJoinVO currentBackup = Mockito.mock(InternalBackupJoinVO.class); + doReturn(currentBackupId).when(currentBackup).getId(); + doReturn(currentBackup).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(hostVOMock).when(kbossBackupProviderSpy).getHostToRestore(virtualMachineMock, false, null); + doNothing().when(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), anyBoolean()); + doReturn(List.of()).when(kbossBackupProviderSpy).getVolumesThatAreNotPartOfTheBackup(any(), any()); + doReturn(List.of()).when(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + doReturn(VirtualMachine.State.Stopped).when(virtualMachineMock).getState(); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, false, null, true); + + verify(internalBackupStoragePoolDaoMock).listByBackupId(currentBackupId); + verify(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + verify(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + assertFalse(result); + } + + @Test + public void orchestrateRestoreVMFromBackupTestSameVmCurrentBackupAnswerFalse() throws AgentUnavailableException, OperationTimedoutException { + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, false); + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + long currentBackupId = 39; + InternalBackupJoinVO currentBackup = Mockito.mock(InternalBackupJoinVO.class); + doReturn(currentBackupId).when(currentBackup).getId(); + doReturn(currentBackup).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(hostVOMock).when(kbossBackupProviderSpy).getHostToRestore(virtualMachineMock, false, null); + doNothing().when(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), anyBoolean()); + doReturn(List.of()).when(kbossBackupProviderSpy).getVolumesThatAreNotPartOfTheBackup(any(), any()); + doReturn(List.of()).when(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + doReturn(VirtualMachine.State.Stopped).when(virtualMachineMock).getState(); + doReturn(new Answer[]{Mockito.mock(Answer.class)}).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + doReturn(false).when(kbossBackupProviderSpy).processRestoreAnswers(any(), any(), anyBoolean()); + + boolean result = kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, false, null, true); + + verify(internalBackupStoragePoolDaoMock).listByBackupId(currentBackupId); + verify(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + verify(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + assertFalse(result); + } + + @Test + public void orchestrateRestoreVMFromBackupTestSameVmQuickRestoreCurrentBackupAnswerTrue() throws AgentUnavailableException, OperationTimedoutException { + doNothing().when(kbossBackupProviderSpy).validateNoVmSnapshots(virtualMachineMock); + doNothing().when(kbossBackupProviderSpy).validateQuickRestore(backupVoMock, true); + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + long currentBackupId = 39; + InternalBackupJoinVO currentBackup = Mockito.mock(InternalBackupJoinVO.class); + doReturn(currentBackupId).when(currentBackup).getId(); + doReturn(currentBackup).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(hostVOMock).when(kbossBackupProviderSpy).getHostToRestore(virtualMachineMock, true, null); + doNothing().when(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), anyBoolean()); + doReturn(List.of()).when(kbossBackupProviderSpy).getVolumesThatAreNotPartOfTheBackup(any(), any()); + doReturn(List.of()).when(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + doReturn(VirtualMachine.State.Stopped).when(virtualMachineMock).getState(); + doReturn(new Answer[]{Mockito.mock(Answer.class)}).when(kbossBackupProviderSpy).sendBackupCommands(anyLong(), any()); + doReturn(true).when(kbossBackupProviderSpy).processRestoreAnswers(any(), any(), anyBoolean()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(currentBackup); + doReturn(List.of()).when(kbossBackupProviderSpy).getVolumesToConsolidate(any(), any(), any(), anyLong(), anyBoolean()); + doReturn(true).when(kbossBackupProviderSpy).finalizeQuickRestore(any(), anyList(), anyLong()); + + boolean result = kbossBackupProviderSpy.orchestrateRestoreVMFromBackup(backupVoMock, virtualMachineMock, true, null, true); + + verify(internalBackupStoragePoolDaoMock).listByBackupId(currentBackupId); + verify(kbossBackupProviderSpy).createAndAttachVolumes(any(), any(), any(), any()); + verify(kbossBackupProviderSpy).populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(any(), any(), any(), any(), any()); + verify(kbossBackupProviderSpy).updateVolumePathsAndSizeIfNeeded(any(), any(), anyList(), anyList(), anyBoolean()); + verify(internalBackupStoragePoolDaoMock).expungeByBackupId(currentBackupId); + verify(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(currentBackup); + verify(kbossBackupProviderSpy).finalizeQuickRestore(any(), anyList(), anyLong()); + assertTrue(result); + } + + @Test + public void orchestrateRestoreBackedUpVolumeTestInvalidState() { + doReturn(new Pair<>(false, null)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + + Pair result = kbossBackupProviderSpy.orchestrateRestoreBackedUpVolume(backupVoMock, virtualMachineMock, null, null, false); + + assertFalse(result.first()); + verify(kbossBackupProviderSpy, Mockito.never()).sendBackupCommand(anyLong(), any()); + } + + @Test (expected = CloudRuntimeException.class) + public void orchestrateRestoreBackedUpVolumeTestAnswerFalse() { + doReturn(new Pair<>(true, null)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + doReturn(volumeVoMock).when(volumeDaoMock).findByUuidIncludingRemoved(any()); + doReturn(hostVOMock).when(hostDaoMock).findByIp(any()); + doReturn(volumeInfoMock).when(kbossBackupProviderSpy).duplicateAndCreateVolume(virtualMachineMock, hostVOMock, backupVolumeInfoMock); + doReturn(volumeObjectToMock).when(volumeInfoMock).getTO(); + doReturn(new Pair(null, null)).when(kbossBackupProviderSpy).generateBackupAndVolumePairForSingleNewVolume(any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).getParentSecondaryStorageUrls(backupVoMock); + doReturn(false).when(kbossBackupProviderSpy).processRestoreAnswers(any(), any(), anyBoolean()); + + Pair result = kbossBackupProviderSpy.orchestrateRestoreBackedUpVolume(backupVoMock, virtualMachineMock, backupVolumeInfoMock, null, false); + + verify(kbossBackupProviderSpy, Mockito.times(1)).sendBackupCommand(anyLong(), any()); + } + + @Test + public void orchestrateRestoreBackedUpVolumeTestQuickRestoreAnswerTrue() { + doReturn(new Pair<>(true, null)).when(kbossBackupProviderSpy).validateCompressionStateForRestoreAndGetBackup(backupId); + doReturn(volumeVoMock).when(volumeDaoMock).findByUuidIncludingRemoved(any()); + doReturn(hostVOMock).when(hostDaoMock).findByIp(any()); + doReturn(volumeInfoMock).when(kbossBackupProviderSpy).duplicateAndCreateVolume(virtualMachineMock, hostVOMock, backupVolumeInfoMock); + doReturn(volumeObjectToMock).when(volumeInfoMock).getTO(); + doReturn(new Pair(null, null)).when(kbossBackupProviderSpy).generateBackupAndVolumePairForSingleNewVolume(any(), any(), any()); + doReturn(Set.of()).when(kbossBackupProviderSpy).getParentSecondaryStorageUrls(backupVoMock); + doReturn(true).when(kbossBackupProviderSpy).processRestoreAnswers(any(), any(), anyBoolean()); + doReturn(volumeVoMock).when(volumeInfoMock).getVolume(); + doReturn(volumeVoMock).when(volumeApiServiceMock).attachVolumeToVM(anyLong(), anyLong(), any(), anyBoolean(), anyBoolean()); + doReturn(true).when(kbossBackupProviderSpy).finalizeQuickRestore(any(), anyList(), anyLong()); + + Pair result = kbossBackupProviderSpy.orchestrateRestoreBackedUpVolume(backupVoMock, virtualMachineMock, backupVolumeInfoMock, null, true); + + assertTrue(result.first()); + + verify(kbossBackupProviderSpy, Mockito.times(1)).sendBackupCommand(anyLong(), any()); + verify(volumeApiServiceMock).attachVolumeToVM(anyLong(), anyLong(), any(), anyBoolean(), anyBoolean()); + verify(kbossBackupProviderSpy).finalizeQuickRestore(any(), anyList(), anyLong()); + } + + @Test + public void startBackupCompressionTestInvalidState() { + doReturn(new Pair<>(false, null)).when(kbossBackupProviderSpy).validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + + boolean result = kbossBackupProviderSpy.startBackupCompression(backupId, 0); + + assertFalse(result); + } + + @Test + public void startBackupCompressionTestNullAnswer() { + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + long parentId = 1332; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of(internalBackupDataStoreVoMock)).when(internalBackupDataStoreDaoMock).listByBackupId(backupId); + InternalBackupDataStoreVO parentDelta = mock(InternalBackupDataStoreVO.class); + doReturn(List.of(parentDelta)).when(internalBackupDataStoreDaoMock).listByBackupId(parentId); + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), any()); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(anyLong()); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(anyLong(), any()); + doReturn("zstd").when(backupOfferingDetailsVoMock).getValue(); + doReturn(List.of(parentVo)).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + doReturn(null).when(kbossBackupProviderSpy).getChainImageStoreUrls(any()); + doReturn(null).when(agentManagerMock).easySend(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.startBackupCompression(backupId, 0); + + assertFalse(result); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.CompressionError); + verify(backupDaoMock).update(backupId, backupVoMock); + } + + @Test + public void startBackupCompressionTestSuccess() { + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + long parentId = 1332; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + InternalBackupJoinVO parentVo = Mockito.mock(InternalBackupJoinVO.class); + doReturn(parentVo).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of(internalBackupDataStoreVoMock)).when(internalBackupDataStoreDaoMock).listByBackupId(backupId); + InternalBackupDataStoreVO parentDelta = mock(InternalBackupDataStoreVO.class); + doReturn(List.of(parentDelta)).when(internalBackupDataStoreDaoMock).listByBackupId(parentId); + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), any()); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(anyLong()); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(anyLong(), any()); + doReturn("zstd").when(backupOfferingDetailsVoMock).getValue(); + doReturn(List.of(parentVo)).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + doReturn(null).when(kbossBackupProviderSpy).getChainImageStoreUrls(any()); + doReturn(answerMock).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + + boolean result = kbossBackupProviderSpy.startBackupCompression(backupId, 0); + + assertTrue(result); + verify(internalBackupServiceJobDaoMock).persist(any()); + } + + @Test + public void finalizeBackupCompressionTestInvalidState() { + doReturn(new Pair<>(false, null)).when(kbossBackupProviderSpy).validateBackupStateForFinalizeCompression(backupId); + + boolean result = kbossBackupProviderSpy.finalizeBackupCompression(backupId, 0); + + assertFalse(result); + } + + @Test + public void finalizeBackupCompressionTestNullAnswer() { + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateBackupStateForFinalizeCompression(backupId); + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(hostVOMock).when(hostDaoMock).findById(0L); + doReturn(null).when(agentManagerMock).easySend(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.finalizeBackupCompression(backupId, 0); + + assertFalse(result); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.CompressionError); + verify(backupDaoMock).update(backupId, backupVoMock); + } + + @Test + public void finalizeBackupCompressionTestCleanup() { + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateBackupStateForFinalizeCompression(backupId); + doReturn(Backup.Status.Removed).when(backupVoMock).getStatus(); + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(hostVOMock).when(hostDaoMock).findById(0L); + doReturn(answerMock).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + + boolean result = kbossBackupProviderSpy.finalizeBackupCompression(backupId, 0); + + assertTrue(result); + verify(backupVoMock, Mockito.never()).setCompressionStatus(Backup.CompressionStatus.Compressed); + verify(backupDaoMock, Mockito.never()).update(backupId, backupVoMock); + } + + @Test + public void finalizeBackupCompressionTestSuccess() { + doReturn(new Pair<>(true, backupVoMock)).when(kbossBackupProviderSpy).validateBackupStateForFinalizeCompression(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(hostVOMock).when(hostDaoMock).findById(0L); + doReturn(answerMock).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn("1").when(answerMock).getDetails(); + doNothing().when(kbossBackupProviderSpy).validateBackupAsyncIfHasOfferingSupport(any(), anyLong(), anyLong()); + + boolean result = kbossBackupProviderSpy.finalizeBackupCompression(backupId, 0); + + assertTrue(result); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.Compressed); + verify(backupDaoMock).update(backupId, backupVoMock); + } + + @Test + public void validateBackupTestInvalidState() { + doReturn(false).when(kbossBackupProviderSpy).validateBackupStateForValidation(backupId); + + boolean result = kbossBackupProviderSpy.validateBackup(backupId, 0); + + assertFalse(result); + } + + @Test + public void validateBackupTestValidateWithHash() { + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForValidation(backupId); + doReturn(backupVoMock).when(backupDaoMock).findById(backupId); + doReturn(backupDetailVoMock).when(backupDetailDaoMock).findDetail(backupId, BackupDetailsDao.BACKUP_HASH); + doReturn(true).when(kbossBackupProviderSpy).validateWithHash(backupId, backupVoMock, backupDetailVoMock); + + boolean result = kbossBackupProviderSpy.validateBackup(backupId, 0); + + assertTrue(result); + verify(kbossBackupProviderSpy).validateWithHash(backupId, backupVoMock, backupDetailVoMock); + } + + @Test + public void validateBackupTestValidateWithValidationVm() { + doReturn(true).when(kbossBackupProviderSpy).validateBackupStateForValidation(backupId); + doReturn(backupVoMock).when(backupDaoMock).findById(backupId); + doReturn(null).when(backupDetailDaoMock).findDetail(backupId, BackupDetailsDao.BACKUP_HASH); + doReturn(true).when(kbossBackupProviderSpy).validateWithValidationVm(backupId, 0, backupVoMock); + + boolean result = kbossBackupProviderSpy.validateBackup(backupId, 0); + + assertTrue(result); + verify(kbossBackupProviderSpy).validateWithValidationVm(backupId, 0, backupVoMock); + } + + @Test + public void finishBackupChainTestInvalidState() { + doReturn(userVmVOMock).when(userVmDaoMock).findById(vmId); + doReturn(VirtualMachine.State.Migrating).when(userVmVOMock).getState(); + + boolean result = kbossBackupProviderSpy.finishBackupChain(virtualMachineMock); + + assertFalse(result); + } + + @Test + public void finishBackupChainTestRunningVm() { + doReturn(userVmVOMock).when(userVmDaoMock).findById(vmId); + doReturn(VirtualMachine.State.Running).when(userVmVOMock).getState(); + doReturn(true).when(kbossBackupProviderSpy).endBackupChain(userVmVOMock); + + boolean result = kbossBackupProviderSpy.finishBackupChain(virtualMachineMock); + + assertTrue(result); + verify(kbossBackupProviderSpy).endBackupChain(userVmVOMock); + } + + @Test + public void finishBackupChainTestBackupError() { + doReturn(userVmVOMock).when(userVmDaoMock).findById(vmId); + doReturn(VirtualMachine.State.BackupError).when(userVmVOMock).getState(); + doReturn(true).when(kbossBackupProviderSpy).normalizeBackupErrorAndFinishChain(userVmVOMock); + + boolean result = kbossBackupProviderSpy.finishBackupChain(virtualMachineMock); + + assertTrue(result); + verify(kbossBackupProviderSpy).normalizeBackupErrorAndFinishChain(userVmVOMock); + } + + @Test + public void prepareVmForSnapshotRevertTestNoCurrentBackup() { + doReturn(null).when(internalBackupJoinDaoMock).findCurrent(vmId); + + kbossBackupProviderSpy.prepareVmForSnapshotRevert(vmSnapshotVoMock, virtualMachineMock); + + verify(kbossBackupProviderSpy, never()).getSucceedingVmSnapshot(any()); + } + + @Test + public void prepareVmForSnapshotRevertTestCurrentBackupBeforeVmSnapshot() { + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(Date.from(Instant.EPOCH)).when(internalBackupJoinVoMock).getDate(); + doReturn(Date.from(Instant.now())).when(vmSnapshotVoMock).getCreated(); + + kbossBackupProviderSpy.prepareVmForSnapshotRevert(vmSnapshotVoMock, virtualMachineMock); + + verify(kbossBackupProviderSpy, never()).getSucceedingVmSnapshot(any()); + } + + @Test (expected = CloudRuntimeException.class) + public void prepareVmForSnapshotRevertTestCurrentBackupAfterVmSnapshotTimeout() throws OperationTimedoutException, AgentUnavailableException { + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(Date.from(Instant.now())).when(internalBackupJoinVoMock).getDate(); + doReturn(Date.from(Instant.EPOCH)).when(vmSnapshotVoMock).getCreated(); + doReturn(List.of()).when(vmSnapshotHelperMock).getVolumeTOList(vmId); + doReturn(vmSnapshotVoMock).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doNothing().when(kbossBackupProviderSpy).createDeleteCommandsAndMergeTrees(any(), any(), any(), any(), anyList()); + doThrow(OperationTimedoutException.class).when(kbossBackupProviderSpy).sendBackupCommands(any(), any()); + + kbossBackupProviderSpy.prepareVmForSnapshotRevert(vmSnapshotVoMock, virtualMachineMock); + + verify(kbossBackupProviderSpy, never()).updateReferencesAfterPrepareForSnapshotRevert(any(), any(), any(), any()); + } + + @Test (expected = CloudRuntimeException.class) + public void prepareVmForSnapshotRevertTestCurrentBackupAfterVmSnapshotNullAnswer() throws OperationTimedoutException, AgentUnavailableException { + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(Date.from(Instant.now())).when(internalBackupJoinVoMock).getDate(); + doReturn(Date.from(Instant.EPOCH)).when(vmSnapshotVoMock).getCreated(); + doReturn(List.of()).when(vmSnapshotHelperMock).getVolumeTOList(vmId); + doReturn(vmSnapshotVoMock).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doNothing().when(kbossBackupProviderSpy).createDeleteCommandsAndMergeTrees(any(), any(), any(), any(), anyList()); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommands(any(), any()); + + kbossBackupProviderSpy.prepareVmForSnapshotRevert(vmSnapshotVoMock, virtualMachineMock); + + verify(kbossBackupProviderSpy, never()).updateReferencesAfterPrepareForSnapshotRevert(any(), any(), any(), any()); + } + + @Test + public void prepareVmForSnapshotRevertTestCurrentBackupAfterVmSnapshotSuccess() throws OperationTimedoutException, AgentUnavailableException { + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findCurrent(vmId); + doReturn(Date.from(Instant.now())).when(internalBackupJoinVoMock).getDate(); + doReturn(Date.from(Instant.EPOCH)).when(vmSnapshotVoMock).getCreated(); + doReturn(List.of()).when(vmSnapshotHelperMock).getVolumeTOList(vmId); + doReturn(vmSnapshotVoMock).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doNothing().when(kbossBackupProviderSpy).createDeleteCommandsAndMergeTrees(any(), any(), any(), any(), anyList()); + doReturn(new Answer[]{}).when(kbossBackupProviderSpy).sendBackupCommands(any(), any()); + doNothing().when(kbossBackupProviderSpy).updateReferencesAfterPrepareForSnapshotRevert(any(), any(), any(), any()); + + kbossBackupProviderSpy.prepareVmForSnapshotRevert(vmSnapshotVoMock, virtualMachineMock); + + verify(kbossBackupProviderSpy).updateReferencesAfterPrepareForSnapshotRevert(any(), any(), any(), any()); + } + + @Test (expected = BackupException.class) + public void finalizeQuickRestoreTestStoppedVmStartException() throws ResourceUnavailableException, InsufficientCapacityException, ResourceAllocationException { + doReturn(userVmVOMock).when(userVmDaoMock).findById(vmId); + doReturn(VirtualMachine.State.Stopped).when(userVmVOMock).getState(); + doThrow(CloudRuntimeException.class).when(userVmManagerMock).startVirtualMachine(anyLong(), any(), any(), any(), anyBoolean()); + + kbossBackupProviderSpy.finalizeQuickRestore(virtualMachineMock, List.of(), 0); + } + + @Test + public void finalizeQuickRestoreTestStoppedVmStartSuccess() { + doReturn(userVmVOMock).when(userVmDaoMock).findById(vmId); + doReturn(true).when(kbossBackupProviderSpy).consolidateVolumes(any(), anyLong(), anyList()); + + boolean result = kbossBackupProviderSpy.finalizeQuickRestore(virtualMachineMock, List.of(), 0); + + assertTrue(result); + verify(kbossBackupProviderSpy).consolidateVolumes(any(), anyLong(), anyList()); + } + + @Test + public void validateWithHashTestNoHosts() { + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(null).when(hostDaoMock).listAllHostsUpByZoneAndHypervisor(anyLong(), any()); + doNothing().when(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithHash(backupId, backupVoMock, null); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(any(), any()); + } + + @Test + public void validateWithHashTestAnswerResultFalse() { + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(List.of(hostVOMock)).when(hostDaoMock).listAllHostsUpByZoneAndHypervisor(anyLong(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(false).when(answerMock).getResult(); + doNothing().when(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithHash(backupId, backupVoMock, null); + + assertFalse(result); + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(any(), any()); + } + + @Test + public void validateWithHashTestDifferentHash() { + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(List.of(hostVOMock)).when(hostDaoMock).listAllHostsUpByZoneAndHypervisor(anyLong(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn("wrongHash").when(answerMock).getDetails(); + doReturn("correctHash").when(backupDetailVoMock).getValue(); + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithHash(backupId, backupVoMock, backupDetailVoMock); + + assertFalse(result); + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(any(), any()); + verify(backupDaoMock, Mockito.never()).update(anyLong(), any()); + } + + @Test + public void validateWithHashTestSameHash() { + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupDeltaTOList(backupId); + doReturn(List.of(hostVOMock)).when(hostDaoMock).listAllHostsUpByZoneAndHypervisor(anyLong(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn("correctHash").when(answerMock).getDetails(); + doReturn("correctHash").when(backupDetailVoMock).getValue(); + + boolean result = kbossBackupProviderSpy.validateWithHash(backupId, backupVoMock, backupDetailVoMock); + + assertTrue(result); + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(kbossBackupProviderSpy, never()).setBackupAsInvalidAndSendAlert(any(), any()); + verify(kbossBackupProviderSpy, never()).setBackupUnableToValidateAndSendAlert(any(), any()); + verify(backupDaoMock).update(anyLong(), any()); + } + + + @Test + public void validateWithValidationVmTestValidationVmIsNull() { + doReturn(null).when(kbossBackupProviderSpy).allocateValidationVm(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.validateWithValidationVm(backupId, 2L, backupVoMock); + + assertFalse(result); + verify(kbossBackupProviderSpy).cleanupValidation(anyBoolean(), any(), any(), any()); + } + + @Test + public void validateWithValidationVmTestPrepareForValidationFails() throws NoTransitionException { + doReturn(userVmVOMock).when(kbossBackupProviderSpy).allocateValidationVm(anyLong(), any()); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(List.of()).when(volumeDaoMock).findByInstance(anyLong()); + doNothing().when(kbossBackupProviderSpy).createValidationVolumesOnPrimaryStorage(any(), any(), any(), any(), any()); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), eq(false)); + doReturn(false).when(kbossBackupProviderSpy).prepareForValidation(anyLong(), any(), any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithValidationVm(backupId, 2L, backupVoMock); + + assertFalse(result); + verify(kbossBackupProviderSpy).cleanupValidation(eq(false), eq(userVmVOMock), eq(backupVoMock), any()); + } + + @Test + public void validateWithValidationVmTestValidateBackupFails() throws NoTransitionException { + doReturn(userVmVOMock).when(kbossBackupProviderSpy).allocateValidationVm(anyLong(), any()); + doReturn(2L).when(userVmVOMock).getHostId(); + doReturn(Hypervisor.HypervisorType.KVM).when(userVmVOMock).getHypervisorType(); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(userVmVOMock).when(userVmDaoMock).findById(anyLong()); + doReturn(List.of()).when(volumeDaoMock).findByInstance(anyLong()); + doNothing().when(kbossBackupProviderSpy).createValidationVolumesOnPrimaryStorage(any(), any(), any(), any(), any()); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), eq(false)); + doReturn(true).when(kbossBackupProviderSpy).prepareForValidation(anyLong(), any(), any(), any()); + doReturn(hypervisorGuruMock).when(hypervisorGuruManagerMock).getGuru(any()); + doReturn(virtualMachineToMock).when(hypervisorGuruMock).implement(any()); + doReturn(false).when(kbossBackupProviderSpy).validateBackup(anyLong(), any(), any(), any(), any(), any()); + doNothing().when(kbossBackupProviderSpy).sendCleanupFailedEmail(any(), any()); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithValidationVm(backupId, 2L, backupVoMock); + + assertFalse(result); + verify(kbossBackupProviderSpy).endBackupChainIfConfigured(backupVoMock); + verify(kbossBackupProviderSpy).cleanupValidation(eq(true), eq(userVmVOMock), eq(backupVoMock), any()); + } + + @Test + public void validateWithValidationVmTestSuccessfulValidation() throws NoTransitionException { + doReturn(userVmVOMock).when(kbossBackupProviderSpy).allocateValidationVm(anyLong(), any()); + doReturn(2L).when(userVmVOMock).getHostId(); + doReturn(Hypervisor.HypervisorType.KVM).when(userVmVOMock).getHypervisorType(); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(2L).when(hostVOMock).getId(); + doReturn(userVmVOMock).when(userVmDaoMock).findById(anyLong()); + doReturn(List.of()).when(volumeDaoMock).findByInstance(anyLong()); + doNothing().when(kbossBackupProviderSpy).createValidationVolumesOnPrimaryStorage(any(), any(), any(), any(), any()); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(Set.of()).when(kbossBackupProviderSpy).generateBackupAndVolumePairsToRestore(any(), any(), any(), eq(false)); + doReturn(true).when(kbossBackupProviderSpy).prepareForValidation(anyLong(), any(), any(), any()); + doReturn(hypervisorGuruMock).when(hypervisorGuruManagerMock).getGuru(any()); + doReturn(virtualMachineToMock).when(hypervisorGuruMock).implement(any()); + doReturn(true).when(kbossBackupProviderSpy).validateBackup(anyLong(), any(), any(), any(), any(), any()); + doNothing().when(kbossBackupProviderSpy).calculateAndSaveHash(any(), any(), anyLong()); + doNothing().when(kbossBackupProviderSpy).sendCleanupFailedEmail(any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithValidationVm(backupId, 2L, backupVoMock); + + assertTrue(result); + verify(kbossBackupProviderSpy).calculateAndSaveHash(any(), eq(backupVoMock), anyLong()); + verify(kbossBackupProviderSpy).cleanupValidation(eq(true), eq(userVmVOMock), eq(backupVoMock), any()); + } + + @Test + public void validateWithValidationVmTestExceptionHandling() throws NoTransitionException { + doReturn(userVmVOMock).when(kbossBackupProviderSpy).allocateValidationVm(anyLong(), any()); + doReturn(hostVOMock).when(hostDaoMock).findById(anyLong()); + doReturn(List.of()).when(volumeDaoMock).findByInstance(anyLong()); + doThrow(new RuntimeException("boom")).when(kbossBackupProviderSpy).createValidationVolumesOnPrimaryStorage(any(), any(), any(), any(), any()); + doNothing().when(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(any(), any()); + + boolean result = kbossBackupProviderSpy.validateWithValidationVm(backupId, 2L, backupVoMock); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupUnableToValidateAndSendAlert(eq(backupVoMock), contains("boom")); + verify(kbossBackupProviderSpy).cleanupValidation(eq(false), eq(userVmVOMock), eq(backupVoMock), any()); + } + + + @Test + public void setBackupAsIsolatedTestPersistIsolatedDetail() { + kbossBackupProviderSpy.setBackupAsIsolated(backupVoMock); + verify(backupDetailDaoMock, Mockito.times(1)).persist(any()); + } + + @Test + public void endBackupChainIfConfiguredTestFeatureDisabled() { + doReturn(false).when(kbossBackupProviderSpy).getValidationEndChainOnFail(backupVoMock); + + kbossBackupProviderSpy.endBackupChainIfConfigured(backupVoMock); + + verify(kbossBackupProviderSpy, never()).endBackupChain(any()); + } + + @Test + public void endBackupChainIfConfiguredTestNotCurrentAndNoCurrentChildren() { + doReturn(true).when(kbossBackupProviderSpy).getValidationEndChainOnFail(backupVoMock); + doReturn(false).when(internalBackupJoinVoMock).getCurrent(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + InternalBackupJoinVO child = mock(InternalBackupJoinVO.class); + doReturn(false).when(child).getCurrent(); + doReturn(List.of(child)).when(kbossBackupProviderSpy).getBackupJoinChildren(any()); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any(), any()); + + kbossBackupProviderSpy.endBackupChainIfConfigured(backupVoMock); + + verify(kbossBackupProviderSpy, never()).endBackupChain(any()); + } + + @Test + public void endBackupChainIfConfiguredTestBackupIsCurrent() { + doReturn(true).when(kbossBackupProviderSpy).getValidationEndChainOnFail(backupVoMock); + doReturn(true).when(internalBackupJoinVoMock).getCurrent(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupJoinChildren(any()); + doReturn(userVmVOMock).when(userVmDaoMock).findByIdIncludingRemoved(anyLong()); + doReturn(true).when(kbossBackupProviderSpy).endBackupChain(any()); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any(), any()); + + kbossBackupProviderSpy.endBackupChainIfConfigured(backupVoMock); + + verify(kbossBackupProviderSpy, times(1)).endBackupChain(userVmVOMock); + } + + @Test + public void endBackupChainIfConfiguredTestLastChildIsCurrent() { + doReturn(true).when(kbossBackupProviderSpy).getValidationEndChainOnFail(backupVoMock); + doReturn(false).when(internalBackupJoinVoMock).getCurrent(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + InternalBackupJoinVO child = mock(InternalBackupJoinVO.class); + doReturn(true).when(child).getCurrent(); + doReturn(List.of(child)).when(kbossBackupProviderSpy).getBackupJoinChildren(any()); + doReturn(userVmVOMock).when(userVmDaoMock).findByIdIncludingRemoved(anyLong()); + doReturn(true).when(kbossBackupProviderSpy).endBackupChain(any()); + doNothing().when(kbossBackupProviderSpy).validateVmState(any(), any(), any(), any()); + + kbossBackupProviderSpy.endBackupChainIfConfigured(backupVoMock); + + verify(kbossBackupProviderSpy, times(1)).endBackupChain(userVmVOMock); + } + + + @Test + public void normalizeBackupErrorAndFinishChainTestAnswerNull() { + doReturn(null).when(vmInstanceDetailsDaoMock).findDetail(anyLong(), any()); + doReturn(backupVoMock).when(backupDaoMock).findLatestByStatusAndVmId(any(), anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(mock(ImageStoreVO.class)).when(imageStoreDaoMock).findById(anyLong()); + doReturn(List.of()).when(internalBackupStoragePoolDaoMock).listByBackupId(anyLong()); + long parentId = 9382; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + doReturn(null).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doNothing().when(kbossBackupProviderSpy).configureKbossTosForCleanup(any(), any(), any(), anyBoolean(), any(), any()); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + + boolean result = kbossBackupProviderSpy.normalizeBackupErrorAndFinishChain(userVmVOMock); + + assertFalse(result); + } + + @Test + public void normalizeBackupErrorAndFinishChainTestAnswerFailed() { + doReturn(null).when(vmInstanceDetailsDaoMock).findDetail(anyLong(), any()); + doReturn(backupVoMock).when(backupDaoMock).findLatestByStatusAndVmId(any(), anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(mock(ImageStoreVO.class)).when(imageStoreDaoMock).findById(anyLong()); + doReturn(List.of()).when(internalBackupStoragePoolDaoMock).listByBackupId(anyLong()); + long parentId = 9382; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + doReturn(null).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doNothing().when(kbossBackupProviderSpy).configureKbossTosForCleanup(any(), any(), any(), anyBoolean(), any(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(false).when(answerMock).getResult(); + + boolean result = kbossBackupProviderSpy.normalizeBackupErrorAndFinishChain(userVmVOMock); + + assertFalse(result); + } + + @Test + public void normalizeBackupErrorAndFinishChainTestSuccessCallsEndChain() { + doReturn(null).when(vmInstanceDetailsDaoMock).findDetail(anyLong(), any()); + doReturn(backupVoMock).when(backupDaoMock).findLatestByStatusAndVmId(any(), anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(mock(ImageStoreVO.class)).when(imageStoreDaoMock).findById(anyLong()); + doReturn(List.of()).when(internalBackupStoragePoolDaoMock).listByBackupId(anyLong()); + long parentId = 9382; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + doReturn(null).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doNothing().when(kbossBackupProviderSpy).configureKbossTosForCleanup(any(), any(), any(), anyBoolean(), any(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn(false).when(kbossBackupProviderSpy).processCleanupBackupErrorAnswer(any(), any()); + doReturn(true).when(kbossBackupProviderSpy).endBackupChain(any()); + + boolean result = kbossBackupProviderSpy.normalizeBackupErrorAndFinishChain(userVmVOMock); + + assertTrue(result); + verify(kbossBackupProviderSpy).endBackupChain(userVmVOMock); + } + + + @Test + public void normalizeBackupErrorAndFinishChainTestChainAlreadyEnded() { + doReturn(null).when(vmInstanceDetailsDaoMock).findDetail(anyLong(), any()); + doReturn(backupVoMock).when(backupDaoMock).findLatestByStatusAndVmId(any(), anyLong()); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(anyLong()); + doReturn(mock(ImageStoreVO.class)).when(imageStoreDaoMock).findById(anyLong()); + doReturn(List.of()).when(internalBackupStoragePoolDaoMock).listByBackupId(anyLong()); + long parentId = 9382; + doReturn(parentId).when(internalBackupJoinVoMock).getParentId(); + doReturn(null).when(internalBackupJoinDaoMock).findById(parentId); + doReturn(List.of()).when(internalBackupDataStoreDaoMock).listByBackupId(anyLong()); + doNothing().when(kbossBackupProviderSpy).configureKbossTosForCleanup(any(), any(), any(), anyBoolean(), any(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn(true).when(kbossBackupProviderSpy).processCleanupBackupErrorAnswer(any(), any()); + InternalBackupJoinVO current = mock(InternalBackupJoinVO.class); + doReturn(current).when(internalBackupJoinDaoMock).findCurrent(anyLong()); + doNothing().when(internalBackupStoragePoolDaoMock).expungeByBackupId(anyLong()); + doNothing().when(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(any()); + + boolean result = kbossBackupProviderSpy.normalizeBackupErrorAndFinishChain(userVmVOMock); + + assertTrue(result); + verify(internalBackupStoragePoolDaoMock).expungeByBackupId(anyLong()); + verify(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(any()); + } + + @Test + public void cleanupValidationTestStartedVmFalseAndValidationNotPreparedAndFailedToDestroyVolume() throws ResourceUnavailableException { + VolumeVO dataVolume = Mockito.mock(VolumeVO.class); + doReturn(Volume.Type.DATADISK).when(dataVolume).getVolumeType(); + doReturn(52L).when(dataVolume).getId(); + doNothing().when(kbossBackupProviderSpy).sendCleanupFailedEmail(any(), any()); + + try (MockedStatic callContextMocked = Mockito.mockStatic(CallContext.class)) { + CallContext callContextMock = Mockito.mock(CallContext.class); + callContextMocked.when(CallContext::current).thenReturn(callContextMock); + + kbossBackupProviderSpy.cleanupValidation(false, userVmVOMock, backupVoMock, List.of(dataVolume)); + + verify(userVmManagerMock, Mockito.never()).stopVirtualMachine(anyLong(), anyBoolean()); + verify(userVmManagerMock, Mockito.times(1)).destroyVm(any(DestroyVMCmd.class), Mockito.eq(false)); + verify(volumeApiServiceMock, Mockito.times(1)).destroyVolume(Mockito.eq(52L), any(), Mockito.eq(true), Mockito.eq(true), Mockito.isNull()); + verify(agentManagerMock, Mockito.never()).easySend(anyLong(), any()); + } + } + + @Test + public void cleanupValidationTestDestroyVmThrowsAndCleanupMailIsSent() throws ResourceUnavailableException { + VolumeVO dataVolume = Mockito.mock(VolumeVO.class); + doReturn(Volume.Type.DATADISK).when(dataVolume).getVolumeType(); + doReturn(88L).when(userVmVOMock).getHostId(); + doReturn(Set.of("secondary-storage-1")).when(kbossBackupProviderSpy).getSecondaryStorageUrls(userVmVOMock); + doThrow(new RuntimeException("boom")).when(userVmManagerMock).destroyVm(any(DestroyVMCmd.class), Mockito.eq(false)); + doReturn(answerMock).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doNothing().when(kbossBackupProviderSpy).sendCleanupFailedEmail(any(), any()); + + try (MockedStatic callContextMocked = Mockito.mockStatic(CallContext.class)) { + CallContext callContextMock = Mockito.mock(CallContext.class); + callContextMocked.when(CallContext::current).thenReturn(callContextMock); + + kbossBackupProviderSpy.cleanupValidation(true, userVmVOMock, backupVoMock, List.of(dataVolume)); + + verify(userVmManagerMock, Mockito.times(1)).destroyVm(any(DestroyVMCmd.class), Mockito.eq(false)); + verify(kbossBackupProviderSpy, Mockito.times(1)).sendCleanupFailedEmail(eq(backupVoMock), contains("Got an unexpected exception while trying to destroy validation VM.")); + verify(agentManagerMock, Mockito.times(1)).easySend(Mockito.eq(88L), any(CleanupKbossValidationCommand.class)); + } + } + + @Test + public void cleanupValidationTestCleanupCommandFails() { + VolumeVO dataVolume = Mockito.mock(VolumeVO.class); + doReturn(Volume.Type.DATADISK).when(dataVolume).getVolumeType(); + doReturn(88L).when(userVmVOMock).getHostId(); + doReturn(Set.of("secondary-storage-1")).when(kbossBackupProviderSpy).getSecondaryStorageUrls(userVmVOMock); + doReturn(null).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(hostVOMock).when(hostDaoMock).findById(88L); + doNothing().when(kbossBackupProviderSpy).sendCleanupFailedEmail(any(), any()); + + try (MockedStatic callContextMocked = Mockito.mockStatic(CallContext.class)) { + CallContext callContextMock = Mockito.mock(CallContext.class); + callContextMocked.when(CallContext::current).thenReturn(callContextMock); + + kbossBackupProviderSpy.cleanupValidation(true, userVmVOMock, backupVoMock, List.of(dataVolume)); + + verify(agentManagerMock, Mockito.times(1)).easySend(Mockito.eq(88L), any(CleanupKbossValidationCommand.class)); + verify(hostDaoMock, Mockito.times(1)).findById(88L); + verify(kbossBackupProviderSpy, times(1)).sendCleanupFailedEmail(any(), any()); + } + } + + @Test + public void cleanupValidationTestNoEmailSentAndAllStepsExecuted() throws ResourceUnavailableException { + VolumeVO rootVolume = Mockito.mock(VolumeVO.class); + doReturn(Volume.Type.ROOT).when(rootVolume).getVolumeType(); + + VolumeVO dataVolume = Mockito.mock(VolumeVO.class); + doReturn(Volume.Type.DATADISK).when(dataVolume).getVolumeType(); + doReturn(52L).when(dataVolume).getId(); + + doReturn(77L).when(userVmVOMock).getId(); + doReturn(88L).when(userVmVOMock).getHostId(); + doReturn(Set.of("secondary-storage-1")).when(kbossBackupProviderSpy).getSecondaryStorageUrls(userVmVOMock); + doReturn(answerMock).when(agentManagerMock).easySend(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn(volumeVoMock).when(volumeApiServiceMock).destroyVolume(Mockito.eq(52L), any(), Mockito.eq(true), Mockito.eq(true), Mockito.isNull()); + + try (MockedStatic callContextMocked = Mockito.mockStatic(CallContext.class)) { + CallContext callContextMock = Mockito.mock(CallContext.class); + callContextMocked.when(CallContext::current).thenReturn(callContextMock); + + kbossBackupProviderSpy.cleanupValidation(true, userVmVOMock, backupVoMock, List.of(rootVolume, dataVolume)); + + verify(userVmManagerMock, Mockito.times(1)).stopVirtualMachine(77L, true); + verify(userVmManagerMock, Mockito.times(1)).destroyVm(any(DestroyVMCmd.class), Mockito.eq(false)); + verify(volumeApiServiceMock, Mockito.times(1)).destroyVolume(Mockito.eq(52L), any(), Mockito.eq(true), Mockito.eq(true), Mockito.isNull()); + verify(agentManagerMock, Mockito.times(1)).easySend(Mockito.eq(88L), any(CleanupKbossValidationCommand.class)); + verify(kbossBackupProviderSpy, Mockito.never()).sendCleanupFailedEmail(any(), any()); + verify(hostDaoMock, Mockito.never()).findById(anyLong()); + } + } + + @Test + public void getVolumesToConsolidateTestSameVmAsBackupFalse() { + VolumeObjectTO volumeObjectTO1 = Mockito.mock(VolumeObjectTO.class); + doReturn(11L).when(volumeObjectTO1).getVolumeId(); + VolumeInfo volumeInfo1 = Mockito.mock(VolumeInfo.class); + doReturn(volumeVoMock).when(volumeInfo1).getVolume(); + doReturn(volumeInfo1).when(volumeDataFactoryMock).getVolume(11L); + + VolumeObjectTO volumeObjectTO2 = Mockito.mock(VolumeObjectTO.class); + doReturn(22L).when(volumeObjectTO2).getVolumeId(); + VolumeInfo volumeInfo2 = Mockito.mock(VolumeInfo.class); + doReturn(Mockito.mock(VolumeVO.class)).when(volumeInfo2).getVolume(); + doReturn(volumeInfo2).when(volumeDataFactoryMock).getVolume(22L); + + doNothing().when(kbossBackupProviderSpy).transitVmState(any(), any(), anyLong()); + doNothing().when(kbossBackupProviderSpy).transitVolumeState(any(), any()); + + List result = kbossBackupProviderSpy.getVolumesToConsolidate(virtualMachineMock, List.of(), List.of(volumeObjectTO1, volumeObjectTO2), 99L, false); + + assertEquals(List.of(volumeInfo1, volumeInfo2), result); + verify(kbossBackupProviderSpy, times(1)).transitVmState(virtualMachineMock, VirtualMachine.Event.RestoringSuccess, 99L); + verify(volumeDataFactoryMock, times(1)).getVolume(11L); + verify(volumeDataFactoryMock, times(1)).getVolume(22L); + verify(kbossBackupProviderSpy, times(2)).transitVolumeState(any(), eq(Volume.Event.RestoreSucceeded)); + } + + @Test + public void getVolumesToConsolidateTestSameVmAsBackupTrueFiltersBySecondaryDeltas() { + VolumeObjectTO volumeObjectTO1 = Mockito.mock(VolumeObjectTO.class); + doReturn(11L).when(volumeObjectTO1).getVolumeId(); + VolumeInfo volumeInfo1 = Mockito.mock(VolumeInfo.class); + doReturn(volumeVoMock).when(volumeInfo1).getVolume(); + doReturn(volumeInfo1).when(volumeDataFactoryMock).getVolume(11L); + + VolumeObjectTO volumeObjectTO2 = Mockito.mock(VolumeObjectTO.class); + doReturn(22L).when(volumeObjectTO2).getVolumeId(); + VolumeInfo volumeInfo2 = Mockito.mock(VolumeInfo.class); + doReturn(Mockito.mock(VolumeVO.class)).when(volumeInfo2).getVolume(); + doReturn(volumeInfo2).when(volumeDataFactoryMock).getVolume(22L); + + InternalBackupDataStoreVO delta = Mockito.mock(InternalBackupDataStoreVO.class); + doReturn(33L).when(delta).getVolumeId(); + + doNothing().when(kbossBackupProviderSpy).transitVmState(any(), any(), anyLong()); + doNothing().when(kbossBackupProviderSpy).transitVolumeState(any(), any()); + + List result = kbossBackupProviderSpy.getVolumesToConsolidate(virtualMachineMock, List.of(delta), List.of(volumeObjectTO1, volumeObjectTO2), 99L, true); + + assertEquals(List.of(), result); + verify(kbossBackupProviderSpy, times(1)).transitVmState(virtualMachineMock, VirtualMachine.Event.RestoringSuccess, 99L); + verify(volumeDataFactoryMock, times(1)).getVolume(11L); + verify(volumeDataFactoryMock, times(1)).getVolume(22L); + verify(kbossBackupProviderSpy, times(2)).transitVolumeState(any(), eq(Volume.Event.RestoreSucceeded)); + } + + @Test + public void checkErrorBackupTestNonErrorStatusDoesNothing() { + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + + kbossBackupProviderSpy.checkErrorBackup(backupVoMock, virtualMachineMock); + + verify(backupVoMock, never()).setStatus(Backup.Status.Failed); + } + + @Test + public void checkErrorBackupTestErrorStatusAndVmIsNullSetsBackupFailed() { + doReturn(Backup.Status.Error).when(backupVoMock).getStatus(); + + kbossBackupProviderSpy.checkErrorBackup(backupVoMock, null); + + verify(backupVoMock).setStatus(Backup.Status.Failed); + } + + @Test + public void checkErrorBackupTestErrorStatusAndVmNotBackupErrorSetsBackupFailed() { + doReturn(Backup.Status.Error).when(backupVoMock).getStatus(); + doReturn(VirtualMachine.State.Running).when(virtualMachineMock).getState(); + + kbossBackupProviderSpy.checkErrorBackup(backupVoMock, virtualMachineMock); + + verify(backupVoMock).setStatus(Backup.Status.Failed); + } + + @Test(expected = InvalidParameterValueException.class) + public void checkErrorBackupTestErrorStatusAndVmInBackupErrorThrows() { + doReturn(Backup.Status.Error).when(backupVoMock).getStatus(); + doReturn(VirtualMachine.State.BackupError).when(virtualMachineMock).getState(); + + kbossBackupProviderSpy.checkErrorBackup(backupVoMock, virtualMachineMock); + + verify(backupVoMock, never()).setStatus(Backup.Status.Failed); + } + + @Test + public void deleteFailedBackupTestFailedBackupIsExpungedAndCleanedUp() { + long backupId = 123L; + doReturn(backupId).when(backupVoMock).getId(); + doReturn(Backup.Status.Failed).when(backupVoMock).getStatus(); + + boolean result = kbossBackupProviderSpy.deleteFailedBackup(backupVoMock); + + assertTrue(result); + verify(backupVoMock).setStatus(Backup.Status.Expunged); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(internalBackupStoragePoolDaoMock).expungeByBackupId(backupId); + verify(internalBackupDataStoreDaoMock).expungeByBackupId(backupId); + verify(backupDetailDaoMock).removeDetails(backupId); + } + + @Test + public void deleteFailedBackupTestNonFailedBackupDoesNothing() { + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + + boolean result = kbossBackupProviderSpy.deleteFailedBackup(backupVoMock); + + assertFalse(result); + verify(backupVoMock, never()).setStatus(Backup.Status.Expunged); + verify(backupDaoMock, never()).update(anyLong(), any()); + verify(internalBackupStoragePoolDaoMock, never()).expungeByBackupId(anyLong()); + verify(internalBackupDataStoreDaoMock, never()).expungeByBackupId(anyLong()); + verify(backupDetailDaoMock, never()).removeDetails(anyLong()); + } + + @Test + public void mergeCurrentDeltaIntoVolumeTestNoDeltaDoesNothing() { + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn(null).when(internalBackupStoragePoolDaoMock).findOneByVolumeId(volumeId); + + kbossBackupProviderSpy.mergeCurrentDeltaIntoVolume(volumeVoMock, virtualMachineMock, "detach", true); + + verify(internalBackupStoragePoolDaoMock, times(1)).findOneByVolumeId(volumeId); + verify(internalBackupJoinDaoMock, never()).findById(anyLong()); + } + + @Test (expected = CloudRuntimeException.class) + public void mergeCurrentDeltaIntoVolumeTestNullAnswer() { + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn(internalBackupStoragePoolVoMock).when(internalBackupStoragePoolDaoMock).findOneByVolumeId(volumeId); + doReturn(backupId).when(internalBackupStoragePoolVoMock).getBackupId(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(null).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doReturn(deltaMergeTreeToMock).when(kbossBackupProviderSpy).createDeltaMergeTree(anyBoolean(), anyBoolean(), any(), any(), any()); + doReturn(null).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + try (MockedStatic volumeObjectMockedStatic = Mockito.mockStatic(VolumeObject.class)) { + when(VolumeObject.getVolumeObject(any(), any())).thenReturn(volumeObjectMock); + + kbossBackupProviderSpy.mergeCurrentDeltaIntoVolume(volumeVoMock, virtualMachineMock, "detach", true); + + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(kbossBackupProviderSpy, never()).expungeOldDeltasAndUpdateVmSnapshotIfNeeded(anyList(), any()); + } + } + + @Test + public void mergeCurrentDeltaIntoVolumeTestNoSucceedingSnapshot() { + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn(internalBackupStoragePoolVoMock).when(internalBackupStoragePoolDaoMock).findOneByVolumeId(volumeId); + doReturn(backupId).when(internalBackupStoragePoolVoMock).getBackupId(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(null).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doReturn(deltaMergeTreeToMock).when(kbossBackupProviderSpy).createDeltaMergeTree(anyBoolean(), anyBoolean(), any(), any(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doReturn(volumeVoMock).when(volumeDaoMock).findById(anyLong()); + doReturn(backupDeltaToMock).when(deltaMergeTreeToMock).getParent(); + doNothing().when(kbossBackupProviderSpy).expungeOldDeltasAndUpdateVmSnapshotIfNeeded(anyList(), any()); + doReturn(List.of()).when(internalBackupStoragePoolDaoMock).listByBackupId(backupId); + doNothing().when(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(any()); + + try (MockedStatic volumeObjectMockedStatic = Mockito.mockStatic(VolumeObject.class)) { + when(VolumeObject.getVolumeObject(any(), any())).thenReturn(volumeObjectMock); + + kbossBackupProviderSpy.mergeCurrentDeltaIntoVolume(volumeVoMock, virtualMachineMock, "detach", true); + + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(volumeDaoMock).update(volumeId, volumeVoMock); + verify(kbossBackupProviderSpy).expungeOldDeltasAndUpdateVmSnapshotIfNeeded(anyList(), any()); + verify(kbossBackupProviderSpy).setEndOfChainAndRemoveCurrentForBackup(any()); + } + } + + @Test + public void mergeCurrentDeltaIntoVolumeTestWithSucceedingSnapshotWithMoreDeltas() { + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn(internalBackupStoragePoolVoMock).when(internalBackupStoragePoolDaoMock).findOneByVolumeId(volumeId); + doReturn(backupId).when(internalBackupStoragePoolVoMock).getBackupId(); + doReturn(internalBackupJoinVoMock).when(internalBackupJoinDaoMock).findById(backupId); + doReturn(vmSnapshotVoMock).when(kbossBackupProviderSpy).getSucceedingVmSnapshot(internalBackupJoinVoMock); + doReturn(deltaMergeTreeToMock).when(kbossBackupProviderSpy).createDeltaMergeTree(anyBoolean(), anyBoolean(), any(), any(), any()); + doReturn(answerMock).when(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + doReturn(true).when(answerMock).getResult(); + doNothing().when(kbossBackupProviderSpy).expungeOldDeltasAndUpdateVmSnapshotIfNeeded(anyList(), any()); + doReturn(List.of(internalBackupStoragePoolVoMock)).when(internalBackupStoragePoolDaoMock).listByBackupId(backupId); + + try (MockedStatic volumeObjectMockedStatic = Mockito.mockStatic(VolumeObject.class)) { + when(VolumeObject.getVolumeObject(any(), any())).thenReturn(volumeObjectMock); + + kbossBackupProviderSpy.mergeCurrentDeltaIntoVolume(volumeVoMock, virtualMachineMock, "detach", true); + + verify(kbossBackupProviderSpy).sendBackupCommand(anyLong(), any()); + verify(volumeDaoMock, never()).update(volumeId, volumeVoMock); + verify(kbossBackupProviderSpy).expungeOldDeltasAndUpdateVmSnapshotIfNeeded(anyList(), any()); + verify(kbossBackupProviderSpy, never()).setEndOfChainAndRemoveCurrentForBackup(any()); + } + } + + @Test + public void getHostToRestoreTestNonQuickRestoreUsesRunningHost() throws AgentUnavailableException { + doReturn(vmId).when(virtualMachineMock).getId(); + doReturn(77L).when(vmSnapshotHelperMock).pickRunningHost(vmId); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(77L); + + HostVO result = kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, false, null); + + assertEquals(hostVOMock, result); + verify(vmSnapshotHelperMock, times(1)).pickRunningHost(vmId); + verify(hostDaoMock, times(1)).findByIdIncludingRemoved(77L); + } + + @Test + public void getHostToRestoreTestQuickRestoreUsesProvidedHostId() throws AgentUnavailableException { + doReturn(Status.Up).when(hostVOMock).getStatus(); + doReturn(false).when(hostVOMock).isInMaintenanceStates(); + doReturn(ResourceState.Enabled).when(hostVOMock).getResourceState(); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(55L); + + HostVO result = kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, 55L); + + assertEquals(hostVOMock, result); + verify(vmSnapshotHelperMock, never()).pickRunningHost(anyLong()); + verify(hostDaoMock, times(1)).findByIdIncludingRemoved(55L); + } + + @Test + public void getHostToRestoreTestQuickRestoreUsesVmLastHostIdWhenHostIdIsNull() throws AgentUnavailableException { + doReturn(99L).when(virtualMachineMock).getLastHostId(); + doReturn(Status.Up).when(hostVOMock).getStatus(); + doReturn(false).when(hostVOMock).isInMaintenanceStates(); + doReturn(ResourceState.Enabled).when(hostVOMock).getResourceState(); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(99L); + + HostVO result = kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, null); + + assertEquals(hostVOMock, result); + verify(hostDaoMock, times(1)).findByIdIncludingRemoved(99L); + } + + @Test(expected = AgentUnavailableException.class) + public void getHostToRestoreTestQuickRestoreWithNoHostIdAndNoLastHostThrows() throws AgentUnavailableException { + doReturn(null).when(virtualMachineMock).getLastHostId(); + + kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, null); + } + + @Test(expected = AgentUnavailableException.class) + public void getHostToRestoreTestQuickRestoreWithHostDownThrows() throws AgentUnavailableException { + doReturn(Status.Down).when(hostVOMock).getStatus(); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(55L); + + kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, 55L); + } + + @Test(expected = AgentUnavailableException.class) + public void getHostToRestoreTestQuickRestoreWithHostInMaintenanceThrows() throws AgentUnavailableException { + doReturn(Status.Up).when(hostVOMock).getStatus(); + doReturn(true).when(hostVOMock).isInMaintenanceStates(); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(55L); + + kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, 55L); + } + + @Test(expected = AgentUnavailableException.class) + public void getHostToRestoreTestQuickRestoreWithHostDisabledThrows() throws AgentUnavailableException { + doReturn(Status.Up).when(hostVOMock).getStatus(); + doReturn(false).when(hostVOMock).isInMaintenanceStates(); + doReturn(ResourceState.Disabled).when(hostVOMock).getResourceState(); + doReturn(hostVOMock).when(hostDaoMock).findByIdIncludingRemoved(55L); + + kbossBackupProviderSpy.getHostToRestore(virtualMachineMock, true, 55L); + } + + @Test + public void gatherSnapshotReferencesOfChildrenSnapshotTestVmSnapshotIsNull() { + List volumeObjectTOs = List.of(volumeObjectToMock); + + Map> result = kbossBackupProviderSpy.gatherSnapshotReferencesOfChildrenSnapshot(volumeObjectTOs, null); + + assertTrue(result.isEmpty()); + verify(vmSnapshotDaoMock, never()).listByParent(anyLong()); + verify(vmSnapshotHelperMock, never()).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(anyLong()); + verify(kbossBackupProviderSpy, never()).mapVolumesToSnapshotReferences(anyList(), anyList(), anyMap()); + } + + @Test + public void gatherSnapshotReferencesOfChildrenSnapshotTestChildrenListIsEmpty() { + doReturn(100L).when(vmSnapshotVoMock).getId(); + doReturn(List.of()).when(vmSnapshotDaoMock).listByParent(100L); + + List volumeObjectTOs = List.of(volumeObjectToMock); + + Map> result = kbossBackupProviderSpy.gatherSnapshotReferencesOfChildrenSnapshot(volumeObjectTOs, vmSnapshotVoMock); + + assertTrue(result.isEmpty()); + verify(vmSnapshotDaoMock, times(1)).listByParent(100L); + verify(vmSnapshotHelperMock, never()).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(anyLong()); + verify(kbossBackupProviderSpy, never()).mapVolumesToSnapshotReferences(anyList(), anyList(), anyMap()); + } + + @Test + public void gatherSnapshotReferencesOfChildrenSnapshotTestSingleChildWithSingleSnapshotReference() { + VMSnapshotVO childSnapshot = Mockito.mock(VMSnapshotVO.class); + SnapshotDataStoreVO snapshotDataStoreVO = Mockito.mock(SnapshotDataStoreVO.class); + + doReturn(100L).when(vmSnapshotVoMock).getId(); + doReturn(List.of(childSnapshot)).when(vmSnapshotDaoMock).listByParent(100L); + doReturn(200L).when(childSnapshot).getId(); + doReturn(List.of(snapshotDataStoreVO)).when(vmSnapshotHelperMock).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(200L); + doNothing().when(kbossBackupProviderSpy).mapVolumesToSnapshotReferences(anyList(), anyList(), anyMap()); + + List volumeObjectTOs = List.of(volumeObjectToMock); + + Map> result = + kbossBackupProviderSpy.gatherSnapshotReferencesOfChildrenSnapshot(volumeObjectTOs, vmSnapshotVoMock); + + assertTrue(result.isEmpty()); + verify(vmSnapshotDaoMock, times(1)).listByParent(100L); + verify(vmSnapshotHelperMock, times(1)).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(200L); + verify(kbossBackupProviderSpy, times(1)).mapVolumesToSnapshotReferences(eq(volumeObjectTOs), anyList(), anyMap()); + } + + @Test + public void gatherSnapshotReferencesOfChildrenSnapshotTestMultipleChildrenAggregatesSnapshotReferences() { + VMSnapshotVO childSnapshot1 = Mockito.mock(VMSnapshotVO.class); + VMSnapshotVO childSnapshot2 = Mockito.mock(VMSnapshotVO.class); + SnapshotDataStoreVO snapshotDataStoreVO1 = Mockito.mock(SnapshotDataStoreVO.class); + SnapshotDataStoreVO snapshotDataStoreVO2 = Mockito.mock(SnapshotDataStoreVO.class); + + doReturn(100L).when(vmSnapshotVoMock).getId(); + doReturn(List.of(childSnapshot1, childSnapshot2)).when(vmSnapshotDaoMock).listByParent(100L); + doReturn(201L).when(childSnapshot1).getId(); + doReturn(202L).when(childSnapshot2).getId(); + doReturn(List.of(snapshotDataStoreVO1)).when(vmSnapshotHelperMock) + .getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(201L); + doReturn(List.of(snapshotDataStoreVO2)).when(vmSnapshotHelperMock) + .getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(202L); + doNothing().when(kbossBackupProviderSpy).mapVolumesToSnapshotReferences(anyList(), anyList(), anyMap()); + + List volumeObjectTOs = List.of(volumeObjectToMock); + + Map> result = + kbossBackupProviderSpy.gatherSnapshotReferencesOfChildrenSnapshot(volumeObjectTOs, vmSnapshotVoMock); + + assertTrue(result.isEmpty()); + verify(vmSnapshotHelperMock, times(1)).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(201L); + verify(vmSnapshotHelperMock, times(1)).getVolumeSnapshotsAssociatedWithKvmDiskOnlyVmSnapshot(202L); + verify(kbossBackupProviderSpy, times(1)).mapVolumesToSnapshotReferences(eq(volumeObjectTOs), anyList(), anyMap()); + } + + @Test + public void createDeltaMergeTreeTestChildIsVolumeWithoutSucceedingSnapshot() { + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), eq(DataStoreRole.Primary)); + doReturn("parent-path").when(internalBackupStoragePoolVoMock).getBackupDeltaParentPath(); + + DeltaMergeTreeTO result = kbossBackupProviderSpy.createDeltaMergeTree(true, true, internalBackupStoragePoolVoMock, + volumeObjectToMock, null); + + assertEquals(volumeObjectToMock, result.getVolumeObjectTO()); + assertTrue(result.getGrandChildren().isEmpty()); + assertEquals(volumeObjectToMock, result.getChild()); + assertEquals("parent-path", result.getParent().getPath()); + } + + @Test + public void createDeltaMergeTreeTestChildIsDeltaWithoutSucceedingSnapshot() { + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), eq(DataStoreRole.Primary)); + doReturn("parent-path").when(internalBackupStoragePoolVoMock).getBackupDeltaParentPath(); + doReturn("child-path").when(internalBackupStoragePoolVoMock).getBackupDeltaPath(); + + DeltaMergeTreeTO result = kbossBackupProviderSpy.createDeltaMergeTree(false, true, internalBackupStoragePoolVoMock, + volumeObjectToMock, null); + + assertEquals(volumeObjectToMock, result.getVolumeObjectTO()); + assertEquals("parent-path", result.getParent().getPath()); + assertEquals("child-path", result.getChild().getPath()); + assertTrue(result.getGrandChildren().isEmpty()); + } + + @Test + public void createDeltaMergeTreeTestChildIsDeltaWithSucceedingSnapshotReferences() { + SnapshotDataStoreVO snapshotRefMock = Mockito.mock(SnapshotDataStoreVO.class); + + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), eq(DataStoreRole.Primary)); + doReturn("parent-path").when(internalBackupStoragePoolVoMock).getBackupDeltaParentPath(); + doReturn("child-path").when(internalBackupStoragePoolVoMock).getBackupDeltaPath(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + doReturn("snapshot-grandchild").when(snapshotRefMock).getInstallPath(); + + doReturn(Map.of(volumeId, List.of(snapshotRefMock))).when(kbossBackupProviderSpy).gatherSnapshotReferencesOfChildrenSnapshot(List.of(volumeObjectToMock), vmSnapshotVoMock); + + DeltaMergeTreeTO result = kbossBackupProviderSpy.createDeltaMergeTree(false, true, internalBackupStoragePoolVoMock, + volumeObjectToMock, vmSnapshotVoMock); + + assertEquals("child-path", result.getChild().getPath()); + assertEquals(1, result.getGrandChildren().size()); + assertEquals("snapshot-grandchild", result.getGrandChildren().get(0).getPath()); + } + + @Test + public void createDeltaMergeTreeTestChildIsDeltaWithSucceedingSnapshotButNoReferencesRebasesToVolumePath() { + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(anyLong(), eq(DataStoreRole.Primary)); + doReturn("parent-path").when(internalBackupStoragePoolVoMock).getBackupDeltaParentPath(); + doReturn("child-path").when(internalBackupStoragePoolVoMock).getBackupDeltaPath(); + doReturn("/volume/path").when(volumeObjectToMock).getPath(); + + doReturn(Map.of()).when(kbossBackupProviderSpy) + .gatherSnapshotReferencesOfChildrenSnapshot(List.of(volumeObjectToMock), vmSnapshotVoMock); + + DeltaMergeTreeTO result = kbossBackupProviderSpy.createDeltaMergeTree(false, false, internalBackupStoragePoolVoMock, + volumeObjectToMock, vmSnapshotVoMock); + + assertEquals(1, result.getGrandChildren().size()); + assertEquals("/volume/path", result.getGrandChildren().get(0).getPath()); + } + + @Test + public void generateBackupAndVolumePairsToRestoreTestSameVmAsBackupMatchesByVolumeId() { + doReturn(77L).when(internalBackupJoinVoMock).getImageStoreId(); + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(77L, DataStoreRole.Image); + doReturn(10L).when(internalBackupDataStoreVoMock).getVolumeId(); + doReturn("delta-path").when(internalBackupDataStoreVoMock).getBackupPath(); + doReturn(10L).when(volumeObjectToMock).getVolumeId(); + + Set> result = kbossBackupProviderSpy.generateBackupAndVolumePairsToRestore(List.of(internalBackupDataStoreVoMock), + List.of(volumeObjectToMock), internalBackupJoinVoMock, true); + + assertEquals(1, result.size()); + Pair pair = result.iterator().next(); + assertEquals(volumeObjectToMock, pair.second()); + assertEquals("delta-path", pair.first().getPath()); + } + + @Test + public void generateBackupAndVolumePairsToRestoreTestDifferentVmMatchesByDeviceId() { + doReturn(77L).when(internalBackupJoinVoMock).getImageStoreId(); + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(77L, DataStoreRole.Image); + doReturn(5L).when(internalBackupDataStoreVoMock).getDeviceId(); + doReturn("delta-path").when(internalBackupDataStoreVoMock).getBackupPath(); + doReturn(5L).when(volumeObjectToMock).getDeviceId(); + + Set> result = kbossBackupProviderSpy.generateBackupAndVolumePairsToRestore(List.of(internalBackupDataStoreVoMock), + List.of(volumeObjectToMock), internalBackupJoinVoMock, false); + + assertEquals(1, result.size()); + Pair pair = result.iterator().next(); + assertEquals(volumeObjectToMock, pair.second()); + assertEquals("delta-path", pair.first().getPath()); + } + + @Test(expected = CloudRuntimeException.class) + public void generateBackupAndVolumePairsToRestoreTestThrowsWhenNoMatchingVolumeExists() { + doReturn(77L).when(internalBackupJoinVoMock).getImageStoreId(); + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(77L, DataStoreRole.Image); + doReturn(10L).when(internalBackupDataStoreVoMock).getVolumeId(); + doReturn(123L).when(internalBackupDataStoreVoMock).getId(); + + kbossBackupProviderSpy.generateBackupAndVolumePairsToRestore(List.of(internalBackupDataStoreVoMock), List.of(volumeObjectToMock), internalBackupJoinVoMock, true); + } + + @Test + public void populateDeltasToRemoveAndToMergeAndUpdateVolumePathsTestVolumeIsPartOfBackupAddsDeltaToRemoveAndUpdatesPath() { + doReturn(dataStoreMock).when(dataStoreManagerMock).getDataStore(11L, DataStoreRole.Primary); + doReturn(10L).when(internalBackupStoragePoolVoMock).getVolumeId(); + doReturn(11L).when(internalBackupStoragePoolVoMock).getStoragePoolId(); + doReturn("delta-path").when(internalBackupStoragePoolVoMock).getBackupDeltaPath(); + doReturn("parent-path").when(internalBackupStoragePoolVoMock).getBackupDeltaParentPath(); + doReturn(10L).when(volumeObjectToMock).getVolumeId(); + + Set deltasToRemove = new java.util.HashSet<>(); + + List result = kbossBackupProviderSpy.populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(List.of(internalBackupStoragePoolVoMock), deltasToRemove, + List.of(volumeObjectToMock), List.of(), "vm-uuid"); + + assertTrue(result.isEmpty()); + assertEquals(1, deltasToRemove.size()); + verify(volumeObjectToMock, times(1)).setPath("parent-path"); + verify(dataStoreManagerMock, times(1)).getDataStore(11L, DataStoreRole.Primary); + } + + @Test + public void populateDeltasToRemoveAndToMergeAndUpdateVolumePathsTestVolumeIsPartOfBackupCreatesMergeTree() { + doReturn(volumeId).when(internalBackupStoragePoolVoMock).getVolumeId(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + + Set deltasToRemove = new java.util.HashSet<>(); + + doReturn(deltaMergeTreeToMock).when(kbossBackupProviderSpy).createDeltaMergeTree(true, false, internalBackupStoragePoolVoMock, volumeObjectToMock, null); + + List result = kbossBackupProviderSpy.populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(List.of(internalBackupStoragePoolVoMock), deltasToRemove, + List.of(volumeObjectToMock), List.of(volumeObjectToMock), "vm-uuid"); + + assertEquals(List.of(deltaMergeTreeToMock), result); + assertTrue(deltasToRemove.isEmpty()); + verify(kbossBackupProviderSpy, times(1)).createDeltaMergeTree(true, false, internalBackupStoragePoolVoMock, volumeObjectToMock, null); + verify(dataStoreManagerMock, never()).getDataStore(anyLong(), eq(DataStoreRole.Primary)); + } + + @Test(expected = CloudRuntimeException.class) + public void populateDeltasToRemoveAndToMergeAndUpdateVolumePathsTestThrowsWhenNoMatchingVolumeExists() { + kbossBackupProviderSpy.populateDeltasToRemoveAndToMergeAndUpdateVolumePaths(List.of(internalBackupStoragePoolVoMock), Set.of(), List.of(), List.of(), "vm-uuid"); + } + + @Test + public void updateVolumePathsAndSizeIfNeededTestUpdatesPathAndSizeWhenVolumePathChanged() { + doReturn(List.of(volumeVoMock)).when(volumeDaoMock).findByInstance(vmId); + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn("old-path").when(volumeVoMock).getPath(); + doReturn(5L).when(volumeVoMock).getDeviceId(); + doReturn(100L).when(volumeVoMock).getSize(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + doReturn("new-path").when(volumeObjectToMock).getPath(); + doReturn(150L).when(backupVolumeInfoMock).getSize(); + doReturn(5L).when(backupVolumeInfoMock).getDeviceId(); + + kbossBackupProviderSpy.updateVolumePathsAndSizeIfNeeded(virtualMachineMock, List.of(volumeObjectToMock), List.of(backupVolumeInfoMock), List.of(), false); + + verify(volumeVoMock).setPath("new-path"); + verify(volumeVoMock).setSize(150L); + verify(volumeDaoMock).update(volumeId, volumeVoMock); + } + + @Test + public void updateVolumePathsAndSizeIfNeededTestUsesMergeTreeParentPathWhenVolumePathDidNotChange() { + doReturn(List.of(volumeVoMock)).when(volumeDaoMock).findByInstance(vmId); + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn("same-path").when(volumeVoMock).getPath(); + doReturn(5L).when(volumeVoMock).getDeviceId(); + doReturn(100L).when(volumeVoMock).getSize(); + doReturn("same-path").when(volumeObjectToMock).getPath(); + doReturn(volumeId).when(volumeObjectToMock).getId(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + doReturn(150L).when(backupVolumeInfoMock).getSize(); + doReturn(5L).when(backupVolumeInfoMock).getDeviceId(); + doReturn(volumeObjectToMock).when(deltaMergeTreeToMock).getChild(); + doReturn(backupDeltaToMock).when(deltaMergeTreeToMock).getParent(); + doReturn("parent-path").when(backupDeltaToMock).getPath(); + + kbossBackupProviderSpy.updateVolumePathsAndSizeIfNeeded(virtualMachineMock, List.of(volumeObjectToMock), List.of(backupVolumeInfoMock), List.of(deltaMergeTreeToMock), + false); + + verify(volumeVoMock).setPath("parent-path"); + verify(volumeVoMock).setSize(150L); + verify(volumeDaoMock).update(volumeId, volumeVoMock); + } + + @Test + public void updateVolumePathsAndSizeIfNeededTestLeavesSizeUntouchedWhenRestoreSizeMatchesCurrentSize() { + doReturn(List.of(volumeVoMock)).when(volumeDaoMock).findByInstance(vmId); + doReturn(vmId).when(virtualMachineMock).getId(); + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn("same-path").when(volumeVoMock).getPath(); + doReturn(5L).when(volumeVoMock).getDeviceId(); + doReturn(100L).when(volumeVoMock).getSize(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + doReturn("same-path").when(volumeObjectToMock).getPath(); + doReturn(100L).when(backupVolumeInfoMock).getSize(); + doReturn(5L).when(backupVolumeInfoMock).getDeviceId(); + + kbossBackupProviderSpy.updateVolumePathsAndSizeIfNeeded(virtualMachineMock, List.of(volumeObjectToMock), List.of(backupVolumeInfoMock), List.of(), false); + + verify(volumeVoMock, never()).setSize(anyLong()); + verify(volumeDaoMock).update(volumeId, volumeVoMock); + } + + @Test + public void updateVolumePathsAndSizeIfNeededTestMatchesByUuidWhenSameVmAsBackup() { + doReturn(List.of(volumeVoMock)).when(volumeDaoMock).findByInstance(vmId); + doReturn(vmId).when(virtualMachineMock).getId(); + doReturn(volumeId).when(volumeVoMock).getId(); + doReturn("same-path").when(volumeVoMock).getPath(); + doReturn("vm-uuid").when(volumeVoMock).getUuid(); + doReturn(100L).when(volumeVoMock).getSize(); + doReturn(volumeId).when(volumeObjectToMock).getVolumeId(); + doReturn("same-path").when(volumeObjectToMock).getPath(); + doReturn("vm-uuid").when(backupVolumeInfoMock).getUuid(); + doReturn(120L).when(backupVolumeInfoMock).getSize(); + + kbossBackupProviderSpy.updateVolumePathsAndSizeIfNeeded(virtualMachineMock, List.of(volumeObjectToMock), List.of(backupVolumeInfoMock), List.of(), true); + + verify(volumeVoMock).setSize(120L); + verify(volumeDaoMock).update(volumeId, volumeVoMock); + } + + + @Test + public void processRemoveBackupFailuresTestNoFailuresReturnsTrueAndRemovesNothing() { + doReturn(backupId).when(internalBackupJoinVoMock).getId(); + + Answer[] deleteAnswers = new Answer[] {answerMock}; + doReturn(true).when(answerMock).getResult(); + + List removedBackupIds = new ArrayList<>(List.of(backupId, 200L)); + + boolean result = kbossBackupProviderSpy.processRemoveBackupFailures(false, deleteAnswers, removedBackupIds, internalBackupJoinVoMock); + + assertTrue(result); + assertEquals(List.of(backupId, 200L), removedBackupIds); + verify(backupDaoMock, never()).findByIdIncludingRemoved(anyLong()); + verify(backupDaoMock, never()).update(anyLong(), any()); + } + + @Test + public void processRemoveBackupFailuresTestFailureOnCurrentBackupNotForcedSetsError() { + doReturn(backupId).when(internalBackupJoinVoMock).getId(); + + BackupDeleteAnswer failedCurrentBackupAnswer = Mockito.mock(BackupDeleteAnswer.class); + doReturn(false).when(failedCurrentBackupAnswer).getResult(); + doReturn(backupId).when(failedCurrentBackupAnswer).getBackupId(); + doReturn("delete failed").when(failedCurrentBackupAnswer).getDetails(); + + doReturn(backupId).when(backupVoMock).getId(); + doReturn(backupVoMock).when(backupDaoMock).findByIdIncludingRemoved(backupId); + + List removedBackupIds = new ArrayList<>(List.of(backupId, 200L)); + + boolean result = kbossBackupProviderSpy.processRemoveBackupFailures(false, new Answer[]{failedCurrentBackupAnswer}, removedBackupIds, internalBackupJoinVoMock); + + assertFalse(result); + assertEquals(List.of(200L), removedBackupIds); + verify(backupVoMock).setStatus(Backup.Status.Error); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(backupDaoMock, never()).findByIdIncludingRemoved(200L); + } + + @Test + public void processRemoveBackupFailuresTestFailureOnCurrentBackupForcedSetBackupAsExpunged() { + BackupDeleteAnswer failedCurrentBackupAnswer = Mockito.mock(BackupDeleteAnswer.class); + doReturn(false).when(failedCurrentBackupAnswer).getResult(); + doReturn(backupId).when(failedCurrentBackupAnswer).getBackupId(); + doReturn("delete failed").when(failedCurrentBackupAnswer).getDetails(); + doReturn(backupVoMock).when(backupDaoMock).findByIdIncludingRemoved(backupId); + + List removedBackupIds = new ArrayList<>(List.of(backupId, 200L)); + + boolean result = kbossBackupProviderSpy.processRemoveBackupFailures(true, new Answer[]{failedCurrentBackupAnswer}, removedBackupIds, internalBackupJoinVoMock); + + assertFalse(result); + assertEquals(List.of(200L), removedBackupIds); + verify(backupVoMock).setStatus(Backup.Status.Expunged); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(backupDaoMock, never()).findByIdIncludingRemoved(200L); + } + + @Test + public void processRemoveBackupFailuresTestFailureOnOtherBackupMarksItExpunged() { + doReturn(backupId).when(internalBackupJoinVoMock).getId(); + + BackupDeleteAnswer failedOtherBackupAnswer = Mockito.mock(BackupDeleteAnswer.class); + doReturn(false).when(failedOtherBackupAnswer).getResult(); + doReturn(200L).when(failedOtherBackupAnswer).getBackupId(); + doReturn("delete failed").when(failedOtherBackupAnswer).getDetails(); + + BackupVO failedBackup = Mockito.mock(BackupVO.class); + doReturn(failedBackup).when(backupDaoMock).findByIdIncludingRemoved(200L); + + List removedBackupIds = new ArrayList<>(List.of(backupId, 200L)); + + boolean result = kbossBackupProviderSpy.processRemoveBackupFailures(false, new Answer[]{failedOtherBackupAnswer}, removedBackupIds, internalBackupJoinVoMock); + + assertFalse(result); + assertEquals(List.of(backupId), removedBackupIds); + verify(failedBackup).setStatus(Backup.Status.Expunged); + verify(backupDaoMock).update(200L, failedBackup); + verify(backupDaoMock, never()).findByIdIncludingRemoved(backupId); + } + + @Test + public void processValidationAnswerTestNullAnswer() { + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), any()); + + boolean result = kbossBackupProviderSpy.processValidationAnswer(null, backupVoMock, userVmVOMock, hostVOMock, null); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("Null answer from host")); + } + + @Test + public void processValidationAnswerTestFalseAnswer() { + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), any()); + + doReturn(false).when(answerMock).getResult(); + doReturn("fail-reason").when(answerMock).getDetails(); + boolean result = kbossBackupProviderSpy.processValidationAnswer(answerMock, backupVoMock, userVmVOMock, hostVOMock, null); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("fail-reason")); + } + + @Test + public void processValidationAnswerTestBootNotValidated() { + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), any()); + + ValidateKbossVmAnswer answer = Mockito.mock(ValidateKbossVmAnswer.class); + doReturn(true).when(answer).getResult(); + doReturn(false).when(answer).isBootValidated(); + + ValidateKbossVmCommand cmd = Mockito.mock(ValidateKbossVmCommand.class); + doReturn(true).when(cmd).isWaitForBoot(); + + boolean result = kbossBackupProviderSpy.processValidationAnswer(answer, backupVoMock, userVmVOMock, hostVOMock, cmd); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("The VM did not boot within the expected time")); + } + + @Test + public void processValidationAnswerTestBootNotValidatedScriptResultFalse() { + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), any()); + + ValidateKbossVmAnswer answer = Mockito.mock(ValidateKbossVmAnswer.class); + doReturn(true).when(answer).getResult(); + doReturn(false).when(answer).isBootValidated(); + doReturn("false").when(answer).getScriptResult(); + + ValidateKbossVmCommand cmd = Mockito.mock(ValidateKbossVmCommand.class); + doReturn(true).when(cmd).isWaitForBoot(); + doReturn(true).when(cmd).isExecuteScript(); + + boolean result = kbossBackupProviderSpy.processValidationAnswer(answer, backupVoMock, userVmVOMock, hostVOMock, cmd); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("The VM did not boot within the expected time")); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("The script did not output the expected output.")); + } + + @Test + public void processValidationAnswerTestBootNotValidatedScriptResultFalseScreenshotPathNull() { + doNothing().when(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), any()); + + ValidateKbossVmAnswer answer = Mockito.mock(ValidateKbossVmAnswer.class); + doReturn(true).when(answer).getResult(); + doReturn(false).when(answer).isBootValidated(); + doReturn("false").when(answer).getScriptResult(); + doReturn(null).when(answer).getScreenshotPath(); + + ValidateKbossVmCommand cmd = Mockito.mock(ValidateKbossVmCommand.class); + doReturn(true).when(cmd).isWaitForBoot(); + doReturn(true).when(cmd).isExecuteScript(); + doReturn(true).when(cmd).isTakeScreenshot(); + + boolean result = kbossBackupProviderSpy.processValidationAnswer(answer, backupVoMock, userVmVOMock, hostVOMock, cmd); + + assertFalse(result); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("The VM did not boot within the expected time")); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("The script did not output the expected output.")); + verify(kbossBackupProviderSpy).setBackupAsInvalidAndSendAlert(eq(backupVoMock), contains("We were unable to take a screenshot of the VM.")); + } + + @Test + public void processValidationAnswerTestBootValidatedScriptResultTrueScreenshotPathNotNull() { + ValidateKbossVmAnswer answer = Mockito.mock(ValidateKbossVmAnswer.class); + doReturn(true).when(answer).getResult(); + doReturn(true).when(answer).isBootValidated(); + doReturn(null).when(answer).getScriptResult(); + doReturn("snap-path").when(answer).getScreenshotPath(); + + ValidateKbossVmCommand cmd = Mockito.mock(ValidateKbossVmCommand.class); + doReturn(true).when(cmd).isWaitForBoot(); + doReturn(true).when(cmd).isExecuteScript(); + doReturn(true).when(cmd).isTakeScreenshot(); + + boolean result = kbossBackupProviderSpy.processValidationAnswer(answer, backupVoMock, userVmVOMock, hostVOMock, cmd); + + assertTrue(result); + verify(kbossBackupProviderSpy, never()).setBackupAsInvalidAndSendAlert(any(), any()); + verify(backupDetailDaoMock).addDetail(eq(backupId), eq(BackupDetailsDao.SCREENSHOT_PATH), eq("snap-path"), eq(false)); + } + + @Test + public void getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommandsTestNoParents() { + doReturn(List.of()).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + + Pair, InternalBackupJoinVO> result = + kbossBackupProviderSpy.getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(backupVoMock, new Commands(Command.OnError.Stop)); + + assertEquals(List.of(), result.first()); + assertNull(result.second()); + } + + @Test + public void getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommandsTestAliveParents() { + doReturn(List.of(internalBackupJoinVoMock)).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock).getStatus(); + + Pair, InternalBackupJoinVO> result = + kbossBackupProviderSpy.getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(backupVoMock, new Commands(Command.OnError.Stop)); + + assertEquals(List.of(), result.first()); + assertEquals(internalBackupJoinVoMock, result.second()); + } + + @Test + public void getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommandsTestDeadParents() { + InternalBackupJoinVO deadParent = Mockito.mock(InternalBackupJoinVO.class); + doReturn(Backup.Status.Removed).when(deadParent).getStatus(); + doReturn(backupId).when(deadParent).getId(); + + doReturn(List.of(deadParent)).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + Commands commands = new Commands(Command.OnError.Stop); + + Pair, InternalBackupJoinVO> result = + kbossBackupProviderSpy.getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(backupVoMock, commands); + + assertEquals(List.of(deadParent), result.first()); + assertNull(result.second()); + verify(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(backupId, commands); + } + + @Test + public void getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommandsTestDeadAndAliveParents() { + InternalBackupJoinVO deadParent = Mockito.mock(InternalBackupJoinVO.class); + doReturn(Backup.Status.Removed).when(deadParent).getStatus(); + doReturn(backupId).when(deadParent).getId(); + + doReturn(List.of(deadParent, internalBackupJoinVoMock)).when(kbossBackupProviderSpy).getBackupJoinParents(backupVoMock, true); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock).getStatus(); + doReturn(null).when(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(anyLong(), any()); + Commands commands = new Commands(Command.OnError.Stop); + + Pair, InternalBackupJoinVO> result = + kbossBackupProviderSpy.getParentsToBeExpungedWithBackupAndAddThemToListOfDeleteCommands(backupVoMock, commands); + + assertEquals(List.of(deadParent), result.first()); + assertEquals(internalBackupJoinVoMock, result.second()); + verify(kbossBackupProviderSpy).addBackupDeltasToDeleteCommand(backupId, commands); + } + + @Test + public void configureValidationStepsTestScreenshot() { + long backupOfferingId = 32L; + + doReturn(backupOfferingId).when(backupVoMock).getBackupOfferingId(); + doReturn(backupOfferingId).when(backupOfferingMock).getId(); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(backupOfferingId); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(backupOfferingId, ApiConstants.VALIDATION_STEPS); + doReturn("screenshot").when(backupOfferingDetailsVoMock).getValue(); + + ValidateKbossVmCommand cmd = new ValidateKbossVmCommand(null, null); + + kbossBackupProviderSpy.configureValidationSteps(cmd, backupVoMock); + + assertTrue(cmd.isTakeScreenshot()); + assertFalse(cmd.isWaitForBoot()); + assertFalse(cmd.isExecuteScript()); + } + + @Test + public void configureValidationStepsTestWaitForBoot() { + long backupOfferingId = 32L; + + doReturn(backupOfferingId).when(backupVoMock).getBackupOfferingId(); + doReturn(backupOfferingId).when(backupOfferingMock).getId(); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(backupOfferingId); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(backupOfferingId, ApiConstants.VALIDATION_STEPS); + doReturn("wait_for_boot").when(backupOfferingDetailsVoMock).getValue(); + + ValidateKbossVmCommand cmd = new ValidateKbossVmCommand(null, null); + + kbossBackupProviderSpy.configureValidationSteps(cmd, backupVoMock); + + assertFalse(cmd.isTakeScreenshot()); + assertTrue(cmd.isWaitForBoot()); + assertFalse(cmd.isExecuteScript()); + } + + @Test + public void configureValidationStepsTestExecuteCommand() { + long backupOfferingId = 32L; + + doReturn(backupOfferingId).when(backupVoMock).getBackupOfferingId(); + doReturn(backupOfferingId).when(backupOfferingMock).getId(); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(backupOfferingId); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(backupOfferingId, ApiConstants.VALIDATION_STEPS); + doReturn("execute_command").when(backupOfferingDetailsVoMock).getValue(); + doReturn(vmInstanceDetailVoMock).when(vmInstanceDetailsDaoMock).findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND); + doReturn(vmId).when(backupVoMock).getVmId(); + + ValidateKbossVmCommand cmd = new ValidateKbossVmCommand(null, null); + + kbossBackupProviderSpy.configureValidationSteps(cmd, backupVoMock); + + assertFalse(cmd.isTakeScreenshot()); + assertFalse(cmd.isWaitForBoot()); + assertTrue(cmd.isExecuteScript()); + } + + @Test + public void configureValidationStepsTestAllSteps() { + long backupOfferingId = 32L; + + doReturn(backupOfferingId).when(backupVoMock).getBackupOfferingId(); + doReturn(backupOfferingId).when(backupOfferingMock).getId(); + doReturn(backupOfferingMock).when(backupOfferingDaoMock).findByIdIncludingRemoved(backupOfferingId); + doReturn(backupOfferingDetailsVoMock).when(backupOfferingDetailsDaoMock).findDetail(backupOfferingId, ApiConstants.VALIDATION_STEPS); + doReturn("screenshot,wait_for_boot,execute_command").when(backupOfferingDetailsVoMock).getValue(); + doReturn(vmInstanceDetailVoMock).when(vmInstanceDetailsDaoMock).findDetail(vmId, VmDetailConstants.VALIDATION_COMMAND); + doReturn(vmId).when(backupVoMock).getVmId(); + + ValidateKbossVmCommand cmd = new ValidateKbossVmCommand(null, null); + + kbossBackupProviderSpy.configureValidationSteps(cmd, backupVoMock); + + assertTrue(cmd.isTakeScreenshot()); + assertTrue(cmd.isWaitForBoot()); + assertTrue(cmd.isExecuteScript()); + } + + @Test + public void validateCompressionStateForRestoreAndGetBackupTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + Pair result = kbossBackupProviderSpy.validateCompressionStateForRestoreAndGetBackup(backupId); + + assertFalse(result.first()); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateCompressionStateForRestoreAndGetBackupTestFinalizingCompression() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.CompressionStatus.FinalizingCompression).when(backupVoMock).getCompressionStatus(); + + Pair result = kbossBackupProviderSpy.validateCompressionStateForRestoreAndGetBackup(backupId); + + assertFalse(result.first()); + verify(backupVoMock).getCompressionStatus(); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateCompressionStateForRestoreAndGetBackupTestValidState() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.CompressionStatus.Compressed).when(backupVoMock).getCompressionStatus(); + + Pair result = kbossBackupProviderSpy.validateCompressionStateForRestoreAndGetBackup(backupId); + + assertTrue(result.first()); + assertEquals(backupVoMock, result.second()); + verify(backupVoMock).getCompressionStatus(); + verify(backupVoMock).setStatus(Backup.Status.Restoring); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRemovalTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + boolean result = kbossBackupProviderSpy.validateBackupStateForRemoval(backupId); + + assertFalse(result); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRemovalTestInvalidState() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Restoring).when(backupVoMock).getStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForRemoval(backupId); + + assertFalse(result); + verify(backupVoMock, Mockito.atLeast(1)).getStatus(); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRemovalTestCompressing() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(Backup.CompressionStatus.Compressing).when(backupVoMock).getCompressionStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForRemoval(backupId); + + assertFalse(result); + verify(backupVoMock).getCompressionStatus(); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRemovalTestValidating() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(Backup.CompressionStatus.Compressed).when(backupVoMock).getCompressionStatus(); + doReturn(Backup.ValidationStatus.Validating).when(backupVoMock).getValidationStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForRemoval(backupId); + + assertFalse(result); + verify(backupVoMock).getValidationStatus(); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRemovalTestValidStates() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(Backup.CompressionStatus.Compressed).when(backupVoMock).getCompressionStatus(); + doReturn(Backup.ValidationStatus.UnableToValidate).when(backupVoMock).getValidationStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForRemoval(backupId); + + assertTrue(result); + verify(backupVoMock).getStatus(); + verify(backupVoMock).getValidationStatus(); + verify(backupVoMock).getCompressionStatus(); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForStartCompressionAndUpdateCompressionStatusTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + Pair result = kbossBackupProviderSpy.validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForStartCompressionAndUpdateCompressionStatusTestInvalidState() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Error).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + + assertFalse(result.first()); + verify(backupVoMock, Mockito.atLeastOnce()).getStatus(); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForStartCompressionAndUpdateCompressionStatusTestValidStates() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForStartCompressionAndUpdateCompressionStatus(backupId); + + assertTrue(result.first()); + assertEquals(backupVoMock, result.second()); + verify(backupVoMock).getStatus(); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.Compressing); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForFinalizeCompressionTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + Pair result = kbossBackupProviderSpy.validateBackupStateForFinalizeCompression(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForFinalizeCompressionTestRestoringBackup() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Restoring).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForFinalizeCompression(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForFinalizeCompressionTestRestoringChildBackup() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(List.of(internalBackupJoinVoMock)).when(kbossBackupProviderSpy).getBackupJoinChildren(backupVoMock); + doReturn(Backup.Status.Restoring).when(internalBackupJoinVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForFinalizeCompression(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForFinalizeCompressionTestAllBackedUp() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + doReturn(List.of(internalBackupJoinVoMock)).when(kbossBackupProviderSpy).getBackupJoinChildren(backupVoMock); + doReturn(Backup.Status.BackedUp).when(internalBackupJoinVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForFinalizeCompression(backupId); + + assertTrue(result.first()); + assertEquals(backupVoMock, result.second()); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.FinalizingCompression); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForFinalizeCompressionTestRemovedBackup() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Removed).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForFinalizeCompression(backupId); + + assertTrue(result.first()); + assertEquals(backupVoMock, result.second()); + verify(backupVoMock).setCompressionStatus(Backup.CompressionStatus.CompressionError); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRestoreBackupToVMTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + Pair result = kbossBackupProviderSpy.validateBackupStateForRestoreBackupToVM(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRestoreBackupToVMTestBackedUp() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForRestoreBackupToVM(backupId); + + assertTrue(result.first()); + assertEquals(Backup.Status.BackedUp, result.second()); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRestoreBackupToVMTestRestoring() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Restoring).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForRestoreBackupToVM(backupId); + + assertTrue(result.first()); + assertEquals(Backup.Status.Restoring, result.second()); + verify(backupDaoMock).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForRestoreBackupToVMTestError() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Error).when(backupVoMock).getStatus(); + + Pair result = kbossBackupProviderSpy.validateBackupStateForRestoreBackupToVM(backupId); + + assertFalse(result.first()); + verify(backupDaoMock, never()).update(backupId, backupVoMock); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + + @Test + public void validateBackupStateForValidationTestUnableToLock() { + doReturn(null).when(kbossBackupProviderSpy).lockBackup(backupId); + + boolean result = kbossBackupProviderSpy.validateBackupStateForValidation(backupId); + + assertFalse(result); + verify(kbossBackupProviderSpy, never()).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForValidationTestInvalidState() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.Removed).when(backupVoMock).getStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForValidation(backupId); + + assertFalse(result); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + + @Test + public void validateBackupStateForValidationTestValidState() { + doReturn(backupVoMock).when(kbossBackupProviderSpy).lockBackup(backupId); + doReturn(Backup.Status.BackedUp).when(backupVoMock).getStatus(); + + boolean result = kbossBackupProviderSpy.validateBackupStateForValidation(backupId); + + assertTrue(result); + verify(kbossBackupProviderSpy).releaseBackup(backupId); + } + +} diff --git a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java new file mode 100644 index 000000000000..511f0ccb7114 --- /dev/null +++ b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupChainKeys.java @@ -0,0 +1,62 @@ +// 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 org.apache.cloudstack.backup; + +/** + * Keys used by the NAS backup provider when storing incremental-chain metadata + * in the existing {@code backup_details} key/value table. Stored here (not on + * the {@code backups} table) so other providers do not need a schema change to + * support their own incremental implementations. + */ +public final class NASBackupChainKeys { + + /** UUID of the parent backup (full or previous incremental). Empty for full backups. */ + public static final String PARENT_BACKUP_ID = "nas.parent_backup_id"; + + /** QEMU dirty-bitmap name created by this backup, used as the {@code } reference for the next one. */ + public static final String BITMAP_NAME = "nas.bitmap_name"; + + /** Identifier shared by every backup in the same chain (the full anchors a chain; its incrementals inherit the id). */ + public static final String CHAIN_ID = "nas.chain_id"; + + /** Position within the chain: 0 for the full, 1 for the first incremental, and so on. */ + public static final String CHAIN_POSITION = "nas.chain_position"; + + /** + * In-memory chain-mode sentinels used by {@code ChainDecision.mode}. The persisted + * full-vs-incremental backup type lives on the {@code backup.type} column (set in + * {@code takeBackup}) — single source of truth. Not duplicated into backup_details. + */ + public static final String TYPE_FULL = "full"; + public static final String TYPE_INCREMENTAL = "incremental"; + // Feature disabled: behave exactly like the pre-incremental full-only backup — no QEMU + // bitmap/checkpoint is created and no chain metadata is persisted. Matches nasbackup.sh's + // "legacy-full" mode token (which sets make_checkpoint=0). + public static final String TYPE_LEGACY_FULL = "legacy-full"; + + /** + * VM-scoped detail (stored in {@code vm_instance_details}) holding the QEMU dirty-bitmap + * name that currently exists on the running VM and is therefore the only valid parent + * for the next incremental backup. Written by {@link #BITMAP_NAME} on each successful + * backup; cleared on restore (the restored disk image has no bitmap, so the next backup + * must be a fresh full). When the VM has no detail, {@code decideChain} forces full. + */ + public static final String VM_ACTIVE_CHECKPOINT_ID = "nas.active_checkpoint_id"; + + private NASBackupChainKeys() { + } +} diff --git a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java index a7121f149e30..8e6d33c4e668 100644 --- a/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java +++ b/plugins/backup/nas/src/main/java/org/apache/cloudstack/backup/NASBackupProvider.java @@ -19,6 +19,7 @@ import com.cloud.agent.AgentManager; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.OperationTimedoutException; +import com.cloud.configuration.Resource; import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.Status; @@ -37,17 +38,21 @@ import com.cloud.storage.dao.VolumeDao; import com.cloud.utils.Pair; import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.db.GlobalLock; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.user.ResourceLimitService; +import com.cloud.vm.VMInstanceDetailVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; import com.cloud.vm.snapshot.VMSnapshot; import com.cloud.vm.snapshot.VMSnapshotDetailsVO; import com.cloud.vm.snapshot.VMSnapshotVO; import com.cloud.vm.snapshot.dao.VMSnapshotDao; import com.cloud.vm.snapshot.dao.VMSnapshotDetailsDao; - import org.apache.cloudstack.backup.dao.BackupDao; +import org.apache.cloudstack.backup.dao.BackupDetailsDao; import org.apache.cloudstack.backup.dao.BackupRepositoryDao; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; @@ -78,6 +83,8 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Configurable { private static final Logger LOG = LogManager.getLogger(NASBackupProvider.class); + private static final int CHAIN_LOCK_TIMEOUT_SECONDS = 300; + ConfigKey NASBackupRestoreMountTimeout = new ConfigKey<>("Advanced", Integer.class, "nas.backup.restore.mount.timeout", "30", @@ -85,6 +92,29 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co true, BackupFrameworkEnabled.key()); + ConfigKey NASBackupFullEvery = new ConfigKey<>("Advanced", Integer.class, + "nas.backup.full.every", + "10", + "Take a full NAS backup every Nth backup; remaining backups in between are incremental. " + + "Counts backups, not days, so it works for hourly, daily, and ad-hoc schedules. " + + "Set to 1 to disable incrementals (every backup is full).", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + + ConfigKey NASBackupIncrementalEnabled = new ConfigKey<>("Advanced", Boolean.class, + "nas.backup.incremental.enabled", + "false", + "Master switch for NAS incremental backups. Defaults to false so existing zones keep the " + + "legacy full-only behavior on upgrade; opt in per-zone when ready to use chains. " + + "When false, every NAS backup is taken as a full regardless of nas.backup.full.every. " + + "Toggling this is safe at any time: switching off forces the next backup to be a fresh " + + "full anchor (existing chains stay restorable), switching back on resumes incrementals " + + "on the next full + incremental cycle.", + true, + ConfigKey.Scope.Zone, + BackupFrameworkEnabled.key()); + @Inject private BackupDao backupDao; @@ -106,6 +136,9 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co @Inject private VMInstanceDao vmInstanceDao; + @Inject + private VMInstanceDetailsDao vmInstanceDetailsDao; + @Inject private PrimaryDataStoreDao primaryDataStoreDao; @@ -115,6 +148,9 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co @Inject private AgentManager agentManager; + @Inject + private ResourceLimitService resourceLimitMgr; + @Inject private VMSnapshotDao vmSnapshotDao; @@ -130,6 +166,9 @@ public class NASBackupProvider extends AdapterBase implements BackupProvider, Co @Inject private DiskOfferingDao diskOfferingDao; + @Inject + private BackupDetailsDao backupDetailsDao; + private Long getClusterIdFromRootVolume(VirtualMachine vm) { VolumeVO rootVolume = volumeDao.getInstanceRootVolume(vm.getId()); StoragePoolVO rootDiskPool = primaryDataStoreDao.findById(rootVolume.getPoolId()); @@ -168,6 +207,330 @@ protected Host getVMHypervisorHost(VirtualMachine vm) { return resourceManager.findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM, vm.getDataCenterId()); } + /** + * Returned by {@link #decideChain(VirtualMachine)} to describe the next backup's place in + * the chain: full vs incremental, the bitmap name to create, and (for incrementals) the + * parent bitmap and parent file path. + */ + static final class ChainDecision { + final String mode; // "full" or "incremental" + final String bitmapNew; + final String bitmapParent; // null for full + // Per-volume parent backup file paths, one per current VM volume in deviceId order. + // null/empty for full. Each volume needs its own parent file because backup files + // are named after each volume's own UUID (root..qcow2 / datadisk..qcow2). + final List parentPaths; + final String chainId; // chain identifier this backup belongs to + final int chainPosition; // 0 for full, N for the Nth incremental in the chain + + private ChainDecision(String mode, String bitmapNew, String bitmapParent, List parentPaths, + String chainId, int chainPosition) { + this.mode = mode; + this.bitmapNew = bitmapNew; + this.bitmapParent = bitmapParent; + this.parentPaths = parentPaths; + this.chainId = chainId; + this.chainPosition = chainPosition; + } + + static ChainDecision fullStart(String bitmapName) { + return new ChainDecision(NASBackupChainKeys.TYPE_FULL, bitmapName, null, null, + UUID.randomUUID().toString(), 0); + } + + /** + * Decision used when the incremental feature is disabled: a plain full backup that + * creates no bitmap and carries no chain identity, so nothing chain/checkpoint-related + * is sent to the agent or persisted. Keeps the feature-off path byte-for-byte legacy. + */ + static ChainDecision legacyFull() { + return new ChainDecision(NASBackupChainKeys.TYPE_LEGACY_FULL, null, null, null, null, 0); + } + + static ChainDecision incremental(String bitmapNew, String bitmapParent, List parentPaths, + String chainId, int chainPosition) { + return new ChainDecision(NASBackupChainKeys.TYPE_INCREMENTAL, bitmapNew, bitmapParent, + parentPaths, chainId, chainPosition); + } + + boolean isIncremental() { + return NASBackupChainKeys.TYPE_INCREMENTAL.equals(mode); + } + + boolean isLegacyFull() { + return NASBackupChainKeys.TYPE_LEGACY_FULL.equals(mode); + } + } + + /** + * Decides whether the next backup for {@code vm} should be a fresh full or an incremental + * appended to the existing chain. Stopped VMs are always full (libvirt {@code backup-begin} + * requires a running QEMU process). The {@code nas.backup.full.every} ConfigKey controls + * how many backups (full + incrementals) form one chain before a new full is forced. + * + *

The decision is anchored on the VM's {@code nas.active_checkpoint_id} detail, which + * records the bitmap that currently exists on the running QEMU. After a restore that + * detail is cleared, so the next backup is automatically full — even though there may be + * a more recent "last backup taken" row in the database. The decision deliberately avoids + * relying on "last backup taken", because that row is misleading after a restore.

+ */ + protected ChainDecision decideChain(VirtualMachine vm) { + // Master switch — when the operator disables incrementals at the zone level the backup + // behaves exactly like the pre-incremental full-only path: no bitmap is generated and no + // chain/checkpoint metadata is created, sent to the agent, or persisted (legacy-full). + Boolean incrementalEnabled = NASBackupIncrementalEnabled.valueIn(vm.getDataCenterId()); + if (incrementalEnabled == null || !incrementalEnabled) { + return ChainDecision.legacyFull(); + } + + // Incremental backups rely on QEMU dirty bitmaps / libvirt checkpoints, which only exist + // on file-based qcow2 storage. Storage such as Ceph-RBD and Linstor cannot carry per-disk + // checkpoints, so a VM with any volume on such a pool must stay on the full-only (legacy) + // path — otherwise an incremental attempt would fail or regress those storages. + if (!allVolumesOnCheckpointCapableStorage(vm)) { + return ChainDecision.legacyFull(); + } + + final String newBitmap = "backup-" + System.currentTimeMillis() / 1000L; + + // Stopped VMs cannot do incrementals — script will also fall back, but we make the + // decision here so we register the right type up-front. + if (VirtualMachine.State.Stopped.equals(vm.getState())) { + return ChainDecision.fullStart(newBitmap); + } + + Integer fullEvery = NASBackupFullEvery.valueIn(vm.getDataCenterId()); + if (fullEvery == null || fullEvery <= 1) { + // Disabled or every-backup-is-full mode. + return ChainDecision.fullStart(newBitmap); + } + + // 1. If the VM has no active_checkpoint_id, there is no bitmap on the host to use as + // a parent. This is the case after restore (we clear it), after VM was just assigned + // to the offering, or on the very first backup. + String activeCheckpoint = readVmActiveCheckpoint(vm.getId()); + if (activeCheckpoint == null) { + return ChainDecision.fullStart(newBitmap); + } + + // 2. The most-recent BackedUp backup is the only safe parent — after restore the + // next backup is always a fresh full, so anything older has a rotated-out bitmap. + // If the latest backup's bitmap doesn't match the VM's active_checkpoint_id, the + // chain is broken: force a full. + Backup parent = findLatestBackedUpBackup(vm.getId()); + if (parent == null || !activeCheckpoint.equals(readDetail(parent, NASBackupChainKeys.BITMAP_NAME))) { + LOG.debug("VM {} latest backup does not match active_checkpoint_id={} — forcing full", + vm.getInstanceName(), activeCheckpoint); + return ChainDecision.fullStart(newBitmap); + } + + String parentChainId = readDetail(parent, NASBackupChainKeys.CHAIN_ID); + int parentChainPosition = chainPosition(parent); + if (parentChainId == null || parentChainPosition == Integer.MAX_VALUE) { + return ChainDecision.fullStart(newBitmap); + } + + // Force a fresh full when the chain has reached the configured length. + if (parentChainPosition + 1 >= fullEvery) { + return ChainDecision.fullStart(newBitmap); + } + + // The script needs the parent backup's on-NAS file path PER VOLUME so it can rebase + // each new qcow2 onto the matching parent. The paths are stored relative to the NAS + // mount root — the script resolves them inside its mount session. When alignment + // fails (volume count changed, etc.) compose returns null and we fall back to full + // so we don't risk corrupting the chain. + List parentPaths = composeParentBackupPaths(parent, vm.getId()); + if (parentPaths == null) { + LOG.debug("VM {} parent backup {} volume layout no longer matches current VM — forcing full", + vm.getInstanceName(), parent.getUuid()); + return ChainDecision.fullStart(newBitmap); + } + return ChainDecision.incremental(newBitmap, activeCheckpoint, parentPaths, + parentChainId, parentChainPosition + 1); + } + + /** + * Incremental backups require QEMU dirty bitmaps / libvirt checkpoints, which are only + * possible on file-based qcow2 storage. Returns {@code true} only when EVERY volume of the + * VM sits on HOST-scope local, {@code SharedMountPoint}, or {@code NetworkFilesystem} (NFS) + * storage. Ceph-RBD, Linstor, and any other pool that cannot carry a per-disk checkpoint + * make this return {@code false} so the caller falls back to the legacy full-only path. A + * volume whose pool can no longer be resolved is treated as incapable (safe default). + */ + protected boolean allVolumesOnCheckpointCapableStorage(VirtualMachine vm) { + List volumes = volumeDao.findByInstance(vm.getId()); + if (volumes == null) { + return true; + } + for (VolumeVO volume : volumes) { + StoragePoolVO pool = primaryDataStoreDao.findById(volume.getPoolId()); + if (pool == null) { + LOG.debug("VM {} volume {} has no resolvable storage pool — forcing legacy full", + vm.getInstanceName(), volume.getUuid()); + return false; + } + boolean checkpointCapable = ScopeType.HOST.equals(pool.getScope()) + || Storage.StoragePoolType.SharedMountPoint.equals(pool.getPoolType()) + || Storage.StoragePoolType.NetworkFilesystem.equals(pool.getPoolType()); + if (!checkpointCapable) { + LOG.debug("VM {} volume {} is on {} (scope {}) which cannot carry checkpoints — forcing legacy full", + vm.getInstanceName(), volume.getUuid(), pool.getPoolType(), pool.getScope()); + return false; + } + } + return true; + } + + /** + * Read the {@code nas.active_checkpoint_id} VM detail. Returns {@code null} when no detail + * exists (post-restore, first backup, or after explicit reset). + */ + private String readVmActiveCheckpoint(long vmId) { + VMInstanceDetailVO d = vmInstanceDetailsDao.findDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + if (d == null) { + return null; + } + String v = d.getValue(); + return (v == null || v.isEmpty()) ? null : v; + } + + /** + * Locate the most-recent {@code BackedUp} backup for {@code vmId}. The chain invariant + * guarantees the latest backup is the only valid incremental parent — after restore the + * next backup is always a fresh full, and {@link #decideChain} checks the bitmap matches. + */ + private Backup findLatestBackedUpBackup(long vmId) { + List history = backupDao.listByVmId(null, vmId); + if (history == null || history.isEmpty()) { + return null; + } + return history.stream() + .filter(b -> Backup.Status.BackedUp.equals(b.getStatus())) + .max(Comparator.comparing(Backup::getDate)) + .orElse(null); + } + + private String readDetail(Backup backup, String key) { + BackupDetailVO d = backupDetailsDao.findDetail(backup.getId(), key); + return d == null ? null : d.getValue(); + } + + /** + * Compose the on-NAS path of EVERY parent backup file (one per VM volume) in the same + * order the script will iterate the current VM's disks (deviceId asc). Relative to the + * NAS mount, matches the layout written by {@code nasbackup.sh}: + * first disk -> {@code /root..qcow2} + * others -> {@code /datadisk..qcow2} + * + * Returns {@code null} if the parent's stored volume count doesn't match the current VM's + * volume count. Volume attach/detach is blocked while a VM is assigned to a backup offering; + * if the offering was removed and re-assigned the active checkpoint is cleared in + * {@link #removeVMFromBackupOffering}, so this method doesn't need to revalidate volume + * identities — a count mismatch is the only way to reach this branch with a non-null + * active_checkpoint_id. + */ + private List composeParentBackupPaths(Backup parent, long vmId) { + // backupPath is stored as externalId by createBackupObject — e.g. + // "i-2-1234-VM/2026.04.27.13.45.00". + String dir = parent.getExternalId(); + if (dir == null || dir.isEmpty()) { + return null; + } + + List parentVols = parent.getBackedUpVolumes(); + if (parentVols == null || parentVols.isEmpty()) { + return null; + } + + List currentVols = volumeDao.findByInstance(vmId); + if (currentVols == null || currentVols.size() != parentVols.size()) { + return null; + } + + // parentVols is in deviceId order at the time the parent was taken. The script names the + // per-disk files from the volume PATH basename (root..qcow2 / datadisk..qcow2, + // see nasbackup.sh: volUuid="${fullpath##*/}"). Use getPath(), NOT getUuid(): after a + // volume migration the uuid and the on-disk path diverge, and the backup file is named by + // path — a uuid-based parent path then fails to resolve for the incremental (test 13). + List paths = new ArrayList<>(parentVols.size()); + for (int i = 0; i < parentVols.size(); i++) { + String volPath = parentVols.get(i).getPath(); + String prefix = (i == 0) ? "root" : "datadisk"; + paths.add(dir + "/" + prefix + "." + volPath + ".qcow2"); + } + return paths; + } + + /** + * Persist chain metadata under backup_details. Stored here (not on the backups table) so + * other providers can implement their own chain semantics without schema changes. + */ + private void persistChainMetadata(Backup backup, ChainDecision decision, String bitmapFromAgent) { + // Only persist nas.bitmap_name when the agent confirmed the bitmap exists on the host. + // The agent wrapper sets bitmapFromAgent=null when nasbackup.sh exits + // EXIT_BITMAP_NOT_SEEDED (=22) — currently only the stopped-VM path where qemu-img + // bitmap --add failed on every source disk. Anchoring the next incremental on a + // bitmap that doesn't exist would force a non-recoverable failure, so we leave the + // detail empty and let the next backup start a fresh full chain. + if (bitmapFromAgent != null && !bitmapFromAgent.isEmpty()) { + backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.BITMAP_NAME, bitmapFromAgent, true)); + } + backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.CHAIN_ID, decision.chainId, true)); + backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.CHAIN_POSITION, + String.valueOf(decision.chainPosition), true)); + // Backup full-vs-incremental type lives on backup.type (set by takeBackup) — single + // source of truth. Not duplicated into backup_details. + if (decision.isIncremental()) { + // Resolve the parent backup's UUID so restore can walk the chain by id, not by path. + String parentUuid = lookupParentBackupUuid(backup.getVmId(), decision.bitmapParent); + if (parentUuid != null) { + backupDetailsDao.persist(new BackupDetailVO(backup.getId(), NASBackupChainKeys.PARENT_BACKUP_ID, parentUuid, true)); + } + } + } + + /** + * Upsert the VM's {@code nas.active_checkpoint_id} detail to {@code bitmapName}. Called + * after every successful backup so the next backup's parent-bitmap decision is anchored + * on what actually exists on QEMU, not on "last backup taken". + */ + private void upsertVmActiveCheckpoint(long vmId, String bitmapName) { + VMInstanceDetailVO existing = vmInstanceDetailsDao.findDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + if (existing == null) { + vmInstanceDetailsDao.addDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID, bitmapName, false); + return; + } + existing.setValue(bitmapName); + vmInstanceDetailsDao.update(existing.getId(), existing); + } + + /** + * Remove the VM's {@code nas.active_checkpoint_id} detail. Called from the restore paths: + * after restore the disk image has no QEMU bitmap attached, so any future incremental + * would be based on stale state. Clearing forces the next backup to be a fresh full. + */ + private void clearVmActiveCheckpoint(long vmId) { + VMInstanceDetailVO existing = vmInstanceDetailsDao.findDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + if (existing != null) { + vmInstanceDetailsDao.removeDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + LOG.debug("Cleared nas.active_checkpoint_id for VM id={} (was {})", vmId, existing.getValue()); + } + } + + private String lookupParentBackupUuid(long vmId, String parentBitmap) { + if (parentBitmap == null) { + return null; + } + for (Backup b : backupDao.listByVmId(null, vmId)) { + String bm = readDetail(b, NASBackupChainKeys.BITMAP_NAME); + if (parentBitmap.equals(bm)) { + return b.getUuid(); + } + } + return null; + } + protected Host getVMHypervisorHostForBackup(VirtualMachine vm) { Long hostId = vm.getHostId(); if (hostId == null && VirtualMachine.State.Running.equals(vm.getState())) { @@ -187,7 +550,7 @@ protected Host getVMHypervisorHostForBackup(VirtualMachine vm) { } @Override - public Pair takeBackup(final VirtualMachine vm, Boolean quiesceVM) { + public Pair takeBackup(final VirtualMachine vm, Boolean quiesceVM, boolean isolated) { final Host host = getVMHypervisorHostForBackup(vm); final BackupRepository backupRepository = backupRepositoryDao.findByBackupOfferingId(vm.getBackupOfferingId()); @@ -205,12 +568,20 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce final String backupPath = String.format("%s/%s", vm.getInstanceName(), new SimpleDateFormat("yyyy.MM.dd.HH.mm.ss").format(creationDate)); - BackupVO backupVO = createBackupObject(vm, backupPath); + // Decide full vs incremental for this backup. Stopped VMs are always full + // (libvirt backup-begin requires a running QEMU process). + ChainDecision decision = decideChain(vm); + + BackupVO backupVO = createBackupObject(vm, backupPath, decision.isIncremental() ? "INCREMENTAL" : "FULL"); TakeBackupCommand command = new TakeBackupCommand(vm.getInstanceName(), backupPath); command.setBackupRepoType(backupRepository.getType()); command.setBackupRepoAddress(backupRepository.getAddress()); command.setMountOptions(backupRepository.getMountOptions()); command.setQuiesce(quiesceVM); + command.setMode(decision.mode); + command.setBitmapNew(decision.bitmapNew); + command.setBitmapParent(decision.bitmapParent); + command.setParentPaths(decision.parentPaths); if (VirtualMachine.State.Stopped.equals(vm.getState())) { List vmVolumes = volumeDao.findByInstance(vm.getId()); @@ -239,16 +610,38 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce backupVO.setDate(new Date()); backupVO.setSize(answer.getSize()); backupVO.setStatus(Backup.Status.BackedUp); + // If the agent fell back to full (stopped VM mid-incremental cycle), record this + // backup as a full and start a new chain. + ChainDecision effective = decision; + if (answer.getIncrementalFallback()) { + effective = ChainDecision.fullStart(decision.bitmapNew); + backupVO.setType("FULL"); + } List volumes = new ArrayList<>(volumeDao.findByInstance(vm.getId())); backupVO.setBackedUpVolumes(backupManager.createVolumeInfoFromVolumes(volumes)); if (backupDao.update(backupVO.getId(), backupVO)) { + // Legacy-full (incremental feature disabled): persist no chain/checkpoint metadata + // and do not touch the VM's active_checkpoint_id — keep the feature-off path legacy. + if (!decision.isLegacyFull()) { + persistChainMetadata(backupVO, effective, answer.getBitmapCreated()); + // Pin the VM's active_checkpoint_id to whichever bitmap the agent actually + // created — the only valid parent for the next incremental (see decideChain). + // If the agent reports no bitmap (bitmapCreated=null), clear any stale detail + // so the next backup starts a fresh full. + String confirmedBitmap = answer.getBitmapCreated(); + if (confirmedBitmap != null) { + upsertVmActiveCheckpoint(vm.getId(), confirmedBitmap); + } else { + clearVmActiveCheckpoint(vm.getId()); + } + } return new Pair<>(true, backupVO); } else { throw new CloudRuntimeException("Failed to update backup"); } } else { logger.error("Failed to take backup for VM {}: {}", vm.getInstanceName(), answer != null ? answer.getDetails() : "No answer received"); - if (answer.getNeedsCleanup()) { + if (answer != null && answer.getNeedsCleanup()) { logger.error("Backup cleanup failed for VM {}. Leaving the backup in Error state. Backup should be manually deleted to free up the space", vm.getInstanceName()); backupVO.setStatus(Backup.Status.Error); backupDao.update(backupVO.getId(), backupVO); @@ -260,11 +653,11 @@ public Pair takeBackup(final VirtualMachine vm, Boolean quiesce } } - private BackupVO createBackupObject(VirtualMachine vm, String backupPath) { + private BackupVO createBackupObject(VirtualMachine vm, String backupPath, String type) { BackupVO backup = new BackupVO(); backup.setVmId(vm.getId()); backup.setExternalId(backupPath); - backup.setType("FULL"); + backup.setType(type); backup.setDate(new Date()); long virtualSize = 0L; for (final Volume volume: volumeDao.findByInstance(vm.getId())) { @@ -286,12 +679,12 @@ private BackupVO createBackupObject(VirtualMachine vm, String backupPath) { } @Override - public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid) { + public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickrestore) { return restoreVMBackup(vm, backup); } @Override - public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup) { + public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { return restoreVMBackup(vm, backup).first(); } @@ -333,6 +726,11 @@ private Pair restoreVMBackup(VirtualMachine vm, Backup backup) } catch (OperationTimedoutException e) { throw new CloudRuntimeException("Operation to restore backup timed out, please try again"); } + // After a restore the QEMU dirty-bitmap chain is gone — clear active_checkpoint_id so + // the next backup is taken as a fresh full and starts a new chain. See decideChain. + if (answer != null && answer.getResult()) { + clearVmActiveCheckpoint(vm.getId()); + } return new Pair<>(answer.getResult(), answer.getDetails()); } @@ -387,7 +785,8 @@ private String getVolumePathSuffix(StoragePoolVO storagePool) { } @Override - public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, Pair vmNameAndState) { + public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore) { final VolumeVO volume = volumeDao.findByUuid(backupVolumeInfo.getUuid()); final DiskOffering diskOffering = diskOfferingDao.findByUuid(backupVolumeInfo.getDiskOfferingId()); final StoragePoolVO pool = primaryDataStoreDao.findByUuid(dataStoreUuid); @@ -457,6 +856,13 @@ public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeI } catch (Exception e) { throw new CloudRuntimeException("Unable to create restored volume due to: " + e); } + // The restored volume is attached to this VM with no QEMU bitmap on its image, so the + // VM's tracked checkpoint is now stale; clear it to force the next backup to be a full + // (mirrors the full restore paths restoreVMFromBackup/restoreBackupToVM). + VirtualMachine restoreTargetVm = vmInstanceDao.findVMByInstanceName(vmNameAndState.first()); + if (restoreTargetVm != null) { + clearVmActiveCheckpoint(restoreTargetVm.getId()); + } } return new Pair<>(answer.getResult(), answer.getDetails()); @@ -477,6 +883,14 @@ private Backup.VolumeInfo getBackedUpVolumeInfo(List backedUp .orElse(null); } + @Override + public boolean handlesChainDeleteResourceAccounting() { + // This provider deletes whole incremental chains (leaf + swept delete-pending ancestors) + // and decrements resource count/usage once per physically-removed backup itself, so the + // manager must not also decrement or remove the DB row. + return true; + } + @Override public boolean deleteBackup(Backup backup, boolean forced) { final BackupRepository backupRepository = backupRepositoryDao.findByBackupOfferingId(backup.getBackupOfferingId()); @@ -495,9 +909,74 @@ public boolean deleteBackup(Backup backup, boolean forced) { throw new CloudRuntimeException(String.format("Unable to find a running KVM host in zone %d to delete backup %s", backup.getZoneId(), backup.getUuid())); } - DeleteBackupCommand command = new DeleteBackupCommand(backup.getExternalId(), backupRepository.getType(), - backupRepository.getAddress(), backupRepository.getMountOptions()); + // Backups outside any tracked chain (legacy or non-incremental providers) are + // deleted straight away — no children semantics apply. + if (readDetail(backup, NASBackupChainKeys.CHAIN_ID) == null) { + return deleteBackupFileAndRow(backup, backupRepository, host); + } + + // Concurrent deletes can mutate the same chain concurrently resulting in inconsistent states. + // take a per-VM lock before modifying the chain. + final GlobalLock chainLock = acquireChainDeleteLock(backup.getVmId()); + try { + Backup current = backupDao.findById(backup.getId()); + if (current == null) { + LOG.debug("Backup {} was already removed by a concurrent chain delete", backup.getUuid()); + return true; + } + backup = current; + + // Snapshot-style cascade: defer the on-NAS rm + DB row while there are live children, + // mark this backup as delete-pending, and let the leaf's deletion sweep it up later. + // See DefaultSnapshotStrategy#deleteSnapshotChain for the same pattern on incremental + // snapshots. forced=true means the caller wants the entire subtree gone right now. + if (forced) { + return cascadeDeleteSubtree(backup, backupRepository, host); + } + + if (hasLiveChildren(backup)) { + markDeletePending(backup); + LOG.debug("Backup {} has live descendants in its chain; setting status as Hidden. " + + "The on-NAS file and DB row will be removed once the last descendant is gone, " + + "or pass forced=true.", + backup.getUuid()); + return true; + } + // No live children — physically delete this backup, then walk up the chain and + // collect any ancestors that were left in Hidden state. + return deleteLeafBackupAndSweepPendingAncestors(backup, backupRepository, host); + } finally { + releaseChainDeleteLock(chainLock); + } + } + + /** + * Take the per-VM lock that serializes chain-mutating backup deletes. + */ + protected GlobalLock acquireChainDeleteLock(long vmId) { + GlobalLock lock = GlobalLock.getInternLock("nas.backup.chain.vm." + vmId); + if (!lock.lock(CHAIN_LOCK_TIMEOUT_SECONDS)) { + lock.releaseRef(); + throw new CloudRuntimeException(String.format( + "Timed out waiting for concurrent backup chain operations on VM %d to finish, please try again", vmId)); + } + return lock; + } + + protected void releaseChainDeleteLock(GlobalLock lock) { + lock.unlock(); + lock.releaseRef(); + } + + /** + * The single physical-delete step: rm the on-NAS directory, then remove the DB row. + * Returns {@code false} (and leaves both intact) if the agent reports failure, so the + * caller's recursion stops cleanly. + */ + private boolean deleteBackupFileAndRow(Backup backup, BackupRepository repo, Host host) { + DeleteBackupCommand command = new DeleteBackupCommand(backup.getExternalId(), repo.getType(), + repo.getAddress(), repo.getMountOptions()); BackupAnswer answer; try { answer = (BackupAnswer) agentManager.send(host.getId(), command); @@ -506,15 +985,157 @@ public boolean deleteBackup(Backup backup, boolean forced) { } catch (OperationTimedoutException e) { throw new CloudRuntimeException("Operation to delete backup timed out, please try again"); } + if (answer == null || !answer.getResult()) { + logger.error("Failed to delete backup file for {} ({}); leaving DB row intact", + backup.getUuid(), backup.getExternalId()); + return false; + } + // Capture the deleted backup's bitmap before the row (and its backup_details) are removed. + String deletedBitmap = readDetail(backup, NASBackupChainKeys.BITMAP_NAME); + backupDao.remove(backup.getId()); + // If this backup owned the bitmap the VM's active_checkpoint_id points to, that on-host QEMU + // dirty-bitmap is gone with it — clear active_checkpoint_id so the next backup starts a fresh + // full chain instead of anchoring an incremental on a deleted checkpoint (test: delete last backup). + if (deletedBitmap != null && deletedBitmap.equals(readVmActiveCheckpoint(backup.getVmId()))) { + clearVmActiveCheckpoint(backup.getVmId()); + } + // Exactly-once resource accounting: decrement at the single point a backup row + file are + // physically removed. This runs for the leaf and for every swept delete-pending ancestor, + // so a chain delete decrements once per actually-removed backup. The manager skips its own + // accounting for this provider (see handlesChainDeleteResourceAccounting()). + long size = backup.getSize() != null ? backup.getSize() : 0L; + resourceLimitMgr.decrementResourceCount(backup.getAccountId(), Resource.ResourceType.backup); + resourceLimitMgr.decrementResourceCount(backup.getAccountId(), Resource.ResourceType.backup_storage, size); + return true; + } - if (answer != null && answer.getResult()) { - return backupDao.remove(backup.getId()); + /** + * Tombstone {@code backup} by moving it to {@link Backup.Status#Hidden}. Idempotent. + * The row stays in the DB so the chain GC can sweep it once its last descendant is deleted + * ({@code listByVmId} is status-agnostic), but it disappears from the user-facing list + * ({@link BackupManagerImpl#listBackups} filters Hidden) and all backup operations refuse it + * (they require {@code BackedUp}). Replaces the previous {@code nas.delete_pending} detail. + */ + private void markDeletePending(Backup backup) { + if (Backup.Status.Hidden.equals(backup.getStatus())) { + return; + } + BackupVO vo = backupDao.findById(backup.getId()); + if (vo != null) { + vo.setStatus(Backup.Status.Hidden); + backupDao.update(vo.getId(), vo); } + } + + /** + * @return true if this backup is a tombstone (Hidden) awaiting chain sweep. + */ + private boolean isDeletePending(Backup backup) { + return Backup.Status.Hidden.equals(backup.getStatus()); + } - logger.debug("There was an error removing the backup with id {}", backup.getId()); + /** + * Whether there are any live (not tombstoned/Hidden, not Removed) descendants of {@code backup} within the + * same chain. This uses {@code CHAIN_POSITION} to detect dependents deeper in the chain. + */ + private boolean hasLiveChildren(Backup backup) { + String chainId = readDetail(backup, NASBackupChainKeys.CHAIN_ID); + if (chainId == null) { + return false; + } + int position = chainPosition(backup); + for (Backup b : backupDao.listByVmId(null, backup.getVmId())) { + if (b.getId() == backup.getId() || !chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) { + continue; + } + if (!isDeletePending(b) && chainPosition(b) > position) { + return true; + } + } return false; } + /** + * Return the chain containing {@code member}, ordered leaf-first + * (highest {@code CHAIN_POSITION} → root). + * + *

Materialises the chain via a single {@link BackupDao#listByVmId} call. Callers that + * previously walked the chain by repeatedly calling {@link #findChainParent} were doing + * O(N) {@code listByVmId} calls (one per ancestor); this collapses that to one. + * + *

If {@code member} has no {@code CHAIN_ID} metadata it is returned as a one-element + * list (it is its own degenerate chain). + */ + private List getChainOrderedLeafToRoot(Backup member) { + String chainId = readDetail(member, NASBackupChainKeys.CHAIN_ID); + if (chainId == null) { + return Collections.singletonList(member); + } + List chain = new ArrayList<>(); + for (Backup b : backupDao.listByVmId(null, member.getVmId())) { + if (chainId.equals(readDetail(b, NASBackupChainKeys.CHAIN_ID))) { + chain.add(b); + } + } + // Descending CHAIN_POSITION = leaf-first (highest position = furthest from root). + chain.sort((a, b) -> Integer.compare(chainPosition(b), chainPosition(a))); + return chain; + } + + /** + * Physically delete the leaf {@code backup}, then walk up the chain while each ancestor + * is in Hidden state. Mirrors the snapshot subsystem pattern: once a leaf is + * gone, garbage-collect any tombstoned parents. + * + *

Caller must guarantee {@code backup} is a leaf (no live children). Each tombstoned + * ancestor is by definition childless once its sole child is deleted here, so no extra + * live-children check is needed inside the loop. + */ + private boolean deleteLeafBackupAndSweepPendingAncestors(Backup backup, BackupRepository repo, Host host) { + // Snapshot the chain BEFORE the leaf delete — deleteBackupFileAndRow removes the row, + // after which the in-memory list still resolves but the DB no longer would. + List chain = getChainOrderedLeafToRoot(backup); + if (!deleteBackupFileAndRow(backup, repo, host)) { + return false; + } + for (Backup member : chain) { + if (member.getId() == backup.getId()) { + continue; + } + if (!isDeletePending(member)) { + break; + } + deleteBackupFileAndRow(member, repo, host); + } + return true; + } + + /** + * Forced delete of {@code root}'s entire chain, leaf-first. NAS backups form a linear + * chain (full → inc → inc → …), not a tree, so we just walk the ordered chain and + * delete each member without re-querying parents. + */ + private boolean cascadeDeleteSubtree(Backup root, BackupRepository repo, Host host) { + for (Backup b : getChainOrderedLeafToRoot(root)) { + if (!deleteBackupFileAndRow(b, repo, host)) { + return false; + } + } + return true; + } + + private int chainPosition(Backup b) { + String s = readDetail(b, NASBackupChainKeys.CHAIN_POSITION); + if (s == null) { + return Integer.MAX_VALUE; // no metadata => sort to end + } + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return Integer.MAX_VALUE; + } + } + public void syncBackupMetrics(Long zoneId) { } @@ -543,6 +1164,11 @@ public boolean assignVMToBackupOffering(VirtualMachine vm, BackupOffering backup @Override public boolean removeVMFromBackupOffering(VirtualMachine vm) { + // Clear the VM's active checkpoint so any future re-assignment to a backup offering + // starts a fresh chain. Without this, a detach-volume + attach-different-volume cycle + // while the offering is unassigned would lead to the next backup trying to rebase + // onto a stale parent (different volume identity, same VM id). + clearVmActiveCheckpoint(vm.getId()); return true; } @@ -629,7 +1255,9 @@ public Boolean crossZoneInstanceCreationEnabled(BackupOffering backupOffering) { @Override public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ - NASBackupRestoreMountTimeout + NASBackupRestoreMountTimeout, + NASBackupFullEvery, + NASBackupIncrementalEnabled }; } diff --git a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java index a512292cd28f..f1d5613ab7fc 100644 --- a/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java +++ b/plugins/backup/nas/src/test/java/org/apache/cloudstack/backup/NASBackupProviderTest.java @@ -28,6 +28,7 @@ import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; @@ -38,22 +39,36 @@ import com.cloud.agent.AgentManager; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.OperationTimedoutException; +import com.cloud.configuration.Resource; import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.Status; import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.Hypervisor; import com.cloud.resource.ResourceManager; +import com.cloud.storage.DiskOfferingVO; +import com.cloud.storage.ScopeType; +import com.cloud.storage.Storage; import com.cloud.storage.Volume; import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.DiskOfferingDao; import com.cloud.storage.dao.VolumeDao; +import com.cloud.user.ResourceLimitService; import com.cloud.utils.Pair; +import com.cloud.utils.db.GlobalLock; +import com.cloud.vm.VMInstanceDetailVO; import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; + +import com.google.gson.Gson; import org.apache.cloudstack.backup.dao.BackupDao; +import org.apache.cloudstack.backup.dao.BackupDetailsDao; import org.apache.cloudstack.backup.dao.BackupRepositoryDao; import org.apache.cloudstack.backup.dao.BackupOfferingDao; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStoreManager; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; @@ -96,6 +111,21 @@ public class NASBackupProviderTest { @Mock private VMSnapshotDao vmSnapshotDaoMock; + @Mock + private BackupDetailsDao backupDetailsDao; + + @Mock + private VMInstanceDetailsDao vmInstanceDetailsDao; + + @Mock + private DiskOfferingDao diskOfferingDao; + + @Mock + private DataStoreManager dataStoreMgr; + + @Mock + private ResourceLimitService resourceLimitMgr; + @Test public void testDeleteBackup() throws OperationTimedoutException, AgentUnavailableException { Long hostId = 1L; @@ -221,7 +251,7 @@ public void takeBackupSuccessfully() throws AgentUnavailableException, Operation Mockito.when(backupDao.persist(Mockito.any(BackupVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); Mockito.when(backupDao.update(Mockito.anyLong(), Mockito.any(BackupVO.class))).thenReturn(true); - Pair result = nasBackupProvider.takeBackup(vm, false); + Pair result = nasBackupProvider.takeBackup(vm, false, false); Assert.assertTrue(result.first()); Assert.assertNotNull(result.second()); @@ -353,4 +383,655 @@ public void testGetVMHypervisorHostFallbackToZoneWideKVMHost() { Mockito.verify(hostDao).findHypervisorHostInCluster(clusterId); Mockito.verify(resourceManager).findOneRandomRunningHostByHypervisor(Hypervisor.HypervisorType.KVM, zoneId); } + + // -- nas.backup.incremental.enabled master switch ------------------------------------ + + /** + * When the operator sets nas.backup.incremental.enabled=false at the zone level, every + * backup must be a fresh full anchor, regardless of VM state or nas.backup.full.every. + * This is a single toggle the + * operator can flip without having to count remaining backups in a chain. + */ + @Test + public void decideChainReturnsLegacyFullWhenIncrementalDisabled() { + Long zoneId = 1L; + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.lenient().when(vm.getDataCenterId()).thenReturn(zoneId); + + // Stub the master switch to false. ConfigKey.valueIn delegates to the framework's + // ConfigDepot at runtime; for the unit test we override the in-memory value via the + // ConfigKey's local override (set by ReflectionTestUtils on the spy provider). + ReflectionTestUtils.setField(nasBackupProvider, "NASBackupIncrementalEnabled", + new org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Boolean.class, + "nas.backup.incremental.enabled", "false", + "test override — disabled", true, + org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone)); + + NASBackupProvider.ChainDecision decision = nasBackupProvider.decideChain(vm); + Assert.assertNotNull(decision); + Assert.assertEquals(NASBackupChainKeys.TYPE_LEGACY_FULL, decision.mode); + Assert.assertNull("legacy-full must not carry a bitmap", decision.bitmapNew); + Assert.assertNull(decision.bitmapParent); + Assert.assertNull("legacy-full must not start a chain", decision.chainId); + Assert.assertEquals(0, decision.chainPosition); + } + + // -- decideChain anchored on VM's active_checkpoint_id ------------------------------- + + /** + * No active_checkpoint_id on the VM (post-restore, first-ever backup, or detail purged) => + * decideChain must return a fresh full. Relying on the last backup taken as the parent + * breaks after a restore, so the decision is anchored on the active checkpoint instead. + */ + @Test + public void decideChainReturnsFullWhenVmHasNoActiveCheckpoint() { + Long zoneId = 1L; + Long vmId = 42L; + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getDataCenterId()).thenReturn(zoneId); + Mockito.when(vm.getState()).thenReturn(VMInstanceVO.State.Running); + + // Master switch defaults to false (opt-in by zone) — explicitly enable it for this + // test so we exercise the "no active_checkpoint_id" branch rather than short-circuit + // at the master-switch gate. + ReflectionTestUtils.setField(nasBackupProvider, "NASBackupIncrementalEnabled", + new org.apache.cloudstack.framework.config.ConfigKey<>("Advanced", Boolean.class, + "nas.backup.incremental.enabled", "true", + "test override — enabled", true, + org.apache.cloudstack.framework.config.ConfigKey.Scope.Zone)); + + Mockito.when(vmInstanceDetailsDao.findDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(null); + + NASBackupProvider.ChainDecision decision = nasBackupProvider.decideChain(vm); + Assert.assertNotNull(decision); + Assert.assertEquals(NASBackupChainKeys.TYPE_FULL, decision.mode); + Assert.assertNull(decision.bitmapParent); + Assert.assertEquals(0, decision.chainPosition); + } + + // -- incremental storage-capability guard (Ceph-RBD / Linstor stay on legacy full) ---- + + /** + * Incremental checkpoints are only possible on file-based qcow2 storage. A VM whose every + * volume sits on NFS / HOST-scope local / SharedMountPoint is checkpoint-capable. + */ + @Test + public void allVolumesOnCheckpointCapableStorageTrueForNfsHostAndSharedMount() { + Long vmId = 55L; + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + + VolumeVO nfsVol = mock(VolumeVO.class); + Mockito.when(nfsVol.getPoolId()).thenReturn(1L); + VolumeVO hostVol = mock(VolumeVO.class); + Mockito.when(hostVol.getPoolId()).thenReturn(2L); + VolumeVO smpVol = mock(VolumeVO.class); + Mockito.when(smpVol.getPoolId()).thenReturn(3L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(nfsVol, hostVol, smpVol)); + + StoragePoolVO nfs = mock(StoragePoolVO.class); + Mockito.when(nfs.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + StoragePoolVO host = mock(StoragePoolVO.class); + Mockito.when(host.getScope()).thenReturn(ScopeType.HOST); + StoragePoolVO smp = mock(StoragePoolVO.class); + Mockito.when(smp.getPoolType()).thenReturn(Storage.StoragePoolType.SharedMountPoint); + Mockito.when(storagePoolDao.findById(1L)).thenReturn(nfs); + Mockito.when(storagePoolDao.findById(2L)).thenReturn(host); + Mockito.when(storagePoolDao.findById(3L)).thenReturn(smp); + + Assert.assertTrue(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm)); + } + + /** + * A single volume on Ceph-RBD (or any pool that cannot carry a per-disk checkpoint) forces + * the whole VM onto the legacy full-only path — avoids regressing RBD/Linstor storages. + */ + @Test + public void allVolumesOnCheckpointCapableStorageFalseWhenAnyVolumeOnRbd() { + Long vmId = 56L; + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + + VolumeVO nfsVol = mock(VolumeVO.class); + Mockito.when(nfsVol.getPoolId()).thenReturn(1L); + VolumeVO rbdVol = mock(VolumeVO.class); + Mockito.when(rbdVol.getPoolId()).thenReturn(9L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(nfsVol, rbdVol)); + + StoragePoolVO nfs = mock(StoragePoolVO.class); + Mockito.when(nfs.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + StoragePoolVO rbd = mock(StoragePoolVO.class); + Mockito.when(rbd.getPoolType()).thenReturn(Storage.StoragePoolType.RBD); + Mockito.when(storagePoolDao.findById(1L)).thenReturn(nfs); + Mockito.when(storagePoolDao.findById(9L)).thenReturn(rbd); + + Assert.assertFalse(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm)); + } + + /** A volume whose storage pool can no longer be resolved is treated as incapable (safe). */ + @Test + public void allVolumesOnCheckpointCapableStorageFalseWhenPoolUnresolvable() { + Long vmId = 57L; + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + VolumeVO vol = mock(VolumeVO.class); + Mockito.when(vol.getPoolId()).thenReturn(1L); + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(List.of(vol)); + Mockito.when(storagePoolDao.findById(1L)).thenReturn(null); + + Assert.assertFalse(nasBackupProvider.allVolumesOnCheckpointCapableStorage(vm)); + } + + // -- restore clears active_checkpoint_id --------------------------------------------- + + /** + * After a successful restoreVMFromBackup, decideChain on the next backup must produce + * a full. We verify this end-to-end by checking that vmInstanceDetailsDao.removeDetail + * is called with the active_checkpoint_id key. + */ + @Test + public void restoreClearsActiveCheckpointDetail() throws AgentUnavailableException, OperationTimedoutException { + Long vmId = 7L; + Long hostId = 8L; + Long backupOfferingId = 9L; + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getId()).thenReturn(vmId); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + Mockito.when(vm.getRemoved()).thenReturn(null); + Mockito.when(vm.getName()).thenReturn("vm7"); + + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupVO backup = new BackupVO(); + backup.setVmId(vmId); + backup.setBackupOfferingId(backupOfferingId); + backup.setExternalId("i-2-7-VM/2026.05.16.10.00.00"); + ReflectionTestUtils.setField(backup, "id", 100L); + // backedUpVolumes defaults to null => BackupVO.getBackedUpVolumes returns emptyList(). + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(backupOfferingId)).thenReturn(repo); + + Mockito.when(volumeDao.findByInstance(vmId)).thenReturn(Collections.emptyList()); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(agentManager.send(Mockito.anyLong(), Mockito.any(RestoreBackupCommand.class))).thenReturn(answer); + + // Pre-existing checkpoint detail so removeDetail has something to "clear". + VMInstanceDetailVO existing = mock(VMInstanceDetailVO.class); + Mockito.when(existing.getValue()).thenReturn("backup-1715000000"); + Mockito.when(vmInstanceDetailsDao.findDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(existing); + + boolean ok = nasBackupProvider.restoreVMFromBackup(vm, backup, false, null); + Assert.assertTrue(ok); + Mockito.verify(vmInstanceDetailsDao).removeDetail(vmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + } + + /** + * Single-volume restore (restoreBackedUpVolume) must also clear the target VM's + * active_checkpoint_id, so the next backup of that VM is a fresh full — the restored + * volume's image carries no QEMU bitmap. + */ + @Test + public void restoreBackedUpVolumeClearsTargetVmActiveCheckpoint() + throws AgentUnavailableException, OperationTimedoutException { + Long targetVmId = 42L; + Long backupOfferingId = 9L; + String targetVmName = "i-2-42-VM"; + String volUuid = "vol-uuid-1"; + String hostIp = "10.0.0.5"; + String dsUuid = "ds-uuid-1"; + + VolumeVO srcVolume = mock(VolumeVO.class); + Mockito.when(srcVolume.getUuid()).thenReturn(volUuid); + Mockito.when(srcVolume.getName()).thenReturn("data1"); + Mockito.when(volumeDao.findByUuid(volUuid)).thenReturn(srcVolume); + + DiskOfferingVO diskOffering = mock(DiskOfferingVO.class); + Mockito.when(diskOffering.getId()).thenReturn(5L); + Mockito.when(diskOffering.getProvisioningType()).thenReturn(Storage.ProvisioningType.THIN); + Mockito.when(diskOfferingDao.findByUuid(Mockito.anyString())).thenReturn(diskOffering); + + StoragePoolVO pool = mock(StoragePoolVO.class); + Mockito.when(pool.getId()).thenReturn(11L); + Mockito.when(pool.getPoolType()).thenReturn(Storage.StoragePoolType.NetworkFilesystem); + Mockito.when(storagePoolDao.findByUuid(dsUuid)).thenReturn(pool); + + HostVO host = mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(8L); + Mockito.when(hostDao.findByIp(hostIp)).thenReturn(host); + + Backup.VolumeInfo backedUp = new Backup.VolumeInfo(volUuid, "i-2-99-VM/2026/data1.qcow2", + Volume.Type.DATADISK, 1024L, 1L, "disk-offering-uuid", null, null); + + BackupVO backup = new BackupVO(); + backup.setVmId(99L); + backup.setBackupOfferingId(backupOfferingId); + backup.setExternalId("i-2-99-VM/2026.06.22.10.00.00"); + backup.setSize(1024L); + backup.setBackedUpVolumes(new Gson().toJson(Collections.singletonList(backedUp))); + ReflectionTestUtils.setField(backup, "id", 200L); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(backupOfferingId)).thenReturn(repo); + + BackupAnswer answer = mock(BackupAnswer.class); + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(agentManager.send(Mockito.anyLong(), Mockito.any(RestoreBackupCommand.class))).thenReturn(answer); + + VMInstanceVO targetVm = mock(VMInstanceVO.class); + Mockito.when(targetVm.getId()).thenReturn(targetVmId); + Mockito.when(vmInstanceDao.findVMByInstanceName(targetVmName)).thenReturn(targetVm); + + VMInstanceDetailVO existing = mock(VMInstanceDetailVO.class); + Mockito.when(existing.getValue()).thenReturn("backup-1718000000"); + Mockito.when(vmInstanceDetailsDao.findDetail(targetVmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID)).thenReturn(existing); + + Pair result = nasBackupProvider.restoreBackedUpVolume( + backup, backedUp, hostIp, dsUuid, new Pair<>(targetVmName, VirtualMachine.State.Stopped), null, false); + + Assert.assertTrue(result.first()); + Mockito.verify(vmInstanceDetailsDao).removeDetail(targetVmId, NASBackupChainKeys.VM_ACTIVE_CHECKPOINT_ID); + } + + // -- delete-pending cascade ---------------------------------------------------------- + + /** + * Deleting an incremental that has a live child must mark the incremental as + * delete-pending in backup_details and NOT touch the on-NAS file or the backups row. + * A parent with live children is soft-deleted (delete-pending) rather than removed. + */ + @Test + public void deleteWithLiveChildMarksDeletePendingAndPreservesFile() + throws AgentUnavailableException, OperationTimedoutException { + Long zoneId = 1L; + Long vmId = 2L; + Long hostId = 3L; + Long offeringId = 4L; + + BackupVO parent = new BackupVO(); + parent.setVmId(vmId); + parent.setBackupOfferingId(offeringId); + parent.setExternalId("i-2-2-VM/2026.05.10.10.00.00"); + parent.setZoneId(zoneId); + ReflectionTestUtils.setField(parent, "id", 50L); + ReflectionTestUtils.setField(parent, "uuid", "parent-uuid"); + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + // Note: host.getId() is intentionally not stubbed — the live-child path never + // contacts the agent (verified below), so the stub would be unnecessary. + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo); + Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm); + + // CHAIN_ID on the parent => not the no-chain fast path. + BackupDetailVO chainIdDetail = new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_ID, "chain-1", true); + BackupDetailVO chainPosDetail = new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_POSITION, "0", true); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_ID)).thenReturn(chainIdDetail); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_POSITION)).thenReturn(chainPosDetail); + + // A live child sits deeper in the chain (higher CHAIN_POSITION). + BackupVO child = new BackupVO(); + child.setVmId(vmId); + child.setBackupOfferingId(offeringId); + child.setExternalId("i-2-2-VM/2026.05.10.10.30.00"); + child.setZoneId(zoneId); + child.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(child, "id", 51L); + ReflectionTestUtils.setField(child, "uuid", "child-uuid"); + + BackupDetailVO childChainId = new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_ID, "chain-1", true); + BackupDetailVO childChainPos = new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_POSITION, "1", true); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_ID)).thenReturn(childChainId); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_POSITION)).thenReturn(childChainPos); + + Mockito.when(backupDao.listByVmId(null, vmId)).thenReturn(List.of(parent, child)); + // Re-read under the chain lock + markDeletePending both load the row by id. + Mockito.when(backupDao.findById(50L)).thenReturn(parent); + Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId); + + boolean result = nasBackupProvider.deleteBackup(parent, false); + Assert.assertTrue(result); + + // No agent traffic — the on-NAS file must be preserved while children are alive. + Mockito.verify(agentManager, Mockito.never()).send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class)); + // No DB row removal — the row is the tombstone marker. + Mockito.verify(backupDao, Mockito.never()).remove(50L); + // A tombstoned backup is NOT decremented — its space is still occupied until swept. + Mockito.verify(resourceLimitMgr, Mockito.never()).decrementResourceCount(Mockito.anyLong(), Mockito.eq(Resource.ResourceType.backup)); + // The tombstoned backup is moved to Status.Hidden (replaces the old DELETE_PENDING detail). + ArgumentCaptor captor = ArgumentCaptor.forClass(BackupVO.class); + Mockito.verify(backupDao).update(Mockito.eq(50L), captor.capture()); + Assert.assertEquals(Backup.Status.Hidden, captor.getValue().getStatus()); + Mockito.verify(backupDetailsDao, Mockito.never()).persist(Mockito.any(BackupDetailVO.class)); + } + + /** + * Deleting a leaf incremental whose parent is delete-pending must (a) delete the leaf and + * then (b) sweep up the tombstoned parent. Mirrors DefaultSnapshotStrategy's + * "delete leaf, then walk up while parent is destroying-and-childless". + */ + @Test + public void deletingLeafSweepsUpDeletePendingParent() + throws AgentUnavailableException, OperationTimedoutException { + Long zoneId = 1L; + Long vmId = 2L; + Long hostId = 3L; + Long offeringId = 4L; + + BackupVO leaf = new BackupVO(); + leaf.setVmId(vmId); + leaf.setBackupOfferingId(offeringId); + leaf.setExternalId("i-2-2-VM/2026.05.10.11.00.00"); + leaf.setZoneId(zoneId); + ReflectionTestUtils.setField(leaf, "id", 51L); + ReflectionTestUtils.setField(leaf, "uuid", "leaf-uuid"); + + BackupVO parent = new BackupVO(); + parent.setVmId(vmId); + parent.setBackupOfferingId(offeringId); + parent.setExternalId("i-2-2-VM/2026.05.10.10.30.00"); + parent.setZoneId(zoneId); + ReflectionTestUtils.setField(parent, "id", 50L); + ReflectionTestUtils.setField(parent, "uuid", "parent-uuid"); + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo); + Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm); + + // Leaf details. CHAIN_POSITION=1 puts the leaf after the full anchor in the + // ordered chain — getChainOrderedLeafToRoot sorts by CHAIN_POSITION descending. + BackupDetailVO leafChainId = new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_ID, "chain-1", true); + BackupDetailVO leafChainPos = new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_POSITION, "1", true); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_ID)).thenReturn(leafChainId); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_POSITION)).thenReturn(leafChainPos); + + // Parent is the tombstoned full anchor (CHAIN_POSITION=0). + BackupDetailVO parentChainId = new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_ID, "chain-1", true); + BackupDetailVO parentChainPos = new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_POSITION, "0", true); + // The parent is the tombstone — now represented by Status.Hidden (was the DELETE_PENDING detail). + parent.setStatus(Backup.Status.Hidden); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_ID)).thenReturn(parentChainId); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_POSITION)).thenReturn(parentChainPos); + + // We still use a mutable list + remove() answer so the DAO contract is realistic. + java.util.List liveBackups = new java.util.ArrayList<>(List.of(parent, leaf)); + Mockito.when(backupDao.listByVmId(null, vmId)).thenAnswer(inv -> new java.util.ArrayList<>(liveBackups)); + // The target row is re-read under the chain lock before any chain decision. + Mockito.when(backupDao.findById(51L)).thenReturn(leaf); + Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId); + + // Agent acknowledges every delete. + Mockito.when(agentManager.send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class))) + .thenReturn(new BackupAnswer(new DeleteBackupCommand(null, null, null, null), true, "ok")); + // backupDao.remove(id) drops the corresponding row from the live list so the next + // listByVmId call reflects post-delete state — mirrors the real DAO contract. + Mockito.when(backupDao.remove(Mockito.anyLong())).thenAnswer(inv -> { + Long id = inv.getArgument(0); + liveBackups.removeIf(b -> b.getId() == id); + return true; + }); + + boolean result = nasBackupProvider.deleteBackup(leaf, false); + Assert.assertTrue(result); + + // Both backups must be physically deleted (leaf first, then tombstoned parent). + Mockito.verify(agentManager, Mockito.times(2)) + .send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class)); + Mockito.verify(backupDao).remove(51L); + Mockito.verify(backupDao).remove(50L); + // Exactly-once resource accounting: decremented for BOTH physically-removed backups + // (leaf + swept ancestor), not just one. + Mockito.verify(resourceLimitMgr, Mockito.times(2)) + .decrementResourceCount(Mockito.anyLong(), Mockito.eq(Resource.ResourceType.backup)); + Mockito.verify(resourceLimitMgr, Mockito.times(2)) + .decrementResourceCount(Mockito.anyLong(), Mockito.eq(Resource.ResourceType.backup_storage), Mockito.any()); + } + + @Test + public void deletingLastLiveMemberCollectsDeeperOrphanTombstones() + throws AgentUnavailableException, OperationTimedoutException { + Long zoneId = 1L; + Long vmId = 2L; + Long hostId = 3L; + Long offeringId = 4L; + + BackupVO full = new BackupVO(); + full.setVmId(vmId); + full.setBackupOfferingId(offeringId); + full.setExternalId("i-2-2-VM/2026.05.10.10.00.00"); + full.setZoneId(zoneId); + full.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(full, "id", 50L); + + // The last live incremental — the one being deleted. + BackupVO inc1 = new BackupVO(); + inc1.setVmId(vmId); + inc1.setBackupOfferingId(offeringId); + inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00"); + inc1.setZoneId(zoneId); + inc1.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(inc1, "id", 51L); + + // A stranded tombstone deeper in the chain: its own descendants are long gone, so + // only a sweep triggered by an ancestor's deletion can ever collect it. + BackupVO orphan = new BackupVO(); + orphan.setVmId(vmId); + orphan.setBackupOfferingId(offeringId); + orphan.setExternalId("i-2-2-VM/2026.05.10.11.00.00"); + orphan.setZoneId(zoneId); + orphan.setStatus(Backup.Status.Hidden); + ReflectionTestUtils.setField(orphan, "id", 52L); + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo); + Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm); + + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_POSITION, "0", true)); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_POSITION, "1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_POSITION, "2", true)); + + Mockito.when(backupDao.listByVmId(null, vmId)).thenReturn(List.of(full, inc1, orphan)); + Mockito.when(backupDao.findById(51L)).thenReturn(inc1); + Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId); + + Mockito.when(agentManager.send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class))) + .thenReturn(new BackupAnswer(new DeleteBackupCommand(null, null, null, null), true, "ok")); + + Assert.assertTrue(nasBackupProvider.deleteBackup(inc1, false)); + + // inc1 and the deeper orphan tombstone are physically removed; the live full survives. + Mockito.verify(agentManager, Mockito.times(2)) + .send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class)); + Mockito.verify(backupDao).remove(51L); + Mockito.verify(backupDao).remove(52L); + Mockito.verify(backupDao, Mockito.never()).remove(50L); + } + + @Test + public void deletingAncestorOfTombstoneWithLiveDescendantTombstonesIt() + throws AgentUnavailableException, OperationTimedoutException { + Long zoneId = 1L; + Long vmId = 2L; + Long hostId = 3L; + Long offeringId = 4L; + + BackupVO full = new BackupVO(); + full.setVmId(vmId); + full.setBackupOfferingId(offeringId); + full.setExternalId("i-2-2-VM/2026.05.10.10.00.00"); + full.setZoneId(zoneId); + full.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(full, "id", 50L); + + BackupVO inc1 = new BackupVO(); + inc1.setVmId(vmId); + inc1.setBackupOfferingId(offeringId); + inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00"); + inc1.setZoneId(zoneId); + inc1.setStatus(Backup.Status.Hidden); + ReflectionTestUtils.setField(inc1, "id", 51L); + + BackupVO inc2 = new BackupVO(); + inc2.setVmId(vmId); + inc2.setBackupOfferingId(offeringId); + inc2.setExternalId("i-2-2-VM/2026.05.10.11.00.00"); + inc2.setZoneId(zoneId); + inc2.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(inc2, "id", 52L); + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo); + Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm); + + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_POSITION, "0", true)); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_POSITION, "2", true)); + + Mockito.when(backupDao.listByVmId(null, vmId)).thenReturn(List.of(full, inc1, inc2)); + Mockito.when(backupDao.findById(50L)).thenReturn(full); + Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId); + + Assert.assertTrue(nasBackupProvider.deleteBackup(full, false)); + + // The full anchor must NOT be physically deleted — inc2 (live) still restores + // through inc1's and full's files. It becomes a tombstone instead. + Mockito.verify(agentManager, Mockito.never()).send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class)); + Mockito.verify(backupDao, Mockito.never()).remove(Mockito.anyLong()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BackupVO.class); + Mockito.verify(backupDao).update(Mockito.eq(50L), captor.capture()); + Assert.assertEquals(Backup.Status.Hidden, captor.getValue().getStatus()); + } + + @Test + public void sweepContinuesPastFailedTombstoneDelete() + throws AgentUnavailableException, OperationTimedoutException { + Long zoneId = 1L; + Long vmId = 2L; + Long hostId = 3L; + Long offeringId = 4L; + + BackupVO full = new BackupVO(); + full.setVmId(vmId); + full.setBackupOfferingId(offeringId); + full.setExternalId("i-2-2-VM/2026.05.10.10.00.00"); + full.setZoneId(zoneId); + full.setStatus(Backup.Status.Hidden); + ReflectionTestUtils.setField(full, "id", 50L); + + BackupVO inc1 = new BackupVO(); + inc1.setVmId(vmId); + inc1.setBackupOfferingId(offeringId); + inc1.setExternalId("i-2-2-VM/2026.05.10.10.30.00"); + inc1.setZoneId(zoneId); + inc1.setStatus(Backup.Status.Hidden); + ReflectionTestUtils.setField(inc1, "id", 51L); + + // The last live member — deleting it triggers the sweep of both tombstones. + BackupVO inc2 = new BackupVO(); + inc2.setVmId(vmId); + inc2.setBackupOfferingId(offeringId); + inc2.setExternalId("i-2-2-VM/2026.05.10.11.00.00"); + inc2.setZoneId(zoneId); + inc2.setStatus(Backup.Status.BackedUp); + ReflectionTestUtils.setField(inc2, "id", 52L); + + VMInstanceVO vm = mock(VMInstanceVO.class); + Mockito.when(vm.getLastHostId()).thenReturn(hostId); + HostVO host = mock(HostVO.class); + Mockito.when(host.getStatus()).thenReturn(Status.Up); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.when(hostDao.findById(hostId)).thenReturn(host); + + BackupRepositoryVO repo = new BackupRepositoryVO(1L, "nas", "test-repo", + "nfs", "address", "sync", 1024L, null); + Mockito.when(backupRepositoryDao.findByBackupOfferingId(offeringId)).thenReturn(repo); + Mockito.when(vmInstanceDao.findByIdIncludingRemoved(vmId)).thenReturn(vm); + + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(50L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(50L, NASBackupChainKeys.CHAIN_POSITION, "0", true)); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(51L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(51L, NASBackupChainKeys.CHAIN_POSITION, "1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_ID)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_ID, "chain-1", true)); + Mockito.when(backupDetailsDao.findDetail(52L, NASBackupChainKeys.CHAIN_POSITION)) + .thenReturn(new BackupDetailVO(52L, NASBackupChainKeys.CHAIN_POSITION, "2", true)); + + Mockito.when(backupDao.listByVmId(null, vmId)).thenReturn(List.of(full, inc1, inc2)); + Mockito.when(backupDao.findById(52L)).thenReturn(inc2); + Mockito.doReturn(mock(GlobalLock.class)).when(nasBackupProvider).acquireChainDeleteLock(vmId); + + // Deletes run leaf-to-root: inc2 succeeds, inc1's rm FAILS, full succeeds. + DeleteBackupCommand dummy = new DeleteBackupCommand(null, null, null, null); + Mockito.when(agentManager.send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class))) + .thenReturn(new BackupAnswer(dummy, true, "ok"), + new BackupAnswer(dummy, false, "rm failed"), + new BackupAnswer(dummy, true, "ok")); + + Assert.assertTrue(nasBackupProvider.deleteBackup(inc2, false)); + + // All three members were attempted; the failed inc1 keeps its row, full is collected. + Mockito.verify(agentManager, Mockito.times(3)) + .send(Mockito.anyLong(), Mockito.any(DeleteBackupCommand.class)); + Mockito.verify(backupDao).remove(52L); + Mockito.verify(backupDao, Mockito.never()).remove(51L); + Mockito.verify(backupDao).remove(50L); + } } diff --git a/plugins/backup/networker/src/main/java/org/apache/cloudstack/backup/NetworkerBackupProvider.java b/plugins/backup/networker/src/main/java/org/apache/cloudstack/backup/NetworkerBackupProvider.java index 4cf4bd111ef1..1cf962edae51 100644 --- a/plugins/backup/networker/src/main/java/org/apache/cloudstack/backup/NetworkerBackupProvider.java +++ b/plugins/backup/networker/src/main/java/org/apache/cloudstack/backup/NetworkerBackupProvider.java @@ -357,7 +357,7 @@ public boolean removeVMFromBackupOffering(VirtualMachine vm) { } @Override - public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup) { + public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { String networkerServer; HostVO hostVO; @@ -407,7 +407,8 @@ public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup) { } @Override - public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, Pair vmNameAndState) { + public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore) { String networkerServer; VolumeVO volume = volumeDao.findByUuid(backupVolumeInfo.getUuid()); final DiskOffering diskOffering = diskOfferingDao.findByUuid(backupVolumeInfo.getDiskOfferingId()); @@ -491,7 +492,7 @@ public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeI } @Override - public Pair takeBackup(VirtualMachine vm, Boolean quiesceVM) { + public Pair takeBackup(VirtualMachine vm, Boolean quiesceVM, boolean isolated) { String networkerServer; String clusterName; @@ -648,7 +649,7 @@ public void syncBackupStorageStats(Long zoneId) { public boolean willDeleteBackupsOnOfferingRemoval() { return false; } @Override - public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid) { + public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickrestore) { return new Pair<>(true, null); } } diff --git a/plugins/backup/veeam/src/main/java/org/apache/cloudstack/backup/VeeamBackupProvider.java b/plugins/backup/veeam/src/main/java/org/apache/cloudstack/backup/VeeamBackupProvider.java index 39970dab3427..9b34af2d6f49 100644 --- a/plugins/backup/veeam/src/main/java/org/apache/cloudstack/backup/VeeamBackupProvider.java +++ b/plugins/backup/veeam/src/main/java/org/apache/cloudstack/backup/VeeamBackupProvider.java @@ -219,7 +219,7 @@ public boolean willDeleteBackupsOnOfferingRemoval() { } @Override - public Pair takeBackup(final VirtualMachine vm, Boolean quiesceVM) { + public Pair takeBackup(final VirtualMachine vm, Boolean quiesceVM, boolean isolated) { final VeeamClient client = getClient(vm.getDataCenterId()); Boolean result = client.startBackupJob(vm.getBackupExternalId()); return new Pair<>(result, null); @@ -256,7 +256,7 @@ public boolean deleteBackup(Backup backup, boolean forced) { } @Override - public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup) { + public boolean restoreVMFromBackup(VirtualMachine vm, Backup backup, boolean quickRestore, Long hostId) { final String restorePointId = backup.getExternalId(); try { return getClient(vm.getDataCenterId()).restoreFullVM(vm.getInstanceName(), restorePointId); @@ -291,7 +291,8 @@ private void prepareForBackupRestoration(VirtualMachine vm) { } @Override - public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, Pair vmNameAndState) { + public Pair restoreBackedUpVolume(Backup backup, Backup.VolumeInfo backupVolumeInfo, String hostIp, String dataStoreUuid, + Pair vmNameAndState, VirtualMachine vm, boolean quickRestore) { final Long zoneId = backup.getZoneId(); final String restorePointId = backup.getExternalId(); return getClient(zoneId).restoreVMToDifferentLocation(restorePointId, null, hostIp, dataStoreUuid); @@ -337,7 +338,7 @@ public List listRestorePoints(VirtualMachine vm) { } @Override - public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid) { + public Pair restoreBackupToVM(VirtualMachine vm, Backup backup, String hostIp, String dataStoreUuid, boolean quickrestore) { final Long zoneId = backup.getZoneId(); final String restorePointId = backup.getExternalId(); final String restoreLocation = vm.getInstanceName(); diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java index a6d1db41ddd2..0c99efd9a2d2 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsCmd.java @@ -21,14 +21,13 @@ import org.apache.cloudstack.api.ACL; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; -import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.QuotaCreditsResponse; import org.apache.cloudstack.api.response.QuotaResponseBuilder; -import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.quota.QuotaService; import javax.inject.Inject; @@ -42,22 +41,35 @@ public class QuotaCreditsCmd extends BaseCmd { @Inject QuotaService _quotaService; - - - @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, required = true, description = "Account Id for which quota credits need to be added") + @Deprecated + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, description = "Name of the Account for which Quota credits will be added. Deprecated, please use '" + + ApiConstants.ACCOUNT_ID + "' instead.") private String accountName; @ACL - @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, required = true, entityType = DomainResponse.class, description = "Domain for which quota credits need to be added") + @Deprecated + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "Domain of the Account specified by '" + ApiConstants.ACCOUNT + "' for which Quota credits will be added. " + + "Deprecated, please use '" + ApiConstants.ACCOUNT_ID + "' instead.") private Long domainId; - @Parameter(name = ApiConstants.VALUE, type = CommandType.DOUBLE, required = true, description = "Value of the credits to be added+, subtracted-") + @ACL + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "ID of the Account for which Quota credits will be added. Cannot be specified with '" + ApiConstants.PROJECT_ID + "'.") + private Long accountId; + + @ACL + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "ID of the Project for which Quota credits will be added. Cannot be specified with '" + ApiConstants.ACCOUNT_ID + "'.") + private Long projectId; + + @Parameter(name = ApiConstants.VALUE, type = CommandType.DOUBLE, required = true, description = "Amount of credits to be added (in case of a positive value) or subtracted (in case of a negative value).") private Double value; - @Parameter(name = "min_balance", type = CommandType.DOUBLE, required = false, description = "Minimum balance threshold of the Account") + @Parameter(name = "min_balance", type = CommandType.DOUBLE, description = "An email will be sent to the Account when the Quota credits get below this threshold.") private Double minBalance; - @Parameter(name = "quota_enforce", type = CommandType.BOOLEAN, required = false, description = "Account for which quota enforce is set to false will not be locked when there is no credit balance") + @Parameter(name = "quota_enforce", type = CommandType.BOOLEAN, description = "Whether to lock the Account when Quota credits are below zero.") private Boolean quotaEnforce; public Double getMinBalance() { @@ -100,31 +112,21 @@ public void setValue(Double value) { this.value = value; } + public Long getAccountId() { + return accountId; + } + + public Long getProjectId() { + return projectId; + } + public QuotaCreditsCmd() { super(); } @Override public void execute() { - Long accountId = null; - Account account = _accountService.getActiveAccountByName(accountName, domainId); - if (account != null) { - accountId = account.getAccountId(); - } - if (accountId == null) { - throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "The Account does not exists or has been removed/disabled"); - } - if (getValue() == null) { - throw new ServerApiException(ApiErrorCode.PARAM_ERROR, "Please send a valid non-empty quota value"); - } - if (getQuotaEnforce() != null) { - _quotaService.setLockAccount(accountId, getQuotaEnforce()); - } - if (getMinBalance() != null) { - _quotaService.setMinBalance(accountId, getMinBalance()); - } - - final QuotaCreditsResponse response = _responseBuilder.addQuotaCredits(accountId, getDomainId(), getValue(), CallContext.current().getCallingUserId(), getQuotaEnforce()); + QuotaCreditsResponse response = _responseBuilder.addQuotaCredits(this); response.setResponseName(getCommandName()); response.setObjectName("quotacredits"); setResponseObject(response); @@ -132,10 +134,6 @@ public void execute() { @Override public long getEntityOwnerId() { - Account account = _accountService.getActiveAccountByName(accountName, domainId); - if (account != null) { - return account.getAccountId(); - } return Account.ACCOUNT_ID_SYSTEM; } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsListCmd.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsListCmd.java index 48bb7ef79e70..7555923600d9 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsListCmd.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaCreditsListCmd.java @@ -18,7 +18,7 @@ import com.cloud.utils.Pair; -import org.apache.cloudstack.api.ACL; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseCmd; @@ -26,8 +26,10 @@ import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.QuotaCreditsResponse; import org.apache.cloudstack.api.response.QuotaResponseBuilder; +import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.time.DateUtils; @@ -44,13 +46,16 @@ public class QuotaCreditsListCmd extends BaseCmd { @Inject QuotaResponseBuilder quotaResponseBuilder; - @ACL - @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, description = "ID of the account for which the credit statement will be generated.") + @Parameter(name = ApiConstants.ACCOUNT_ID, type = CommandType.UUID, entityType = AccountResponse.class, + description = "ID of the Account for which the credit statement will be generated. Cannot be specified with '" + ApiConstants.PROJECT_ID + "'.") private Long accountId; - @ACL - @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "ID of the domain for which credit statement will be generated. " + - "Available only for administrators.") + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "ID of the Project for which the credit statement will be generated. Cannot be specified with '" + ApiConstants.ACCOUNT_ID + "'.") + private Long projectId; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, description = "ID of the Domain for which credit statement will be generated. " + + "Available only for administrators.", authorized = {RoleType.Admin, RoleType.DomainAdmin}) private Long domainId; @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, description = "End date of the credit statement. If not provided, the current date will be " + @@ -97,14 +102,18 @@ public void setStartDate(Date startDate) { this.startDate = startDate; } - public Boolean getRecursive() { - return recursive; + public boolean isRecursive() { + return BooleanUtils.isTrue(recursive); } public void setRecursive(Boolean recursive) { this.recursive = recursive; } + public Long getProjectId() { + return projectId; + } + @Override public void execute() { Pair, Integer> responses = quotaResponseBuilder.createQuotaCreditsListResponse(this); @@ -116,7 +125,10 @@ public void execute() { @Override public long getEntityOwnerId() { - return -1; + if (ObjectUtils.allNull(accountId, projectId)) { + return -1; + } + return _accountService.finalizeAccountId(accountId, null, null, projectId); } } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaSummaryCmd.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaSummaryCmd.java index 870b9b6df6e5..89ab96ebb9f8 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaSummaryCmd.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/command/QuotaSummaryCmd.java @@ -116,6 +116,9 @@ public void setListAll(Boolean listAll) { } public Long getProjectId() { + if (projectId == null || projectId == -1L) { + return null; + } return projectId; } @@ -129,9 +132,10 @@ public QuotaAccountStateFilter getAccountStateToShow() { @Override public long getEntityOwnerId() { - if (ObjectUtils.allNull(accountId, accountName, projectId)) { + Long convertedProjectId = getProjectId(); + if (ObjectUtils.allNull(accountId, accountName, convertedProjectId)) { return -1; } - return _accountService.finalizeAccountId(accountId, accountName, domainId, projectId); + return _accountService.finalizeAccountId(accountId, accountName, domainId, convertedProjectId); } } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java index 63bf043f4fa9..c1a677da9355 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilder.java @@ -19,6 +19,7 @@ import com.cloud.user.User; import org.apache.cloudstack.api.command.QuotaBalanceCmd; import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd; +import org.apache.cloudstack.api.command.QuotaCreditsCmd; import org.apache.cloudstack.api.command.QuotaCreditsListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd; @@ -54,7 +55,7 @@ public interface QuotaResponseBuilder { Pair, Integer> createQuotaSummaryResponse(QuotaSummaryCmd cmd); - QuotaCreditsResponse addQuotaCredits(Long accountId, Long domainId, Double amount, Long updatedBy, Boolean enforce); + QuotaCreditsResponse addQuotaCredits(QuotaCreditsCmd cmd); List listQuotaEmailTemplates(QuotaEmailTemplateListCmd cmd); diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java index b71057e64234..6727ab58141f 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImpl.java @@ -44,13 +44,23 @@ import com.cloud.event.ActionEvent; import com.cloud.event.EventTypes; import com.cloud.exception.InvalidParameterValueException; -import com.cloud.exception.PermissionDeniedException; +import com.cloud.network.VpnUserVO; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.LoadBalancerDao; +import com.cloud.network.dao.LoadBalancerVO; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.VpnUserDao; +import com.cloud.network.rules.PortForwardingRuleVO; +import com.cloud.network.rules.dao.PortForwardingRulesDao; +import com.cloud.network.security.SecurityGroupVO; +import com.cloud.network.security.dao.SecurityGroupDao; +import com.cloud.network.vpc.VpcVO; import com.cloud.offerings.dao.NetworkOfferingDao; import com.cloud.offerings.NetworkOfferingVO; +import com.cloud.storage.BucketVO; +import com.cloud.storage.dao.BucketDao; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.dao.SnapshotDao; @@ -67,7 +77,9 @@ import com.cloud.utils.DateUtil; import com.cloud.utils.Pair; import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.TransactionLegacy; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.Ip; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.dao.VMInstanceDao; import org.apache.cloudstack.acl.ControlledEntity; @@ -77,6 +89,7 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.QuotaBalanceCmd; import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd; +import org.apache.cloudstack.api.command.QuotaCreditsCmd; import org.apache.cloudstack.api.command.QuotaCreditsListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd; @@ -114,6 +127,7 @@ import org.apache.cloudstack.quota.dao.QuotaTariffUsageDao; import org.apache.cloudstack.quota.dao.QuotaUsageDao; import org.apache.cloudstack.quota.dao.QuotaUsageJoinDao; +import org.apache.cloudstack.quota.dao.VpcDao; import org.apache.cloudstack.quota.vo.QuotaAccountVO; import org.apache.cloudstack.quota.vo.QuotaBalanceVO; import org.apache.cloudstack.quota.vo.QuotaCreditsVO; @@ -180,6 +194,8 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { @Inject private NetworkDao networkDao; @Inject + private VpcDao vpcDao; + @Inject private NetworkOfferingDao networkOfferingDao; @Inject private SnapshotDao snapshotDao; @@ -190,6 +206,16 @@ public class QuotaResponseBuilderImpl implements QuotaResponseBuilder { @Inject private VolumeDao volumeDao; @Inject + private BucketDao bucketDao; + @Inject + private VpnUserDao vpnUserDao; + @Inject + private LoadBalancerDao loadBalancerDao; + @Inject + private PortForwardingRulesDao portForwardingRulesDao; + @Inject + private SecurityGroupDao securityGroupDao; + @Inject private QuotaUsageJoinDao quotaUsageJoinDao; @Inject private QuotaTariffUsageDao quotaTariffUsageDao; @@ -412,45 +438,52 @@ protected QuotaStatementItemResponse createStatementItem(int usageType, List history = createQuotaConsumptionHistory(usageRecords, quotaUsed); + item.setHistory(history); + } + return item; } - protected void setStatementItemResources(QuotaStatementItemResponse statementItem, int usageType, List quotaUsageRecords, boolean showResources) { - if (!showResources) { - return; - } - + protected void setStatementItemResources(QuotaStatementItemResponse statementItem, int usageType, List quotaUsageRecords) { List itemDetails = new ArrayList<>(); - Map quotaUsagesValuesAggregatedById = quotaUsageRecords + Map> quotaUsagesAggregatedByResourceId = quotaUsageRecords .stream() .filter(quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType) != null) - .collect(Collectors.groupingBy( - quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType), - Collectors.reducing(new BigDecimal(0), QuotaUsageJoinVO::getQuotaUsed, BigDecimal::add) + .collect(Collectors.groupingBy(quotaUsageJoinVo -> getResourceIdByUsageType(quotaUsageJoinVo, usageType) )); - for (Map.Entry entry : quotaUsagesValuesAggregatedById.entrySet()) { - QuotaStatementItemResourceResponse detail = new QuotaStatementItemResourceResponse(); - - detail.setQuotaUsed(entry.getValue()); - + for (Map.Entry> entry : quotaUsagesAggregatedByResourceId.entrySet()) { QuotaUsageResourceVO resource = getResourceFromIdAndType(entry.getKey(), usageType); + + QuotaStatementItemResourceResponse detail = new QuotaStatementItemResourceResponse(); if (resource != null) { detail.setResourceId(resource.getUuid()); detail.setDisplayName(resource.getName()); detail.setRemoved(resource.isRemoved()); } else { detail.setDisplayName(""); - } + BigDecimal quotaUsed = entry.getValue().stream() + .map(QuotaUsageJoinVO::getQuotaUsed) + .reduce(BigDecimal.ZERO, BigDecimal::add); + List history = createQuotaConsumptionHistory(entry.getValue(), quotaUsed); + detail.setQuotaUsed(quotaUsed); + detail.setHistory(history); + itemDetails.add(detail); } + statementItem.setResources(itemDetails); } @@ -470,6 +503,7 @@ protected QuotaUsageResourceVO getResourceFromIdAndType(long resourceId, int usa switch (usageType) { case QuotaTypes.ALLOCATED_VM: case QuotaTypes.RUNNING_VM: + case QuotaTypes.BACKUP: VMInstanceVO vmInstance = vmInstanceDao.findByIdIncludingRemoved(resourceId); if (vmInstance != null) { return new QuotaUsageResourceVO(vmInstance.getUuid(), vmInstance.getHostName(), vmInstance.getRemoved()); @@ -496,11 +530,18 @@ protected QuotaUsageResourceVO getResourceFromIdAndType(long resourceId, int usa break; case QuotaTypes.NETWORK_BYTES_SENT: case QuotaTypes.NETWORK_BYTES_RECEIVED: + case QuotaTypes.NETWORK: NetworkVO network = networkDao.findByIdIncludingRemoved(resourceId); if (network != null) { return new QuotaUsageResourceVO(network.getUuid(), network.getName(), network.getRemoved()); } break; + case QuotaTypes.VPC: + VpcVO vpc = vpcDao.findByIdIncludingRemoved(resourceId); + if (vpc != null) { + return new QuotaUsageResourceVO(vpc.getUuid(), vpc.getName(), vpc.getRemoved()); + } + break; case QuotaTypes.TEMPLATE: case QuotaTypes.ISO: VMTemplateVO vmTemplate = vmTemplateDao.findByIdIncludingRemoved(resourceId); @@ -520,10 +561,76 @@ protected QuotaUsageResourceVO getResourceFromIdAndType(long resourceId, int usa return new QuotaUsageResourceVO(ipAddress.getUuid(), ipAddress.getName(), ipAddress.getRemoved()); } break; + case QuotaTypes.BUCKET: + BucketVO bucket = bucketDao.findByIdIncludingRemoved(resourceId); + if (bucket != null) { + return new QuotaUsageResourceVO(bucket.getUuid(), bucket.getName(), bucket.getRemoved()); + } + break; + case QuotaTypes.VPN_USERS: + VpnUserVO vpnUser = vpnUserDao.findByIdIncludingRemoved(resourceId); + if (vpnUser != null) { + return new QuotaUsageResourceVO(vpnUser.getUuid(), vpnUser.getUsername(), null); + } + break; + case QuotaTypes.SECURITY_GROUP: + SecurityGroupVO securityGroup = securityGroupDao.findByIdIncludingRemoved(resourceId); + if (securityGroup != null) { + return new QuotaUsageResourceVO(securityGroup.getUuid(), securityGroup.getName(), null); + } + break; + case QuotaTypes.LOAD_BALANCER_POLICY: + LoadBalancerVO loadBalancer = loadBalancerDao.findByIdIncludingRemoved(resourceId); + if (loadBalancer != null) { + return new QuotaUsageResourceVO(loadBalancer.getUuid(), loadBalancer.getName(), loadBalancer.getRemoved()); + } + break; + case QuotaTypes.PORT_FORWARDING_RULE: + PortForwardingRuleVO portForwardingRule = portForwardingRulesDao.findByIdIncludingRemoved(resourceId); + if (portForwardingRule == null) { + return null; + } + IPAddressVO source = ipAddressDao.findByIdIncludingRemoved(portForwardingRule.getSourceIpAddressId()); + Ip destination = portForwardingRule.getDestinationIpAddress(); + if (ObjectUtils.anyNull(source, destination)) { + return null; + } + String displayName = String.format("%s:%s-%s to %s:%s-%s", source.getAddress(), portForwardingRule.getSourcePortStart(), + portForwardingRule.getSourcePortEnd(), destination, portForwardingRule.getDestinationPortStart(), + portForwardingRule.getDestinationPortEnd()); + return new QuotaUsageResourceVO(portForwardingRule.getUuid(), displayName, portForwardingRule.getRemoved()); } return null; } + protected List createQuotaConsumptionHistory(List quotaUsage, BigDecimal quotaUsed) { + if (quotaUsed.equals(BigDecimal.ZERO)) { + logger.debug("Not generating Quota consumption history because the item has not consumed any Quota in the period."); + return null; + } + + Map history = new HashMap<>(); + for (QuotaUsageJoinVO record : quotaUsage) { + if (ObjectUtils.anyNull(record.getUsageItemId(), record.getQuotaUsed())) { + continue; + } + + QuotaStatementItemHistoryResponse item = history.computeIfAbsent( + record.getEndDate(), + key -> new QuotaStatementItemHistoryResponse() + ); + if (item.getStartDate() == null || item.getStartDate().after(record.getStartDate())) { + item.setStartDate(record.getStartDate()); + } + item.setEndDate(record.getEndDate()); + item.setQuotaConsumed(item.getQuotaConsumed().add(record.getQuotaUsed())); + + history.put(record.getEndDate(), item); + } + + return history.values().stream().sorted(Comparator.comparing(QuotaStatementItemHistoryResponse::getEndDate)).collect(Collectors.toList()); + } + @Override public Pair, Integer> listQuotaTariffPlans(final QuotaTariffListCmd cmd) { Date startDate = cmd.getEffectiveDate(); @@ -661,49 +768,88 @@ protected void validateEndDateOnCreatingNewQuotaTariff(QuotaTariffVO newQuotaTar } @Override - public QuotaCreditsResponse addQuotaCredits(Long accountId, Long domainId, Double amount, Long updatedBy, Boolean enforce) { - Date depositedOn = new Date(); - QuotaBalanceVO qb = _quotaBalanceDao.findLaterBalanceEntry(accountId, domainId, depositedOn); + public QuotaCreditsResponse addQuotaCredits(QuotaCreditsCmd cmd) { + Double value = cmd.getValue(); + if (value == null) { + throw new InvalidParameterValueException("Please specify a valid amount of credits."); + } - if (qb != null) { - throw new InvalidParameterValueException(String.format("Incorrect deposit date [%s], as there are balance entries after this date.", - depositedOn)); + Long accountId = _accountMgr.finalizeAccountId(cmd.getAccountId(), cmd.getAccountName(), cmd.getDomainId(), cmd.getProjectId()); + AccountVO account = _accountDao.findById(accountId); + Long domainId = account.getDomainId(); + + Date depositedOn = new Date(); + boolean lockAccountEnforcement = "true".equalsIgnoreCase(QuotaConfig.QuotaEnableEnforcement.value()); + + QuotaCreditsVO result; + try (TransactionLegacy ignored = TransactionLegacy.open(TransactionLegacy.USAGE_DB)) { + QuotaBalanceVO qb = _quotaBalanceDao.findLaterBalanceEntry(accountId, domainId, depositedOn); + if (qb != null) { + throw new InvalidParameterValueException(String.format("Incorrect deposit date [%s], as there are balance entries after this date.", + depositedOn)); + } + result = persistQuotaCredits(cmd, value, depositedOn, account, lockAccountEnforcement); + } finally { + // Swap back to cloud + TransactionLegacy.open(TransactionLegacy.CLOUD_DB).close(); } - QuotaCreditsVO credits = new QuotaCreditsVO(accountId, domainId, new BigDecimal(amount), updatedBy); + UserVO creditor = getCreditorForQuotaCredits(result); + return createQuotaCreditsResponse(result, creditor); + } + + protected QuotaCreditsVO persistQuotaCredits(QuotaCreditsCmd cmd, Double value, Date depositedOn, AccountVO account, boolean lockAccountEnforcement) { + Long accountId = account.getId(); + Long domainId = account.getDomainId(); + long callingUserId = CallContext.current().getCallingUserId(); + QuotaCreditsVO credits = new QuotaCreditsVO(accountId, domainId, new BigDecimal(value), callingUserId); credits.setUpdatedOn(depositedOn); QuotaCreditsVO result = quotaCreditsDao.saveCredits(credits); - if (result == null) { - logger.error("Unable to add credits to account ID [{}].", accountId); - throw new CloudRuntimeException("Unable to add credits to account."); - } - final AccountVO account = _accountDao.findById(accountId); - if (account == null) { - throw new InvalidParameterValueException("Account does not exist with account id " + accountId); - } - final boolean lockAccountEnforcement = "true".equalsIgnoreCase(QuotaConfig.QuotaEnableEnforcement.value()); - final BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId); - logger.debug("Depositing [{}] credits on adjusted date [{}]; current balance is [{}].", amount, + BigDecimal currentAccountBalance = _quotaBalanceDao.getLastQuotaBalance(accountId, domainId); + logger.debug("Depositing [{}] credits on adjusted date [{}]; current balance is [{}].", value, DateUtil.displayDateInTimezone(QuotaManagerImpl.getUsageAggregationTimeZone(), depositedOn), currentAccountBalance); - // update quota account with the balance _quotaService.saveQuotaAccount(account, currentAccountBalance, depositedOn); + + Boolean enforceQuota = cmd.getQuotaEnforce(); + if (enforceQuota != null) { + _quotaService.setLockAccount(accountId, enforceQuota); + } + + Double minBalance = cmd.getMinBalance(); + if (minBalance != null) { + _quotaService.setMinBalance(accountId, minBalance); + } + if (lockAccountEnforcement) { - if (currentAccountBalance.compareTo(new BigDecimal(0)) >= 0) { - if (account.getState() == Account.State.LOCKED) { - logger.info("UnLocking account " + account.getAccountName() + " , due to positive balance " + currentAccountBalance); - _accountMgr.enableAccount(account.getAccountName(), domainId, accountId); - } - } else { // currentAccountBalance < 0 then lock the account - if (_quotaManager.isLockable(account) && account.getState() == Account.State.ENABLED && enforce) { - logger.info("Locking account " + account.getAccountName() + " , due to negative balance " + currentAccountBalance); - _accountMgr.lockAccount(account.getAccountName(), domainId, accountId); - } + // Need to open a transaction for the cloud data base, and then swap back to cloud_usage + try (TransactionLegacy ignored = TransactionLegacy.open(TransactionLegacy.CLOUD_DB)) { + lockOrUnlockAccountIfRequired(currentAccountBalance, account, enforceQuota); + } finally { + TransactionLegacy.open(TransactionLegacy.USAGE_DB).close(); } } - UserVO creditor = getCreditorForQuotaCredits(result); - return createQuotaCreditsResponse(result, creditor); + return result; + } + + protected void lockOrUnlockAccountIfRequired(BigDecimal currentAccountBalance, AccountVO account, Boolean enforceQuota) { + Long accountId = account.getId(); + Long domainId = account.getDomainId(); + String accountName = account.getAccountName(); + + if (currentAccountBalance.compareTo(BigDecimal.ZERO) >= 0) { + if (account.getState() == Account.State.LOCKED) { + logger.info("Unlocking Account [{}] due to positive balance.", accountName); + _accountMgr.enableAccount(accountName, domainId, accountId); + } + return; + } + + if (Boolean.TRUE.equals(enforceQuota) && account.getState() == Account.State.ENABLED && _quotaManager.isLockable(account)) { + logger.info("Locking Account [{}] due to negative balance.", accountName); + _accountMgr.lockAccount(accountName, domainId, accountId); + } } private QuotaEmailTemplateResponse createQuotaEmailResponse(QuotaEmailTemplatesVO template) { @@ -1036,26 +1182,16 @@ public Pair, Integer> createQuotaCreditsListResponse( } protected List getCreditsForQuotaCreditsList(QuotaCreditsListCmd cmd) { - Long accountId = cmd.getAccountId(); - Long domainId = cmd.getDomainId(); + Long accountId = getAccountIdForQuotaStatement(cmd.getEntityOwnerId(), null); + Pair> baseDomainAndFilteredDomains = getDomainIdsForQuotaStatement(accountId, cmd.getDomainId(), cmd.isRecursive()); Date startDate = cmd.getStartDate(); Date endDate = cmd.getEndDate(); - boolean isRecursive = cmd.getRecursive(); - - if (ObjectUtils.allNull(accountId, domainId)) { - throw new InvalidParameterValueException("Please provide either account ID or domain ID."); - } if (startDate.after(endDate)) { throw new InvalidParameterValueException("The start date must be before the end date."); } - Account caller = CallContext.current().getCallingAccount(); - if (domainId != null && _accountMgr.isNormalUser(caller.getAccountId())) { - throw new PermissionDeniedException("Regular users are not allowed to generate domain statements."); - } - - return quotaCreditsDao.findCredits(accountId, domainId, startDate, endDate, isRecursive); + return quotaCreditsDao.findCredits(accountId, baseDomainAndFilteredDomains.second(), startDate, endDate); } /** @@ -1208,6 +1344,14 @@ public QuotaResourceStatementResponse createQuotaResourceStatement(QuotaResource return createQuotaResourceStatementResponse(resourceUuid, usageType, quotaResourceStatementItemResponseList, totalQuotaUsed); } + /** + * Determines the appropriate Account ID to use for Quota statement-related operations while ensuring correct permissions. + * + * @param providedAccountId the ID of the Account provided to the command. + * @param fallbackAccountId the ID of a fallback Account to use for User Accounts if no specific Account ID was provided. + * If null, then we fallback to the User Account itself. + * @return the account ID to be used for the Quota statement, or null if no specific Account limitation is required. + */ protected Long getAccountIdForQuotaStatement(long providedAccountId, Long fallbackAccountId) { Account caller = CallContext.current().getCallingAccount(); @@ -1235,6 +1379,17 @@ protected Long getAccountIdForQuotaStatement(long providedAccountId, Long fallba return caller.getAccountId(); } + /** + * Determines the Domains for which a Quota statement should be generated while ensuring correct permissions. + * + * @param finalAccountId the Account ID determined via org.apache.cloudstack.api.response.QuotaResponseBuilderImpl#getAccountIdForQuotaStatement(long, java.lang.Long). + * @param providedDomainId the Domain ID provided to the command. + * @param isRecursive the recursion flag provided to the command. + * @return A pair containing: + * - The base Domain's ID as the first element. This can be null if we are not limiting by Domain. + * - A list containing the base Domain's ID and optionally its children if + * the recursion flag is true. Also nullable. + */ protected Pair> getDomainIdsForQuotaStatement(Long finalAccountId, Long providedDomainId, boolean isRecursive) { if (finalAccountId != null) { // Access to the provided account has already been validated diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemHistoryResponse.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemHistoryResponse.java new file mode 100644 index 000000000000..7799326ea54b --- /dev/null +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemHistoryResponse.java @@ -0,0 +1,68 @@ +//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 org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import java.math.BigDecimal; +import java.util.Date; + +public class QuotaStatementItemHistoryResponse extends BaseResponse { + + @SerializedName(ApiConstants.START_DATE) + @Param(description = "Start date of the item.") + private Date startDate; + + @SerializedName(ApiConstants.END_DATE) + @Param(description = "End date of the item.") + private Date endDate; + + @SerializedName(ApiConstants.QUOTA_CONSUMED) + @Param(description = "Amount of quota consumed.") + private BigDecimal quotaConsumed = BigDecimal.ZERO; + + public QuotaStatementItemHistoryResponse() { + } + + public Date getStartDate() { + return startDate; + } + + public void setStartDate(Date startDate) { + this.startDate = startDate; + } + + public Date getEndDate() { + return endDate; + } + + public void setEndDate(Date endDate) { + this.endDate = endDate; + } + + public BigDecimal getQuotaConsumed() { + return quotaConsumed; + } + + public void setQuotaConsumed(BigDecimal quotaConsumed) { + this.quotaConsumed = quotaConsumed; + } + +} diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResourceResponse.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResourceResponse.java index 3e052f733391..68585a90d393 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResourceResponse.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResourceResponse.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.api.response; import java.math.BigDecimal; +import java.util.List; import com.google.gson.annotations.SerializedName; @@ -43,6 +44,10 @@ public class QuotaStatementItemResourceResponse extends BaseResponse { @Param(description = "Indicates whether the resource is removed or active.") private boolean removed; + @SerializedName(ApiConstants.HISTORY) + @Param(description = "Quota consumption history.") + private List history; + public void setQuotaUsed(BigDecimal quotaUsed) { this.quotaUsed = quotaUsed; } @@ -58,4 +63,9 @@ public void setDisplayName(String displayName) { public void setRemoved(boolean removed) { this.removed = removed; } + + public void setHistory(List history) { + this.history = history; + } + } diff --git a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResponse.java b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResponse.java index 0747c5a9172d..f04fffdf7790 100644 --- a/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResponse.java +++ b/plugins/database/quota/src/main/java/org/apache/cloudstack/api/response/QuotaStatementItemResponse.java @@ -48,6 +48,10 @@ public class QuotaStatementItemResponse extends BaseResponse { @Param(description = "Item's resources.") private List resources; + @SerializedName(ApiConstants.HISTORY) + @Param(description = "Quota consumption history.") + private List history; + public QuotaStatementItemResponse(final int usageType) { this.usageType = usageType; } @@ -92,4 +96,7 @@ public void setResources(List resources) { this.resources = resources; } + public void setHistory(List history) { + this.history = history; + } } diff --git a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaCreditsCmdTest.java b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaCreditsCmdTest.java index 06dd57ad41d3..b02cffbd2f0a 100644 --- a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaCreditsCmdTest.java +++ b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/command/QuotaCreditsCmdTest.java @@ -16,16 +16,9 @@ // under the License. package org.apache.cloudstack.api.command; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyDouble; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.nullable; - import java.lang.reflect.Field; -import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; -import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.QuotaCreditsResponse; import org.apache.cloudstack.api.response.QuotaResponseBuilder; import org.apache.cloudstack.quota.QuotaService; @@ -78,23 +71,13 @@ public void testQuotaCreditsCmd() throws NoSuchFieldException, IllegalAccessExce AccountVO acc = new AccountVO(); acc.setId(2L); - Mockito.when(accountService.getActiveAccountByName(nullable(String.class), nullable(Long.class))).thenReturn(acc); - - Mockito.when(responseBuilder.addQuotaCredits(nullable(Long.class), nullable(Long.class), nullable(Double.class), nullable(Long.class), nullable(Boolean.class))).thenReturn(new QuotaCreditsResponse()); - - // No value provided test - try { - cmd.execute(); - } catch (ServerApiException e) { - assertTrue(e.getErrorCode().equals(ApiErrorCode.PARAM_ERROR)); - } + Mockito.when(responseBuilder.addQuotaCredits(cmd)).thenReturn(new QuotaCreditsResponse()); // With value provided test cmd.setValue(11.80); cmd.execute(); - Mockito.verify(quotaService, Mockito.times(0)).setLockAccount(anyLong(), anyBoolean()); - Mockito.verify(quotaService, Mockito.times(1)).setMinBalance(anyLong(), anyDouble()); - Mockito.verify(responseBuilder, Mockito.times(1)).addQuotaCredits(nullable(Long.class), nullable(Long.class), nullable(Double.class), nullable(Long.class), nullable(Boolean.class)); + + Mockito.verify(responseBuilder, Mockito.times(1)).addQuotaCredits(cmd); } } diff --git a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java index 37307db45629..bcc1c02cde74 100644 --- a/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java +++ b/plugins/database/quota/src/test/java/org/apache/cloudstack/api/response/QuotaResponseBuilderImplTest.java @@ -44,6 +44,7 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.QuotaBalanceCmd; import org.apache.cloudstack.api.command.QuotaConfigureEmailCmd; +import org.apache.cloudstack.api.command.QuotaCreditsCmd; import org.apache.cloudstack.api.command.QuotaCreditsListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateListCmd; import org.apache.cloudstack.api.command.QuotaEmailTemplateUpdateCmd; @@ -52,6 +53,7 @@ import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.discovery.ApiDiscoveryService; import org.apache.cloudstack.jsinterpreter.JsInterpreterHelper; +import org.apache.cloudstack.quota.QuotaManager; import org.apache.cloudstack.quota.QuotaService; import org.apache.cloudstack.quota.QuotaStatement; import org.apache.cloudstack.quota.activationrule.presetvariables.PresetVariableDefinition; @@ -192,6 +194,12 @@ public class QuotaResponseBuilderImplTest extends TestCase { @Mock EntityManager entityManagerMock; + @Mock + QuotaManager quotaManagerMock; + + @Mock + QuotaBalanceVO quotaBalanceVoMock; + @Before public void setup() { CallContext.register(callerUserMock, callerAccountMock); @@ -243,28 +251,6 @@ public void createQuotaTariffResponseTestIfReturnsActivationRuleWithoutPermissio assertNull(tariffResponse.getActivationRule()); } - @Test - public void testAddQuotaCredits() { - final long accountId = 2L; - final long domainId = 1L; - final double amount = 11.0; - final long updatedBy = 2L; - - QuotaCreditsVO credit = new QuotaCreditsVO(); - credit.setCredit(new BigDecimal(amount)); - - Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenReturn(credit); - Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(Mockito.anyLong(), Mockito.anyLong())).thenReturn(new BigDecimal(111)); - Mockito.doReturn(userVoMock).when(quotaResponseBuilderSpy).getCreditorForQuotaCredits(credit); - - AccountVO account = new AccountVO(); - account.setState(Account.State.LOCKED); - Mockito.when(accountDaoMock.findById(Mockito.anyLong())).thenReturn(account); - - QuotaCreditsResponse resp = quotaResponseBuilderSpy.addQuotaCredits(accountId, domainId, amount, updatedBy, true); - assertTrue(resp.getCredit().compareTo(credit.getCredit()) == 0); - } - @Test public void testListQuotaEmailTemplates() { QuotaEmailTemplateListCmd cmd = new QuotaEmailTemplateListCmd(); @@ -710,33 +696,18 @@ public void createQuotaCreditsListResponseTestReturnsObject() { } private QuotaCreditsListCmd createQuotaCreditsListCmdForTests() { - Mockito.doReturn(false).when(accountManagerMock).isNormalUser(Mockito.anyLong()); - QuotaCreditsListCmd cmd = new QuotaCreditsListCmd(); - cmd.setAccountId(1L); - cmd.setDomainId(2L); + QuotaCreditsListCmd cmd = Mockito.mock(QuotaCreditsListCmd.class); + Mockito.doReturn(1L).when(cmd).getEntityOwnerId(); + Mockito.doReturn(2L).when(cmd).getDomainId(); + Mockito.doReturn(new Date()).when(cmd).getStartDate(); + Mockito.doReturn(new Date()).when(cmd).getEndDate(); return cmd; } - @Test(expected = InvalidParameterValueException.class) - public void getCreditsForQuotaCreditsListTestThrowsInvalidParameterValueExceptionWhenBothAccountIdAndDomainIdAreNull() { - QuotaCreditsListCmd cmd = new QuotaCreditsListCmd(); - - quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd); - } - @Test(expected = InvalidParameterValueException.class) public void getCreditsForQuotaCreditsListTestThrowsInvalidParameterValueExceptionWhenStartDateIsAfterEndDate() { QuotaCreditsListCmd cmd = createQuotaCreditsListCmdForTests(); - cmd.setStartDate(new Date()); - cmd.setEndDate(DateUtils.addDays(new Date(), -1)); - - quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd); - } - - @Test(expected = PermissionDeniedException.class) - public void getCreditsForQuotaCreditsListTestThrowsPermissionDeniedExceptionWhenDomainIdIsProvidedAndCallerIsNormalUser() { - QuotaCreditsListCmd cmd = createQuotaCreditsListCmdForTests(); - Mockito.doReturn(true).when(accountManagerMock).isNormalUser(Mockito.anyLong()); + Mockito.doReturn(DateUtils.addDays(new Date(), -1)).when(cmd).getEndDate(); quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd); } @@ -747,7 +718,7 @@ public void getCreditsForQuotaCreditsListTestReturnsData() { List expected = new ArrayList<>(); expected.add(new QuotaCreditsVO()); - Mockito.doReturn(expected).when(quotaCreditsDaoMock).findCredits(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), Mockito.any(), Mockito.anyBoolean()); + Mockito.doReturn(expected).when(quotaCreditsDaoMock).findCredits(Mockito.anyLong(), Mockito.any(), Mockito.any(), Mockito.any()); List result = quotaResponseBuilderSpy.getCreditsForQuotaCreditsList(cmd); @@ -1007,7 +978,6 @@ private List getQuotaUsagesForTest() { @Test public void createStatementItemTestReturnItem() { List quotaUsages = getQuotaUsagesForTest(); - Mockito.doNothing().when(quotaResponseBuilderSpy).setStatementItemResources(Mockito.any(), Mockito.anyInt(), Mockito.any(), Mockito.anyBoolean()); QuotaStatementItemResponse result = quotaResponseBuilderSpy.createStatementItem(0, quotaUsages, false); @@ -1018,15 +988,6 @@ public void createStatementItemTestReturnItem() { Assert.assertEquals(quotaTypeExpected.getQuotaName(), result.getUsageName()); } - @Test - public void setStatementItemResourcesTestDoNotShowResourcesDoNothing() { - QuotaStatementItemResponse item = new QuotaStatementItemResponse(1); - - quotaResponseBuilderSpy.setStatementItemResources(item, 0, getQuotaUsagesForTest(), false); - - Assert.assertNull(item.getResources()); - } - @Test public void getAccountIdForQuotaStatementTestReturnsProvidedAccount() { long providedAccountId = 200L; @@ -1185,4 +1146,226 @@ public void retrieveResourceTestReturnsCorrectResource() { Assert.assertNotNull(result); Assert.assertEquals(mockResource, result); } + + @Test + public void lockOrUnlockAccountIfRequiredTestPositiveBalanceUnlocksAccount() { + Mockito.doReturn(Account.State.LOCKED).when(accountMock).getState(); + + quotaResponseBuilderSpy.lockOrUnlockAccountIfRequired(BigDecimal.TEN, accountMock, true); + + Mockito.verify(accountManagerMock).enableAccount(accountMock.getAccountName(), domainVoMock.getId(), accountMock.getId()); + Mockito.verify(accountManagerMock, Mockito.never()).lockAccount(Mockito.anyString(), Mockito.anyLong(), Mockito.anyLong()); + } + + @Test + public void lockOrUnlockAccountIfRequiredTestNegativeBalanceLocksAccount() { + Mockito.doReturn(Account.State.ENABLED).when(accountMock).getState(); + Mockito.doReturn(true).when(quotaManagerMock).isLockable(accountMock); + + quotaResponseBuilderSpy.lockOrUnlockAccountIfRequired(BigDecimal.valueOf(-10), accountMock, true); + + Mockito.verify(accountManagerMock).lockAccount(accountMock.getAccountName(), domainVoMock.getId(), accountMock.getId()); + Mockito.verify(accountManagerMock, Mockito.never()).enableAccount(Mockito.anyString(), Mockito.anyLong(), Mockito.anyLong()); + } + + @Test + public void addQuotaCreditsTestValidParameters() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Mockito.doReturn(10D).when(cmd).getValue(); + Mockito.doReturn(BigDecimal.TEN).when(quotaCreditsVoMock).getCredit(); + Mockito.doReturn(accountMock).when(accountDaoMock).findById(Mockito.anyLong()); + Mockito.doReturn(null).when(quotaBalanceDaoMock).findLaterBalanceEntry(Mockito.anyLong(), Mockito.anyLong(), + Mockito.any()); + Mockito.doReturn(quotaCreditsVoMock).when(quotaResponseBuilderSpy).persistQuotaCredits(Mockito.any(), Mockito.anyDouble(), + Mockito.any(), Mockito.any(), Mockito.anyBoolean()); + Mockito.doReturn(userVoMock).when(quotaResponseBuilderSpy).getCreditorForQuotaCredits(Mockito.any()); + + QuotaCreditsResponse response = quotaResponseBuilderSpy.addQuotaCredits(cmd); + + Assert.assertEquals(BigDecimal.TEN, response.getCredit()); + } + + @Test(expected = InvalidParameterValueException.class) + public void addQuotaCreditsTestThrowsExceptionWhenValueIsNull() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Mockito.doReturn(null).when(cmd).getValue(); + + quotaResponseBuilderSpy.addQuotaCredits(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void addQuotaCreditsTestThrowsExceptionWhenDepositDateIsIncorrect() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Mockito.doReturn(100.0).when(cmd).getValue(); + Mockito.doReturn(accountMock).when(accountDaoMock).findById(Mockito.anyLong()); + Mockito.doReturn(quotaBalanceVoMock).when(quotaBalanceDaoMock).findLaterBalanceEntry(Mockito.anyLong(), Mockito.anyLong(), Mockito.any()); + + quotaResponseBuilderSpy.addQuotaCredits(cmd); + } + + @Test + public void persistQuotaCreditsTestSavesCreditsAndBalanceSuccessfully() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Long accountId = 1L; + Long domainId = 2L; + Double value = 10D; + Date depositedOn = new Date(); + AccountVO account = Mockito.mock(AccountVO.class); + BigDecimal currentBalance = BigDecimal.ZERO; + + Mockito.doReturn(accountId).when(account).getId(); + Mockito.doReturn(domainId).when(account).getDomainId(); + Mockito.doReturn(null).when(cmd).getQuotaEnforce(); + Mockito.doReturn(null).when(cmd).getMinBalance(); + Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(accountId, domainId)).thenReturn(currentBalance); + + QuotaCreditsVO result = quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false); + + Assert.assertNotNull(result); + Assert.assertEquals(BigDecimal.TEN, result.getCredit()); + Assert.assertEquals(accountId, result.getAccountId()); + Assert.assertEquals(depositedOn, result.getUpdatedOn()); + Mockito.verify(quotaServiceMock).saveQuotaAccount(account, currentBalance, depositedOn); + Mockito.verify(quotaServiceMock, Mockito.never()).setLockAccount(Mockito.anyLong(), Mockito.anyBoolean()); + Mockito.verify(quotaServiceMock, Mockito.never()).setMinBalance(Mockito.anyLong(), Mockito.anyDouble()); + Mockito.verify(quotaResponseBuilderSpy, Mockito.never()).lockOrUnlockAccountIfRequired(Mockito.any(), Mockito.any(), Mockito.anyBoolean()); + } + + @Test + public void persistQuotaCreditsTestCallsSetLockAccountWhenQuotaEnforceProvided() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Long accountId = 1L; + Double value = 100.0; + Date depositedOn = new Date(); + AccountVO account = Mockito.mock(AccountVO.class); + + Mockito.doReturn(accountId).when(account).getId(); + Mockito.when(cmd.getQuotaEnforce()).thenReturn(Boolean.TRUE); + Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false); + + Mockito.verify(quotaServiceMock).setLockAccount(accountId, Boolean.TRUE); + } + + @Test + public void persistQuotaCreditsTestCallsSetMinBalanceWhenProvided() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Long accountId = 1L; + Double value = 100.0; + Date depositedOn = new Date(); + AccountVO account = Mockito.mock(AccountVO.class); + + Mockito.when(cmd.getMinBalance()).thenReturn(50.0); + Mockito.doReturn(accountId).when(account).getId(); + Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, false); + + Mockito.verify(quotaServiceMock).setMinBalance(accountId, 50.0); + } + + @Test + public void persistQuotaCreditsTestLocksOrUnlocksAccountWhenEnforcementIsEnabledGlobally() { + QuotaCreditsCmd cmd = Mockito.mock(QuotaCreditsCmd.class); + Long accountId = 1L; + Long domainId = 2L; + Double value = 100.0; + Date depositedOn = new Date(); + AccountVO account = Mockito.mock(AccountVO.class); + BigDecimal currentBalance = BigDecimal.ZERO; + + Mockito.doReturn(accountId).when(account).getId(); + Mockito.doReturn(domainId).when(account).getDomainId(); + Mockito.when(cmd.getMinBalance()).thenReturn(50.0); + Mockito.when(quotaCreditsDaoMock.saveCredits(Mockito.any(QuotaCreditsVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + Mockito.when(quotaBalanceDaoMock.getLastQuotaBalance(accountId, domainId)).thenReturn(currentBalance); + + quotaResponseBuilderSpy.persistQuotaCredits(cmd, value, depositedOn, account, true); + + Mockito.verify(quotaResponseBuilderSpy).lockOrUnlockAccountIfRequired(currentBalance, account, false); + } + + @Test + public void createQuotaConsumptionHistoryTestReturnsNullForZeroQuotaUsed() { + List result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(new ArrayList<>(), BigDecimal.ZERO); + + Assert.assertNull(result); + } + + @Test + public void createQuotaConsumptionHistoryTestIgnoresNullQuotaUsed() { + Date now = new Date(); + + List usageRecords = new ArrayList<>(); + QuotaUsageJoinVO record1 = new QuotaUsageJoinVO(); + record1.setStartDate(now); + record1.setEndDate(now); + record1.setQuotaUsed(null); + record1.setUsageItemId(10L); + + QuotaUsageJoinVO record2 = new QuotaUsageJoinVO(); + record2.setStartDate(new Date(now.getTime() + 1000)); + record2.setEndDate(new Date(now.getTime() + 1000)); + record2.setQuotaUsed(BigDecimal.valueOf(10)); + record2.setUsageItemId(11L); + + usageRecords.add(record1); + usageRecords.add(record2); + + BigDecimal totalQuotaUsed = BigDecimal.valueOf(10); + + List result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(usageRecords, totalQuotaUsed); + + Assert.assertNotNull(result); + Assert.assertEquals(1, result.size()); + Assert.assertEquals(BigDecimal.valueOf(10), result.get(0).getQuotaConsumed()); + } + + @Test + public void createQuotaConsumptionHistoryTestCorrectlyAggregatesRecords() { + List usageRecords = new ArrayList<>(); + Date now = new Date(); + + QuotaUsageJoinVO record1 = new QuotaUsageJoinVO(); + record1.setStartDate(now); + record1.setEndDate(new Date(now.getTime() + 1000)); + record1.setQuotaUsed(BigDecimal.valueOf(5)); + record1.setUsageItemId(10L); + + QuotaUsageJoinVO record2 = new QuotaUsageJoinVO(); + record2.setStartDate(new Date(now.getTime() + 2000)); + record2.setEndDate(new Date(now.getTime() + 3000)); + record2.setQuotaUsed(BigDecimal.valueOf(15)); + record2.setUsageItemId(11L); + + QuotaUsageJoinVO record3 = new QuotaUsageJoinVO(); + record3.setStartDate(new Date(now.getTime() + 2000)); + record3.setEndDate(new Date(now.getTime() + 3000)); + record3.setQuotaUsed(BigDecimal.valueOf(5)); + record3.setUsageItemId(11L); + + usageRecords.add(record1); + usageRecords.add(record2); + usageRecords.add(record3); + + BigDecimal totalQuotaUsed = BigDecimal.valueOf(25); + + List result = quotaResponseBuilderSpy.createQuotaConsumptionHistory(usageRecords, totalQuotaUsed); + + Assert.assertNotNull(result); + Assert.assertEquals(2, result.size()); + + QuotaStatementItemHistoryResponse firstHistory = result.get(0); + QuotaStatementItemHistoryResponse secondHistory = result.get(1); + + Assert.assertEquals(BigDecimal.valueOf(5), firstHistory.getQuotaConsumed()); + Assert.assertEquals(record1.getStartDate(), firstHistory.getStartDate()); + Assert.assertEquals(record1.getEndDate(), firstHistory.getEndDate()); + + Assert.assertEquals(BigDecimal.valueOf(20), secondHistory.getQuotaConsumed()); + Assert.assertEquals(record2.getStartDate(), secondHistory.getStartDate()); + Assert.assertEquals(record2.getEndDate(), secondHistory.getEndDate()); + } } diff --git a/plugins/dns/powerdns/pom.xml b/plugins/dns/powerdns/pom.xml new file mode 100644 index 000000000000..3edb74f1ce60 --- /dev/null +++ b/plugins/dns/powerdns/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + cloud-plugin-dns-powerdns + Apache CloudStack Plugin - PowerDNS + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../../pom.xml + + diff --git a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java new file mode 100644 index 000000000000..4b28fab17ab1 --- /dev/null +++ b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsClient.java @@ -0,0 +1,390 @@ +// 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 org.apache.cloudstack.dns.powerdns; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.dns.exception.DnsAuthenticationException; +import org.apache.cloudstack.dns.exception.DnsConflictException; +import org.apache.cloudstack.dns.exception.DnsNotFoundException; +import org.apache.cloudstack.dns.exception.DnsOperationException; +import org.apache.cloudstack.dns.exception.DnsProviderException; +import org.apache.cloudstack.dns.exception.DnsTransportException; +import org.apache.commons.collections.CollectionUtils; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.cloud.utils.StringUtils; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +public class PowerDnsClient implements AutoCloseable { + public static final Logger logger = LoggerFactory.getLogger(PowerDnsClient.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int SOCKET_TIMEOUT_MS = 10_000; + private static final int MAX_CONNECTIONS_TOTAL = 50; + private static final int MAX_CONNECTIONS_PER_ROUTE = 10; + private static final String API_PREFIX = "/api/v1"; + public static final String DEFAULT_SERVER_NAME = "localhost"; + + private final CloseableHttpClient httpClient; + + public PowerDnsClient() { + PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); + connectionManager.setMaxTotal(MAX_CONNECTIONS_TOTAL); + connectionManager.setDefaultMaxPerRoute(MAX_CONNECTIONS_PER_ROUTE); + + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(CONNECT_TIMEOUT_MS) + .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS) + .setSocketTimeout(SOCKET_TIMEOUT_MS) + .build(); + + this.httpClient = HttpClientBuilder.create() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .evictIdleConnections(30, TimeUnit.SECONDS) + .disableCookieManagement() + .build(); + } + + public String resolveServerId(String baseUrl, Integer port, String apiKey, String externalServerId) throws DnsProviderException { + if (StringUtils.isNotBlank(externalServerId)) { + return validateServerId(baseUrl, port, apiKey, externalServerId); + } + return discoverAuthoritativeServerId(baseUrl, port, apiKey); + } + + public String validateServerId(String baseUrl, Integer port, String apiKey, String externalServerId) throws DnsProviderException { + String encodedServer = URLEncoder.encode(externalServerId, StandardCharsets.UTF_8); + HttpGet request = new HttpGet(buildUrl(baseUrl, port, "/servers/" + encodedServer)); + JsonNode server = execute(request, apiKey, 200); + if (!ApiConstants.AUTHORITATIVE.equalsIgnoreCase(server.path("daemon_type").asText(null))) { + throw new DnsOperationException(String.format("Server %s is not authoritative type=%s", externalServerId, + server.path("daemon_type").asText(null))); + } + return externalServerId; + } + + public String discoverAuthoritativeServerId(String baseUrl, Integer port, String apiKey) throws DnsProviderException { + String url = buildUrl(baseUrl, port , "/servers"); + HttpGet request = new HttpGet(url); + JsonNode servers = execute(request, apiKey, 200); + if (servers == null || !servers.isArray() || servers.isEmpty()) { + throw new DnsOperationException("No servers returned by PowerDNS API"); + } + String fallbackId = null; + for (JsonNode server : servers) { + String daemonType = server.path("daemon_type").asText(null); + if (!ApiConstants.AUTHORITATIVE.equalsIgnoreCase(daemonType)) { + continue; + } + String serverId = server.path(ApiConstants.ID).asText(null); + if (StringUtils.isBlank(serverId)) { + continue; + } + // Prefer localhost if present + if (DEFAULT_SERVER_NAME.equals(serverId)) { + return serverId; + } + if (fallbackId == null) { + fallbackId = serverId; + } + } + if (fallbackId != null) { + return fallbackId; + } + throw new DnsOperationException("No authoritative PowerDNS server found"); + } + + public String createZone(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName, + String zoneKind, boolean dnsSecFlag, List nameServers) throws DnsProviderException { + + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + ObjectNode json = MAPPER.createObjectNode(); + json.put(ApiConstants.NAME, normalizedZone); + json.put(ApiConstants.KIND, zoneKind); + json.put(ApiConstants.DNS_SEC, dnsSecFlag); + if (!CollectionUtils.isEmpty(nameServers)) { + ArrayNode nsArray = json.putArray(ApiConstants.NAME_SERVERS); + for (String ns : nameServers) { + nsArray.add(ns.endsWith(".") ? ns : ns + "."); + } + } + HttpPost request = new HttpPost(buildUrl(baseUrl, port, "/servers/" + externalServerId + "/zones")); + request.setEntity(new StringEntity(json.toString(), StandardCharsets.UTF_8)); + JsonNode response = execute(request, apiKey, 201); + if (response == null) { + throw new DnsOperationException("Empty response from DNS server"); + } + String zoneId = response.path(ApiConstants.ID).asText(); + if (StringUtils.isBlank(zoneId)) { + throw new DnsOperationException("PowerDNS returned empty zone id"); + } + return zoneId; + } + + public void updateZone(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName, + String zoneKind, Boolean dnsSecFlag, List nameServers) throws DnsProviderException { + + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + String url = buildUrl(baseUrl, port,"/servers/" + externalServerId + "/zones/" + encodedZone); + + ObjectNode json = MAPPER.createObjectNode(); + if (dnsSecFlag != null) { + json.put(ApiConstants.DNS_SEC, dnsSecFlag); + } + if (StringUtils.isNotBlank(zoneKind)) { + json.put(ApiConstants.KIND, zoneKind); + } + if (!CollectionUtils.isEmpty(nameServers)) { + ArrayNode nsArray = json.putArray(ApiConstants.NAME_SERVERS); + for (String ns : nameServers) { + nsArray.add(ns.endsWith(".") ? ns : ns + "."); + } + } + HttpPut request = new HttpPut(url); + request.setEntity(new StringEntity(json.toString(), StandardCharsets.UTF_8)); + execute(request, apiKey, 204); + } + + public void deleteZone(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName) throws DnsProviderException { + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpDelete request = new HttpDelete(buildUrl(baseUrl, port, "/servers/" + externalServerId + "/zones/" + encodedZone)); + execute(request, apiKey, 204, 404); + } + + public String modifyRecord(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName, + String recordName, String type, long ttl, List contents, String changeType) throws DnsProviderException { + + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String normalizedRecord = normalizeRecordName(recordName, normalizedZone); + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode rrsets = root.putArray(ApiConstants.RR_SETS); + ObjectNode rrset = rrsets.addObject(); + rrset.put(ApiConstants.NAME, normalizedRecord); + rrset.put(ApiConstants.TYPE, type.toUpperCase()); + rrset.put(ApiConstants.TTL, ttl); + rrset.put(ApiConstants.CHANGE_TYPE, changeType); + ArrayNode records = rrset.putArray(ApiConstants.RECORDS); + if (!CollectionUtils.isEmpty(contents)) { + for (String content : contents) { + ObjectNode record = records.addObject(); + record.put(ApiConstants.CONTENT, content); + record.put(ApiConstants.DISABLED, false); + } + } + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpPatch request = new HttpPatch(buildUrl(baseUrl, port, "/servers/" + externalServerId + "/zones/" + encodedZone)); + request.setEntity(new StringEntity(root.toString(), StandardCharsets.UTF_8)); + execute(request, apiKey, 204); + return normalizedRecord.endsWith(".") ? normalizedRecord.substring(0, normalizedRecord.length() - 1) : normalizedRecord; + } + + public Iterable listRecords(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName) throws DnsProviderException { + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpGet request = new HttpGet(buildUrl(baseUrl, port, "/servers/" + externalServerId + "/zones/" + encodedZone)); + JsonNode zoneNode = execute(request, apiKey, 200); + if (zoneNode == null || !zoneNode.has(ApiConstants.RR_SETS)) { + return Collections.emptyList(); + } + JsonNode rrsets = zoneNode.path(ApiConstants.RR_SETS); + return rrsets.isArray() ? rrsets : Collections.emptyList(); + } + + public boolean zoneExists(String baseUrl, Integer port, String apiKey, String externalServerId, String zoneName) { + try { + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + HttpGet request = new HttpGet(buildUrl(baseUrl, port, "/servers/" + externalServerId + "/zones/" + encodedZone)); + execute(request, apiKey, 200); + return true; + } catch (DnsProviderException | IllegalArgumentException e) { + return false; + } + } + + public boolean recordExists(String baseUrl, Integer port, String apiKey, + String externalServerId, String zoneName, + String recordName, String type) throws DnsProviderException { + + validateServerId(baseUrl, port, apiKey, externalServerId); + String normalizedZone = normalizeZone(zoneName); + String normalizedRecord = normalizeRecordName(recordName, normalizedZone); + String encodedZone = URLEncoder.encode(normalizedZone, StandardCharsets.UTF_8); + String urlPath = "/servers/" + externalServerId + "/zones/" + encodedZone + + "?rrset_name=" + URLEncoder.encode(normalizedRecord, StandardCharsets.UTF_8) + + "&rrset_type=" + type.toUpperCase(); + HttpGet request = new HttpGet(buildUrl(baseUrl, port, urlPath)); + JsonNode zoneNode = execute(request, apiKey, 200); + if (zoneNode == null || !zoneNode.has(ApiConstants.RR_SETS)) { + return false; + } + JsonNode rrsets = zoneNode.path(ApiConstants.RR_SETS); + return rrsets.isArray() && !rrsets.isEmpty(); + } + + private JsonNode execute(HttpUriRequest request, String apiKey, int... expectedStatus) throws DnsProviderException { + request.addHeader(ApiConstants.X_API_KEY, apiKey); + request.addHeader("Accept", "application/json"); + request.addHeader(ApiConstants.CONTENT_TYPE, "application/json"); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + int status = response.getStatusLine().getStatusCode(); + String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : null; + + for (int expected : expectedStatus) { + if (status == expected) { + if (body != null && !body.isEmpty()) { + return MAPPER.readTree(body); + } else { + return null; + } + } + } + if (status == 404) { + throw new DnsNotFoundException("Resource not found: " + body); + } else if (status == 401 || status == 403) { + throw new DnsAuthenticationException("Invalid API key"); + } else if (status == 409) { + throw new DnsConflictException("Conflict: " + body); + } + throw new DnsOperationException("Unexpected PowerDNS response: HTTP " + status + " Body: " + body); + } catch (IOException ex) { + throw new DnsTransportException("Error communicating with PowerDNS", ex); + } + } + + private String buildUrl(String baseUrl, Integer port, String path) { + String fullUrl = normalizeBaseUrl(baseUrl); + if (port != null && port > 0) { + try { + URI uri = new URI(fullUrl); + if (uri.getPort() == -1) { + fullUrl = fullUrl + ":" + port; + } + } catch (URISyntaxException e) { + fullUrl = fullUrl + ":" + port; + } + } + if (!path.startsWith("/")) { + path = "/" + path; + } + return fullUrl + API_PREFIX + path; + } + + private String normalizeBaseUrl(String baseUrl) { + if (baseUrl == null) { + throw new IllegalArgumentException("Base URL cannot be null"); + } + String url = baseUrl.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "http://" + url; + } + if (url.endsWith("/")) { + url = url.substring(0, url.length() - 1); + } + return url; + } + + private String normalizeZone(String zoneName) { + if (StringUtils.isBlank(zoneName)) { + throw new IllegalArgumentException("Zone name must not be null or empty"); + } + String zone = zoneName.trim().toLowerCase(); + if (!zone.endsWith(".")) { + zone = zone + "."; + } + if (zone.length() < 2) { + throw new IllegalArgumentException("Zone name is too short"); + } + return zone; + } + + String normalizeRecordName(String recordName, String zoneName) { + if (recordName == null) { + throw new IllegalArgumentException("Record name must not be null"); + } + String normalizedZone = normalizeZone(zoneName); + String name = recordName.trim().toLowerCase(); + // Apex of the zone + if (name.equals("@") || name.isEmpty()) { + return normalizedZone; + } + + String zoneWithoutDot = normalizedZone.substring(0, normalizedZone.length() - 1); + // Already absolute (ends with dot) + if (name.endsWith(".")) { + // Check if the record belongs to the zone + if (!name.equals(normalizedZone) && !name.endsWith("." + zoneWithoutDot + ".")) { + throw new IllegalArgumentException( + String.format("Record '%s' does not belong to zone '%s'", recordName, zoneName) + ); + } + return name; + } + if (name.contains(".")) { + return name + "."; + } + // Relative name → append zone + return name + "." + normalizedZone; + } + + @Override + public void close() { + try { + httpClient.close(); + } catch (IOException e) { + logger.warn("Failed to close PowerDNS HTTP client", e); + } + } +} diff --git a/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java new file mode 100644 index 000000000000..9996f705e481 --- /dev/null +++ b/plugins/dns/powerdns/src/main/java/org/apache/cloudstack/dns/powerdns/PowerDnsProvider.java @@ -0,0 +1,202 @@ +// 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 org.apache.cloudstack.dns.powerdns; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.dns.DnsProvider; +import org.apache.cloudstack.dns.DnsProviderType; +import org.apache.cloudstack.dns.DnsRecord; +import org.apache.cloudstack.dns.DnsServer; +import org.apache.cloudstack.dns.DnsZone; +import org.apache.cloudstack.dns.exception.DnsProviderException; +import org.apache.logging.log4j.util.Strings; + +import com.cloud.utils.StringUtils; +import com.cloud.utils.component.AdapterBase; +import com.fasterxml.jackson.databind.JsonNode; + +public class PowerDnsProvider extends AdapterBase implements DnsProvider { + private PowerDnsClient client; + static String PDNS_SERVER_ID = "pdnsServerId"; + + @Override + public DnsProviderType getProviderType() { + return DnsProviderType.PowerDNS; + } + + public void validate(DnsServer server) throws DnsProviderException { + validateRequiredServerFields(server); + client.validateServerId(server.getUrl(), server.getPort(), server.getDnsApiKey(), server.getDetail(PDNS_SERVER_ID)); + } + + @Override + public String validateAndResolveServer(DnsServer server) throws Exception { + validateRequiredServerFields(server); + String resolvedDnsServerId = client.resolveServerId(server.getUrl(), server.getPort(), server.getDnsApiKey(), server.getDetail(PDNS_SERVER_ID)); + if (Strings.isNotBlank(resolvedDnsServerId)) { + server.appendDetails(PDNS_SERVER_ID, resolvedDnsServerId); + } + return resolvedDnsServerId; + } + + @Override + public String provisionZone(DnsServer server, DnsZone zone) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + return client.createZone( + server.getUrl(), + server.getPort(), + server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), + zone.getName(), + ApiConstants.NATIVE_ZONE, false, server.getNameServers() + ); + } + + @Override + public void deleteZone(DnsServer server, DnsZone zone) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + client.deleteZone(server.getUrl(), server.getPort(), server.getDnsApiKey(), server.getDetail(PDNS_SERVER_ID), zone.getName()); + } + + @Override + public void updateZone(DnsServer server, DnsZone zone) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + client.updateZone( + server.getUrl(), + server.getPort(), + server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), + zone.getName(), ApiConstants.NATIVE_ZONE, false, server.getNameServers()); + } + + public enum ChangeType { + REPLACE, DELETE + } + + @Override + public String addRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + return applyRecord( + server.getUrl(), + server.getPort(), + server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), + zone.getName(), record, ChangeType.REPLACE); + } + + @Override + public String updateRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + return addRecord(server, zone, record); + } + + @Override + public String deleteRecord(DnsServer server, DnsZone zone, DnsRecord record) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + return applyRecord(server.getUrl(), + server.getPort(), + server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), + zone.getName(), record, ChangeType.DELETE); + } + + public String applyRecord(String serverUrl, Integer port, String apiKey, String externalServerId, String zoneName, + DnsRecord record, ChangeType changeType) throws DnsProviderException { + + return client.modifyRecord(serverUrl, port, apiKey, externalServerId, zoneName, record.getName(), + record.getType().name(), record.getTtl(), record.getContents(), changeType.name()); + } + + @Override + public List listRecords(DnsServer server, DnsZone zone) throws DnsProviderException { + validateRequiredServerAndZoneFields(server, zone); + List records = new ArrayList<>(); + Iterable rrsetNodes = client.listRecords(server.getUrl(), server.getPort(), server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), zone.getName()); + + for (JsonNode rrset : rrsetNodes) { + String name = rrset.path(ApiConstants.NAME).asText(); + String typeStr = rrset.path(ApiConstants.TYPE).asText(); + int ttl = rrset.path(ApiConstants.TTL).asInt(0); + if (!"SOA".equalsIgnoreCase(typeStr)) { + try { + List contents = new ArrayList<>(); + JsonNode recordsNode = rrset.path(ApiConstants.RECORDS); + if (recordsNode.isArray()) { + for (JsonNode rec : recordsNode) { + String content = rec.path(ApiConstants.CONTENT).asText(); + if (!content.isEmpty()) { + contents.add(content); + } + } + } + records.add(new DnsRecord(name, DnsRecord.RecordType.valueOf(typeStr), contents, ttl)); + } catch (Exception ignored) { + // Skip unsupported record types + } + } + } + return records; + } + + public boolean dnsRecordExists(DnsServer server, DnsZone zone, String recordName, String recordType) throws DnsProviderException { + return client.recordExists(server.getUrl(), server.getPort(), server.getDnsApiKey(), + server.getDetail(PDNS_SERVER_ID), zone.getName(), recordName, recordType); + } + + @Override + public boolean dnsZoneExists(DnsServer server, DnsZone zone) { + return client.zoneExists(server.getUrl(), server.getPort(), server.getDnsApiKey(), server.getDetail(PDNS_SERVER_ID), zone.getName()); + } + + void validateRequiredServerAndZoneFields(DnsServer server, DnsZone zone) { + validateRequiredServerFields(server); + if (StringUtils.isBlank(zone.getName())) { + throw new IllegalArgumentException("Zone name cannot be empty"); + } + } + + void validateRequiredServerFields(DnsServer server) { + if (StringUtils.isBlank(server.getUrl())) { + throw new IllegalArgumentException("PowerDNS API URL cannot be empty"); + } + if (StringUtils.isBlank(server.getDnsApiKey())) { + throw new IllegalArgumentException("PowerDNS API key cannot be empty"); + } + } + + @Override + public boolean configure(String name, Map params) { + if (client == null) { + client = new PowerDnsClient(); + } + return true; + } + + @Override + public boolean stop() { + if (client != null) { + client.close(); + } + return true; + } +} diff --git a/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/module.properties b/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/module.properties new file mode 100644 index 000000000000..baec3fde6a6a --- /dev/null +++ b/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/module.properties @@ -0,0 +1,18 @@ +# 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. +name=powerdns +parent=dns diff --git a/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/spring-dns-powerdns-context.xml b/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/spring-dns-powerdns-context.xml new file mode 100644 index 000000000000..c9e4937dac55 --- /dev/null +++ b/plugins/dns/powerdns/src/main/resources/META-INF/cloudstack/powerdns/spring-dns-powerdns-context.xml @@ -0,0 +1,31 @@ + + + + + diff --git a/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/DnsProviderUtilTest.java b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/DnsProviderUtilTest.java new file mode 100644 index 000000000000..a2b571b154f9 --- /dev/null +++ b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/DnsProviderUtilTest.java @@ -0,0 +1,101 @@ +// 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 org.apache.cloudstack.dns; + +import static org.apache.cloudstack.dns.DnsProviderUtil.appendPublicSuffixToZone; +import static org.apache.cloudstack.dns.DnsProviderUtil.normalizeDomainForDb; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.Collection; + +import org.apache.logging.log4j.util.Strings; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class DnsProviderUtilTest { + private final String userZoneName; + private final String publicSuffix; + private final String expectedResult; + private final boolean expectException; + + public DnsProviderUtilTest(String userZoneName, + String publicSuffix, + String expectedResult, + boolean expectException) { + this.userZoneName = userZoneName; + this.publicSuffix = publicSuffix; + this.expectedResult = expectedResult; + this.expectException = expectException; + } + + @Parameterized.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + {"tenant1.com", "example.com", "tenant1.example.com", false}, + {"dev.tenant2.com", "example.com", "dev.tenant2.example.com", false}, + {"tenant3.example.com", "example.com", "tenant3.example.com", false}, + {"Tenant1.CoM", "ExAmple.CoM", "tenant1.example.com", false}, + {"tenant1.com.", "example.com.", "tenant1.example.com", false}, + {"tenant1.com", "", "tenant1.com", false}, + {"tenant1.com", null, "tenant1.com", false}, + {"test.abc.com", "abc.com", "test.abc.com", false}, + {"sub.test.abc.com", "abc.com", "sub.test.abc.com", false}, + {"test.ai.abc.com", "abc.com", "test.ai.abc.com", false}, + {"deep.sub.abc.com", "abc.com", "deep.sub.abc.com", false}, + {"abc.com", "xyz.com", "abc.xyz.com", false}, + {"test.xyz.com", "xyz.com", "test.xyz.com", false}, + {"test.com.xyz.com", "xyz.com", "test.com.xyz.com", false}, + {"tenant", "example.com", null, true}, // single label + {"test", "abc.com", null, true}, + {"example.com.", "example.com", null, true}, + {"example.com", "example.com", null, true}, // root level forbidden + {"abc.com", "abc.com", null, true}, // root level forbidden + {"tenant1.org", "example.com", null, true}, // TLD mismatch + {"test.ai", "abc.com", null, true}, // TLD mismatch + {null, "example.com", null, true}, + }); + } + + @Test + public void testAppendPublicSuffix() { + if (expectException) { + try { + executeAppendSuffixTest(userZoneName, publicSuffix); + fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException ignored) { + // noop + } + } else { + String result; + if (Strings.isNotBlank(publicSuffix)) { + result = executeAppendSuffixTest(userZoneName, publicSuffix); + } else { + result = appendPublicSuffixToZone(normalizeDomainForDb(userZoneName), publicSuffix); + } + assertEquals(expectedResult, result); + } + } + + String executeAppendSuffixTest(String zoneName, String domainSuffix) { + return appendPublicSuffixToZone(normalizeDomainForDb(zoneName), domainSuffix); + } +} diff --git a/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/NormalizeDnsRecordValueTest.java b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/NormalizeDnsRecordValueTest.java new file mode 100644 index 000000000000..f5bd16a81908 --- /dev/null +++ b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/NormalizeDnsRecordValueTest.java @@ -0,0 +1,197 @@ +// 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 org.apache.cloudstack.dns; + +import static org.apache.cloudstack.dns.DnsRecord.RecordType.A; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.AAAA; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.CNAME; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.MX; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.NS; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.PTR; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.SRV; +import static org.apache.cloudstack.dns.DnsRecord.RecordType.TXT; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.Collection; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +@RunWith(Parameterized.class) +public class NormalizeDnsRecordValueTest { + + private final String description; + private final String input; + private final DnsRecord.RecordType recordType; + private final String expected; + private final boolean expectException; + + public NormalizeDnsRecordValueTest(String description, String input, + DnsRecord.RecordType recordType, + String expected, boolean expectException) { + this.description = description; + this.input = input; + this.recordType = recordType; + this.expected = expected; + this.expectException = expectException; + } + + @Parameterized.Parameters(name = "{0}") + public static Collection data() { + return Arrays.asList(new Object[][] { + + // ---------------------------------------------------------------- + // Guard: blank/null value — all record types should throw + // ---------------------------------------------------------------- + {"null value, A record", null, A, null, true}, + {"empty value, A record", "", A, null, true}, + {"blank value, A record", " ", A, null, true}, + + {"null value, AAAA record", null, AAAA, null, true}, + {"null value, CNAME record", null, CNAME, null, true}, + {"null value, MX record", null, MX, null, true}, + {"null value, TXT record", null, TXT, null, true}, + {"null value, SRV record", null, SRV, null, true}, + {"null value, NS record", null, NS, null, true}, + {"null value, PTR record", null, PTR, null, true}, + + // ---------------------------------------------------------------- + // A record + // ---------------------------------------------------------------- + {"A: valid IPv4", "93.184.216.34", A, "93.184.216.34", false}, + {"A: valid IPv4 with whitespace", " 93.184.216.34 ", A, "93.184.216.34", false}, + {"A: loopback", "127.0.0.1", A, "127.0.0.1", false}, + {"A: all-zeros", "0.0.0.0", A, "0.0.0.0", false}, + {"A: broadcast", "255.255.255.255", A, "255.255.255.255", false}, + {"A: private 10.x", "10.0.0.1", A, "10.0.0.1", false}, + {"A: private 192.168.x", "192.168.1.1", A, "192.168.1.1", false}, + + {"A: IPv6 rejected", "2001:db8::1", A, null, true}, + {"A: domain rejected", "example.com", A, null, true}, + {"A: partial IP rejected", "192.168.1", A, null, true}, + {"A: trailing dot rejected", "93.184.216.34.", A, null, true}, + {"A: octet out of range rejected", "256.0.0.1", A, null, true}, + + // ---------------------------------------------------------------- + // AAAA record + // ---------------------------------------------------------------- + {"AAAA: full IPv6", "2001:0db8:0000:0000:0000:0000:0000:0001", AAAA, + "2001:0db8:0000:0000:0000:0000:0000:0001", false}, + + {"AAAA: compressed IPv6", "2001:db8::1", AAAA, "2001:db8::1", false}, + {"AAAA: loopback", "::1", AAAA, "::1", false}, + {"AAAA: all zeros", "::", AAAA, "::", false}, + {"AAAA: whitespace", " 2001:db8::1 ", AAAA, "2001:db8::1", false}, + + {"AAAA: IPv4 rejected", "93.184.216.34", AAAA, null, true}, + {"AAAA: domain rejected", "example.com", AAAA, null, true}, + {"AAAA: invalid hex rejected", "2001:db8::xyz", AAAA, null, true}, + + // ---------------------------------------------------------------- + // CNAME record + // ---------------------------------------------------------------- + {"CNAME: basic", "target.example.com", CNAME, "target.example.com.", false}, + {"CNAME: uppercase", "TARGET.EXAMPLE.COM", CNAME, "target.example.com.", false}, + {"CNAME: trailing dot", "target.example.com.", CNAME, "target.example.com.", false}, + {"CNAME: whitespace", " target.example.com ", CNAME, "target.example.com.", false}, + {"CNAME: subdomain", "sub.target.example.com", CNAME, "sub.target.example.com.", false}, + + {"CNAME: IP rejected", "192.168.1.1", CNAME, null, true}, + {"CNAME: invalid label", "-bad.example.com", CNAME, null, true}, + + // ---------------------------------------------------------------- + // NS record + // ---------------------------------------------------------------- + {"NS: basic", "ns1.example.com", NS, "ns1.example.com.", false}, + {"NS: uppercase", "NS1.EXAMPLE.COM", NS, "ns1.example.com.", false}, + {"NS: trailing dot", "ns1.example.com.", NS, "ns1.example.com.", false}, + {"NS: subdomain", "ns1.sub.example.com", NS, "ns1.sub.example.com.", false}, + + {"NS: IP rejected", "8.8.8.8", NS, null, true}, + {"NS: invalid label", "ns1-.example.com", NS, null, true}, + + // ---------------------------------------------------------------- + // PTR record + // ---------------------------------------------------------------- + {"PTR: basic", "host.example.com", PTR, "host.example.com.", false}, + {"PTR: in-addr.arpa", "1.168.192.in-addr.arpa", PTR, "1.168.192.in-addr.arpa.", false}, + {"PTR: uppercase", "HOST.EXAMPLE.COM", PTR, "host.example.com.", false}, + {"PTR: trailing dot", "host.example.com.", PTR, "host.example.com.", false}, + + {"PTR: IP rejected", "192.168.1.1", PTR, null, true}, + {"PTR: invalid label", "-host.example.com", PTR, null, true}, + + // ---------------------------------------------------------------- + // MX record + // ---------------------------------------------------------------- + {"MX: standard", "10 mail.example.com", MX, "10 mail.example.com.", false}, + {"MX: zero priority", "0 mail.example.com", MX, "0 mail.example.com.", false}, + {"MX: max priority", "65535 mail.example.com", MX, "65535 mail.example.com.", false}, + {"MX: uppercase", "10 MAIL.EXAMPLE.COM", MX, "10 mail.example.com.", false}, + {"MX: trailing dot", "10 mail.example.com.", MX, "10 mail.example.com.", false}, + {"MX: extra whitespace", "10 mail.example.com", MX, "10 mail.example.com.", false}, + + {"MX: missing domain", "10", MX, null, true}, + {"MX: priority out of range", "65536 mail.example.com", MX, null, true}, + {"MX: non-numeric priority", "abc mail.example.com", MX, null, true}, + {"MX: IP rejected", "10 192.168.1.1", MX, null, true}, + + // ---------------------------------------------------------------- + // SRV record + // ---------------------------------------------------------------- + {"SRV: standard", "10 20 443 target.example.com", SRV, "10 20 443 target.example.com.", false}, + {"SRV: zeros", "0 0 1 target.example.com", SRV, "0 0 1 target.example.com.", false}, + {"SRV: max values", "65535 65535 65535 target.example.com", SRV, "65535 65535 65535 target.example.com.", false}, + {"SRV: uppercase", "10 20 443 TARGET.EXAMPLE.COM", SRV, "10 20 443 target.example.com.", false}, + {"SRV: trailing dot", "10 20 443 target.example.com.", SRV, "10 20 443 target.example.com.", false}, + + {"SRV: missing target", "10 20 443", SRV, null, true}, + {"SRV: port 0", "10 20 0 target.example.com", SRV, null, true}, + {"SRV: priority out of range", "65536 20 443 target.example.com", SRV, null, true}, + {"SRV: IP rejected", "10 20 443 192.168.1.1", SRV, null, true}, + + // ---------------------------------------------------------------- + // TXT record + // ---------------------------------------------------------------- + {"TXT: trim", " hello world ", TXT, "hello world", false}, + {"TXT: already clean", "v=spf1 include:example.com ~all", TXT, "v=spf1 include:example.com ~all", false}, + {"TXT: special chars", "v=DKIM1; k=rsa; p=MIGf", TXT, "v=DKIM1; k=rsa; p=MIGf", false}, + {"TXT: unicode", "héllo wörld", TXT, "héllo wörld", false}, + {"TXT: multiple spaces", "key=value with spaces", TXT, "key=value with spaces", false}, + {"TXT: quoted", "\"quoted value\"", TXT, "\"quoted value\"", false}, + {"TXT: blank", " ", TXT, null, true}, + {"TXT: newline", "\n", TXT, null, true}, + }); + } + + @Test + public void testNormalizeDnsRecordValue() { + if (expectException) { + try { + DnsProviderUtil.normalizeDnsRecordValue(input, recordType); + fail("Expected IllegalArgumentException for [" + description + "] input='" + input + "'"); + } catch (IllegalArgumentException ignored) {} + } else { + String result = DnsProviderUtil.normalizeDnsRecordValue(input, recordType); + assertEquals(description, expected, result); + } + } +} diff --git a/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.java b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.java new file mode 100644 index 000000000000..5b55368770b7 --- /dev/null +++ b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsClientTest.java @@ -0,0 +1,432 @@ +// 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 org.apache.cloudstack.dns.powerdns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.apache.cloudstack.dns.exception.DnsOperationException; +import org.apache.cloudstack.dns.exception.DnsAuthenticationException; +import org.apache.cloudstack.dns.exception.DnsConflictException; +import org.apache.cloudstack.dns.exception.DnsNotFoundException; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.stubbing.Answer; +import org.springframework.test.util.ReflectionTestUtils; + +import com.fasterxml.jackson.databind.JsonNode; + +@RunWith(MockitoJUnitRunner.class) +public class PowerDnsClientTest { + + PowerDnsClient client; + CloseableHttpClient httpClientMock; + + @Before + public void setUp() { + client = new PowerDnsClient(); + httpClientMock = mock(CloseableHttpClient.class); + ReflectionTestUtils.setField(client, "httpClient", httpClientMock); + } + + private CloseableHttpResponse createResponse(int statusCode, String jsonBody) { + CloseableHttpResponse responseMock = mock(CloseableHttpResponse.class); + StatusLine statusLineMock = mock(StatusLine.class); + when(responseMock.getStatusLine()).thenReturn(statusLineMock); + when(statusLineMock.getStatusCode()).thenReturn(statusCode); + + if (jsonBody != null) { + when(responseMock.getEntity()).thenReturn(new StringEntity(jsonBody, StandardCharsets.UTF_8)); + } + + return responseMock; + } + + private void mockHttpResponse(int statusCode, String jsonBody) throws IOException { + CloseableHttpResponse response = createResponse(statusCode, jsonBody); + when(httpClientMock.execute(any(HttpUriRequest.class))).thenReturn(response); + } + + @Test + public void testNormalizeApexRecord() { + String result = client.normalizeRecordName("@", "example.com"); + assertEquals("example.com.", result); + + result = client.normalizeRecordName("", "example.com"); + assertEquals("example.com.", result); + } + + @Test + public void testNormalizeRelativeRecord() { + String result = client.normalizeRecordName("www", "example.com"); + assertEquals("www.example.com.", result); + + result = client.normalizeRecordName("WWW", "example.com"); // test case-insensitive + assertEquals("www.example.com.", result); + } + + @Test + public void testNormalizeAbsoluteRecordWithinZone() { + String result = client.normalizeRecordName("www.example.com.", "example.com"); + assertEquals("www.example.com.", result); + } + + @Test(expected = IllegalArgumentException.class) + public void testNormalizeAbsoluteRecordOutsideZoneThrows() { + client.normalizeRecordName("other.com.", "example.com"); + } + + @Test + public void testNormalizeDottedNameWithoutTrailingDot() { + String result = client.normalizeRecordName("api.test.com", "example.com"); + assertEquals("api.test.com.", result); + } + + @Test + public void testNormalizeRelativeSubdomain() { + String result = client.normalizeRecordName("mail", "example.com"); + assertEquals("mail.example.com.", result); + } + + @Test(expected = IllegalArgumentException.class) + public void testNormalizeNullRecordNameThrows() { + client.normalizeRecordName(null, "example.com"); + } + + @Test + public void testNormalizeZoneNormalization() { + String result = client.normalizeRecordName("www", "Example.Com"); + assertEquals("www.example.com.", result); + + result = client.normalizeRecordName("www", "example.com."); + assertEquals("www.example.com.", result); + } + + @Test + public void testDiscoverAuthoritativeServerIdSuccess() throws Exception { + mockHttpResponse(200, "[{\"id\":\"localhost\", \"daemon_type\":\"authoritative\"}]"); + String result = client.discoverAuthoritativeServerId("http://pdns:8081", null, "apikey"); + assertEquals("localhost", result); + } + + @Test + public void testDiscoverAuthoritativeServerIdFallback() throws Exception { + mockHttpResponse(200, "[{\"id\":\"server1\", \"daemon_type\":\"recursor\"}, {\"id\":\"server2\", \"daemon_type\":\"authoritative\"}]"); + String result = client.discoverAuthoritativeServerId("http://pdns", 8081, "apikey"); + assertEquals("server2", result); + } + + @Test(expected = DnsOperationException.class) + public void testDiscoverAuthoritativeServerIdEmpty() throws Exception { + mockHttpResponse(200, "[]"); + client.discoverAuthoritativeServerId("http://pdns", 8081, "apikey"); + } + + @Test(expected = DnsOperationException.class) + public void testDiscoverAuthoritativeServerIdNoAuthoritative() throws Exception { + mockHttpResponse(200, "[{\"id\":\"server1\", \"daemon_type\":\"recursor\"}]"); + client.discoverAuthoritativeServerId("http://pdns", 8081, "apikey"); + } + + @Test + public void testValidateServerIdSuccess() throws Exception { + mockHttpResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + String result = client.validateServerId("http://pdns", 8081, "apikey", "abc"); + assertEquals("abc", result); + } + + @Test(expected = DnsOperationException.class) + public void testValidateServerIdNotAuthoritative() throws Exception { + mockHttpResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"recursor\"}"); + client.validateServerId("http://pdns", 8081, "apikey", "abc"); + } + + @Test + public void testResolveServerIdWithExternalId() throws Exception { + mockHttpResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + String result = client.resolveServerId("http://pdns", 8081, "apikey", "abc"); + assertEquals("abc", result); + } + + @Test + public void testResolveServerIdWithoutExternalId() throws Exception { + mockHttpResponse(200, "[{\"id\":\"localhost\", \"daemon_type\":\"authoritative\"}]"); + String result = client.resolveServerId("http://pdns", 8081, "apikey", null); + assertEquals("localhost", result); + } + + @Test + public void testCreateZone() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getMethod().equals("GET")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getMethod().equals("POST")) { + return createResponse(201, "{\"id\":\"example.com.\"}"); + } + return createResponse(500, null); + } + }); + + String result = client.createZone("http://pdns", 8081, "apikey", "abc", "example.com", "Native", false, Arrays.asList("ns1.com")); + assertEquals("example.com.", result); + } + + @Test + public void testUpdateZone() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getMethod().equals("GET")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getMethod().equals("PUT")) { + return createResponse(204, null); + } + return createResponse(500, null); + } + }); + + client.updateZone("http://pdns", 8081, "apikey", "abc", "example.com", "Native", true, Arrays.asList("ns1.com")); + // No exception means success + } + + @Test + public void testDeleteZone() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getMethod().equals("GET")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getMethod().equals("DELETE")) { + return createResponse(204, null); + } + return createResponse(500, null); + } + }); + + client.deleteZone("http://pdns", 8081, "apikey", "abc", "example.com"); + } + + @Test + public void testModifyRecord() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getMethod().equals("GET")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getMethod().equals("PATCH")) { + return createResponse(204, null); + } + return createResponse(500, null); + } + }); + + String result = client.modifyRecord("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A", 300, Arrays.asList("1.2.3.4"), "REPLACE"); + assertEquals("www.example.com", result); + } + + @Test + public void testModifyRecordApex() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getMethod().equals("GET")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getMethod().equals("PATCH")) { + return createResponse(204, null); + } + return createResponse(500, null); + } + }); + + String result = client.modifyRecord("http://pdns", 8081, "apikey", "abc", "example.com", "@", "A", 300, Arrays.asList("1.2.3.4"), "REPLACE"); + assertEquals("example.com", result); + } + + @Test + public void testListRecords() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + // validateServerId uses /servers/abc + // listRecords uses /servers/abc/zones/example.com. + if (request.getURI().getPath().endsWith("abc")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getURI().getPath().endsWith("example.com.")) { + return createResponse(200, "{\"rrsets\":[{\"name\":\"www.example.com.\",\"type\":\"A\"}]}"); + } + return createResponse(500, null); + } + }); + + Iterable records = client.listRecords("http://pdns", 8081, "apikey", "abc", "example.com"); + assertNotNull(records); + assertTrue(records.iterator().hasNext()); + assertEquals("www.example.com.", records.iterator().next().path("name").asText()); + } + + @Test + public void testListRecordsEmpty() throws Exception { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + if (request.getURI().getPath().endsWith("abc")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + if (request.getURI().getPath().endsWith("example.com.")) { + return createResponse(200, "{}"); + } + return createResponse(500, null); + } + }); + + Iterable records = client.listRecords("http://pdns", 8081, "apikey", "abc", "example.com"); + assertNotNull(records); + assertTrue(!records.iterator().hasNext()); + } + + @Test(expected = DnsNotFoundException.class) + public void testExecuteThrowsNotFound() throws Exception { + mockHttpResponse(404, "Not Found"); + client.validateServerId("http://pdns", 8081, "apikey", "abc"); + } + + @Test(expected = DnsAuthenticationException.class) + public void testExecuteThrowsAuthError() throws Exception { + mockHttpResponse(401, "Unauthorized"); + client.validateServerId("http://pdns", 8081, "apikey", "abc"); + } + + @Test(expected = DnsConflictException.class) + public void testExecuteThrowsConflictError() throws Exception { + mockHttpResponse(409, "Conflict"); + client.validateServerId("http://pdns", 8081, "apikey", "abc"); + } + + @Test(expected = DnsOperationException.class) + public void testExecuteThrowsUnexpectedStatus() throws Exception { + mockHttpResponse(500, "Server Error"); + client.validateServerId("http://pdns", 8081, "apikey", "abc"); + } + // Route helper: GET /servers/abc → validate; GET /zones/... → zone response + private void mockRecordExists(String zoneJson) throws IOException { + when(httpClientMock.execute(any(HttpUriRequest.class))).thenAnswer(new Answer() { + @Override + public CloseableHttpResponse answer(InvocationOnMock invocation) { + HttpUriRequest request = invocation.getArgument(0); + String path = request.getURI().getPath(); + if (path.endsWith("/abc")) { + return createResponse(200, "{\"id\":\"abc\", \"daemon_type\":\"authoritative\"}"); + } + // zone query (contains /zones/) + if (zoneJson == null) { + return createResponse(200, null); // empty body → execute() returns null + } + return createResponse(200, zoneJson); + } + }); + } + + @Test + public void testRecordExistsZoneNodeNull() throws Exception { + // execute() returns null → zoneNode == null → false + mockRecordExists(null); + boolean result = client.recordExists("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A"); + assertEquals(false, result); + } + + @Test + public void testRecordExistsMissingRrSetsField() throws Exception { + // response has no "rrsets" key → !zoneNode.has(RR_SETS) → false + mockRecordExists("{}"); + boolean result = client.recordExists("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A"); + assertEquals(false, result); + } + + @Test + public void testRecordExistsRrSetsNotArray() throws Exception { + // rrsets is a scalar string, not an ArrayNode → isArray() == false → false + mockRecordExists("{\"rrsets\":\"not-an-array\"}"); + boolean result = client.recordExists("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A"); + assertEquals(false, result); + } + + @Test + public void testRecordExistsEmptyRrSetsArray() throws Exception { + // rrsets is an empty array → isArray() == true && isEmpty() == true → false + mockRecordExists("{\"rrsets\":[]}"); + boolean result = client.recordExists("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A"); + assertEquals(false, result); + } + + @Test + public void testRecordExistsNonEmptyRrSetsArray() throws Exception { + // rrsets is a non-empty array → isArray() == true && !isEmpty() → true + mockRecordExists("{\"rrsets\":[{\"name\":\"www.example.com.\",\"type\":\"A\"}]}"); + boolean result = client.recordExists("http://pdns", 8081, "apikey", "abc", "example.com", "www", "A"); + assertEquals(true, result); + } + + @Test + public void testCloseSucceeds() throws Exception { + // httpClient.close() completes normally → no exception propagated + CloseableHttpClient mockClient = mock(CloseableHttpClient.class); + ReflectionTestUtils.setField(client, "httpClient", mockClient); + client.close(); + org.mockito.Mockito.verify(mockClient).close(); + } + + @Test + public void testCloseSwallowsIOException() throws Exception { + // httpClient.close() throws IOException → caught and logged (warn), no rethrow + CloseableHttpClient mockClient = mock(CloseableHttpClient.class); + org.mockito.Mockito.doThrow(new IOException("connection reset")).when(mockClient).close(); + ReflectionTestUtils.setField(client, "httpClient", mockClient); + client.close(); // must NOT throw + } +} diff --git a/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsProviderTest.java b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsProviderTest.java new file mode 100644 index 000000000000..eae1c9867140 --- /dev/null +++ b/plugins/dns/powerdns/src/test/java/org/apache/cloudstack/dns/powerdns/PowerDnsProviderTest.java @@ -0,0 +1,391 @@ +// 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 org.apache.cloudstack.dns.powerdns; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; + + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import org.apache.cloudstack.dns.DnsRecord; +import org.apache.cloudstack.dns.DnsRecord.RecordType; +import org.apache.cloudstack.dns.DnsServer; +import org.apache.cloudstack.dns.DnsZone; +import org.apache.cloudstack.dns.DnsProviderType; +import org.apache.cloudstack.dns.exception.DnsProviderException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +@RunWith(MockitoJUnitRunner.class) +public class PowerDnsProviderTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PowerDnsProvider provider; + private PowerDnsClient clientMock; + private DnsServer serverMock; + private DnsZone zoneMock; + + @Before + public void setUp() { + provider = new PowerDnsProvider(); + clientMock = mock(PowerDnsClient.class); + serverMock = mock(DnsServer.class); + zoneMock = mock(DnsZone.class); + ReflectionTestUtils.setField(provider, "client", clientMock); + + when(serverMock.getUrl()).thenReturn("http://pdns:8081"); + when(serverMock.getDnsApiKey()).thenReturn("secret"); + when(serverMock.getPort()).thenReturn(8081); + when(serverMock.getDetail(PowerDnsProvider.PDNS_SERVER_ID)).thenReturn("localhost"); + when(serverMock.getNameServers()).thenReturn(Arrays.asList("ns1.example.com")); + + when(zoneMock.getName()).thenReturn("example.com"); + } + + @Test + public void testGetProviderType() { + assertEquals(DnsProviderType.PowerDNS, provider.getProviderType()); + } + + @Test + public void testConfigureCreatesClientWhenNull() { + PowerDnsProvider freshProvider = new PowerDnsProvider(); + boolean result = freshProvider.configure("test", new HashMap<>()); + assertTrue(result); + assertNotNull(ReflectionTestUtils.getField(freshProvider, "client")); + } + + @Test + public void testConfigureDoesNotReplaceExistingClient() { + PowerDnsClient existingClient = mock(PowerDnsClient.class); + ReflectionTestUtils.setField(provider, "client", existingClient); + + boolean result = provider.configure("test", new HashMap<>()); + + assertTrue(result); + assertEquals(existingClient, ReflectionTestUtils.getField(provider, "client")); + } + + @Test + public void testStopClosesClient() { + boolean result = provider.stop(); + assertTrue(result); + verify(clientMock, times(1)).close(); + } + + @Test + public void testStopWithNullClientSucceeds() { + ReflectionTestUtils.setField(provider, "client", null); + boolean result = provider.stop(); + assertTrue(result); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerFieldsNullUrl() { + when(serverMock.getUrl()).thenReturn(null); + provider.validateRequiredServerFields(serverMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerFieldsBlankUrl() { + when(serverMock.getUrl()).thenReturn(" "); + provider.validateRequiredServerFields(serverMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerFieldsNullApiKey() { + when(serverMock.getDnsApiKey()).thenReturn(null); + provider.validateRequiredServerFields(serverMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerFieldsBlankApiKey() { + when(serverMock.getDnsApiKey()).thenReturn(""); + provider.validateRequiredServerFields(serverMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerAndZoneFieldsBlankZoneName() { + when(zoneMock.getName()).thenReturn(" "); + provider.validateRequiredServerAndZoneFields(serverMock, zoneMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateServerAndZoneFieldsNullZoneName() { + when(zoneMock.getName()).thenReturn(null); + provider.validateRequiredServerAndZoneFields(serverMock, zoneMock); + } + + @Test + public void testValidateDelegatesToClient() throws DnsProviderException { + when(clientMock.validateServerId(anyString(), anyInt(), anyString(), anyString())).thenReturn("localhost"); + provider.validate(serverMock); + verify(clientMock).validateServerId("http://pdns:8081", 8081, "secret", "localhost"); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateThrowsWhenServerUrlBlank() throws DnsProviderException { + when(serverMock.getUrl()).thenReturn(""); + provider.validate(serverMock); + } + + @Test + public void testValidateAndResolveServer() throws Exception { + when(clientMock.resolveServerId(anyString(), anyInt(), anyString(), anyString())).thenReturn("localhost"); + String result = provider.validateAndResolveServer(serverMock); + assertEquals("localhost", result); + verify(clientMock).resolveServerId("http://pdns:8081", 8081, "secret", "localhost"); + } + + @Test(expected = IllegalArgumentException.class) + public void testValidateAndResolveServerThrowsWhenUrlBlank() throws Exception { + when(serverMock.getUrl()).thenReturn(null); + provider.validateAndResolveServer(serverMock); + } + + @Test + public void testProvisionZoneDelegatesToClient() throws DnsProviderException { + when(clientMock.createZone(anyString(), anyInt(), anyString(), anyString(), anyString(), anyString(), eq(false), anyList())).thenReturn("example.com."); + String zoneId = provider.provisionZone(serverMock, zoneMock); + assertEquals("example.com.", zoneId); + verify(clientMock).createZone("http://pdns:8081", 8081, "secret", "localhost", "example.com", + "Native", false, Arrays.asList("ns1.example.com")); + } + + @Test(expected = IllegalArgumentException.class) + public void testProvisionZoneThrowsWhenZoneNameBlank() throws DnsProviderException { + when(zoneMock.getName()).thenReturn(null); + provider.provisionZone(serverMock, zoneMock); + } + + @Test + public void testDeleteZoneDelegatesToClient() throws DnsProviderException { + provider.deleteZone(serverMock, zoneMock); + verify(clientMock).deleteZone("http://pdns:8081", 8081, "secret", "localhost", "example.com"); + } + + @Test(expected = IllegalArgumentException.class) + public void testDeleteZoneThrowsWhenZoneNameBlank() throws DnsProviderException { + when(zoneMock.getName()).thenReturn(""); + provider.deleteZone(serverMock, zoneMock); + } + + @Test + public void testUpdateZoneDelegatesToClient() throws DnsProviderException { + provider.updateZone(serverMock, zoneMock); + verify(clientMock).updateZone("http://pdns:8081", 8081, "secret", "localhost", "example.com", + "Native", false, Arrays.asList("ns1.example.com")); + } + + @Test + public void testAddRecordDelegatesToClient() throws DnsProviderException { + DnsRecord record = new DnsRecord("www", RecordType.A, Arrays.asList("1.2.3.4"), 300); + when(clientMock.modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyList(), anyString())).thenReturn("www.example.com"); + + String result = provider.addRecord(serverMock, zoneMock, record); + + assertEquals("www.example.com", result); + verify(clientMock).modifyRecord("http://pdns:8081", 8081, "secret", "localhost", "example.com", + "www", "A", 300L, Arrays.asList("1.2.3.4"), "REPLACE"); + } + + @Test(expected = IllegalArgumentException.class) + public void testAddRecordThrowsWhenServerUrlBlank() throws DnsProviderException { + when(serverMock.getUrl()).thenReturn(""); + DnsRecord record = new DnsRecord("www", RecordType.A, Arrays.asList("1.2.3.4"), 300); + provider.addRecord(serverMock, zoneMock, record); + } + + @Test + public void testUpdateRecordDelegatesToAddRecord() throws DnsProviderException { + DnsRecord record = new DnsRecord("mail", RecordType.MX, Arrays.asList("10 mail.example.com"), 300); + when(clientMock.modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyList(), anyString())).thenReturn("mail.example.com"); + + String result = provider.updateRecord(serverMock, zoneMock, record); + + assertEquals("mail.example.com", result); + verify(clientMock).modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + eq("mail"), eq("MX"), eq(300L), eq(Arrays.asList("10 mail.example.com")), eq("REPLACE")); + } + + @Test + public void testDeleteRecordDelegatesToClientWithDeleteChangeType() throws DnsProviderException { + DnsRecord record = new DnsRecord("old", RecordType.CNAME, Arrays.asList("target.com"), 600); + when(clientMock.modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyList(), anyString())).thenReturn("old.example.com"); + + String result = provider.deleteRecord(serverMock, zoneMock, record); + + assertEquals("old.example.com", result); + verify(clientMock).modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + eq("old"), eq("CNAME"), eq(600L), eq(Arrays.asList("target.com")), eq("DELETE")); + } + + @Test + public void testApplyRecordPassesChangeTypeToClient() throws DnsProviderException { + DnsRecord record = new DnsRecord("txt", RecordType.TXT, Arrays.asList("v=spf1 include:example.com ~all"), 3600); + when(clientMock.modifyRecord(anyString(), anyInt(), anyString(), anyString(), anyString(), + anyString(), anyString(), anyLong(), anyList(), anyString())).thenReturn("txt.example.com"); + + provider.applyRecord("http://pdns:8081", 8081, "secret", "localhost", "example.com", + record, PowerDnsProvider.ChangeType.REPLACE); + + verify(clientMock).modifyRecord("http://pdns:8081", 8081, "secret", "localhost", "example.com", + "txt", "TXT", 3600L, Arrays.asList("v=spf1 include:example.com ~all"), "REPLACE"); + } + + @Test + public void testListRecordsParsesRrsets() throws DnsProviderException { + ObjectNode aRecord = MAPPER.createObjectNode(); + aRecord.put("name", "www.example.com."); + aRecord.put("type", "A"); + aRecord.put("ttl", 300); + ArrayNode records = aRecord.putArray("records"); + records.addObject().put("content", "1.2.3.4"); + + ObjectNode mxRecord = MAPPER.createObjectNode(); + mxRecord.put("name", "example.com."); + mxRecord.put("type", "MX"); + mxRecord.put("ttl", 600); + ArrayNode mxRecords = mxRecord.putArray("records"); + mxRecords.addObject().put("content", "10 mail.example.com"); + + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(Arrays.asList(aRecord, mxRecord)); + + List result = provider.listRecords(serverMock, zoneMock); + + assertEquals(2, result.size()); + + DnsRecord first = result.get(0); + assertEquals("www.example.com.", first.getName()); + assertEquals(RecordType.A, first.getType()); + assertEquals(300, first.getTtl()); + assertEquals(Arrays.asList("1.2.3.4"), first.getContents()); + + DnsRecord second = result.get(1); + assertEquals("example.com.", second.getName()); + assertEquals(RecordType.MX, second.getType()); + assertEquals(600, second.getTtl()); + assertEquals(Arrays.asList("10 mail.example.com"), second.getContents()); + } + + @Test + public void testListRecordsSkipsSoaRecords() throws DnsProviderException { + ObjectNode soaRecord = MAPPER.createObjectNode(); + soaRecord.put("name", "example.com."); + soaRecord.put("type", "SOA"); + soaRecord.put("ttl", 3600); + soaRecord.putArray("records").addObject().put("content", "ns1.example.com. admin.example.com. ..."); + + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(Collections.singletonList(soaRecord)); + + List result = provider.listRecords(serverMock, zoneMock); + assertTrue(result.isEmpty()); + } + + @Test + public void testListRecordsSkipsUnknownRecordTypes() throws DnsProviderException { + ObjectNode unknownRecord = MAPPER.createObjectNode(); + unknownRecord.put("name", "test.example.com."); + unknownRecord.put("type", "UNKNOWNTYPE"); + unknownRecord.put("ttl", 300); + unknownRecord.putArray("records").addObject().put("content", "some-data"); + + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(Collections.singletonList(unknownRecord)); + + List result = provider.listRecords(serverMock, zoneMock); + assertTrue(result.isEmpty()); + } + + @Test + public void testListRecordsIgnoresEmptyContentEntries() throws DnsProviderException { + ObjectNode aRecord = MAPPER.createObjectNode(); + aRecord.put("name", "host.example.com."); + aRecord.put("type", "A"); + aRecord.put("ttl", 300); + ArrayNode records = aRecord.putArray("records"); + records.addObject().put("content", ""); + records.addObject().put("content", "5.6.7.8"); + + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(Collections.singletonList(aRecord)); + + List result = provider.listRecords(serverMock, zoneMock); + assertEquals(1, result.size()); + assertEquals(Collections.singletonList("5.6.7.8"), result.get(0).getContents()); + } + + @Test + public void testListRecordsReturnsEmptyListWhenClientReturnsEmpty() throws DnsProviderException { + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenReturn(Collections.emptyList()); + + List result = provider.listRecords(serverMock, zoneMock); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test(expected = DnsProviderException.class) + public void testListRecordsPropagatesClientException() throws DnsProviderException { + when(clientMock.listRecords(anyString(), anyInt(), anyString(), anyString(), anyString())) + .thenThrow(mock(DnsProviderException.class)); + + provider.listRecords(serverMock, zoneMock); + } + + @Test(expected = IllegalArgumentException.class) + public void testListRecordsThrowsWhenZoneNameBlank() throws DnsProviderException { + when(zoneMock.getName()).thenReturn(""); + provider.listRecords(serverMock, zoneMock); + } + + @Test + public void testChangeTypeValues() { + assertEquals("REPLACE", PowerDnsProvider.ChangeType.REPLACE.name()); + assertEquals("DELETE", PowerDnsProvider.ChangeType.DELETE.name()); + } +} diff --git a/plugins/hypervisors/hyperv/DotNet/ServerResource/HypervResource/HypervResourceController.cs b/plugins/hypervisors/hyperv/DotNet/ServerResource/HypervResource/HypervResourceController.cs index 7e31ced3e389..84c67d7a9650 100644 --- a/plugins/hypervisors/hyperv/DotNet/ServerResource/HypervResource/HypervResourceController.cs +++ b/plugins/hypervisors/hyperv/DotNet/ServerResource/HypervResource/HypervResourceController.cs @@ -1159,7 +1159,11 @@ public JContainer StartCommand([FromBody]dynamic cmd) try { string systemVmIsoPath = null; - String uriStr = (String)cmd.secondaryStorage; + String uriStr; + foreach (var item in cmd.secondaryStorages) + { + uriStr = item; + } if (!String.IsNullOrEmpty(uriStr)) { NFSTO share = new NFSTO(); diff --git a/plugins/hypervisors/hyperv/src/main/java/com/cloud/hypervisor/hyperv/resource/HypervDirectConnectResource.java b/plugins/hypervisors/hyperv/src/main/java/com/cloud/hypervisor/hyperv/resource/HypervDirectConnectResource.java index 6ad06f426a79..12d6c42fd7e6 100644 --- a/plugins/hypervisors/hyperv/src/main/java/com/cloud/hypervisor/hyperv/resource/HypervDirectConnectResource.java +++ b/plugins/hypervisors/hyperv/src/main/java/com/cloud/hypervisor/hyperv/resource/HypervDirectConnectResource.java @@ -455,7 +455,7 @@ public final Answer executeRequest(final Command cmd) { if (s_hypervMgr != null) { final String secondary = s_hypervMgr.prepareSecondaryStorageStore(Long.parseLong(zoneId)); if (secondary != null) { - ((StartCommand)cmd).setSecondaryStorage(secondary); + ((StartCommand)cmd).setSecondaryStorages(List.of(secondary)); } } else { logger.error("Hyperv manager isn't available. Couldn't check and copy the System VM ISO."); diff --git a/plugins/hypervisors/kvm/pom.xml b/plugins/hypervisors/kvm/pom.xml index 255ada09ef4f..a00530268e83 100644 --- a/plugins/hypervisors/kvm/pom.xml +++ b/plugins/hypervisors/kvm/pom.xml @@ -62,6 +62,21 @@ rados ${cs.rados-java.version} + + com.github.jai-imageio + jai-imageio-core + 1.4.0 + + + com.dynatrace.hash4j + hash4j + 0.29.0 + + + com.mikesamuel + json-sanitizer + 1.2.3 + com.linbit.linstor.api java-linstor diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BlockCommitListener.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BlockCommitListener.java index d360aa481372..ab4513642efa 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BlockCommitListener.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BlockCommitListener.java @@ -27,18 +27,14 @@ import org.libvirt.event.BlockJobStatus; import org.libvirt.event.BlockJobType; -import java.util.concurrent.Semaphore; - public class BlockCommitListener implements BlockJobListener { - private Semaphore semaphore; private String result; private String vmName; private Logger logger; private String logid; - protected BlockCommitListener(Semaphore semaphore, String vmName, String logid) { - this.semaphore = semaphore; + protected BlockCommitListener(String vmName, String logid) { this.vmName = vmName; this.logid = logid; logger = LogManager.getLogger(getClass()); @@ -54,24 +50,22 @@ public void onEvent(Domain domain, String diskPath, BlockJobType type, BlockJobS return; } + ThreadContext.put("logcontextid", logid); + logger.debug("Received status [{}] on disk [{}] while listening for block commit of VM [{}].", status, diskPath, vmName); switch (status) { case COMPLETED: result = null; - semaphore.release(); return; case READY: try { - ThreadContext.put("logcontextid", logid); logger.debug("Pivoting disk [{}] of VM [{}].", diskPath, vmName); domain.blockJobAbort(diskPath, Domain.BlockJobAbortFlags.PIVOT); } catch (LibvirtException ex) { result = String.format("Failed to pivot disk due to [%s].", ex.getMessage()); - semaphore.release(); } return; default: result = String.format("Failed to block commit disk with status [%s].", status); - semaphore.release(); } } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BridgeVifDriver.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BridgeVifDriver.java index d6fc0479faf1..327ec46e0ecf 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BridgeVifDriver.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/BridgeVifDriver.java @@ -48,6 +48,7 @@ public class BridgeVifDriver extends VifDriverBase { private final Object _vnetBridgeMonitor = new Object(); private String _modifyVlanPath; private String _modifyVxlanPath; + private String _macIpScriptPath; private String _controlCidr = NetUtils.getLinkLocalCIDR(); private Long libvirtVersion; @@ -83,6 +84,14 @@ public void configure(Map params) throws ConfigurationException throw new ConfigurationException("Unable to find " + vxlanScript); } + if (Boolean.TRUE.equals(AgentPropertiesFileHandler.getPropertyValue(AgentProperties.VM_NETWORK_MACIP_STATIC))) { + _macIpScriptPath = Script.findScript(networkScriptsDir, "modifymacip.sh"); + if (_macIpScriptPath == null) { + throw new ConfigurationException("Unable to find modifymacip.sh"); + } + logger.info("VM network MAC/IP static script configured: {}", _macIpScriptPath); + } + libvirtVersion = (Long) params.get("libvirtVersion"); if (libvirtVersion == null) { libvirtVersion = 0L; @@ -279,11 +288,14 @@ public LibvirtVMDef.InterfaceDef plug(NicTO nic, String guestOsType, String nicA } intf.setLinkStateUp(nic.isEnabled()); + executeMacIpScript(intf.getBrName(), nic.getMac(), nic.getIp(), nic.getIp6Address(), nic.getNicSecIps()); + return intf; } @Override public void unplug(LibvirtVMDef.InterfaceDef iface, boolean deleteBr) { + executeMacIpScript(iface.getBrName(), iface.getMacAddress()); deleteVnetBr(iface.getBrName(), deleteBr); } @@ -403,6 +415,60 @@ private void deleteVnetBr(String brName, boolean deleteBr) { } } + private void executeMacIpScript(String brName, String mac) { + if (_macIpScriptPath == null || mac == null || brName == null) { + return; + } + try { + final Script command = new Script(_macIpScriptPath, _timeout, logger); + command.add("-o", "delete"); + command.add("-b", brName); + command.add("-m", mac); + final String result = command.execute(); + if (result != null) { + logger.warn("MAC/IP script returned error for delete on {}: {}", mac, result); + } + } catch (Exception e) { + // Managing host neighbour/route entries is best-effort and must never break VM lifecycle operations + logger.warn("Failed to run MAC/IP script for delete on {} ({})", mac, brName, e); + } + } + + private void executeMacIpScript(String brName, String mac, String ipv4, String ipv6, List secondaryIps) { + if (_macIpScriptPath == null || mac == null || brName == null) { + return; + } + try { + final Script command = new Script(_macIpScriptPath, _timeout, logger); + command.add("-o", "add"); + command.add("-b", brName); + command.add("-m", mac); + if (ipv4 != null && !ipv4.isEmpty()) { + command.add("-4", ipv4); + } + command.add("-6", NetUtils.ipv6LinkLocal(mac).toString()); + if (ipv6 != null && !ipv6.isEmpty()) { + command.add("-6", ipv6); + } + if (secondaryIps != null) { + for (String secIp : secondaryIps) { + if (NetUtils.isValidIp6(secIp)) { + command.add("-6", secIp); + } else { + command.add("-4", secIp); + } + } + } + final String result = command.execute(); + if (result != null) { + logger.warn("MAC/IP script returned error for add on {}: {}", mac, result); + } + } catch (Exception e) { + // Managing host neighbour/route entries is best-effort and must never break VM lifecycle operations + logger.warn("Failed to run MAC/IP script for add on {} ({})", mac, brName, e); + } + } + private void deleteExistingLinkLocalRouteTable(String linkLocalBr) { Script command = new Script("/bin/bash", _timeout); command.add("-c"); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 41716881fa4a..4281036d9456 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -36,6 +36,7 @@ import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; @@ -51,8 +52,6 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -74,6 +73,9 @@ import javax.xml.xpath.XPathFactory; import com.cloud.agent.api.to.VirtualMachineMetadataTO; +import com.cloud.utils.exception.BackupException; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import com.cloud.agent.api.to.DataObjectType; import org.apache.cloudstack.api.ApiConstants.IoDriverPolicy; import org.apache.cloudstack.command.CommandInfo; import org.apache.cloudstack.command.ReconcileCommandService; @@ -394,6 +396,20 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final int IMAGE_SERVER_DEFAULT_PORT = 54322; public static final String IMAGE_SERVER_SYSTEMD_UNIT_NAME = "cloudstack-image-server"; + private static final String BLOCK_PULL_COMMAND = "virsh blockpull --domain %s --path %s"; + + private static final String SNAPSHOT_XML = "\n" + + "%s\n" + + "\n" + + " \n" + + "%s" + + " \n" + + ""; + + private static final String TAG_DISK_SNAPSHOT = "\n" + + "\n" + + "\n"; + protected int qcow2DeltaMergeTimeout; private String modifyVlanPath; @@ -594,6 +610,8 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv public static final String CGROUP_V2 = "cgroup2fs"; + public static final String AGENT_IS_NOT_CONNECTED = "QEMU guest agent is not connected"; + /** * Virsh command to merge (blockcommit) snapshot into the base file.

* 1st parameter: VM's name;
@@ -612,7 +630,7 @@ public long getHypervisorQemuVersion() { @Override public synchronized void registerStatusUpdater(AgentStatusUpdater updater) { - if (AgentPropertiesFileHandler.getPropertyValue(AgentProperties.LIBVIRT_EVENTS_ENABLED)) { + if (isLibvirtEventsEnabled()) { try { Connect conn = LibvirtConnection.getConnection(); if (libvirtDomainListener != null) { @@ -2358,7 +2376,7 @@ public String startVM(final Connect conn, final String vmName, final String doma public boolean stop() { try { final Connect conn = LibvirtConnection.getConnection(); - if (AgentPropertiesFileHandler.getPropertyValue(AgentProperties.LIBVIRT_EVENTS_ENABLED) && libvirtDomainListener != null) { + if (isLibvirtEventsEnabled() && libvirtDomainListener != null) { LOGGER.debug("Clearing old domain listener"); conn.removeLifecycleListener(libvirtDomainListener); } @@ -4409,6 +4427,7 @@ public StartupCommand[] initialize() { if (hostSupportsOvfExport()) { cmd.getHostDetails().put(HOST_OVFTOOL_VERSION, getHostOvfToolVersion()); } + addBackupJobDetails(cmd.getHostDetails()); HealthCheckResult healthCheckResult = getHostHealthCheckResult(); if (healthCheckResult != HealthCheckResult.IGNORE) { cmd.setHostHealthCheckResult(healthCheckResult == HealthCheckResult.SUCCESS); @@ -4442,6 +4461,18 @@ public StartupCommand[] initialize() { return startupCommandsArray; } + private void addBackupJobDetails(Map details) { + Integer maxCompressionOperations = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.BACKUP_COMPRESSION_MAX_CONCURRENT_OPERATIONS_PER_HOST); + if (maxCompressionOperations != null) { + details.put(AgentProperties.BACKUP_COMPRESSION_MAX_CONCURRENT_OPERATIONS_PER_HOST.getName(), maxCompressionOperations.toString()); + } + + Integer maxValidationOperations = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.BACKUP_VALIDATION_MAX_CONCURRENT_OPERATIONS_PER_HOST); + if (maxValidationOperations != null) { + details.put(AgentProperties.BACKUP_VALIDATION_MAX_CONCURRENT_OPERATIONS_PER_HOST.getName(), maxValidationOperations.toString()); + } + } + protected List getHostTags() { List hostTagsList = new ArrayList<>(); String hostTags = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.HOST_TAGS); @@ -5147,7 +5178,7 @@ public DiskDef getDiskWithPathOfVolumeObjectTO(List disks, VolumeObject return disks.stream() .filter(diskDef -> diskDef.getDiskPath() != null && diskDef.getDiskPath().contains(vol.getPath())) .findFirst() - .orElseThrow(() -> new CloudRuntimeException(String.format("Unable to find volume [%s].", vol.getUuid()))); + .orElseThrow(() -> new CloudRuntimeException(String.format("Unable to find volume [%s] with path [%s].", vol.getUuid(), vol.getPath()))); } protected String getDiskPathFromDiskDef(DiskDef disk) { @@ -6579,7 +6610,7 @@ public static String generateSecretUUIDFromString(String seed) { } /** - * Merges the snapshot into base file. + * Merges the delta into a base file. * * @param vm Domain of the VM; * @param diskLabel Disk label to manage snapshot and base file; @@ -6591,15 +6622,19 @@ public static String generateSecretUUIDFromString(String seed) { * @param conn Libvirt connection; * @throws LibvirtException */ - public void mergeSnapshotIntoBaseFile(Domain vm, String diskLabel, String baseFilePath, String topFilePath, boolean active, String snapshotName, VolumeObjectTO volume, + public void mergeDeltaIntoBaseFile(Domain vm, String diskLabel, String baseFilePath, String topFilePath, boolean active, String snapshotName, VolumeObjectTO volume, Connect conn) throws LibvirtException { - if (AgentPropertiesFileHandler.getPropertyValue(AgentProperties.LIBVIRT_EVENTS_ENABLED)) { + if (isLibvirtEventsEnabled()) { mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(vm, diskLabel, baseFilePath, topFilePath, active, snapshotName, volume, conn); } else { mergeSnapshotIntoBaseFileWithoutEvents(vm, diskLabel, baseFilePath, topFilePath, active, snapshotName, volume, conn); } } + protected Boolean isLibvirtEventsEnabled() { + return AgentPropertiesFileHandler.getPropertyValue(AgentProperties.LIBVIRT_EVENTS_ENABLED); + } + /** * This method only works if LIBVIRT_EVENTS_ENABLED is true. * */ @@ -6616,40 +6651,26 @@ protected void mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(Domain commitFlags |= Domain.BlockCommitFlags.ACTIVE; } - Semaphore semaphore = getSemaphoreToWaitForMerge(); - BlockCommitListener blockCommitListener = getBlockCommitListener(semaphore, vmName); - vm.addBlockJobListener(blockCommitListener); - - logger.info("Starting block commit of snapshot [{}] of VM [{}]. Using parameters: diskLabel [{}]; baseFilePath [{}]; topFilePath [{}]; commitFlags [{}]", snapshotName, - vmName, diskLabel, baseFilePath, topFilePath, commitFlags); + BlockCommitListener blockCommitListener = getBlockCommitListener(vmName); + try { + vm.addBlockJobListener(blockCommitListener); - vm.blockCommit(diskLabel, baseFilePath, topFilePath, 0, commitFlags); + logger.info("Starting block commit of QCOW2 delta [{}] of VM [{}]. Using parameters: diskLabel [{}]; baseFilePath [{}]; topFilePath [{}]; commitFlags [{}]", + snapshotName, + vmName, diskLabel, baseFilePath, topFilePath, commitFlags); - Thread checkProgressThread = new Thread(() -> checkBlockCommitProgress(vm, diskLabel, vmName, snapshotName, topFilePath, baseFilePath)); - checkProgressThread.start(); + vm.blockCommit(diskLabel, baseFilePath, topFilePath, 0, commitFlags); - String errorMessage = String.format("the block commit of top file [%s] into base file [%s] for snapshot [%s] of VM [%s]." + - " The job will be left running to avoid data corruption, but ACS will return an error and volume [%s] will need to be normalized manually. If the commit" + - " involved the active image, the pivot will need to be manually done.", topFilePath, baseFilePath, snapshotName, vmName, volume); - try { - if (!semaphore.tryAcquire(qcow2DeltaMergeTimeout, TimeUnit.SECONDS)) { - throw new CloudRuntimeException("Timed out while waiting for " + errorMessage); - } - } catch (InterruptedException e) { - throw new CloudRuntimeException("Interrupted while waiting for " + errorMessage); + checkBlockCommitProgress(vm, diskLabel, vmName, snapshotName, topFilePath, baseFilePath); } finally { vm.removeBlockJobListener(blockCommitListener); } String mergeResult = blockCommitListener.getResult(); - try { - checkProgressThread.join(); - } catch (InterruptedException ex) { - throw new CloudRuntimeException(String.format("Exception while running wait block commit task of snapshot [%s] and VM [%s].", snapshotName, vmName)); - } - if (mergeResult != null) { - String commitError = String.format("Failed %s The failure occurred due to [%s].", errorMessage, mergeResult); + String commitError = String.format("Failed the block commit of top file [%s] into base file [%s] for snapshot [%s] of VM [%s]. The job will be left running to avoid" + + " data corruption, but ACS will return an error and volume [%s] will need to be normalized manually. If the commit involved the active image, the pivot will" + + " need to be manually done. The failure occurred due to [%s].", topFilePath, baseFilePath, snapshotName, vmName, volume, mergeResult); logger.error(commitError); throw new CloudRuntimeException(commitError); } @@ -6706,15 +6727,8 @@ protected String buildMergeCommand(String vmName, String diskLabel, String baseF /** * This was created to facilitate testing. * */ - protected BlockCommitListener getBlockCommitListener(Semaphore semaphore, String vmName) { - return new BlockCommitListener(semaphore, vmName, ThreadContext.get("logcontextid")); - } - - /** - * This was created to facilitate testing. - * */ - protected Semaphore getSemaphoreToWaitForMerge() { - return new Semaphore(0); + protected BlockCommitListener getBlockCommitListener(String vmName) { + return new BlockCommitListener(vmName, ThreadContext.get("logcontextid")); } protected void checkBlockCommitProgress(Domain vm, String diskLabel, String vmName, String snapshotName, String topFilePath, String baseFilePath) { @@ -6730,8 +6744,8 @@ protected void checkBlockCommitProgress(Domain vm, String diskLabel, String vmNa try { Thread.sleep(1000); } catch (InterruptedException ex) { - logger.debug("Thread that was tracking the progress {} was interrupted.", partialLog, ex); - return; + logger.trace("Thread that was tracking the progress for the block commit job {} was interrupted. Ignoring.", partialLog, ex); + continue; } try { @@ -7038,7 +7052,7 @@ private static boolean isClvmVolume(DiskDef disk, VirtualMachineTO vmSpec) { continue; } VolumeObjectTO volumeTO = (VolumeObjectTO) diskTO.getData(); - if (!diskPath.equals(volumeTO.getPath()) && !diskPath.equals(diskTO.getPath())) { + if (!diskPath.substring(diskPath.lastIndexOf(File.separator) + 1).equals(volumeTO.getPath())) { continue; } DataStoreTO dataStore = volumeTO.getDataStore(); @@ -7141,4 +7155,261 @@ static boolean checkIfVolumeGroupIsClustered(String vgName) { return false; } + + public Map createDiskOnlyVmSnapshotForRunningVm(List> volumeTosAndNewPaths, String vmName, String snapshotName, + boolean quiesceVm) throws BackupException { + logger.info("Taking disk-only VM snapshot of running VM [{}].", vmName); + + Domain dm = null; + try { + LibvirtUtilitiesHelper libvirtUtilitiesHelper = getLibvirtUtilitiesHelper(); + Connect conn = libvirtUtilitiesHelper.getConnection(); + List disks = getDisks(conn, vmName); + + dm = getDomain(conn, vmName); + + if (dm == null) { + throw new BackupException(String.format("Creation of disk-only VM snapshot failed as we could not find the VM [%s].", vmName), true); + } + + Pair> snapshotXmlAndVolumeToNewPathMap = createSnapshotXmlAndNewVolumePathMap(volumeTosAndNewPaths, disks, snapshotName); + + int flagsToUseForRunningVmSnapshotCreation = getFlagsToUseForRunningVmSnapshotCreation(quiesceVm); + String snapshotXml = snapshotXmlAndVolumeToNewPathMap.first(); + + logger.info("Creating disk-only VM snapshot for VM [{}] using parameters: snapshotXml [{}]; flags [{}].", vmName, snapshotXml, flagsToUseForRunningVmSnapshotCreation); + + dm.snapshotCreateXML(snapshotXml, flagsToUseForRunningVmSnapshotCreation); + + return snapshotXmlAndVolumeToNewPathMap.second(); + } catch (LibvirtException e) { + String errorMsg = String.format("Creation of disk-only VM snapshot for VM [%s] failed due to %s.", vmName, e.getMessage()); + boolean isVmConsistent = false; + if (e.getMessage().contains(AGENT_IS_NOT_CONNECTED)) { + errorMsg = "QEMU guest agent is not connected. If the VM has been recently started, it might connect soon. Otherwise the VM does not have the" + + " guest agent installed; thus the QuiesceVM parameter is not supported."; + isVmConsistent = true; + } + logger.error(errorMsg, e); + throw new BackupException(errorMsg, isVmConsistent); + } finally { + if (dm != null) { + try { + dm.free(); + } catch (LibvirtException l) { + logger.trace("Ignoring Libvirt error.", l); + } + } + } + } + + public Map createDiskOnlyVMSnapshotOfStoppedVm(List> volumeTosAndNewPaths, String vmName) { + logger.info("Creating volume deltas for stopped VM [{}].", vmName); + + Map mapVolumeToSnapshotSize = new HashMap<>(); + try { + for (Pair volumeObjectTOAndNewPath : volumeTosAndNewPaths) { + VolumeObjectTO volumeObjectTO = volumeObjectTOAndNewPath.first(); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); + KVMStoragePool kvmStoragePool = getStoragePoolMgr().getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + + String snapshotPath = volumeObjectTOAndNewPath.second(); + String snapshotFullPath = kvmStoragePool.getLocalPathFor(snapshotPath); + QemuImgFile newDelta = new QemuImgFile(snapshotFullPath, QemuImg.PhysicalDiskFormat.QCOW2); + + String currentDeltaFullPath = kvmStoragePool.getLocalPathFor(volumeObjectTO.getPath()); + QemuImgFile currentDelta = new QemuImgFile(currentDeltaFullPath, QemuImg.PhysicalDiskFormat.QCOW2); + + QemuImg qemuImg = new QemuImg(0); + + logger.debug("Creating new delta [{}] for volume [{}] as part of the delta creation process for VM [{}].", newDelta, volumeObjectTO.getUuid(), vmName); + qemuImg.create(newDelta, currentDelta); + + mapVolumeToSnapshotSize.put(volumeObjectTO.getUuid(), getFileSize(currentDeltaFullPath)); + } + } catch (Exception e) { + logger.error("Exception while creating volume delta for VM [{}]. Deleting leftover deltas.", vmName, e); + cleanupLeftoverDeltas(volumeTosAndNewPaths, mapVolumeToSnapshotSize); + throw new BackupException(String.format("An exception was caught during the delta creation for VM [%s]. The leftover deltas have been deleted.", vmName), true); + } + + return mapVolumeToSnapshotSize; + } + + protected void cleanupLeftoverDeltas(List> volumeTosAndNewPaths, Map mapVolumeToSnapshotSize) { + for (Pair volumeObjectTOAndNewPath : volumeTosAndNewPaths) { + VolumeObjectTO volumeObjectTO = volumeObjectTOAndNewPath.first(); + Long volSize = mapVolumeToSnapshotSize.get(volumeObjectTO.getUuid()); + if (volSize == null) { + continue; + } + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); + KVMStoragePool kvmStoragePool = getStoragePoolMgr().getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + try { + Files.deleteIfExists(Path.of(kvmStoragePool.getLocalPathFor(volumeObjectTOAndNewPath.second()))); + } catch (IOException ex) { + logger.warn("Tried to delete leftover delta at [{}]. Failed.", volumeObjectTOAndNewPath.second(), ex); + } + } + } + + public void mergeDeltaForStoppedVm(DeltaMergeTreeTO deltaMergeTreeTO) throws QemuImgException, IOException, LibvirtException { + logger.debug("Merging delta [{}] for stopped VM.", deltaMergeTreeTO); + + QemuImg qemuImg = new QemuImg(qcow2DeltaMergeTimeout * 1000); + DataTO parentTo = deltaMergeTreeTO.getParent(); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) parentTo.getDataStore(); + KVMStoragePool storagePool = storagePoolManager.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + String childLocalPath = storagePool.getLocalPathFor(deltaMergeTreeTO.getChild().getPath()); + + QemuImgFile parent = new QemuImgFile(storagePool.getLocalPathFor(parentTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile child = new QemuImgFile(childLocalPath, QemuImg.PhysicalDiskFormat.QCOW2); + + logger.debug("Committing child delta [{}] into parent delta [{}].", parentTo, deltaMergeTreeTO.getChild()); + qemuImg.commit(child, parent, true); + + List grandChildren = deltaMergeTreeTO.getGrandChildren().stream() + .map(deltaTo -> new QemuImgFile(storagePool.getLocalPathFor(deltaTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2)) + .collect(Collectors.toList()); + + logger.debug("Rebasing grand-children [{}] into parent at [{}].", grandChildren, parent.getFileName()); + for (QemuImgFile grandChild : grandChildren) { + qemuImg.rebase(grandChild, parent, parent.getFormat().toString(), false); + } + + logger.debug("Deleting child at [{}] as it is useless.", childLocalPath); + + Files.deleteIfExists(Path.of(childLocalPath)); + } + + public void mergeDeltaForRunningVm(DeltaMergeTreeTO mergeTreeTO, String vmName, VolumeObjectTO volumeObjectTO) throws LibvirtException, QemuImgException { + logger.debug("Merging delta [{}] for running VM [{}].", mergeTreeTO, vmName); + + QemuImg qemuImg = new QemuImg(qcow2DeltaMergeTimeout * 1000); + Connect conn = libvirtUtilitiesHelper.getConnection(); + Domain domain = getDomain(conn, vmName); + List disks = getDisks(conn, vmName); + + DataTO childTO = mergeTreeTO.getChild(); + DataTO parentSnapshotTO = mergeTreeTO.getParent(); + KVMStoragePool storagePool = libvirtUtilitiesHelper.getPrimaryPoolFromDataTo(volumeObjectTO, storagePoolManager); + + boolean active = DataObjectType.VOLUME.equals(childTO.getObjectType()); + String label = getDiskWithPathOfVolumeObjectTO(disks, volumeObjectTO).getDiskLabel(); + String parentSnapshotLocalPath = storagePool.getLocalPathFor(parentSnapshotTO.getPath()); + String childDeltaPath = storagePool.getLocalPathFor(childTO.getPath()); + + logger.debug("Found label [{}] for [{}]. Will merge delta at [{}] into delta at [{}].", label, volumeObjectTO, parentSnapshotLocalPath, childDeltaPath); + + mergeDeltaIntoBaseFile(domain, label, parentSnapshotLocalPath, childDeltaPath, active, childTO.getPath(), volumeObjectTO, conn); + + QemuImgFile parent = new QemuImgFile(parentSnapshotLocalPath, QemuImg.PhysicalDiskFormat.QCOW2); + + logger.debug("Rebasing grand-children [{}] into parent at [{}].", mergeTreeTO.getGrandChildren(), parentSnapshotLocalPath); + for (DataTO grandChildTo : mergeTreeTO.getGrandChildren()) { + if (checkIfFileIsInActiveChainForVm(domain, grandChildTo)) { + logger.debug("Grand-child [{}] is on the active chain of VM [{}], thus Libvirt has already rebased it, will ignore it.", grandChildTo, vmName); + continue; + } + QemuImgFile grandChild = new QemuImgFile(storagePool.getLocalPathFor(grandChildTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2); + qemuImg.rebase(grandChild, parent, parent.getFormat().toString(), false); + } + } + + private boolean checkIfFileIsInActiveChainForVm(Domain vm, DataTO dataTO) throws LibvirtException { + String xml = vm.getXMLDesc(0); + KVMStoragePool storagePool = libvirtUtilitiesHelper.getPrimaryPoolFromDataTo(dataTO, storagePoolManager); + return xml.contains(storagePool.getLocalPathFor(dataTO.getPath())); + } + + public int getFlagsToUseForRunningVmSnapshotCreation(boolean quiesceVm) { + int flags = quiesceVm ? Domain.SnapshotCreateFlags.QUIESCE : 0; + flags += Domain.SnapshotCreateFlags.DISK_ONLY + + Domain.SnapshotCreateFlags.ATOMIC + + Domain.SnapshotCreateFlags.NO_METADATA; + return flags; + } + + public Pair> createSnapshotXmlAndNewVolumePathMap(List> volumeTosAndNewPaths, List disks, String snapshotName) { + StringBuilder stringBuilder = new StringBuilder(); + Map volumeObjectToNewPathMap = new HashMap<>(); + + for (Pair volumeObjectTOAndPath : volumeTosAndNewPaths) { + LibvirtVMDef.DiskDef diskdef = getDiskWithPathOfVolumeObjectTO(disks, volumeObjectTOAndPath.first()); + String newPath = volumeObjectTOAndPath.second(); + stringBuilder.append(String.format(TAG_DISK_SNAPSHOT, diskdef.getDiskLabel(), getSnapshotTemporaryPath(diskdef.getDiskPath(), newPath))); + + long snapSize = getFileSize(diskdef.getDiskPath()); + + volumeObjectToNewPathMap.put(volumeObjectTOAndPath.first().getUuid(), snapSize); + } + + String snapshotXml = String.format(SNAPSHOT_XML, snapshotName, stringBuilder); + return new Pair<>(snapshotXml, volumeObjectToNewPathMap); + } + + public long getFileSize(String path) { + return new File(path).length(); + } + + public boolean pullVolumeBackingFile(VolumeObjectTO volumeObjectTO, String vmName) throws LibvirtException { + Connect conn = libvirtUtilitiesHelper.getConnection(); + + Domain vm = getDomain(conn, vmName); + List disks = getDisks(conn, vmName); + DiskDef diskDef = getDiskWithPathOfVolumeObjectTO(disks, volumeObjectTO); + + String diskLabel = diskDef.getDiskLabel(); + Script.runSimpleBashScript(String.format(BLOCK_PULL_COMMAND, vmName, diskLabel)); + + boolean result = checkBlockPullProgress(vm, diskLabel, vmName, volumeObjectTO.getUuid()); + + if (!result) { + logger.warn("Failed to block pull volume [{}] of VM [{}], aborting.", volumeObjectTO, vmName); + vm.blockJobAbort(diskLabel, Domain.BlockJobAbortFlags.ASYNC); + } + return result; + } + + protected Boolean checkBlockPullProgress(Domain vm, String diskLabel, String vmName, String volumeUuid) { + int timeout = qcow2DeltaMergeTimeout; + DomainBlockJobInfo result; + long lastCommittedBytes = 0; + long endBytes = 0; + String partialLog = String.format("for volume [%s] of VM [%s]", volumeUuid, vmName); + while (timeout > 0) { + timeout -= 1; + + try { + Thread.sleep(1000); + } catch (InterruptedException ex) { + logger.trace("Thread that was tracking the block pull progress {} was interrupted. Ignoring.", partialLog, ex); + continue; + } + + try { + result = vm.getBlockJobInfo(diskLabel, 0); + } catch (LibvirtException ex) { + logger.warn("Exception while getting block job info {}: [{}].", partialLog, ex.getMessage(), ex); + return false; + } + + if (result == null || result.type == 0 && result.end == 0 && result.cur == 0) { + logger.debug("Block pull job {} has finished.", partialLog); + return true; + } + + long currentCommittedBytes = result.cur; + if (currentCommittedBytes > lastCommittedBytes) { + logger.debug("The block pull {} is at [{}] of [{}].", partialLog, currentCommittedBytes, result.end); + } + lastCommittedBytes = currentCommittedBytes; + endBytes = result.end; + } + logger.warn(String.format("Block pull %s has timed out after waiting at least %s seconds. The progress of the operation was [%s] of [%s].", partialLog, + qcow2DeltaMergeTimeout, lastCommittedBytes, endBytes)); + return false; + } + + } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtDomainXMLParser.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtDomainXMLParser.java index e114669b8b56..1a4d23af1c90 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtDomainXMLParser.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtDomainXMLParser.java @@ -131,6 +131,7 @@ public boolean parseDomainXML(String domXML) { fmt = DiskDef.DiskFmtType.valueOf(diskFmtType.toUpperCase()); } def.defFileBasedDisk(diskFile, diskLabel, DiskDef.DiskBus.valueOf(bus.toUpperCase()), fmt); + parseBackingFiles(disk, def); } else if (device.equalsIgnoreCase("cdrom")) { def.defISODisk(diskFile, i+1, diskLabel, DiskDef.DiskType.FILE); } @@ -405,6 +406,21 @@ public boolean parseDomainXML(String domXML) { return false; } + private void parseBackingFiles(Element disk, DiskDef def) { + NodeList backingStoreNodeList = disk.getElementsByTagName("backingStore"); + List backingStoreList = new ArrayList<>(); + while (backingStoreNodeList.getLength() > 0) { + Element backingStore = (Element)backingStoreNodeList.item(0); + String path = getAttrValue("source", "file", backingStore); + if (StringUtils.isEmpty(path)) { + break; + } + backingStoreList.add(path.substring(path.lastIndexOf(File.separator))+1); + backingStoreNodeList = backingStore.getElementsByTagName("backingStore"); + } + def.setBackingStoreList(backingStoreList); + } + /** * Parse the memballoon tag. * @param devices the devices tag. diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtMigrateResourceBetweenSecondaryStorages.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtMigrateResourceBetweenSecondaryStorages.java new file mode 100644 index 000000000000..a3dd2040cfc8 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtMigrateResourceBetweenSecondaryStorages.java @@ -0,0 +1,123 @@ +// +// 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; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.to.DataTO; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.resource.CommandWrapper; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.utils.qemu.QemuImageOptions; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.libvirt.LibvirtException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +public abstract class LibvirtMigrateResourceBetweenSecondaryStorages extends CommandWrapper { + + protected static final String BACKUP = "backup"; + protected static final String SNAPSHOT = "snapshot"; + + protected Set filesToRemove; + protected List> resourcesToUpdate; + protected String resourceType; + protected int wait; + + public String copyResourceToDestDataStore(DataTO resource, String resourceCurrentPath, KVMStoragePool destImagePool, String resourceParentPath) throws QemuImgException, LibvirtException { + String resourceDestDataStoreFullPath = destImagePool.getLocalPathFor(resource.getPath()); + String resourceDestCheckpointPath = resourceDestDataStoreFullPath.replace("snapshots", "checkpoints"); + + QemuImgFile resourceOrigin = new QemuImgFile(resourceCurrentPath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile resourceDestination = new QemuImgFile(resourceDestDataStoreFullPath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile parentResource = null; + + if (resourceParentPath != null) { + parentResource = new QemuImgFile(resourceParentPath, QemuImg.PhysicalDiskFormat.QCOW2); + } + + logger.debug("Migrating {} [{}] to [{}] with {}", resourceType, resourceOrigin, resourceDestination, parentResource == null ? "no parent." : String.format("parent [%s].", parentResource)); + + long resourceId = resource.getId(); + + createDirsIfNeeded(resourceDestDataStoreFullPath, resourceId); + + QemuImg qemuImg = new QemuImg(wait); + qemuImg.convert(resourceOrigin, resourceDestination, parentResource, null, null, new QemuImageOptions(resourceOrigin.getFormat(), resourceOrigin.getFileName(), null), + null, true, false, false, false, null, null); + + filesToRemove.add(resourceCurrentPath); + + if (SNAPSHOT.equals(resourceType)) { + String resourceCurrentCheckpointPath = resourceCurrentPath.replace("snapshots", "checkpoints"); + createDirsIfNeeded(resourceDestCheckpointPath, resourceId); + migrateCheckpointFile(resourceCurrentPath, resourceDestDataStoreFullPath); + filesToRemove.add(resourceCurrentCheckpointPath); + resourcesToUpdate.add(new Pair<>(resourceId, resourceDestCheckpointPath)); + } + + return resourceDestDataStoreFullPath; + } + + private void migrateCheckpointFile(String resourceCurrentPath, String resourceDestDataStoreFullPath) { + resourceCurrentPath = resourceCurrentPath.replace("snapshots", "checkpoints"); + resourceDestDataStoreFullPath = resourceDestDataStoreFullPath.replace("snapshots", "checkpoints"); + + String copyCommand = String.format("cp %s %s", resourceCurrentPath, resourceDestDataStoreFullPath); + Script.runSimpleBashScript(copyCommand); + } + + public void removeResourceFromSourceDataStore(String resourcePath) { + logger.debug("Removing file [{}].", resourcePath); + try { + Files.deleteIfExists(Path.of(resourcePath)); + } catch (IOException ex) { + logger.error("Failed to remove {} [{}].", resourceType, resourcePath, ex); + } + } + + public String rebaseResourceToNewParentPath(String resourcePath, String parentResourcePath) throws LibvirtException, QemuImgException { + QemuImgFile resource = new QemuImgFile(resourcePath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile parentResource = new QemuImgFile(parentResourcePath, QemuImg.PhysicalDiskFormat.QCOW2); + + QemuImg qemuImg = new QemuImg(wait); + qemuImg.rebase(resource, parentResource, parentResource.getFormat().toString(), false); + + return resourcePath; + } + + private void createDirsIfNeeded(String resourceFullPath, Long resourceId) { + String dirs = resourceFullPath.substring(0, resourceFullPath.lastIndexOf(File.separator)); + try { + Files.createDirectories(Path.of(dirs)); + } catch (IOException e) { + throw new BackupException(String.format("Error while creating directories for migration of %s [%s].", resourceType, resourceId), e, true); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtStorageVolumeXMLParser.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtStorageVolumeXMLParser.java index 1b6f73039ca5..00126a07cd3a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtStorageVolumeXMLParser.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtStorageVolumeXMLParser.java @@ -61,6 +61,26 @@ public LibvirtStorageVolumeDef parseStorageVolumeXML(String volXML) { return null; } + public String getBackingFileNameIfExists(String volXML) { + try { + DocumentBuilder builder = ParserUtils.getSaferDocumentBuilderFactory().newDocumentBuilder(); + + InputSource is = new InputSource(); + is.setCharacterStream(new StringReader(volXML)); + Document doc = builder.parse(is); + + Element rootElement = doc.getDocumentElement(); + Element backingStore = (Element)rootElement.getElementsByTagName("backingStore").item(0); + if (backingStore != null) { + String[] paths = getTagValue("path", backingStore).split("/"); + return paths[paths.length-1]; + } + } catch (ParserConfigurationException | SAXException | IOException e) { + logger.error(e.toString(), e); + } + return null; + } + private static String getTagValue(String tag, Element eElement) { NodeList nlList = eElement.getElementsByTagName(tag).item(0).getChildNodes(); Node nValue = nlList.item(0); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java index 7f6725b6d152..74529d9d5fa2 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java @@ -975,6 +975,7 @@ public String toString() { private BlockIOSize logicalBlockIOSize = null; private BlockIOSize physicalBlockIOSize = null; private DiskGeometry geometry = null; + private List backingStoreList = null; // Ordered list of backing stores, the first in the list is the immediate backing store, and the last in the list is the base public DiscardType getDiscard() { return _discard; @@ -1346,6 +1347,14 @@ public String getSourcePath() { return _sourcePath; } + public List getBackingStoreList() { + return backingStoreList; + } + + public void setBackingStoreList(List backingStoreList) { + this.backingStoreList = backingStoreList; + } + @Override public String toString() { StringBuilder diskBuilder = new StringBuilder(); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java new file mode 100644 index 000000000000..dc34a4cb62d8 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtBaseConvertCommandWrapper.java @@ -0,0 +1,283 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.NfsTO; +import com.cloud.hypervisor.kvm.resource.LibvirtDomainXMLParser; +import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ServerResource; +import com.cloud.storage.Storage; +import com.cloud.utils.FileUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.script.Script; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.vm.UnmanagedInstanceTO; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public abstract class LibvirtBaseConvertCommandWrapper extends CommandWrapper { + + protected KVMStoragePool getTemporaryStoragePool(DataStoreTO conversionTemporaryLocation, KVMStoragePoolManager storagePoolMgr) { + if (conversionTemporaryLocation instanceof NfsTO) { + NfsTO nfsTO = (NfsTO) conversionTemporaryLocation; + return storagePoolMgr.getStoragePoolByURI(nfsTO.getUrl()); + } else { + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) conversionTemporaryLocation; + return storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + } + } + + protected List getTemporaryDisksFromParsedXml(KVMStoragePool pool, LibvirtDomainXMLParser xmlParser, + String convertedBasePath, + String conversionPoolPath, String vmVolumesPrefix) { + List disksDefs = xmlParser.getDisks(); + disksDefs = disksDefs.stream().filter(x -> x.getDiskType() == LibvirtVMDef.DiskDef.DiskType.FILE && + x.getDeviceType() == LibvirtVMDef.DiskDef.DeviceType.DISK).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(disksDefs)) { + String err = String.format("Cannot find any disk defined on the converted XML domain %s.xml, " + + "checking disks at: %s with prefix: %s", convertedBasePath, conversionPoolPath, vmVolumesPrefix); + logger.warn(err); + return getTemporaryDisksWithPrefixFromTemporaryPool(pool, conversionPoolPath, vmVolumesPrefix); + } + sanitizeDisksPath(disksDefs); + return getPhysicalDisksFromDefPaths(disksDefs, pool); + } + + private List getPhysicalDisksFromDefPaths(List disksDefs, KVMStoragePool pool) { + List disks = new ArrayList<>(); + for (LibvirtVMDef.DiskDef diskDef : disksDefs) { + KVMPhysicalDisk physicalDisk = pool.getPhysicalDisk(diskDef.getDiskPath()); + disks.add(physicalDisk); + } + return disks; + } + + protected List getTemporaryDisksWithPrefixFromTemporaryPool(KVMStoragePool pool, String path, String prefix) { + String msg = String.format("Could not parse correctly the converted XML domain, checking for disks on %s with prefix %s", path, prefix); + logger.info(msg); + pool.refresh(); + List disksWithPrefix = pool.listPhysicalDisks() + .stream() + .filter(x -> x.getName().startsWith(prefix) && !x.getName().endsWith(".xml")) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(disksWithPrefix)) { + msg = String.format("Could not find any converted disk with prefix %s on temporary location %s", prefix, path); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + return disksWithPrefix; + } + + protected void cleanupDisksAndDomainFromTemporaryLocation(List disks, + KVMStoragePool temporaryStoragePool, + String temporaryConvertUuid, boolean xmlExists) { + for (KVMPhysicalDisk disk : disks) { + logger.info(String.format("Cleaning up temporary disk %s after conversion from temporary location", disk.getName())); + temporaryStoragePool.deletePhysicalDisk(disk.getName(), Storage.ImageFormat.QCOW2); + } + if (xmlExists) { + logger.info(String.format("Cleaning up temporary domain %s after conversion from temporary location", temporaryConvertUuid)); + FileUtil.deleteFiles(temporaryStoragePool.getLocalPath(), temporaryConvertUuid, ".xml"); + } + } + + protected void sanitizeDisksPath(List disks) { + for (LibvirtVMDef.DiskDef disk : disks) { + String[] diskPathParts = disk.getDiskPath().split("/"); + String relativePath = diskPathParts[diskPathParts.length - 1]; + disk.setDiskPath(relativePath); + } + } + + protected List moveTemporaryDisksToDestination(List temporaryDisks, + List destinationStoragePools, + KVMStoragePoolManager storagePoolMgr) { + List targetDisks = new ArrayList<>(); + if (temporaryDisks.size() != destinationStoragePools.size()) { + String warn = String.format("Discrepancy between the converted instance disks (%s) " + + "and the expected number of disks (%s)", temporaryDisks.size(), destinationStoragePools.size()); + logger.warn(warn); + } + for (int i = 0; i < temporaryDisks.size(); i++) { + String poolPath = destinationStoragePools.get(i); + KVMStoragePool destinationPool = storagePoolMgr.getStoragePool(Storage.StoragePoolType.NetworkFilesystem, poolPath); + if (destinationPool == null) { + String err = String.format("Could not find a storage pool by URI: %s", poolPath); + logger.error(err); + continue; + } + if (destinationPool.getType() != Storage.StoragePoolType.NetworkFilesystem) { + String err = String.format("Storage pool by URI: %s is not an NFS storage", poolPath); + logger.error(err); + continue; + } + KVMPhysicalDisk sourceDisk = temporaryDisks.get(i); + if (logger.isDebugEnabled()) { + String msg = String.format("Trying to copy converted instance disk number %s from the temporary location %s" + + " to destination storage pool %s", i, sourceDisk.getPool().getLocalPath(), destinationPool.getUuid()); + logger.debug(msg); + } + + String destinationName = UUID.randomUUID().toString(); + + try { + if (destinationPool.getAvailable() < sourceDisk.getSize()) { + String msg = String.format("Not enough space on destination pool %s (%s bytes) to copy disk %s (size %s)", + destinationPool.getUuid(), destinationPool.getAvailable(), sourceDisk.getName(), sourceDisk.getSize()); + logger.error(msg); + throw new CloudRuntimeException(msg); + } + + KVMPhysicalDisk destinationDisk = storagePoolMgr.copyPhysicalDisk(sourceDisk, destinationName, destinationPool, 7200 * 1000); + targetDisks.add(destinationDisk); + } catch (Exception e) { + String err = String.format("Error copying converted instance disk number %s from the temporary location %s" + + " to destination storage pool %s: %s", i, sourceDisk.getPool().getLocalPath(), destinationPool.getUuid(), e.getMessage()); + logger.error(err, e); + cleanupMovedDisksOnDestinationPool(targetDisks); + return null; + } + } + return targetDisks; + } + + private void cleanupMovedDisksOnDestinationPool(List targetDisks) { + if (CollectionUtils.isEmpty(targetDisks)) { + return; + } + for (KVMPhysicalDisk disk : targetDisks) { + logger.info(String.format("Cleaning up disk %s from pool %s after conversion", disk.getName(), disk.getPool().getUuid())); + disk.getPool().deletePhysicalDisk(disk.getName(), Storage.ImageFormat.QCOW2); + } + } + + protected UnmanagedInstanceTO getConvertedUnmanagedInstance(String baseName, + List vmDisks, + LibvirtDomainXMLParser xmlParser) { + UnmanagedInstanceTO instanceTO = new UnmanagedInstanceTO(); + instanceTO.setName(baseName); + instanceTO.setDisks(getUnmanagedInstanceDisks(vmDisks, xmlParser)); + instanceTO.setNics(getUnmanagedInstanceNics(xmlParser)); + return instanceTO; + } + + private List getUnmanagedInstanceNics(LibvirtDomainXMLParser xmlParser) { + List nics = new ArrayList<>(); + if (xmlParser != null) { + List interfaces = xmlParser.getInterfaces(); + for (LibvirtVMDef.InterfaceDef interfaceDef : interfaces) { + UnmanagedInstanceTO.Nic nic = new UnmanagedInstanceTO.Nic(); + nic.setMacAddress(interfaceDef.getMacAddress()); + nic.setNicId(interfaceDef.getBrName()); + nic.setAdapterType(interfaceDef.getModel().toString()); + nics.add(nic); + } + } + return nics; + } + + protected List getUnmanagedInstanceDisks(List vmDisks, LibvirtDomainXMLParser xmlParser) { + List instanceDisks = new ArrayList<>(); + List diskDefs = xmlParser != null ? xmlParser.getDisks() : null; + for (int i = 0; i< vmDisks.size(); i++) { + KVMPhysicalDisk physicalDisk = vmDisks.get(i); + KVMStoragePool storagePool = physicalDisk.getPool(); + UnmanagedInstanceTO.Disk disk = new UnmanagedInstanceTO.Disk(); + disk.setPosition(i); + Pair storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); + disk.setDatastoreHost(storagePoolHostAndPath.first()); + disk.setDatastorePath(storagePoolHostAndPath.second()); + disk.setDatastoreName(storagePool.getUuid()); + disk.setDatastoreType(storagePool.getType().name()); + disk.setCapacity(physicalDisk.getVirtualSize()); + disk.setFileBaseName(physicalDisk.getName()); + if (CollectionUtils.isNotEmpty(diskDefs)) { + LibvirtVMDef.DiskDef diskDef = diskDefs.get(i); + disk.setController(diskDef.getBusType() != null ? diskDef.getBusType().toString() : LibvirtVMDef.DiskDef.DiskBus.VIRTIO.toString()); + } else { + // If the job is finished but we cannot parse the XML, the guest VM can use the virtio driver + disk.setController(LibvirtVMDef.DiskDef.DiskBus.VIRTIO.toString()); + } + instanceDisks.add(disk); + } + return instanceDisks; + } + + protected Pair getNfsStoragePoolHostAndPath(KVMStoragePool storagePool) { + String sourceHostIp = null; + String sourcePath = null; + List commands = new ArrayList<>(); + commands.add(new String[]{Script.getExecutableAbsolutePath("mount")}); + commands.add(new String[]{Script.getExecutableAbsolutePath("grep"), storagePool.getLocalPath()}); + String storagePoolMountPoint = Script.executePipedCommands(commands, 0).second(); + logger.debug(String.format("NFS Storage pool: %s - local path: %s, mount point: %s", storagePool.getUuid(), storagePool.getLocalPath(), storagePoolMountPoint)); + if (StringUtils.isNotEmpty(storagePoolMountPoint)) { + String[] res = storagePoolMountPoint.strip().split(" "); + res = res[0].split(":"); + if (res.length > 1) { + sourceHostIp = res[0].strip(); + sourcePath = res[1].strip(); + } + } + return new Pair<>(sourceHostIp, sourcePath); + } + + protected LibvirtDomainXMLParser parseMigratedVMXmlDomain(String installPath) throws IOException { + String xmlPath = String.format("%s.xml", installPath); + if (!new File(xmlPath).exists()) { + String err = String.format("Conversion failed. Unable to find the converted XML domain, expected %s", xmlPath); + logger.error(err); + throw new CloudRuntimeException(err); + } + String xml; + try (InputStream is = new BufferedInputStream(new FileInputStream(xmlPath))) { + xml = IOUtils.toString(is, Charset.defaultCharset()); + } + final LibvirtDomainXMLParser parser = new LibvirtDomainXMLParser(); + try { + parser.parseDomainXML(xml); + return parser; + } catch (RuntimeException e) { + String err = String.format("Error parsing the converted instance XML domain at %s: %s", xmlPath, e.getMessage()); + logger.error(err, e); + logger.debug(xml); + return null; + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckOnHostCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckOnHostCommandWrapper.java index f901fd97ca76..c069416cca54 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckOnHostCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCheckOnHostCommandWrapper.java @@ -55,7 +55,7 @@ public Answer execute(final CheckOnHostCommand command, final LibvirtComputingRe if (hasHeartBeat) { return new CheckOnHostAnswer(command, true, "Heart is beating"); } else { - return new CheckOnHostAnswer(command, "Heart is not beating"); + return new CheckOnHostAnswer(command, false, "Heart is not beating"); } } catch (final InterruptedException e) { return new CheckOnHostAnswer(command, "CheckOnHostCommand: can't get status of host: InterruptedException"); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupConvertedInstanceDisksCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupConvertedInstanceDisksCommandWrapper.java new file mode 100644 index 000000000000..05afd8b2234d --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupConvertedInstanceDisksCommandWrapper.java @@ -0,0 +1,67 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.CleanupConvertedInstanceDisksCommand; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtDomainXMLParser; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.ResourceWrapper; + +import java.io.File; +import java.util.List; + +@ResourceWrapper(handles = CleanupConvertedInstanceDisksCommand.class) +public class LibvirtCleanupConvertedInstanceDisksCommandWrapper extends LibvirtBaseConvertCommandWrapper { + + @Override + public Answer execute(CleanupConvertedInstanceDisksCommand command, LibvirtComputingResource serverResource) { + DataStoreTO vmVolumesStore = command.getVmVolumesStore(); + String vmVolumesPrefix = command.getVmVolumesPrefix(); + + final KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); + KVMStoragePool conversionPool = getTemporaryStoragePool(vmVolumesStore, storagePoolMgr); + final String conversionPoolPath = conversionPool.getLocalPath(); + + try { + String volumesBasePath = String.format("%s/%s", conversionPoolPath, vmVolumesPrefix); + String xmlPath = String.format("%s.xml", volumesBasePath); + boolean xmlExists = new File(xmlPath).exists(); + + LibvirtDomainXMLParser xmlParser = xmlExists ? parseMigratedVMXmlDomain(volumesBasePath) : null; + List temporaryDisks = xmlExists && xmlParser != null ? + getTemporaryDisksFromParsedXml(conversionPool, xmlParser, volumesBasePath, conversionPoolPath, vmVolumesPrefix) : + getTemporaryDisksWithPrefixFromTemporaryPool(conversionPool, conversionPoolPath, vmVolumesPrefix); + + cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, conversionPool, vmVolumesPrefix, xmlExists); + + } catch (Exception e) { + String error = String.format("Error cleaning up converted disks with prefix %s from %s, due to: %s", + vmVolumesPrefix, conversionPoolPath, e.getMessage()); + logger.error(error, e); + return new Answer(command, false, error); + } + + return new Answer(command); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossValidationCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossValidationCommandWrapper.java new file mode 100644 index 000000000000..c23a3ef0c979 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossValidationCommandWrapper.java @@ -0,0 +1,50 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import org.apache.cloudstack.backup.CleanupKbossValidationCommand; + +@ResourceWrapper(handles = CleanupKbossValidationCommand.class) +public class LibvirtCleanupKbossValidationCommandWrapper extends CommandWrapper { + @Override + public Answer execute(CleanupKbossValidationCommand command, LibvirtComputingResource serverResource) { + KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); + cleanupSecondaryStorages(command, storagePoolMgr); + return new Answer(command); + } + + /** + * The objective of this command is to remove the secondary storage references after the validation VM was stopped. + * Since the getStoragePoolByURI and deleteStoragePool have a reference counter, where the first method increases the count and the second one + * decreases the count, we must call the deleteStoragePool twice so that the command is count negative. + * */ + private void cleanupSecondaryStorages(CleanupKbossValidationCommand command, KVMStoragePoolManager storagePoolMgr) { + logger.info("Cleaning up secondary storage references after backup validation process using VM [{}].", command.getVmName()); + for (String secondaryUrl : command.getSecondaryStorages()) { + logger.debug("Cleaning up secondary storage reference for secondary at [{}].", secondaryUrl); + KVMStoragePool secondary = storagePoolMgr.getStoragePoolByURI(secondaryUrl); + storagePoolMgr.deleteStoragePool(secondary.getType(), secondary.getUuid()); + storagePoolMgr.deleteStoragePool(secondary.getType(), secondary.getUuid()); + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossVmBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossVmBackupCommandWrapper.java new file mode 100644 index 000000000000..430d1c6ea3c2 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCleanupKbossVmBackupCommandWrapper.java @@ -0,0 +1,231 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtDomainXMLParser; +import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; +import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.Pair; +import org.apache.cloudstack.backup.CleanupKbossBackupErrorAnswer; +import org.apache.cloudstack.backup.CleanupKbossBackupErrorCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.storage.to.KbossTO; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.commons.lang3.StringUtils; +import org.libvirt.Domain; +import org.libvirt.Error; +import org.libvirt.LibvirtException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@ResourceWrapper(handles = CleanupKbossBackupErrorCommand.class) +public class LibvirtCleanupKbossVmBackupCommandWrapper extends CommandWrapper { + @Override + public Answer execute(CleanupKbossBackupErrorCommand command, LibvirtComputingResource serverResource) { + List kbossTOS = command.getKbossTOs(); + KVMStoragePoolManager storagePoolManager = serverResource.getStoragePoolMgr(); + + logger.info("Cleaning up backup error for VM [{}].", command.getVmName()); + cleanupBackupDeltasOnSecondary(command, storagePoolManager, kbossTOS); + + if (command.isRunningVM()) { + Pair, Boolean> volumeTosAndIsVmRunning = cleanupRunningVm(command, serverResource); + return new CleanupKbossBackupErrorAnswer(command, volumeTosAndIsVmRunning.first(), volumeTosAndIsVmRunning.second()); + } + + return new CleanupKbossBackupErrorAnswer(command, mergeDeltasForStoppedVmIfNeeded(command, serverResource), false); + } + + private Pair, Boolean> cleanupRunningVm(CleanupKbossBackupErrorCommand command, LibvirtComputingResource serverResource) { + Domain dm = null; + try { + dm = serverResource.getDomain(serverResource.getLibvirtUtilitiesHelper().getConnection(), command.getVmName()); + return new Pair<>(mergeDeltasForRunningVmIfNeeded(command, serverResource, dm), true); + } catch (LibvirtException e) { + if (e.getError().getCode() == Error.ErrorNumber.VIR_ERR_NO_DOMAIN && isVmReallyStopped(command, serverResource)) { + return new Pair<>(mergeDeltasForStoppedVmIfNeeded(command, serverResource), false); + } + logger.error("Error while trying to get VM [{}]. Aborting the process.", command.getVmName(), e); + return new Pair<>(List.of(), false); + } finally { + if (dm != null) { + try { + dm.free(); + } catch (LibvirtException e) { + logger.warn("Ignoring Libvirt exception.", e); + } + } + } + } + + private List mergeDeltasForStoppedVmIfNeeded(CleanupKbossBackupErrorCommand command, LibvirtComputingResource serverResource) { + List volumeObjectTOList = new ArrayList<>(); + for (KbossTO kbossTO : command.getKbossTOs()) { + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO)volumeObjectTO.getDataStore(); + KVMStoragePool kvmStoragePool = serverResource.getStoragePoolMgr().getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + boolean backupErrorDeltaExists = Files.exists(Path.of(kvmStoragePool.getLocalPathFor(volumeObjectTO.getPath()))); + boolean parentBackupDeltaExists = Files.exists(Path.of(kvmStoragePool.getLocalPathFor(kbossTO.getDeltaPathOnPrimary()))); + boolean shouldBaseDeltaExist = kbossTO.getParentDeltaPathOnPrimary() != null; + boolean baseDeltaExists = shouldBaseDeltaExist && Files.exists(Path.of(kvmStoragePool.getLocalPathFor(kbossTO.getParentDeltaPathOnPrimary()))); + + if(!mergeDeltaIfNeeded(serverResource, kbossTO, backupErrorDeltaExists, parentBackupDeltaExists, shouldBaseDeltaExist, baseDeltaExists, + false, volumeObjectTO, volumeObjectTOList)) { + return List.of(); + } + } + return volumeObjectTOList; + } + + private List mergeDeltasForRunningVmIfNeeded(CleanupKbossBackupErrorCommand command, LibvirtComputingResource serverResource, Domain dm) throws LibvirtException { + String xmlDesc = dm.getXMLDesc(0); + LibvirtDomainXMLParser parser = new LibvirtDomainXMLParser(); + parser.parseDomainXML(xmlDesc); + List volumeObjectTOList = new ArrayList<>(); + for (KbossTO kbossTO : command.getKbossTOs()) { + LibvirtVMDef.DiskDef diskDef = parser.getDisks().stream() + .filter(disk -> StringUtils.contains(disk.getDiskPath(), kbossTO.getVolumeObjectTO().getPath()) || + StringUtils.contains(disk.getDiskPath(), kbossTO.getDeltaPathOnPrimary()) || + StringUtils.contains(disk.getDiskPath(), kbossTO.getParentDeltaPathOnPrimary())).findFirst().orElse(null); + + if (diskDef == null) { + logger.warn("Volume [{}] does not match any record we have. This must be manually normalized.", kbossTO.getVolumeObjectTO().getUuid()); + return List.of(); + } + + boolean backupErrorDeltaExists = diskDef.getDiskPath().contains(kbossTO.getVolumeObjectTO().getPath()); + boolean parentBackupDeltaExists = diskDef.getDiskPath().contains(kbossTO.getDeltaPathOnPrimary()) || + diskDef.getBackingStoreList().stream().anyMatch(path -> path.contains(kbossTO.getDeltaPathOnPrimary())); + boolean shouldBaseDeltaExist = kbossTO.getParentDeltaPathOnPrimary() != null; + boolean baseDeltaExists = shouldBaseDeltaExist && StringUtils.contains(diskDef.getDiskPath(), kbossTO.getParentDeltaPathOnPrimary()) || + diskDef.getBackingStoreList().stream().anyMatch(path -> StringUtils.contains(path, kbossTO.getParentDeltaPathOnPrimary())); + + mergeDeltaIfNeeded(serverResource, kbossTO, backupErrorDeltaExists, parentBackupDeltaExists, shouldBaseDeltaExist, baseDeltaExists, true, + kbossTO.getVolumeObjectTO(), volumeObjectTOList); + } + + return volumeObjectTOList; + } + + private boolean mergeDeltaIfNeeded(LibvirtComputingResource serverResource, KbossTO kbossTO, boolean backupErrorDeltaExists, + boolean parentBackupDeltaExists, boolean shouldBaseDeltaExist, boolean baseDeltaExists, boolean runningVm, VolumeObjectTO volumeObjectTO, + List volumeObjectTOList) { + DeltaMergeTreeTO deltaMergeTreeTO; + if (!backupErrorDeltaExists) { + if (parentBackupDeltaExists && (!shouldBaseDeltaExist || baseDeltaExists)) { + volumeObjectTO.setPath(kbossTO.getDeltaPathOnPrimary()); + logger.debug("Volume [{}] is already consistent. Its path is [{}].", volumeObjectTO.getUuid(), volumeObjectTO.getPath()); + volumeObjectTOList.add(volumeObjectTO); + return true; + } else if (baseDeltaExists) { + volumeObjectTO.setPath(kbossTO.getParentDeltaPathOnPrimary()); + logger.debug("Volume [{}] is already consistent. Its path is [{}].", volumeObjectTO.getUuid(), volumeObjectTO.getPath()); + volumeObjectTOList.add(volumeObjectTO); + return true; + } else { + logger.warn("Volume [{}] is inconsistent in an anomalous way. We cannot normalize it automatically.", volumeObjectTO.getUuid()); + return false; + } + } else if (parentBackupDeltaExists) { + logger.debug("Volume [{}] is inconsistent, but we can normalize it. We will merge the delta created by this backup with the delta created by the previous " + + "backup.", volumeObjectTO.getUuid()); + deltaMergeTreeTO = new DeltaMergeTreeTO(volumeObjectTO, new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, + kbossTO.getDeltaPathOnPrimary()), volumeObjectTO, List.of()); + } else if (baseDeltaExists) { + logger.debug("Volume [{}] is inconsistent, but we can normalize it. We will merge the delta created by this backup with the base volume.", + volumeObjectTO.getUuid()); + deltaMergeTreeTO = new DeltaMergeTreeTO(volumeObjectTO, new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, + kbossTO.getParentDeltaPathOnPrimary()), volumeObjectTO, List.of()); + } else { + logger.warn("Volume [{}] is inconsistent in an anomalous way. We cannot normalize it automatically.", volumeObjectTO.getUuid()); + return false; + } + + try { + if (runningVm) { + serverResource.mergeDeltaForRunningVm(deltaMergeTreeTO, volumeObjectTO.getVmName(), volumeObjectTO); + } else { + serverResource.mergeDeltaForStoppedVm(deltaMergeTreeTO); + } + volumeObjectTO.setPath(deltaMergeTreeTO.getParent().getPath()); + volumeObjectTOList.add(volumeObjectTO); + return true; + } catch (QemuImgException | IOException | LibvirtException ex) { + logger.error("Got an exception while trying to merge delta for volume [{}].", volumeObjectTO.getUuid(), ex); + return false; + } + } + + private boolean isVmReallyStopped(CleanupKbossBackupErrorCommand command, LibvirtComputingResource serverResource) { + VolumeObjectTO volume = command.getKbossTOs().stream() + .filter(kbossTO -> kbossTO.getVolumeObjectTO().getDeviceId() == 0) + .map(KbossTO::getVolumeObjectTO).findFirst().orElseThrow(); + PrimaryDataStoreTO primary = (PrimaryDataStoreTO)volume.getDataStore(); + KVMStoragePool storage = serverResource.getStoragePoolMgr().getStoragePool(primary.getPoolType(), primary.getUuid()); + KVMPhysicalDisk disk = storage.getPhysicalDisk(volume.getUuid()); + File diskFile = new File(disk.getPath()); + long time1 = diskFile.lastModified(); + try { + Thread.sleep(30 * 1000); + } catch (InterruptedException ex) { + throw new RuntimeException(ex); + } + long time2 = diskFile.lastModified(); + if (time1 != time2) { + logger.info("VM root disk [{}] has been modified in the last 30 seconds. It seems the VM is running, even though we were unable to find it. If the VM is " + + "running on this host, you can try again later. If the VM was somehow migrated, you should update the database directly."); + return false; + } + logger.warn("VM [{}] was not found by Libvirt and the root disk has not had any writes on the last 30 seconds. Assuming that it is stopped.", command.getVmName()); + return true; + } + + private void cleanupBackupDeltasOnSecondary(CleanupKbossBackupErrorCommand command, KVMStoragePoolManager storagePoolManager, List kbossTOS) { + KVMStoragePool storagePool = null; + try { + storagePool = storagePoolManager.getStoragePoolByURI(command.getImageStoreUrl()); + for (KbossTO kbossTO : kbossTOS) { + String deltaPath = storagePool.getLocalPathFor(kbossTO.getDeltaPathOnSecondary()); + logger.debug("Cleaning up file at [{}] if it exists.", deltaPath); + try { + Files.deleteIfExists(Path.of(deltaPath)); + } catch (IOException e) { + logger.error("Unable to delete leftover backup delta at [{}].", deltaPath); + } + } + } finally { + if (storagePool != null) { + storagePoolManager.deleteStoragePool(storagePool.getType(), storagePool.getUuid()); + } + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCompressBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCompressBackupCommandWrapper.java new file mode 100644 index 000000000000..2cc08311cbce --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCompressBackupCommandWrapper.java @@ -0,0 +1,150 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.to.DataTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.backup.Backup; +import org.apache.cloudstack.backup.CompressBackupCommand; +import org.apache.cloudstack.storage.formatinspector.Qcow2Inspector; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.utils.qemu.QemuImageOptions; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.libvirt.LibvirtException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.stream.Collectors; + +@ResourceWrapper(handles = CompressBackupCommand.class) +public class LibvirtCompressBackupCommandWrapper extends CommandWrapper { + public static final String COMPRESSION_TYPE = "compression_type"; + private static final int MIN_QCOW_2_VERSION_FOR_ZSTD = 3; + + @Override + public Answer execute(CompressBackupCommand command, LibvirtComputingResource serverResource) { + List secondaryStorages = new ArrayList<>(); + List deltas = command.getBackupDeltasToCompress(); + KVMStoragePoolManager storagePoolManager = serverResource.getStoragePoolMgr(); + + logger.info("Starting compression for backup deltas [{}].", deltas); + try { + QemuImg qemuImg = new QemuImg(command.getWait() * 1000); + Integer rateLimit = validateAndGetRateLimit(command, qemuImg); + + KVMStoragePool mainSecStorage = storagePoolManager.getStoragePoolByURI(deltas.stream().findFirst().orElseThrow().getChild().getDataStore().getUrl()); + secondaryStorages.add(mainSecStorage); + secondaryStorages.addAll(command.getBackupChainImageStoreUrls().stream().map(storagePoolManager::getStoragePoolByURI).collect(Collectors.toList())); + + if (!checkAvailableStorage(command, mainSecStorage, storagePoolManager)) { + return new Answer(command, false, "Not enough available space on secondary."); + } + + for (DeltaMergeTreeTO delta : deltas) { + DataTO child = delta.getChild(); + + QemuImgFile backingFile = null; + DataTO parent = delta.getParent(); + if (parent != null) { + KVMStoragePool parentSecondaryStorage = storagePoolManager.getStoragePoolByURI(parent.getDataStore().getUrl()); + secondaryStorages.add(parentSecondaryStorage); + backingFile = new QemuImgFile(parentSecondaryStorage.getLocalPathFor(parent.getPath()), QemuImg.PhysicalDiskFormat.QCOW2); + } + + String fullDeltaPath = mainSecStorage.getLocalPathFor(child.getPath()); + String compressedPath = fullDeltaPath + ".comp"; + QemuImgFile originalBackup = new QemuImgFile(fullDeltaPath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile compressedBackup = new QemuImgFile(compressedPath, QemuImg.PhysicalDiskFormat.QCOW2); + + HashMap options = new HashMap<>(); + Backup.CompressionLibrary compressionLib = getCompressionLibrary(command, fullDeltaPath); + setCompressionTypeOptionIfAvailable(qemuImg, options, compressionLib); + int coroutines = command.getCoroutines(); + logger.info("Starting compression for backup delta [{}] with parent [{}] using [{}] coroutines.", child, parent, coroutines); + qemuImg.convert(originalBackup, compressedBackup, backingFile, options, null, new QemuImageOptions(originalBackup.getFormat(), originalBackup.getFileName(), + null), null, false, false, true, true, coroutines, rateLimit); + } + } catch (LibvirtException | QemuImgException e) { + return new Answer(command, e); + } finally { + for (KVMStoragePool secondaryStorage : secondaryStorages) { + storagePoolManager.deleteStoragePool(secondaryStorage.getType(), secondaryStorage.getUuid()); + } + } + + return new Answer(command); + } + + private Integer validateAndGetRateLimit(CompressBackupCommand command, QemuImg qemuImg) { + if (command.getRateLimit() < 1) { + return null; + } + + if (qemuImg.getVersion() < QemuImg.QEMU_5_2) { + throw new CloudRuntimeException("Qemu version is lower than 5.2.0, unable to set the rate limit."); + } + + return command.getRateLimit(); + } + + /** + * Sets the compression type option if qemu-img is at least in version 5.1. Otherwise, will not set it and qemu will use zlib. + * */ + private void setCompressionTypeOptionIfAvailable(QemuImg qemuImg, HashMap options, Backup.CompressionLibrary compressionLib) { + if (qemuImg.getVersion() >= QemuImg.QEMU_5_1) { + options.put(COMPRESSION_TYPE, compressionLib.name()); + return; + } + logger.warn("Qemu is at a lower version than 5.1, we will not be able to use zstd to compress backups. Only zlib is supported for this version. Current version is [{}].", + qemuImg.getVersion()); + } + + private Backup.CompressionLibrary getCompressionLibrary(CompressBackupCommand command, String fullDeltaPath) { + Backup.CompressionLibrary compressionLib = command.getCompressionLib(); + if (compressionLib == Backup.CompressionLibrary.zlib || !Qcow2Inspector.validateQcow2Version(fullDeltaPath, MIN_QCOW_2_VERSION_FOR_ZSTD)) { + logger.debug("Compression for delta [{}] will use zlib as the compression library.", fullDeltaPath); + return Backup.CompressionLibrary.zlib; + } + + logger.debug("Compression for delta [{}] will try to use zstd as the compression library.", fullDeltaPath); + return Backup.CompressionLibrary.zstd; + } + + /** + * Validates available storage. Forces Libvirt to refresh storage info so that we have the most up to date data. + * */ + private boolean checkAvailableStorage(CompressBackupCommand command, KVMStoragePool mainSecStorage, KVMStoragePoolManager storagePoolManager) { + logger.debug("Checking available storage [{}].", mainSecStorage); + mainSecStorage = storagePoolManager.getStoragePool(mainSecStorage.getType(), mainSecStorage.getUuid(), true, false); + if (mainSecStorage.getAvailable() < command.getMinFreeStorage()) { + logger.warn("There is not enough available space for compression of backup! Available size is [{}], needed [{}]. Aborting compression.", + mainSecStorage.getAvailable(), command.getMinFreeStorage()); + return false; + } + return true; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConsolidateVolumesCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConsolidateVolumesCommandWrapper.java new file mode 100644 index 000000000000..30c8849df5fa --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtConsolidateVolumesCommandWrapper.java @@ -0,0 +1,59 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import org.apache.cloudstack.backup.ConsolidateVolumesAnswer; +import org.apache.cloudstack.backup.ConsolidateVolumesCommand; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.libvirt.LibvirtException; + +import java.util.ArrayList; +import java.util.List; + +@ResourceWrapper(handles = ConsolidateVolumesCommand.class) +public class LibvirtConsolidateVolumesCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(ConsolidateVolumesCommand command, LibvirtComputingResource serverResource) { + List volumeObjectTOs = command.getVolumesToConsolidate(); + String vmName = command.getVmName(); + + List successfulConsolidations = new ArrayList<>(); + try { + for (VolumeObjectTO volumeObjectTO : volumeObjectTOs) { + if (!serverResource.pullVolumeBackingFile(volumeObjectTO, vmName)) { + return new ConsolidateVolumesAnswer(command, false, "Failed to consolidate all volumes.", successfulConsolidations); + } + successfulConsolidations.add(volumeObjectTO); + } + } catch (LibvirtException ex) { + return new ConsolidateVolumesAnswer(command, false, ex.getMessage(), successfulConsolidations); + } + + KVMStoragePoolManager kvmStoragePoolManager = serverResource.getStoragePoolMgr(); + for (String secStorageUuid : command.getSecondaryStorageUuids()) { + kvmStoragePoolManager.deleteStoragePool(Storage.StoragePoolType.NetworkFilesystem, secStorageUuid); + } + return new ConsolidateVolumesAnswer(command, true, "Success", successfulConsolidations); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateDiskOnlyVMSnapshotCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateDiskOnlyVMSnapshotCommandWrapper.java index 84d17a1a1161..26265f82467b 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateDiskOnlyVMSnapshotCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtCreateDiskOnlyVMSnapshotCommandWrapper.java @@ -19,180 +19,29 @@ package com.cloud.hypervisor.kvm.resource.wrapper; import com.cloud.agent.api.Answer; -import com.cloud.agent.api.VMSnapshotTO; import com.cloud.agent.api.storage.CreateDiskOnlyVmSnapshotAnswer; import com.cloud.agent.api.storage.CreateDiskOnlyVmSnapshotCommand; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; -import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; -import com.cloud.hypervisor.kvm.storage.KVMStoragePool; -import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; -import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; import com.cloud.vm.VirtualMachine; -import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; -import org.apache.cloudstack.storage.to.VolumeObjectTO; -import org.apache.cloudstack.utils.qemu.QemuImg; -import org.apache.cloudstack.utils.qemu.QemuImgException; -import org.apache.cloudstack.utils.qemu.QemuImgFile; -import org.libvirt.Connect; -import org.libvirt.Domain; -import org.libvirt.LibvirtException; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; @ResourceWrapper(handles = CreateDiskOnlyVmSnapshotCommand.class) public class LibvirtCreateDiskOnlyVMSnapshotCommandWrapper extends CommandWrapper { - private static final String SNAPSHOT_XML = "\n" + - "%s\n" + - "\n" + - " \n" + - "%s" + - " \n" + - ""; - - private static final String TAG_DISK_SNAPSHOT = "\n" + - "\n" + - "\n"; - @Override public Answer execute(CreateDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) { VirtualMachine.State state = cmd.getVmState(); - if (VirtualMachine.State.Running.equals(state)) { - return takeDiskOnlyVmSnapshotOfRunningVm(cmd, resource); - } - - return takeDiskOnlyVmSnapshotOfStoppedVm(cmd, resource); - } - - protected Answer takeDiskOnlyVmSnapshotOfRunningVm(CreateDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) { - String vmName = cmd.getVmName(); - logger.info("Taking disk-only VM snapshot of running VM [{}].", vmName); - - Domain dm = null; try { - LibvirtUtilitiesHelper libvirtUtilitiesHelper = resource.getLibvirtUtilitiesHelper(); - Connect conn = libvirtUtilitiesHelper.getConnection(); - List volumeObjectTOS = cmd.getVolumeTOs(); - List disks = resource.getDisks(conn, vmName); - - dm = resource.getDomain(conn, vmName); - - if (dm == null) { - return new CreateDiskOnlyVmSnapshotAnswer(cmd, false, String.format("Creation of disk-only VM Snapshot failed as we could not find the VM [%s].", vmName), null); - } - - VMSnapshotTO target = cmd.getTarget(); - Pair>> snapshotXmlAndVolumeToNewPathMap = createSnapshotXmlAndNewVolumePathMap(volumeObjectTOS, disks, target, resource); - - dm.snapshotCreateXML(snapshotXmlAndVolumeToNewPathMap.first(), getFlagsToUseForRunningVmSnapshotCreation(target)); - - return new CreateDiskOnlyVmSnapshotAnswer(cmd, true, null, snapshotXmlAndVolumeToNewPathMap.second()); - } catch (LibvirtException e) { - String errorMsg = String.format("Creation of disk-only VM snapshot for VM [%s] failed due to %s.", vmName, e.getMessage()); - logger.error(errorMsg, e); - if (e.getMessage().contains("QEMU guest agent is not connected")) { - errorMsg = "QEMU guest agent is not connected. If the VM has been recently started, it might connect soon. Otherwise the VM does not have the" + - " guest agent installed; thus the QuiesceVM parameter is not supported."; - return new CreateDiskOnlyVmSnapshotAnswer(cmd, false, errorMsg, null); - } - return new CreateDiskOnlyVmSnapshotAnswer(cmd, false, e.getMessage(), null); - } finally { - if (dm != null) { - try { - dm.free(); - } catch (LibvirtException l) { - logger.trace("Ignoring libvirt error.", l); - } + if (VirtualMachine.State.Running.equals(state)) { + return new CreateDiskOnlyVmSnapshotAnswer(cmd, true, null, resource.createDiskOnlyVmSnapshotForRunningVm(cmd.getVolumeTosAndNewPaths(), cmd.getVmName(), + cmd.getTarget().getSnapshotName(), cmd.getTarget().getQuiescevm())); } + return new CreateDiskOnlyVmSnapshotAnswer(cmd, true, null, resource.createDiskOnlyVMSnapshotOfStoppedVm(cmd.getVolumeTosAndNewPaths(), cmd.getVmName())); + } catch (BackupException ex) { + return new Answer(cmd, ex); } } - - protected Answer takeDiskOnlyVmSnapshotOfStoppedVm(CreateDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) { - String vmName = cmd.getVmName(); - logger.info("Taking disk-only VM snapshot of stopped VM [{}].", vmName); - - Map> mapVolumeToSnapshotSizeAndNewVolumePath = new HashMap<>(); - - List volumeObjectTos = cmd.getVolumeTOs(); - KVMStoragePoolManager storagePoolMgr = resource.getStoragePoolMgr(); - try { - for (VolumeObjectTO volumeObjectTO : volumeObjectTos) { - PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); - KVMStoragePool kvmStoragePool = storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); - - String snapshotPath = UUID.randomUUID().toString(); - String snapshotFullPath = kvmStoragePool.getLocalPathFor(snapshotPath); - QemuImgFile newDelta = new QemuImgFile(snapshotFullPath, QemuImg.PhysicalDiskFormat.QCOW2); - - String currentDeltaFullPath = kvmStoragePool.getLocalPathFor(volumeObjectTO.getPath()); - QemuImgFile currentDelta = new QemuImgFile(currentDeltaFullPath, QemuImg.PhysicalDiskFormat.QCOW2); - - QemuImg qemuImg = new QemuImg(0); - - logger.debug("Creating new delta for volume [{}] as part of the disk-only VM snapshot process for VM [{}].", volumeObjectTO.getUuid(), vmName); - qemuImg.create(newDelta, currentDelta); - - mapVolumeToSnapshotSizeAndNewVolumePath.put(volumeObjectTO.getUuid(), new Pair<>(getFileSize(currentDeltaFullPath), snapshotPath)); - } - } catch (LibvirtException | QemuImgException e) { - logger.error("Exception while creating disk-only VM snapshot for VM [{}]. Deleting leftover deltas.", vmName, e); - for (VolumeObjectTO volumeObjectTO : volumeObjectTos) { - Pair volSizeAndNewPath = mapVolumeToSnapshotSizeAndNewVolumePath.get(volumeObjectTO.getUuid()); - PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); - KVMStoragePool kvmStoragePool = storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); - - if (volSizeAndNewPath == null) { - continue; - } - try { - Files.deleteIfExists(Path.of(kvmStoragePool.getLocalPathFor(volSizeAndNewPath.second()))); - } catch (IOException ex) { - logger.warn("Tried to delete leftover snapshot at [{}] failed.", volSizeAndNewPath.second(), ex); - } - } - return new Answer(cmd, e); - } - - return new CreateDiskOnlyVmSnapshotAnswer(cmd, true, null, mapVolumeToSnapshotSizeAndNewVolumePath); - } - - protected int getFlagsToUseForRunningVmSnapshotCreation(VMSnapshotTO target) { - int flags = target.getQuiescevm() ? Domain.SnapshotCreateFlags.QUIESCE : 0; - flags += Domain.SnapshotCreateFlags.DISK_ONLY + - Domain.SnapshotCreateFlags.ATOMIC + - Domain.SnapshotCreateFlags.NO_METADATA; - return flags; - } - - protected Pair>> createSnapshotXmlAndNewVolumePathMap(List volumeObjectTOS, List disks, VMSnapshotTO target, LibvirtComputingResource resource) { - StringBuilder stringBuilder = new StringBuilder(); - Map> volumeObjectToNewPathMap = new HashMap<>(); - - for (VolumeObjectTO volumeObjectTO : volumeObjectTOS) { - LibvirtVMDef.DiskDef diskdef = resource.getDiskWithPathOfVolumeObjectTO(disks, volumeObjectTO); - String newPath = UUID.randomUUID().toString(); - stringBuilder.append(String.format(TAG_DISK_SNAPSHOT, diskdef.getDiskLabel(), resource.getSnapshotTemporaryPath(diskdef.getDiskPath(), newPath))); - - long snapSize = getFileSize(diskdef.getDiskPath()); - - volumeObjectToNewPathMap.put(volumeObjectTO.getUuid(), new Pair<>(snapSize, newPath)); - } - - String snapshotXml = String.format(SNAPSHOT_XML, target.getSnapshotName(), stringBuilder); - return new Pair<>(snapshotXml, volumeObjectToNewPathMap); - } - - protected long getFileSize(String path) { - return new File(path).length(); - } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtFinalizeBackupCompressionCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtFinalizeBackupCompressionCommandWrapper.java new file mode 100644 index 000000000000..4b02c20c6d26 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtFinalizeBackupCompressionCommandWrapper.java @@ -0,0 +1,73 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; + +import org.apache.cloudstack.backup.FinalizeBackupCompressionCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +@ResourceWrapper(handles = FinalizeBackupCompressionCommand.class) +public class LibvirtFinalizeBackupCompressionCommandWrapper extends CommandWrapper { + @Override + public Answer execute(FinalizeBackupCompressionCommand command, LibvirtComputingResource serverResource) { + KVMStoragePool storagePool = null; + KVMStoragePoolManager storagePoolManager = serverResource.getStoragePoolMgr(); + long totalPhysicalSize = 0; + + if (command.isCleanup()) { + logger.info("Cleaning up compressed backup deltas [{}].", command.getBackupDeltaTOList()); + } else { + logger.info("Finalizing backup compression for deltas [{}].", command.getBackupDeltaTOList()); + } + try { + storagePool = storagePoolManager.getStoragePoolByURI(command.getBackupDeltaTOList().get(0).getDataStore().getUrl()); + for (BackupDeltaTO delta : command.getBackupDeltaTOList()) { + Path deltaPath = Path.of(storagePool.getLocalPathFor(delta.getPath())); + Path compressedDeltaPath = Path.of(deltaPath + ".comp"); + + if (command.isCleanup()) { + logger.debug("Cleaning up backup delta at [{}].", compressedDeltaPath); + Files.deleteIfExists(compressedDeltaPath); + continue; + } + + logger.debug("Moving compressed backup delta at [{}] to [{}].", compressedDeltaPath, deltaPath); + Files.move(compressedDeltaPath, deltaPath, StandardCopyOption.REPLACE_EXISTING); + totalPhysicalSize += Files.size(deltaPath); + } + } catch (IOException e) { + return new Answer(command, e); + } finally { + if (storagePool != null) { + storagePoolManager.deleteStoragePool(storagePool.getType(), storagePool.getUuid()); + } + } + return new Answer(command, true, String.valueOf(totalPhysicalSize)); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetStorageStatsCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetStorageStatsCommandWrapper.java index 419b54492583..424622dfc2ce 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetStorageStatsCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetStorageStatsCommandWrapper.java @@ -36,7 +36,7 @@ public final class LibvirtGetStorageStatsCommandWrapper extends CommandWrapper getIpAddresses(String output, String macAddress) { continue; } String device = parts[0]; - String mac = parts[1]; + String mac = parts[parts.length - 3]; if (found) { if (!device.equals("-") || !mac.equals("-")) { break; @@ -128,8 +128,8 @@ private Pair getIpAddresses(String output, String macAddress) { continue; } found = true; - String ipFamily = parts[2]; - String ipPart = parts[3].split("/")[0]; + String ipFamily = parts[parts.length - 2]; + String ipPart = parts[parts.length - 1].split("/")[0]; if (ipFamily.equals("ipv4")) { ipv4 = ipPart; } else if (ipFamily.equals("ipv6")) { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java index ab6bdd6135d5..1109d240a035 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVolumesOnStorageCommandWrapper.java @@ -62,7 +62,7 @@ public Answer execute(final GetVolumesOnStorageCommand command, final LibvirtCom final String keyword = command.getKeyword(); final KVMStoragePoolManager storagePoolMgr = libvirtComputingResource.getStoragePoolMgr(); - final KVMStoragePool storagePool = storagePoolMgr.getStoragePool(pool.getType(), pool.getUuid(), true); + final KVMStoragePool storagePool = storagePoolMgr.getStoragePool(pool.getType(), pool.getUuid(), true, true); if (StringUtils.isNotBlank(volumePath)) { return addVolumeByVolumePath(command, storagePool, volumePath); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java index 5602da156799..28e24a9e0f2d 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtImportConvertedInstanceCommandWrapper.java @@ -18,22 +18,9 @@ // package com.cloud.hypervisor.kvm.resource.wrapper; -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.Charset; -import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import java.util.stream.Collectors; -import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.vm.UnmanagedInstanceTO; -import org.apache.commons.collections.CollectionUtils; -import org.apache.commons.io.IOUtils; -import org.apache.commons.lang3.StringUtils; import com.cloud.agent.api.Answer; import com.cloud.agent.api.ImportConvertedInstanceAnswer; @@ -44,20 +31,14 @@ import com.cloud.hypervisor.Hypervisor; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; import com.cloud.hypervisor.kvm.resource.LibvirtDomainXMLParser; -import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; import com.cloud.hypervisor.kvm.storage.KVMPhysicalDisk; import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; -import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; -import com.cloud.storage.Storage; -import com.cloud.utils.FileUtil; -import com.cloud.utils.Pair; -import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.utils.script.Script; +import org.apache.commons.collections4.CollectionUtils; @ResourceWrapper(handles = ImportConvertedInstanceCommand.class) -public class LibvirtImportConvertedInstanceCommandWrapper extends CommandWrapper { +public class LibvirtImportConvertedInstanceCommandWrapper extends LibvirtBaseConvertCommandWrapper { @Override public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResource serverResource) { @@ -79,16 +60,22 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour List temporaryDisks = xmlParser == null ? getTemporaryDisksWithPrefixFromTemporaryPool(temporaryStoragePool, temporaryConvertPath, temporaryConvertUuid) : - getTemporaryDisksFromParsedXml(temporaryStoragePool, xmlParser, convertedBasePath); + getTemporaryDisksFromParsedXml(temporaryStoragePool, xmlParser, convertedBasePath, temporaryConvertPath, temporaryConvertUuid); - List disks = null; + List disks; if (forceConvertToPool) { // Force flag to use the conversion path, no need to move disks disks = temporaryDisks; } else { disks = moveTemporaryDisksToDestination(temporaryDisks, destinationStoragePools, storagePoolMgr); - cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, temporaryStoragePool, temporaryConvertUuid); + cleanupDisksAndDomainFromTemporaryLocation(temporaryDisks, temporaryStoragePool, temporaryConvertUuid, true); + } + + if (CollectionUtils.isEmpty(disks)) { + String msg = String.format("Unable to import the converted disks for VM %s from destination pools", sourceInstanceName); + logger.error(msg); + return new ImportConvertedInstanceAnswer(cmd, false, msg); } UnmanagedInstanceTO convertedInstanceTO = getConvertedUnmanagedInstance(temporaryConvertUuid, @@ -106,200 +93,4 @@ public Answer execute(ImportConvertedInstanceCommand cmd, LibvirtComputingResour } } } - - protected KVMStoragePool getTemporaryStoragePool(DataStoreTO conversionTemporaryLocation, KVMStoragePoolManager storagePoolMgr) { - if (conversionTemporaryLocation instanceof NfsTO) { - NfsTO nfsTO = (NfsTO) conversionTemporaryLocation; - return storagePoolMgr.getStoragePoolByURI(nfsTO.getUrl()); - } else { - PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) conversionTemporaryLocation; - return storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); - } - } - - protected List getTemporaryDisksFromParsedXml(KVMStoragePool pool, LibvirtDomainXMLParser xmlParser, String convertedBasePath) { - List disksDefs = xmlParser.getDisks(); - disksDefs = disksDefs.stream().filter(x -> x.getDiskType() == LibvirtVMDef.DiskDef.DiskType.FILE && - x.getDeviceType() == LibvirtVMDef.DiskDef.DeviceType.DISK).collect(Collectors.toList()); - if (CollectionUtils.isEmpty(disksDefs)) { - String err = String.format("Cannot find any disk defined on the converted XML domain %s.xml", convertedBasePath); - logger.error(err); - throw new CloudRuntimeException(err); - } - sanitizeDisksPath(disksDefs); - return getPhysicalDisksFromDefPaths(disksDefs, pool); - } - - private List getPhysicalDisksFromDefPaths(List disksDefs, KVMStoragePool pool) { - List disks = new ArrayList<>(); - for (LibvirtVMDef.DiskDef diskDef : disksDefs) { - KVMPhysicalDisk physicalDisk = pool.getPhysicalDisk(diskDef.getDiskPath()); - disks.add(physicalDisk); - } - return disks; - } - - protected List getTemporaryDisksWithPrefixFromTemporaryPool(KVMStoragePool pool, String path, String prefix) { - String msg = String.format("Could not parse correctly the converted XML domain, checking for disks on %s with prefix %s", path, prefix); - logger.info(msg); - pool.refresh(); - List disksWithPrefix = pool.listPhysicalDisks() - .stream() - .filter(x -> x.getName().startsWith(prefix) && !x.getName().endsWith(".xml")) - .collect(Collectors.toList()); - if (CollectionUtils.isEmpty(disksWithPrefix)) { - msg = String.format("Could not find any converted disk with prefix %s on temporary location %s", prefix, path); - logger.error(msg); - throw new CloudRuntimeException(msg); - } - return disksWithPrefix; - } - - private void cleanupDisksAndDomainFromTemporaryLocation(List disks, - KVMStoragePool temporaryStoragePool, - String temporaryConvertUuid) { - for (KVMPhysicalDisk disk : disks) { - logger.info(String.format("Cleaning up temporary disk %s after conversion from temporary location", disk.getName())); - temporaryStoragePool.deletePhysicalDisk(disk.getName(), Storage.ImageFormat.QCOW2); - } - logger.info(String.format("Cleaning up temporary domain %s after conversion from temporary location", temporaryConvertUuid)); - FileUtil.deleteFiles(temporaryStoragePool.getLocalPath(), temporaryConvertUuid, ".xml"); - } - - protected void sanitizeDisksPath(List disks) { - for (LibvirtVMDef.DiskDef disk : disks) { - String[] diskPathParts = disk.getDiskPath().split("/"); - String relativePath = diskPathParts[diskPathParts.length - 1]; - disk.setDiskPath(relativePath); - } - } - - protected List moveTemporaryDisksToDestination(List temporaryDisks, - List destinationStoragePools, - KVMStoragePoolManager storagePoolMgr) { - List targetDisks = new ArrayList<>(); - if (temporaryDisks.size() != destinationStoragePools.size()) { - String warn = String.format("Discrepancy between the converted instance disks (%s) " + - "and the expected number of disks (%s)", temporaryDisks.size(), destinationStoragePools.size()); - logger.warn(warn); - } - for (int i = 0; i < temporaryDisks.size(); i++) { - String poolPath = destinationStoragePools.get(i); - KVMStoragePool destinationPool = storagePoolMgr.getStoragePool(Storage.StoragePoolType.NetworkFilesystem, poolPath); - if (destinationPool == null) { - String err = String.format("Could not find a storage pool by URI: %s", poolPath); - logger.error(err); - continue; - } - if (destinationPool.getType() != Storage.StoragePoolType.NetworkFilesystem) { - String err = String.format("Storage pool by URI: %s is not an NFS storage", poolPath); - logger.error(err); - continue; - } - KVMPhysicalDisk sourceDisk = temporaryDisks.get(i); - if (logger.isDebugEnabled()) { - String msg = String.format("Trying to copy converted instance disk number %s from the temporary location %s" + - " to destination storage pool %s", i, sourceDisk.getPool().getLocalPath(), destinationPool.getUuid()); - logger.debug(msg); - } - - String destinationName = UUID.randomUUID().toString(); - - KVMPhysicalDisk destinationDisk = storagePoolMgr.copyPhysicalDisk(sourceDisk, destinationName, destinationPool, 7200 * 1000); - targetDisks.add(destinationDisk); - } - return targetDisks; - } - - private UnmanagedInstanceTO getConvertedUnmanagedInstance(String baseName, - List vmDisks, - LibvirtDomainXMLParser xmlParser) { - UnmanagedInstanceTO instanceTO = new UnmanagedInstanceTO(); - instanceTO.setName(baseName); - instanceTO.setDisks(getUnmanagedInstanceDisks(vmDisks, xmlParser)); - instanceTO.setNics(getUnmanagedInstanceNics(xmlParser)); - return instanceTO; - } - - private List getUnmanagedInstanceNics(LibvirtDomainXMLParser xmlParser) { - List nics = new ArrayList<>(); - if (xmlParser != null) { - List interfaces = xmlParser.getInterfaces(); - for (LibvirtVMDef.InterfaceDef interfaceDef : interfaces) { - UnmanagedInstanceTO.Nic nic = new UnmanagedInstanceTO.Nic(); - nic.setMacAddress(interfaceDef.getMacAddress()); - nic.setNicId(interfaceDef.getBrName()); - nic.setAdapterType(interfaceDef.getModel().toString()); - nics.add(nic); - } - } - return nics; - } - - protected List getUnmanagedInstanceDisks(List vmDisks, LibvirtDomainXMLParser xmlParser) { - List instanceDisks = new ArrayList<>(); - List diskDefs = xmlParser != null ? xmlParser.getDisks() : null; - for (int i = 0; i< vmDisks.size(); i++) { - KVMPhysicalDisk physicalDisk = vmDisks.get(i); - KVMStoragePool storagePool = physicalDisk.getPool(); - UnmanagedInstanceTO.Disk disk = new UnmanagedInstanceTO.Disk(); - disk.setPosition(i); - Pair storagePoolHostAndPath = getNfsStoragePoolHostAndPath(storagePool); - disk.setDatastoreHost(storagePoolHostAndPath.first()); - disk.setDatastorePath(storagePoolHostAndPath.second()); - disk.setDatastoreName(storagePool.getUuid()); - disk.setDatastoreType(storagePool.getType().name()); - disk.setCapacity(physicalDisk.getVirtualSize()); - disk.setFileBaseName(physicalDisk.getName()); - if (CollectionUtils.isNotEmpty(diskDefs)) { - LibvirtVMDef.DiskDef diskDef = diskDefs.get(i); - disk.setController(diskDef.getBusType() != null ? diskDef.getBusType().toString() : LibvirtVMDef.DiskDef.DiskBus.VIRTIO.toString()); - } else { - // If the job is finished but we cannot parse the XML, the guest VM can use the virtio driver - disk.setController(LibvirtVMDef.DiskDef.DiskBus.VIRTIO.toString()); - } - instanceDisks.add(disk); - } - return instanceDisks; - } - - protected Pair getNfsStoragePoolHostAndPath(KVMStoragePool storagePool) { - String sourceHostIp = null; - String sourcePath = null; - List commands = new ArrayList<>(); - commands.add(new String[]{Script.getExecutableAbsolutePath("mount")}); - commands.add(new String[]{Script.getExecutableAbsolutePath("grep"), storagePool.getLocalPath()}); - String storagePoolMountPoint = Script.executePipedCommands(commands, 0).second(); - logger.debug(String.format("NFS Storage pool: %s - local path: %s, mount point: %s", storagePool.getUuid(), storagePool.getLocalPath(), storagePoolMountPoint)); - if (StringUtils.isNotEmpty(storagePoolMountPoint)) { - String[] res = storagePoolMountPoint.strip().split(" "); - res = res[0].split(":"); - if (res.length > 1) { - sourceHostIp = res[0].strip(); - sourcePath = res[1].strip(); - } - } - return new Pair<>(sourceHostIp, sourcePath); - } - - protected LibvirtDomainXMLParser parseMigratedVMXmlDomain(String installPath) throws IOException { - String xmlPath = String.format("%s.xml", installPath); - if (!new File(xmlPath).exists()) { - String err = String.format("Conversion failed. Unable to find the converted XML domain, expected %s", xmlPath); - logger.error(err); - throw new CloudRuntimeException(err); - } - InputStream is = new BufferedInputStream(new FileInputStream(xmlPath)); - String xml = IOUtils.toString(is, Charset.defaultCharset()); - final LibvirtDomainXMLParser parser = new LibvirtDomainXMLParser(); - try { - parser.parseDomainXML(xml); - return parser; - } catch (RuntimeException e) { - String err = String.format("Error parsing the converted instance XML domain at %s: %s", xmlPath, e.getMessage()); - logger.error(err, e); - logger.debug(xml); - return null; - } - } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMergeDiskOnlyVMSnapshotCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMergeDiskOnlyVMSnapshotCommandWrapper.java index ad0678080ed7..ad687cbb3be1 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMergeDiskOnlyVMSnapshotCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMergeDiskOnlyVMSnapshotCommandWrapper.java @@ -21,42 +21,26 @@ import com.cloud.agent.api.Answer; import com.cloud.agent.api.storage.MergeDiskOnlyVmSnapshotCommand; -import com.cloud.agent.api.storage.SnapshotMergeTreeTO; -import com.cloud.agent.api.to.DataObjectType; -import com.cloud.agent.api.to.DataTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; -import com.cloud.hypervisor.kvm.resource.LibvirtVMDef; -import com.cloud.hypervisor.kvm.storage.KVMStoragePool; -import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; import com.cloud.resource.CommandWrapper; import com.cloud.resource.ResourceWrapper; import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.vm.VirtualMachine; -import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; -import org.apache.cloudstack.storage.to.SnapshotObjectTO; -import org.apache.cloudstack.storage.to.VolumeObjectTO; -import org.apache.cloudstack.utils.qemu.QemuImg; import org.apache.cloudstack.utils.qemu.QemuImgException; -import org.apache.cloudstack.utils.qemu.QemuImgFile; -import org.libvirt.Connect; -import org.libvirt.Domain; import org.libvirt.LibvirtException; import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; -import java.util.stream.Collectors; @ResourceWrapper(handles = MergeDiskOnlyVmSnapshotCommand.class) public class LibvirtMergeDiskOnlyVMSnapshotCommandWrapper extends CommandWrapper { @Override public Answer execute(MergeDiskOnlyVmSnapshotCommand command, LibvirtComputingResource serverResource) { - VirtualMachine.State vmState = command.getVmState(); + boolean isVmRunning = command.isVmRunning(); try { - if (VirtualMachine.State.Running.equals(vmState)) { + if (isVmRunning) { return mergeDiskOnlySnapshotsForRunningVM(command, serverResource); } return mergeDiskOnlySnapshotsForStoppedVM(command, serverResource); @@ -66,83 +50,28 @@ public Answer execute(MergeDiskOnlyVmSnapshotCommand command, LibvirtComputingRe } protected Answer mergeDiskOnlySnapshotsForStoppedVM(MergeDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) throws QemuImgException, LibvirtException { - QemuImg qemuImg = new QemuImg(resource.getCmdsTimeout()); - KVMStoragePoolManager storageManager = resource.getStoragePoolMgr(); + List deltaMergeTreeTOList = cmd.getDeltaMergeTreeToList(); - List snapshotMergeTreeTOList = cmd.getSnapshotMergeTreeToList(); + logger.debug("Merging deltas for stopped VM [{}] using the following Delta Merge Trees [{}].", cmd.getVmName(), deltaMergeTreeTOList); - logger.debug("Merging disk-only snapshots for stopped VM [{}] using the following Snapshot Merge Trees [{}].", cmd.getVmName(), snapshotMergeTreeTOList); - - for (SnapshotMergeTreeTO snapshotMergeTreeTO : snapshotMergeTreeTOList) { - DataTO parentTo = snapshotMergeTreeTO.getParent(); - PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) parentTo.getDataStore(); - KVMStoragePool storagePool = storageManager.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); - String childLocalPath = storagePool.getLocalPathFor(snapshotMergeTreeTO.getChild().getPath()); - - QemuImgFile parent = new QemuImgFile(storagePool.getLocalPathFor(parentTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2); - QemuImgFile child = new QemuImgFile(childLocalPath, QemuImg.PhysicalDiskFormat.QCOW2); - - logger.debug("Committing child delta [{}] into parent snapshot [{}].", parentTo, snapshotMergeTreeTO.getChild()); - qemuImg.commit(child, parent, true); - - List grandChildren = snapshotMergeTreeTO.getGrandChildren().stream() - .map(snapshotTo -> new QemuImgFile(storagePool.getLocalPathFor(snapshotTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2)) - .collect(Collectors.toList()); - - logger.debug("Rebasing grandChildren [{}] into parent at [{}].", grandChildren, parent.getFileName()); - for (QemuImgFile grandChild : grandChildren) { - qemuImg.rebase(grandChild, parent, parent.getFormat().toString(), false); - } - - logger.debug("Deleting child at [{}] as it is useless.", childLocalPath); + for (DeltaMergeTreeTO deltaMergeTreeTO : deltaMergeTreeTOList) { try { - Files.deleteIfExists(Path.of(childLocalPath)); - } catch (IOException e) { - return new Answer(cmd, e); + resource.mergeDeltaForStoppedVm(deltaMergeTreeTO); + } catch (IOException ex) { + return new Answer(cmd, ex); } } return new Answer(cmd, true, null); } - protected Answer mergeDiskOnlySnapshotsForRunningVM(MergeDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) throws LibvirtException, QemuImgException { + protected Answer mergeDiskOnlySnapshotsForRunningVM(MergeDiskOnlyVmSnapshotCommand cmd, LibvirtComputingResource resource) throws QemuImgException, LibvirtException { String vmName = cmd.getVmName(); - List snapshotMergeTreeTOList = cmd.getSnapshotMergeTreeToList(); - - LibvirtUtilitiesHelper libvirtUtilitiesHelper = resource.getLibvirtUtilitiesHelper(); - Connect conn = libvirtUtilitiesHelper.getConnection(); - Domain domain = resource.getDomain(conn, vmName); - List disks = resource.getDisks(conn, vmName); - KVMStoragePoolManager storageManager = resource.getStoragePoolMgr(); - QemuImg qemuImg = new QemuImg(resource.getCmdsTimeout()); + List deltaMergeTreeTOs = cmd.getDeltaMergeTreeToList(); - logger.debug("Merging disk-only snapshots for running VM [{}] using the following Snapshot Merge Trees [{}].", vmName, snapshotMergeTreeTOList); + logger.debug("Merging deltas for running VM [{}] using the following Delta Merge Trees [{}].", vmName, deltaMergeTreeTOs); - for (SnapshotMergeTreeTO mergeTreeTO : snapshotMergeTreeTOList) { - DataTO childTO = mergeTreeTO.getChild(); - SnapshotObjectTO parentSnapshotTO = (SnapshotObjectTO) mergeTreeTO.getParent(); - VolumeObjectTO volumeObjectTO = parentSnapshotTO.getVolume(); - KVMStoragePool storagePool = libvirtUtilitiesHelper.getPrimaryPoolFromDataTo(volumeObjectTO, storageManager); - - boolean active = DataObjectType.VOLUME.equals(childTO.getObjectType()); - String label = resource.getDiskWithPathOfVolumeObjectTO(disks, volumeObjectTO).getDiskLabel(); - String parentSnapshotLocalPath = storagePool.getLocalPathFor(parentSnapshotTO.getPath()); - String childDeltaPath = storagePool.getLocalPathFor(childTO.getPath()); - - logger.debug("Found label [{}] for [{}]. Will merge delta at [{}] into delta at [{}].", label, volumeObjectTO, parentSnapshotLocalPath, childDeltaPath); - - resource.mergeSnapshotIntoBaseFile(domain, label, parentSnapshotLocalPath, childDeltaPath, active, childTO.getPath(), - volumeObjectTO, conn); - - QemuImgFile parent = new QemuImgFile(parentSnapshotLocalPath, QemuImg.PhysicalDiskFormat.QCOW2); - - List grandChildren = mergeTreeTO.getGrandChildren().stream() - .map(snapshotTo -> new QemuImgFile(storagePool.getLocalPathFor(snapshotTo.getPath()), QemuImg.PhysicalDiskFormat.QCOW2)) - .collect(Collectors.toList()); - - logger.debug("Rebasing grandChildren [{}] into parent at [{}].", grandChildren, parentSnapshotLocalPath); - for (QemuImgFile grandChild : grandChildren) { - qemuImg.rebase(grandChild, parent, parent.getFormat().toString(), false); - } + for (DeltaMergeTreeTO deltaMergeTreeTO : deltaMergeTreeTOs) { + resource.mergeDeltaForRunningVm(deltaMergeTreeTO, vmName, deltaMergeTreeTO.getVolumeObjectTO()); } return new Answer(cmd, true, null); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateBackupsBetweenSecondaryStoragesCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateBackupsBetweenSecondaryStoragesCommandWrapper.java new file mode 100644 index 000000000000..f786a0821d05 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateBackupsBetweenSecondaryStoragesCommandWrapper.java @@ -0,0 +1,132 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.MigrateBackupsBetweenSecondaryStoragesCommand; +import com.cloud.agent.api.MigrateBetweenSecondaryStoragesCommandAnswer; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.agent.api.to.DataTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtMigrateResourceBetweenSecondaryStorages; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.Pair; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.commons.lang3.BooleanUtils; +import org.libvirt.LibvirtException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@ResourceWrapper(handles = MigrateBackupsBetweenSecondaryStoragesCommand.class) +public class LibvirtMigrateBackupsBetweenSecondaryStoragesCommandWrapper extends LibvirtMigrateResourceBetweenSecondaryStorages { + + @Override + public Answer execute(MigrateBackupsBetweenSecondaryStoragesCommand command, LibvirtComputingResource serverResource) { + resourceType = BACKUP; + filesToRemove = new HashSet<>(); + resourcesToUpdate = new ArrayList<>(); + wait = command.getWait() * 1000; + + DataStoreTO srcDataStore = command.getSrcDataStore(); + DataStoreTO destDataStore = command.getDestDataStore(); + KVMStoragePoolManager storagePoolManager = serverResource.getStoragePoolMgr(); + + Set imagePools = new HashSet<>(); + KVMStoragePool destImagePool = storagePoolManager.getStoragePoolByURI(destDataStore.getUrl()); + imagePools.add(destImagePool); + + String imagePoolUrl; + KVMStoragePool imagePool = null; + + List> backupChains = command.getBackupChain(); + + try { + Map parentBackupPathMap = new HashMap<>(); + Map parentBackupMigrationMap = new HashMap<>(); + + Map backupPathMap = new HashMap<>(); + Map backupMigrationMap = new HashMap<>(); + + for (List chain : backupChains) { + long lastBackupId = 0; + boolean backupWasMigrated = false; + + backupPathMap.clear(); + backupMigrationMap.clear(); + + for (DataTO backup : chain) { + lastBackupId = backup.getId(); + + imagePoolUrl = backup.getDataStore().getUrl(); + imagePool = storagePoolManager.getStoragePoolByURI(imagePoolUrl); + imagePools.add(imagePool); + + String volumeId = backup.getPath().split("/")[2]; + String resourceCurrentPath = imagePool.getLocalPathFor(backup.getPath()); + String resourceParentPath = parentBackupPathMap.get(volumeId); + + if (imagePoolUrl.equals(srcDataStore.getUrl())) { + backupPathMap.put(volumeId, copyResourceToDestDataStore(backup, resourceCurrentPath, destImagePool, resourceParentPath)); + backupMigrationMap.put(volumeId, true); + backupWasMigrated = true; + } else { + if (BooleanUtils.isTrue(parentBackupMigrationMap.get(volumeId))) { + backupPathMap.put(volumeId, rebaseResourceToNewParentPath(resourceCurrentPath, resourceParentPath)); + } else { + backupPathMap.put(volumeId, resourceCurrentPath); + } + backupMigrationMap.put(volumeId, false); + } + } + + parentBackupPathMap.clear(); + parentBackupPathMap.putAll(backupPathMap); + + parentBackupMigrationMap.clear(); + parentBackupMigrationMap.putAll(backupMigrationMap); + + if (backupWasMigrated) { + resourcesToUpdate.add(new Pair<>(lastBackupId, null)); + } + } + } catch (LibvirtException | QemuImgException e) { + logger.error("Exception while migrating backups [{}] to secondary storage [{}] due to: [{}].", + command.getBackupChain(), imagePool, e.getMessage(), e); + return new MigrateBetweenSecondaryStoragesCommandAnswer(command, false, "Migration of backups between secondary storages failed", resourcesToUpdate); + } finally { + for (String file : filesToRemove) { + removeResourceFromSourceDataStore(file); + } + + for (KVMStoragePool storagePool : imagePools) { + storagePoolManager.deleteStoragePool(storagePool.getType(), storagePool.getUuid()); + } + } + + return new MigrateBetweenSecondaryStoragesCommandAnswer(command, true, "success", resourcesToUpdate); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareValidationCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareValidationCommandWrapper.java new file mode 100644 index 000000000000..1d9ed5631fa1 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPrepareValidationCommandWrapper.java @@ -0,0 +1,91 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.to.DataStoreTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.backup.PrepareValidationCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.libvirt.LibvirtException; + +import java.util.ArrayList; +import java.util.List; + +@ResourceWrapper(handles = PrepareValidationCommand.class) +public class LibvirtPrepareValidationCommandWrapper extends CommandWrapper { + @Override + public Answer execute(PrepareValidationCommand command, LibvirtComputingResource resource) { + List> backingFileAndVolumeList = command.getBackupToVolumeList(); + + List secondaryReferences = new ArrayList<>(); + KVMStoragePoolManager storagePoolMgr = resource.getStoragePoolMgr(); + try { + for (String imageStoreUri : command.getImageStoreSet()) { + secondaryReferences.add(storagePoolMgr.getStoragePoolByURI(imageStoreUri)); + } + + for (Pair backingFileAndVolume : backingFileAndVolumeList) { + logger.debug("Preparing volume [{}] for validation.", backingFileAndVolume.second()); + BackupDeltaTO backupDelta = backingFileAndVolume.first(); + DataStoreTO dataStoreTO = backupDelta.getDataStore(); + KVMStoragePool imageStore = storagePoolMgr.getStoragePoolByURI(dataStoreTO.getUrl()); + secondaryReferences.add(imageStore); + + createVolume(command, backingFileAndVolume, imageStore, backupDelta, storagePoolMgr); + } + } catch (LibvirtException | QemuImgException e) { + logger.error("Failed to prepare VM [{}] for validation due to:", backingFileAndVolumeList.get(0).second().getVmName(), e); + throw new CloudRuntimeException(e); + } finally { + for (KVMStoragePool secondary : secondaryReferences) { + storagePoolMgr.deleteStoragePool(secondary.getType(), secondary.getUuid()); + } + } + return new Answer(command); + } + + private void createVolume(PrepareValidationCommand command, Pair volumeAndBackingFile, KVMStoragePool imageStore, BackupDeltaTO backupDelta, + KVMStoragePoolManager storagePoolMgr) throws LibvirtException, QemuImgException { + String fullBackupPath = imageStore.getLocalPathFor(backupDelta.getPath()); + + VolumeObjectTO volumeObjectTO = volumeAndBackingFile.second(); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); + KVMStoragePool primaryStoragePool = storagePoolMgr.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + String fullVolumePath = primaryStoragePool.getLocalPathFor(volumeObjectTO.getPath()); + + QemuImgFile backup = new QemuImgFile(fullBackupPath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile volume = new QemuImgFile(fullVolumePath, QemuImg.PhysicalDiskFormat.QCOW2); + + QemuImg qemuImg = new QemuImg(command.getWait() * 1000); + + qemuImg.create(volume, backup); + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java index 22dbfbdd67a2..cc2a0868fe17 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java @@ -60,6 +60,15 @@ public class LibvirtRestoreBackupCommandWrapper extends CommandWrapper/dev/null | grep -q '\"backing-filename\"'"; private String getVolumeUuidFromPath(String volumePath, PrimaryDataStoreTO volumePool) { if (Storage.StoragePoolType.Linstor.equals(volumePool.getPoolType())) { @@ -270,10 +279,27 @@ private boolean replaceVolumeWithBackup(KVMStoragePoolManager storagePoolMgr, Pr return replaceBlockDeviceWithBackup(storagePoolMgr, volumePool, volumePath, backupPath, timeout, createTargetVolume, size); } + // For NAS-backed incremental backups, the source qcow2 has a backing-file + // reference to its parent (set by nasbackup.sh's qemu-img rebase). A plain + // rsync would copy only the differential blocks, leaving a volume that + // depends on a backing file the primary storage doesn't have. Flatten the + // chain via qemu-img convert, which follows the backing-file links and + // produces a single self-contained qcow2. + if (hasBackingChain(backupPath)) { + int flattenExit = Script.runSimpleBashScriptForExitValue( + String.format(QEMU_IMG_FLATTEN_COMMAND, backupPath, volumePath), timeout, false); + return flattenExit == 0; + } + int exitValue = Script.runSimpleBashScriptForExitValue(String.format(RSYNC_COMMAND, backupPath, volumePath), timeout, false); return exitValue == 0; } + private boolean hasBackingChain(String qcow2Path) { + return Script.runSimpleBashScriptForExitValue( + String.format(QEMU_IMG_HAS_BACKING_COMMAND, qcow2Path)) == 0; + } + private boolean replaceBlockDeviceWithBackup(KVMStoragePoolManager storagePoolMgr, PrimaryDataStoreTO volumePool, String volumePath, String backupPath, int timeout, boolean createTargetVolume, Long size) { KVMStoragePool volumeStoragePool = storagePoolMgr.getStoragePool(volumePool.getPoolType(), volumePool.getUuid()); QemuImg qemu; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreKbossBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreKbossBackupCommandWrapper.java new file mode 100644 index 000000000000..a9010b46c1d4 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreKbossBackupCommandWrapper.java @@ -0,0 +1,123 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.storage.Storage; +import com.cloud.utils.Pair; +import org.apache.cloudstack.backup.RestoreKbossBackupAnswer; +import org.apache.cloudstack.backup.RestoreKbossBackupCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.libvirt.LibvirtException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; + +@ResourceWrapper(handles = RestoreKbossBackupCommand.class) +public class LibvirtRestoreKbossBackupCommandWrapper extends CommandWrapper { + @Override + public Answer execute(RestoreKbossBackupCommand cmd, LibvirtComputingResource resource) { + Set> backupToAndVolumeObjectPairs = cmd.getBackupAndVolumePairs(); + Set deltasToRemove = cmd.getDeltasToRemove(); + Set secondaryStorageUrls = cmd.getSecondaryStorageUrls(); + + KVMStoragePoolManager storagePoolManager = resource.getStoragePoolMgr(); + + Set secondaryStorageUuids = new HashSet<>(); + try { + KVMStoragePool secondaryStorage = mountSecondaryStorages(secondaryStorageUrls, backupToAndVolumeObjectPairs.stream().findFirst().get().first().getDataStore().getUrl(), + storagePoolManager, secondaryStorageUuids); + + restoreVolumes(backupToAndVolumeObjectPairs, secondaryStorage, storagePoolManager, cmd.isQuickRestore(), cmd.getWait() * 1000); + + deleteDeltas(deltasToRemove, storagePoolManager); + } catch (LibvirtException | QemuImgException | IOException e) { + return new RestoreKbossBackupAnswer(cmd, e, secondaryStorageUuids); + } finally { + if (!cmd.isQuickRestore()) { + for (String uuid : secondaryStorageUuids) { + storagePoolManager.deleteStoragePool(Storage.StoragePoolType.NetworkFilesystem, uuid); + } + } + } + return new RestoreKbossBackupAnswer(cmd, secondaryStorageUuids); + } + + protected void restoreVolumes(Set> backupToAndVolumeObjectPairs, KVMStoragePool secondaryStorage, KVMStoragePoolManager storagePoolManager, + boolean quickRestore, int timeoutInMillis) throws LibvirtException, QemuImgException { + for (Pair backupToVolumeToPair : backupToAndVolumeObjectPairs) { + String fullBackupPath = secondaryStorage.getLocalPathFor(backupToVolumeToPair.first().getPath()); + + VolumeObjectTO volumeObjectTO = backupToVolumeToPair.second(); + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) volumeObjectTO.getDataStore(); + KVMStoragePool primaryStoragePool = storagePoolManager.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + String fullVolumePath = primaryStoragePool.getLocalPathFor(volumeObjectTO.getPath()); + + QemuImgFile backup = new QemuImgFile(fullBackupPath, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile volume = new QemuImgFile(fullVolumePath, QemuImg.PhysicalDiskFormat.QCOW2); + + QemuImg qemuImg = getQemuImg(timeoutInMillis); + + if (quickRestore) { + logger.info("Creating delta over old volume [{}] at [{}] with backing store stored at [{}].", volumeObjectTO.getUuid(), fullVolumePath, fullBackupPath); + qemuImg.create(volume, backup); + } else { + logger.info("Restoring volume [{}] at [{}] with backup stored at [{}].", volumeObjectTO.getUuid(), fullVolumePath, fullBackupPath); + qemuImg.convert(backup, volume); + } + } + } + + protected QemuImg getQemuImg(int timeoutInMillis) throws LibvirtException, QemuImgException { + return new QemuImg(timeoutInMillis); + } + + protected void deleteDeltas(Set deltasToRemove, KVMStoragePoolManager storagePoolManager) throws IOException { + for (BackupDeltaTO deltaToRemove : deltasToRemove) { + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) deltaToRemove.getDataStore(); + KVMStoragePool primaryStoragePool = storagePoolManager.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + String fullDeltaPath = primaryStoragePool.getLocalPathFor(deltaToRemove.getPath()); + logger.debug("Deleting leftover delta [{}].", fullDeltaPath); + Files.deleteIfExists(Path.of(fullDeltaPath)); + } + } + + protected KVMStoragePool mountSecondaryStorages(Set parentSecondaryStorageUrls, String secondaryStorageUrl, KVMStoragePoolManager storagePoolManager, Set secondaryStorageUuids) { + for (String url : parentSecondaryStorageUrls) { + KVMStoragePool pool = storagePoolManager.getStoragePoolByURI(url); + secondaryStorageUuids.add(pool.getUuid()); + } + KVMStoragePool pool = storagePoolManager.getStoragePoolByURI(secondaryStorageUrl); + secondaryStorageUuids.add(pool.getUuid()); + return pool; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRevertSnapshotCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRevertSnapshotCommandWrapper.java index 16c1a5a2fac1..507744fdc316 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRevertSnapshotCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRevertSnapshotCommandWrapper.java @@ -28,6 +28,7 @@ import com.cloud.agent.properties.AgentProperties; import com.cloud.agent.properties.AgentPropertiesFileHandler; +import com.cloud.storage.Storage; import org.apache.cloudstack.storage.command.RevertSnapshotCommand; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.storage.to.SnapshotObjectTO; @@ -58,6 +59,8 @@ import org.apache.cloudstack.utils.qemu.QemuImgFile; import org.libvirt.LibvirtException; +import static com.cloud.hypervisor.kvm.storage.KVMStorageProcessor.poolTypesToDeleteChainInfo; + @ResourceWrapper(handles = RevertSnapshotCommand.class) public class LibvirtRevertSnapshotCommandWrapper extends CommandWrapper { @@ -128,7 +131,7 @@ public Answer execute(final RevertSnapshotCommand command, final LibvirtComputin return new Answer(command, false, result); } } else { - revertVolumeToSnapshot(secondaryStoragePool, snapshotOnPrimaryStorage, snapshot, primaryPool, libvirtComputingResource); + revertVolumeToSnapshot(secondaryStoragePool, snapshotOnPrimaryStorage, snapshot, primaryPool, libvirtComputingResource, command.isDeleteChain()); } } @@ -161,7 +164,7 @@ protected String getFullPathAccordingToStorage(KVMStoragePool kvmStoragePool, St * Reverts the volume to the snapshot. */ protected void revertVolumeToSnapshot(KVMStoragePool kvmStoragePoolSecondary, SnapshotObjectTO snapshotOnPrimaryStorage, SnapshotObjectTO snapshotOnSecondaryStorage, - KVMStoragePool kvmStoragePoolPrimary, LibvirtComputingResource resource) { + KVMStoragePool kvmStoragePoolPrimary, LibvirtComputingResource resource, boolean deleteChain) { VolumeObjectTO volumeObjectTo = snapshotOnSecondaryStorage.getVolume(); String volumePath = getFullPathAccordingToStorage(kvmStoragePoolPrimary, volumeObjectTo.getPath()); @@ -178,6 +181,11 @@ protected void revertVolumeToSnapshot(KVMStoragePool kvmStoragePoolSecondary, Sn try { replaceVolumeWithSnapshot(volumePath, snapshotPath); + if (volumeObjectTo.getChainInfo() != null && poolTypesToDeleteChainInfo.contains(kvmStoragePoolPrimary.getType()) && + volumeObjectTo.getFormat() == Storage.ImageFormat.QCOW2 && deleteChain) { + logger.debug("Deleting leftover backup delta at [{}].", volumeObjectTo.getChainInfo()); + kvmStoragePoolPrimary.deletePhysicalDisk(volumeObjectTo.getChainInfo(), volumeObjectTo.getFormat()); + } logger.debug(String.format("Successfully reverted volume [%s] to snapshot [%s].", volumeObjectTo, snapshotToPrint)); } catch (LibvirtException | QemuImgException ex) { throw new CloudRuntimeException(String.format("Unable to revert volume [%s] to snapshot [%s] due to [%s].", volumeObjectTo, snapshotToPrint, ex.getMessage()), ex); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java index 567986465906..486989661909 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java @@ -21,13 +21,16 @@ import java.io.File; import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import com.cloud.agent.resource.virtualnetwork.VRScripts; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; import com.cloud.utils.FileUtil; import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.commons.collections4.CollectionUtils; import org.libvirt.Connect; import org.libvirt.DomainInfo.DomainState; import org.libvirt.LibvirtException; @@ -64,7 +67,9 @@ public Answer execute(final StartCommand command, final LibvirtComputingResource final KVMStoragePoolManager storagePoolMgr = libvirtComputingResource.getStoragePoolMgr(); final LibvirtUtilitiesHelper libvirtUtilitiesHelper = libvirtComputingResource.getLibvirtUtilitiesHelper(); Connect conn = null; + List secondaryStorages = new ArrayList<>(); try { + mountSecondaryStoragesIfNeeded(command, libvirtComputingResource, secondaryStorages); vm = libvirtComputingResource.createVMFromSpec(vmSpec); conn = libvirtUtilitiesHelper.getConnectionByType(vm.getHvsType()); @@ -167,6 +172,17 @@ public Answer execute(final StartCommand command, final LibvirtComputingResource } finally { if (state != DomainState.VIR_DOMAIN_RUNNING) { storagePoolMgr.disconnectPhysicalDisksViaVmSpec(vmSpec); + for (KVMStoragePool secondaryStorage : secondaryStorages) { + libvirtComputingResource.getStoragePoolMgr().deleteStoragePool(secondaryStorage.getType(), secondaryStorage.getUuid()); + } + } + } + } + + private void mountSecondaryStoragesIfNeeded(StartCommand command, LibvirtComputingResource libvirtComputingResource, List secondaryStorages) { + if (CollectionUtils.isNotEmpty(command.getSecondaryStorages())) { + for (String secondaryStorageUrl : command.getSecondaryStorages()) { + secondaryStorages.add(libvirtComputingResource.getStoragePoolMgr().getStoragePoolByURI(secondaryStorageUrl)); } } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java index 42953aa9f835..106fe31a0f18 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupCommandWrapper.java @@ -42,6 +42,16 @@ @ResourceWrapper(handles = TakeBackupCommand.class) public class LibvirtTakeBackupCommandWrapper extends CommandWrapper { private static final Integer EXIT_CLEANUP_FAILED = 20; + // nasbackup.sh prints this on stdout when it could not proceed as an incremental and + // completed a full backup instead; the orchestrator then records the backup as a full. + private static final String INCREMENTAL_FALLBACK_MARKER = "INCREMENTAL_FALLBACK=true"; + + private static final String MODE_FULL = "full"; + private static final String MODE_INCREMENTAL = "incremental"; + // Incremental feature disabled: plain full backup with no QEMU bitmap/checkpoint and no + // chain metadata. Matches nasbackup.sh's "legacy-full" mode (make_checkpoint=0). + private static final String MODE_LEGACY_FULL = "legacy-full"; + @Override public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvirtComputingResource) { final String vmName = command.getVmName(); @@ -54,6 +64,13 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir KVMStoragePoolManager storagePoolMgr = libvirtComputingResource.getStoragePoolMgr(); int timeout = command.getWait() > 0 ? command.getWait() * 1000 : libvirtComputingResource.getCmdsTimeout(); + // Pre-validate incremental args here rather than relying on the script to error out. + // Keeps the script agnostic to caller policy (it just does what it's told). + String validationError = validateBackupArgs(command); + if (validationError != null) { + return new BackupAnswer(command, false, validationError); + } + List diskPaths = new ArrayList<>(); if (Objects.nonNull(volumePaths)) { for (int idx = 0; idx < volumePaths.size(); idx++) { @@ -69,8 +86,63 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir } } - List commands = new ArrayList<>(); - commands.add(new String[]{ + Pair result = runBackupScript(libvirtComputingResource, command, vmName, backupRepoType, backupRepoAddress, + mountOptions, backupPath, diskPaths, command.getMode(), + command.getBitmapNew(), command.getBitmapParent(), command.getParentPaths(), timeout); + + if (result.first() != 0) { + logger.debug("Failed to take VM backup: " + result.second()); + BackupAnswer answer = new BackupAnswer(command, false, result.second().trim()); + if (EXIT_CLEANUP_FAILED.equals(result.first())) { + logger.debug("Backup cleanup failed"); + answer.setNeedsCleanup(true); + } + return answer; + } + + // The script self-heals to a full backup when an incremental can't proceed (e.g. the + // parent checkpoint can't be re-registered) and signals it with INCREMENTAL_FALLBACK + // on stdout. Detect it, then strip the marker line before parsing the backup size. + String rawStdout = result.second(); + boolean incrementalFallback = rawStdout.contains(INCREMENTAL_FALLBACK_MARKER); + String stdout = stripMarkerLines(rawStdout).trim(); + long backupSize = parseBackupSize(stdout, diskPaths); + + BackupAnswer answer = new BackupAnswer(command, true, stdout); + answer.setSize(backupSize); + // A successful run always created command.getBitmapNew() (full and incremental both do; + // it is null for legacy-full, which the orchestrator treats as "no bitmap"). + answer.setBitmapCreated(command.getBitmapNew()); + answer.setIncrementalFallback(incrementalFallback); + return answer; + } + + /** Remove nasbackup.sh's stdout signalling marker lines so they don't pollute size parsing. */ + private String stripMarkerLines(String stdout) { + if (stdout == null || stdout.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String line : stdout.split("\n", -1)) { + if (line.contains(INCREMENTAL_FALLBACK_MARKER)) { + continue; + } + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(line); + } + return sb.toString(); + } + + /** + * Run nasbackup.sh once with the given args. Returns the exit code + captured stdout. + */ + private Pair runBackupScript(LibvirtComputingResource libvirtComputingResource, + TakeBackupCommand command, String vmName, String backupRepoType, String backupRepoAddress, + String mountOptions, String backupPath, List diskPaths, String mode, + String bitmapNew, String bitmapParent, List parentPaths, int timeout) { + List argv = new ArrayList<>(Arrays.asList( libvirtComputingResource.getNasBackupPath(), "-o", "backup", "-v", vmName, @@ -80,35 +152,79 @@ public Answer execute(TakeBackupCommand command, LibvirtComputingResource libvir "-p", backupPath, "-q", command.getQuiesce() != null && command.getQuiesce() ? "true" : "false", "-d", diskPaths.isEmpty() ? "" : String.join(",", diskPaths) - }); + )); + if (mode != null && !mode.isEmpty()) { + argv.add("-M"); + argv.add(mode); + } + if (bitmapNew != null && !bitmapNew.isEmpty()) { + argv.add("--bitmap-new"); + argv.add(bitmapNew); + } + if (bitmapParent != null && !bitmapParent.isEmpty()) { + argv.add("--bitmap-parent"); + argv.add(bitmapParent); + } + if (parentPaths != null && !parentPaths.isEmpty()) { + argv.add("--parent-paths"); + argv.add(String.join(",", parentPaths)); + } - Pair result = Script.executePipedCommands(commands, timeout); + List commands = new ArrayList<>(); + commands.add(argv.toArray(new String[0])); + return Script.executePipedCommands(commands, timeout); + } - if (result.first() != 0) { - logger.debug("Failed to take VM backup: " + result.second()); - BackupAnswer answer = new BackupAnswer(command, false, result.second().trim()); - if (result.first() == EXIT_CLEANUP_FAILED) { - logger.debug("Backup cleanup failed"); - answer.setNeedsCleanup(true); + /** + * Return a human-readable validation error string, or {@code null} if the command's + * incremental-backup args are internally consistent. + */ + private String validateBackupArgs(TakeBackupCommand command) { + String mode = command.getMode(); + if (mode == null || mode.isEmpty()) { + return null; // legacy full-only — no extra args expected + } + if (MODE_INCREMENTAL.equals(mode)) { + if (command.getBitmapNew() == null || command.getBitmapNew().isEmpty()) { + return "incremental mode requires bitmapNew"; } - return answer; + if (command.getBitmapParent() == null || command.getBitmapParent().isEmpty()) { + return "incremental mode requires bitmapParent"; + } + if (command.getParentPaths() == null || command.getParentPaths().isEmpty()) { + return "incremental mode requires parentPaths"; + } + return null; + } + if (MODE_FULL.equals(mode)) { + if (command.getBitmapNew() == null || command.getBitmapNew().isEmpty()) { + return "full mode requires bitmapNew (the bitmap to create for the next incremental)"; + } + return null; + } + if (MODE_LEGACY_FULL.equals(mode)) { + return null; // feature-off full backup — no bitmap or chain args expected } + return "Unknown backup mode: " + mode; + } + /** + * Sum the per-disk size lines emitted by nasbackup.sh. Single-volume mode emits one + * line containing just the byte count; multi-volume mode emits one line per disk + * whose first whitespace-separated token is the byte count. + */ + private long parseBackupSize(String stdout, List diskPaths) { long backupSize = 0L; if (CollectionUtils.isNullOrEmpty(diskPaths)) { - List outputLines = Arrays.asList(result.second().trim().split("\n")); + List outputLines = Arrays.asList(stdout.split("\n")); if (!outputLines.isEmpty()) { backupSize = Long.parseLong(outputLines.get(outputLines.size() - 1).trim()); } } else { - String[] outputLines = result.second().trim().split("\n"); - for(String line : outputLines) { + for (String line : stdout.split("\n")) { backupSize = backupSize + Long.parseLong(line.split(" ")[0].trim()); } } - - BackupAnswer answer = new BackupAnswer(command, true, result.second().trim()); - answer.setSize(backupSize); - return answer; + return backupSize; } } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupHashCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupHashCommandWrapper.java new file mode 100644 index 000000000000..64669b3b4b5c --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeBackupHashCommandWrapper.java @@ -0,0 +1,72 @@ +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.dynatrace.hash4j.hashing.HashStream128; +import com.dynatrace.hash4j.hashing.HashValue128; +import com.dynatrace.hash4j.hashing.Hashing; +import org.apache.cloudstack.backup.TakeBackupHashCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; + +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +@ResourceWrapper(handles = TakeBackupHashCommand.class) +public class LibvirtTakeBackupHashCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(TakeBackupHashCommand command, LibvirtComputingResource resource) { + String backupUuid = command.getBackupUuid(); + logger.info("Taking hash of backup [{}].", backupUuid); + + KVMStoragePoolManager storagePoolManager = resource.getStoragePoolMgr(); + KVMStoragePool imagePool = null; + try { + imagePool = storagePoolManager.getStoragePoolByURI(command.getBackupDeltaTOList().get(0).getDataStore().getUrl()); + HashStream128 hashStream128 = Hashing.xxh3_128().hashStream(); + for (BackupDeltaTO backupDelta : command.getBackupDeltaTOList()) { + try (InputStream is = new BufferedInputStream(new FileInputStream(imagePool.getLocalPathFor(backupDelta.getPath())))) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = is.read(buffer)) != -1) { + hashStream128.putBytes(buffer, 0, bytesRead); + } + } catch (IOException e) { + throw new CloudRuntimeException(e); + } + } + HashValue128 hash = hashStream128.get(); + String hashString = hash.toString(); + logger.info("The xxHash128 of backup [{}] is [{}].", backupUuid, hashString); + return new Answer(command, true, hashString); + } finally { + if (imagePool != null) { + storagePoolManager.deleteStoragePool(imagePool.getType(), imagePool.getUuid()); + } + } + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeKbossBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeKbossBackupCommandWrapper.java new file mode 100644 index 000000000000..d2332f4f99b1 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtTakeKbossBackupCommandWrapper.java @@ -0,0 +1,393 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; +import org.apache.cloudstack.backup.TakeKbossBackupAnswer; +import org.apache.cloudstack.backup.TakeKbossBackupCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.storage.to.KbossTO; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.utils.qemu.QemuImageOptions; +import org.apache.cloudstack.utils.qemu.QemuImg; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.apache.cloudstack.utils.qemu.QemuImgFile; +import org.apache.commons.collections4.CollectionUtils; +import org.libvirt.LibvirtException; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +@ResourceWrapper(handles = TakeKbossBackupCommand.class) +public class LibvirtTakeKbossBackupCommandWrapper extends CommandWrapper { + @Override + public Answer execute(TakeKbossBackupCommand command, LibvirtComputingResource resource) { + String vmName = command.getVmName(); + logger.info("Starting backup process for VM [{}].", vmName); + List kbossTOS = command.getKbossTOs(); + List> volumeTosAndNewPaths = + kbossTOS.stream().map(kbossTO -> new Pair<>(kbossTO.getVolumeObjectTO(), kbossTO.getDeltaPathOnPrimary())).collect(Collectors.toList()); + + Map> mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize = new HashMap<>(); + Map mapVolumeUuidToNewVolumePath = new HashMap<>(); + + KVMStoragePoolManager storagePoolManager = resource.getStoragePoolMgr(); + boolean runningVM = command.isRunningVM(); + + try { + if (runningVM) { + resource.createDiskOnlyVmSnapshotForRunningVm(volumeTosAndNewPaths, vmName, UUID.randomUUID().toString(), command.isQuiesceVm()); + } else { + resource.createDiskOnlyVMSnapshotOfStoppedVm(volumeTosAndNewPaths, vmName); + } + + backupVolumes(command, resource, storagePoolManager, kbossTOS, volumeTosAndNewPaths, vmName, runningVM, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize); + + cleanupVm(command, resource, kbossTOS, vmName, runningVM, mapVolumeUuidToNewVolumePath); + } catch (BackupException ex) { + return new TakeKbossBackupAnswer(command, ex); + } + + return new TakeKbossBackupAnswer(command, true, mapVolumeUuidToNewVolumePath, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize); + } + + /** + * Backup (copy) volumes to secondary storage. Will also populate the mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize argument. + * The timeout for this method is guided by the wait time for the given command, if the wait time is bigger than 24 days, there will be an overflow on the timeout. + *
+ * If an exception is caught while copying the volumes, will try to recover the VM to the previous state so that it is consistent. + * */ + protected void backupVolumes(TakeKbossBackupCommand command, LibvirtComputingResource resource, KVMStoragePoolManager storagePoolManager, List kbossTOS, + List> volumeTosAndNewPaths, String vmName, boolean runningVM, + Map> mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize) { + try { + int maxWaitInMillis = command.getWait() * 1000; + for (KbossTO kbossTO : kbossTOS) { + long startTimeMillis = System.currentTimeMillis(); + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + String volumeUuid = volumeObjectTO.getUuid(); + + logger.debug("Backing up volume [{}].", volumeUuid); + Pair deltaPathOnSecondaryAndSize = copyBackupDeltaToSecondary(storagePoolManager, kbossTO, command.getBackupChainImageStoreUrls(), + command.getImageStoreUrl(), maxWaitInMillis); + + mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize.put(volumeUuid, deltaPathOnSecondaryAndSize); + maxWaitInMillis = calculateRemainingTime(maxWaitInMillis, startTimeMillis); + } + } catch (Exception ex) { + logger.error("There has been an exception during the backup creation process. We will try to revert the VM [{}] to its previous state. The exception is: {}", vmName, + ex.getMessage(), ex); + recoverPreviousVmStateAndDeletePartialBackup(resource, volumeTosAndNewPaths, vmName, runningVM, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize, storagePoolManager, + command.getImageStoreUrl()); + + throw new BackupException(String.format("There was an exception during the backup process for VM [%s], but the VM has been successfully normalized.", vmName), ex, + true); + } + } + + protected int calculateRemainingTime(int maxWaitInMillis, long startTimeMillis) throws TimeoutException { + maxWaitInMillis -= (int)(System.currentTimeMillis() - startTimeMillis); + if (maxWaitInMillis < 0) { + throw new TimeoutException("Timeout while converting backups to secondary storage."); + } + return maxWaitInMillis; + } + + /** + * For each KbossTO, will merge its DeltaMergeTreeTO (if it exists). Also, if this is the end of the chain, will also end the chain for the volume. + * Will populate the mapVolumeUuidToNewVolumePath argument. + * */ + protected void cleanupVm(TakeKbossBackupCommand command, LibvirtComputingResource resource, List kbossTOS, String vmName, boolean runningVM, + Map mapVolumeUuidToNewVolumePath) { + for (KbossTO kbossTO : kbossTOS) { + VolumeObjectTO volumeObjectTO = kbossTO.getVolumeObjectTO(); + String currentVolumePath = volumeObjectTO.getPath(); + String volumeUuid = volumeObjectTO.getUuid(); + DeltaMergeTreeTO deltaMergeTreeTO = kbossTO.getDeltaMergeTreeTO(); + volumeObjectTO.setPath(kbossTO.getDeltaPathOnPrimary()); + + if (deltaMergeTreeTO != null) { + List snapshotDataStoreVos = kbossTO.getVmSnapshotDeltaPaths(); + mergeBackupDelta(resource, deltaMergeTreeTO, volumeObjectTO, vmName, runningVM, volumeUuid, snapshotDataStoreVos.isEmpty()); + } + + if (command.isEndChain() || command.isIsolated()) { + String baseVolumePath = currentVolumePath; + if (deltaMergeTreeTO != null && deltaMergeTreeTO.getChild().getPath().equals(baseVolumePath)) { + baseVolumePath = deltaMergeTreeTO.getParent().getPath(); + } + endChainForVolume(resource, volumeObjectTO, vmName, runningVM, volumeUuid, baseVolumePath); + mapVolumeUuidToNewVolumePath.put(volumeUuid, baseVolumePath); + } else { + mapVolumeUuidToNewVolumePath.put(volumeUuid, kbossTO.getDeltaPathOnPrimary()); + } + } + } + + /** + * Copy the backup delta to the secondary storage. Since we created a snapshot on top of the volume, the volume is now the backup delta. + * If there were snapshots created after the last backup, they'll be copied alongside and merged in the secondary storage. + * */ + protected Pair copyBackupDeltaToSecondary(KVMStoragePoolManager storagePoolManager, KbossTO kbossTO, List chainImageStoreUrls, String imageStoreUrl, + int waitInMillis) { + VolumeObjectTO delta = kbossTO.getVolumeObjectTO(); + String parentDeltaPathOnSecondary = kbossTO.getPathBackupParentOnSecondary(); + List deltaPathsToCopy = CollectionUtils.isEmpty(kbossTO.getVmSnapshotDeltaPaths()) ? new ArrayList<>() : new ArrayList<>(kbossTO.getVmSnapshotDeltaPaths()); + deltaPathsToCopy.add(delta.getPath()); + + KVMStoragePool parentImagePool = null; + List chainImagePools = null; + KVMStoragePool imagePool = null; + long backupSize; + final String backupOnSecondary = kbossTO.getDeltaPathOnSecondary(); + ArrayList temporaryDeltasToRemove = new ArrayList<>(); + boolean result = false; + try { + imagePool = storagePoolManager.getStoragePoolByURI(imageStoreUrl); + if (chainImageStoreUrls != null) { + parentImagePool = storagePoolManager.getStoragePoolByURI(chainImageStoreUrls.get(0)); + chainImagePools = chainImageStoreUrls.subList(1, chainImageStoreUrls.size()).stream().map(storagePoolManager::getStoragePoolByURI).collect(Collectors.toList()); + } + + PrimaryDataStoreTO primaryDataStoreTO = (PrimaryDataStoreTO) delta.getDataStore(); + KVMStoragePool primaryPool = storagePoolManager.getStoragePool(primaryDataStoreTO.getPoolType(), primaryDataStoreTO.getUuid()); + + String topDelta = backupOnSecondary; + while (!deltaPathsToCopy.isEmpty()) { + String backupDeltaFullPathOnSecondary = imagePool.getLocalPathFor(topDelta); + temporaryDeltasToRemove.add(backupDeltaFullPathOnSecondary); + String parentBackupFullPath = null; + + if (parentDeltaPathOnSecondary != null) { + parentBackupFullPath = parentImagePool.getLocalPathFor(parentDeltaPathOnSecondary); + } + + String backupDeltaFullPathOnPrimary = primaryPool.getLocalPathFor(deltaPathsToCopy.remove(0)); + convertDeltaToSecondary(backupDeltaFullPathOnPrimary, backupDeltaFullPathOnSecondary, parentBackupFullPath, delta.getUuid(), waitInMillis); + + if (!deltaPathsToCopy.isEmpty()) { + parentDeltaPathOnSecondary = topDelta; + topDelta = getRelativePathOnSecondaryForBackup(delta.getAccountId(), delta.getVolumeId(), UUID.randomUUID().toString()); + parentImagePool = imagePool; + } + } + + String backupOnSecondaryFullPath = imagePool.getLocalPathFor(backupOnSecondary); + + commitTopDeltaOnBaseBackupOnSecondaryIfNeeded(topDelta, backupOnSecondary, imagePool, backupOnSecondaryFullPath, waitInMillis); + + backupSize = Files.size(Path.of(backupOnSecondaryFullPath)); + result = true; + } catch (LibvirtException | QemuImgException | IOException e) { + logger.error("Exception while converting backup [{}] to secondary storage [{}] due to: [{}].", delta.getPath(), imagePool, e.getMessage(), e); + throw new BackupException("Exception while converting backup to secondary storage.", e, true); + } finally { + removeTemporaryDeltas(temporaryDeltasToRemove, result); + + if (parentImagePool != null) { + storagePoolManager.deleteStoragePool(parentImagePool.getType(), parentImagePool.getUuid()); + } + if (chainImagePools != null) { + chainImagePools.forEach(pool -> storagePoolManager.deleteStoragePool(pool.getType(), pool.getUuid())); + } + if (imagePool != null) { + storagePoolManager.deleteStoragePool(imagePool.getType(), imagePool.getUuid()); + } + } + return new Pair<>(backupOnSecondary, backupSize); + } + + /** + * If there were VM snapshots created after the last backup, we will have copied them alongside the backup delta. If this is the case, we will commit all of them into a single + * base file so that we are left with one file per volume per backup. + * */ + protected void commitTopDeltaOnBaseBackupOnSecondaryIfNeeded(String topDelta, String backupOnSecondary, KVMStoragePool imagePool, String backupOnSecondaryFullPath, + int waitInMillis) throws LibvirtException, QemuImgException { + if (topDelta.equals(backupOnSecondary)) { + return; + } + + QemuImg qemuImg = new QemuImg(waitInMillis); + QemuImgFile topDeltaImg = new QemuImgFile(imagePool.getLocalPathFor(topDelta), QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile baseDeltaImg = new QemuImgFile(backupOnSecondaryFullPath, QemuImg.PhysicalDiskFormat.QCOW2); + + logger.debug("Committing top delta [{}] on base delta [{}].", topDeltaImg, baseDeltaImg); + qemuImg.commit(topDeltaImg, baseDeltaImg, true); + } + + /** + * Will remove any temporary deltas created on secondary storage. If result is true, this means that the backup was a success and the first "temporary delta" is our backup, so + * it will not be removed. + *
+ * There are two uses for this method:
+ * - If we fail to backup we have to clean up the secondary storage.
+ * - If we had VM snapshots created after the last backup, we copied multiple files to secondary storage, and thus we have to clean them up after merging them. + * */ + protected void removeTemporaryDeltas(List temporaryDeltasToRemove, boolean result) { + if (result) { + temporaryDeltasToRemove.remove(0); + } + logger.debug("Removing temporary deltas {}.", temporaryDeltasToRemove); + for (String delta : temporaryDeltasToRemove) { + try { + Files.deleteIfExists(Path.of(delta)); + } catch (IOException ex) { + logger.error("Failed to remove temporary delta [{}]. Will not stop the backup process, but this should be investigated.", delta, ex); + } + } + } + + /** + * Converts a delta from primary storage to secondary storage, if a parent was given, will set it as the backing file for the delta being copied. + * + * @param pathDeltaOnPrimary absolute path of the delta to be copied. + * @param pathDeltaOnSecondary absolute path of the destination of the delta to be copied. + * @param pathParentOnSecondary absolute path of the parent delta, if it exists. + * @param volumeUuid volume uuid, used for logging. + * @param waitInMillis timeout in milliseconds. + * */ + protected void convertDeltaToSecondary(String pathDeltaOnPrimary, String pathDeltaOnSecondary, String pathParentOnSecondary, String volumeUuid, int waitInMillis) + throws QemuImgException, LibvirtException { + QemuImgFile backupDestination = new QemuImgFile(pathDeltaOnSecondary, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile backupOrigin = new QemuImgFile(pathDeltaOnPrimary, QemuImg.PhysicalDiskFormat.QCOW2); + QemuImgFile parentBackup = null; + + if (pathParentOnSecondary != null) { + parentBackup = new QemuImgFile(pathParentOnSecondary, QemuImg.PhysicalDiskFormat.QCOW2); + } + + logger.debug("Converting delta [{}] to [{}] with {}", backupOrigin, backupDestination, parentBackup == null ? "no parent." : String.format("parent [%s].", parentBackup)); + + createDirsIfNeeded(pathDeltaOnSecondary, volumeUuid); + + QemuImg qemuImg = new QemuImg(waitInMillis); + qemuImg.convert(backupOrigin, backupDestination, parentBackup, null, null, new QemuImageOptions(backupOrigin.getFormat(), backupOrigin.getFileName(), null), null, + true, false, false, false, null, null); + } + + + protected void endChainForVolume(LibvirtComputingResource resource, VolumeObjectTO volumeObjectTO, String vmName, boolean isVmRunning, String volumeUuid, String baseVolumePath) + throws BackupException { + + BackupDeltaTO baseVolume = new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, baseVolumePath); + DeltaMergeTreeTO deltaMergeTreeTO = new DeltaMergeTreeTO(volumeObjectTO, baseVolume, volumeObjectTO, new ArrayList<>()); + + logger.debug("Ending backup chain for volume [{}], the next backup will be a full backup.", volumeObjectTO.getUuid()); + + mergeBackupDelta(resource, deltaMergeTreeTO, volumeObjectTO, vmName, isVmRunning, volumeUuid, false); + } + + /** + * Tries to recover the previous state of the VM. Should only be called if an exception in the backup creation process happened.
+ * For each volume, will:
+ * - Merge back any backup deltas created; + * - Remove the data backed up to the secondary storage; + * */ + protected void recoverPreviousVmStateAndDeletePartialBackup(LibvirtComputingResource resource, List> volumeTosAndNewPaths, String vmName, + boolean runningVm, Map> mapVolumeUuidToDeltaPathOnSecondaryAndSize, KVMStoragePoolManager storagePoolManager, String imageStoreUrl) { + for (Pair volumeObjectTOAndNewPath : volumeTosAndNewPaths) { + VolumeObjectTO volumeObjectTO = volumeObjectTOAndNewPath.first(); + String volumeUuid = volumeObjectTO.getUuid(); + + BackupDeltaTO oldDelta = new BackupDeltaTO(volumeObjectTO.getDataStore(), Hypervisor.HypervisorType.KVM, volumeObjectTO.getPath()); + volumeObjectTO.setPath(volumeObjectTOAndNewPath.second()); + DeltaMergeTreeTO deltaMergeTreeTO = new DeltaMergeTreeTO(volumeObjectTO, oldDelta, volumeObjectTO, new ArrayList<>()); + + mergeBackupDelta(resource, deltaMergeTreeTO, volumeObjectTO, vmName, runningVm, volumeUuid, false); + + Pair deltaPathOnSecondaryAndSize = mapVolumeUuidToDeltaPathOnSecondaryAndSize.get(volumeUuid); + if (deltaPathOnSecondaryAndSize == null) { + continue; + } + + cleanupDeltaOnSecondary(storagePoolManager, imageStoreUrl, deltaPathOnSecondaryAndSize.first()); + } + } + + protected void cleanupDeltaOnSecondary(KVMStoragePoolManager storagePoolManager, String imageStoreUrl, String deltaPath) { + KVMStoragePool imagePool = null; + + try { + imagePool = storagePoolManager.getStoragePoolByURI(imageStoreUrl); + String fullDeltaPath = imagePool.getLocalPathFor(deltaPath); + + logger.debug("Cleaning up delta at [{}] as part of the post backup error normalization effort.", fullDeltaPath); + + Files.deleteIfExists(Path.of(fullDeltaPath)); + } catch (IOException e) { + logger.error("Exception while trying to cleanup delta at [{}].", deltaPath, e); + } finally { + if (imagePool != null) { + storagePoolManager.deleteStoragePool(imagePool.getType(), imagePool.getUuid()); + } + } + } + + + protected void mergeBackupDelta(LibvirtComputingResource resource, DeltaMergeTreeTO deltaMergeTreeTO, VolumeObjectTO volumeObjectTO, String vmName, boolean isVmRunning, + String volumeUuid, boolean countNewestDeltaAsGrandchild) throws BackupException { + try { + if (isVmRunning) { + resource.mergeDeltaForRunningVm(deltaMergeTreeTO, vmName, volumeObjectTO); + } else { + if (countNewestDeltaAsGrandchild) { + deltaMergeTreeTO.addGrandChild(volumeObjectTO); + } + resource.mergeDeltaForStoppedVm(deltaMergeTreeTO); + } + } catch (LibvirtException | QemuImgException | IOException e) { + logger.error("Exception while merging the last backup delta using delta merge tree [{}] for VM [{}] and volume [{}].", deltaMergeTreeTO, vmName, volumeUuid, e); + throw new BackupException(String.format("Exception during backup wrap-up phase for VM [%s].", vmName), e, false); + } + } + + protected String getRelativePathOnSecondaryForBackup(long accountId, long volumeId, String backupPath) { + return String.format("%s%s%s%s%s%s%s", "backups", File.separator, accountId, File.separator, volumeId, File.separator, backupPath); + } + + protected void createDirsIfNeeded(String deltaFullPath, String volumeUuid) { + String dirs = deltaFullPath.substring(0, deltaFullPath.lastIndexOf(File.separator)); + try { + Files.createDirectories(Path.of(dirs)); + } catch (IOException e) { + throw new BackupException(String.format("Error while creating directories for backup of volume [%s].", volumeUuid), e, true); + } + } + +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtValidateKbossVmCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtValidateKbossVmCommandWrapper.java new file mode 100644 index 000000000000..eec68cd464e3 --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtValidateKbossVmCommandWrapper.java @@ -0,0 +1,225 @@ +// +// 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 com.cloud.agent.api.Answer; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.DateUtil; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.script.Script; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.json.JsonSanitizer; +import org.apache.cloudstack.backup.ValidateKbossVmAnswer; +import org.apache.cloudstack.backup.ValidateKbossVmCommand; +import org.libvirt.Domain; +import org.libvirt.LibvirtException; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Random; + +@ResourceWrapper(handles = ValidateKbossVmCommand.class) +public class LibvirtValidateKbossVmCommandWrapper extends CommandWrapper { + + private static final String SCREENSHOT_COMMAND = "virsh screenshot --domain %s --file %s"; + private static final String GUEST_SYNC_COMMAND = "{\"execute\": \"guest-sync\", \"arguments\":{\"id\":%s}}"; + private static final String GUEST_EXEC_COMMAND = "{\"execute\": \"guest-exec\", \"arguments\":{\"path\":\"%s\",\"arg\":%s,\"capture-output\":true}}"; + private static final String GUEST_EXEC_STATUS_COMMAND = "{\"execute\": \"guest-exec-status\", \"arguments\":{\"pid\":%s}}"; + + @Override + public Answer execute(ValidateKbossVmCommand command, LibvirtComputingResource serverResource) { + VirtualMachineTO vmTo = command.getVm(); + KVMStoragePool secondaryStorage = null; + KVMStoragePoolManager storagePoolMgr = serverResource.getStoragePoolMgr(); + try { + Domain vm = serverResource.getDomain(serverResource.getLibvirtUtilitiesHelper().getConnection(), vmTo.getName()); + secondaryStorage = storagePoolMgr.getStoragePoolByURI(command.getBackupDeltaTO().getDataStore().getUrl()); + logger.info("Validating VM [{}].", vm.getName()); + boolean bootValidated = waitForBoot(command, vm); + String screenshotPath = takeScreenshot(command, vm, secondaryStorage); + String scriptResult = runScript(command, vm); + return new ValidateKbossVmAnswer(command, bootValidated, screenshotPath, scriptResult); + } catch (LibvirtException e) { + logger.error("Received Libvirt exception while trying to validate VM [{}].", vmTo.getName(), e); + return new Answer(command, e); + } finally { + if (secondaryStorage != null) { + storagePoolMgr.deleteStoragePool(secondaryStorage.getType(), secondaryStorage.getUuid()); + } + } + } + + private boolean waitForBoot(ValidateKbossVmCommand cmd, Domain vm) throws LibvirtException { + if (!cmd.isWaitForBoot()) { + return false; + } + Random random = new Random(); + Integer bootTimeout = cmd.getBootTimeout(); + logger.debug("Waiting for validation VM [{}] to boot. We will wait for [{}] seconds at most.", vm.getName(), bootTimeout); + while (bootTimeout > 0) { + bootTimeout -= 5; + int randomInt = random.nextInt(); + try { + String result = vm.qemuAgentCommand(String.format(GUEST_SYNC_COMMAND, randomInt), 1, 0); + if (result.contains(String.valueOf(randomInt))) { + logger.info("Validation VM [{}] has booted.", vm.getName()); + return true; + } + } catch (LibvirtException ex) { + if (!ex.getMessage().contains(LibvirtComputingResource.AGENT_IS_NOT_CONNECTED)) { + logger.error("Got an unexpected Libvirt Exception, giving up on validating VM [{}].", vm.getName(), ex); + throw ex; + } + } + try { + Thread.sleep(5 * 1000L); + } catch (InterruptedException e) { + logger.debug("Got interrupted while waiting for VM [{}] to boot. Ignoring.", vm.getName()); + } + } + logger.debug("Boot wait timed out for VM [{}].", vm.getName()); + return false; + } + + private String takeScreenshot(ValidateKbossVmCommand command, Domain vm, KVMStoragePool secondaryStorage) throws LibvirtException { + if (!command.isTakeScreenshot()) { + return null; + } + String vmName = vm.getName(); + try { + logger.info("Waiting [{}] seconds to take screenshot of validation VM [{}].", command.getScreenshotWait(), vm.getName()); + Thread.sleep(command.getScreenshotWait() * 1000L); + } catch (InterruptedException e) { + logger.debug("Got interrupted while waiting to take screenshot of validation VM [{}].", vm.getName()); + } + logger.info("Taking screenshot of VM [{}].", vmName); + String ssPath = secondaryStorage.getLocalPathFor(command.getBackupDeltaTO().getPath()) + String.format("-screenshot-%s", DateUtil.getDateInSystemTimeZone()); + if (Script.runSimpleBashScript(String.format(SCREENSHOT_COMMAND, vmName, ssPath)) == null) { + throw new CloudRuntimeException(String.format("Got an unexpected error while trying to execute the screenshot validation step for VM [%s].", vmName)); + } + try { + return tryToConvertFileToPng(ssPath); + } catch (IOException ex) { + throw new CloudRuntimeException(ex); + } + } + + private String tryToConvertFileToPng(String ssPath) throws IOException { + File inputFile = new File(ssPath); + String pngPath = ssPath + ".png"; + File outputFile = new File(pngPath); + BufferedImage image = ImageIO.read(inputFile); + + String warnMessage = String.format("Unable to convert screenshot at [%s] to PNG. Leaving it as is.", ssPath); + if (image == null) { + logger.warn(warnMessage); + return ssPath.substring(ssPath.indexOf("backups")); + } + boolean result = ImageIO.write(image, "png", outputFile); + + if (result) { + logger.debug("Successfully converted image at [{}] to PNG at [{}].", ssPath, pngPath); + Files.deleteIfExists(Path.of(ssPath)); + return pngPath.substring(ssPath.indexOf("backups")); + } else { + logger.warn(warnMessage); + return ssPath.substring(ssPath.indexOf("backups")); + } + } + + private String runScript(ValidateKbossVmCommand command, Domain vm) throws LibvirtException { + if (!command.isExecuteScript()) { + return null; + } + String script = command.getScriptToExecute(); + if (script == null) { + logger.warn("This command is malformed, we should execute an script for VM [{}], but no script was configured. Please review the original VM configurations.", vm.getName()); + return null; + } + String arguments = command.getScriptArguments(); + if (arguments == null) { + arguments = "[]"; + } else { + arguments = "[\"" + arguments.replace(",", "\",\"") + "\"]"; + } + logger.debug("Running validation script [{}] with arguments [{}] on dummy validation VM [{}].", script, arguments, vm.getName()); + String guestCommand = String.format(GUEST_EXEC_COMMAND, script, arguments); + String sanitizedGuestCommand = JsonSanitizer.sanitize(guestCommand); + String execResult; + try { + execResult = vm.qemuAgentCommand(sanitizedGuestCommand, command.getScriptTimeout(), 0); + } catch (LibvirtException ex) { + return ex.getMessage(); + } + JsonObject root = new JsonParser().parse(execResult).getAsJsonObject(); + JsonObject ret = root.getAsJsonObject("return"); + String pid = ret.get("pid").getAsString(); + + return waitForCommandResult(command, vm, pid, script, arguments); + } + + private String waitForCommandResult(ValidateKbossVmCommand command, Domain vm, String pid, String script, String arguments) throws LibvirtException { + JsonObject root; + JsonObject ret; + String expectedResult = command.getExpectedResult(); + Integer timeout = command.getScriptTimeout(); + while (timeout > 0) { + timeout -= 5; + try { + String statusResult = vm.qemuAgentCommand(String.format(GUEST_EXEC_STATUS_COMMAND, pid), 1, 0); + root = new JsonParser().parse(statusResult).getAsJsonObject(); + ret = root.getAsJsonObject("return"); + + boolean exited = ret.get("exited").getAsBoolean(); + if (exited) { + int exitCode = ret.get("exitcode").getAsInt(); + String outData64Coded = ret.get("out-data").getAsString(); + logger.debug("Script [{}] with arguments [{}] that ran on validation VM [{}] exited with code [{}] and had output [{}].", script, arguments, vm.getName(), + exitCode, outData64Coded); + if (expectedResult.equals("0") && exitCode == 0) { + return null; + } + if (outData64Coded.equals(expectedResult)) { + return null; + } + return outData64Coded; + } + } catch (LibvirtException ex) { + logger.error("Caught unexpected Libvirt exception while waiting for validation script result of VM [{}]. Will try again later.", vm.getName(), ex); + } + try { + Thread.sleep(5 * 1000L); + } catch (InterruptedException e) { + logger.debug("Got interrupted while waiting for execution of validation script for VM [{}]. Ignoring.", vm.getName()); + } + } + logger.error("Script [{}] that was executed on validation VM [{}] has timed out, giving up.", script, vm.getName()); + return "Timeout"; + } +} diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java index ba689d5107f7..495ae45a2896 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/IscsiAdmStorageAdaptor.java @@ -16,9 +16,14 @@ // under the License. package com.cloud.hypervisor.kvm.storage; +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Path; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.nio.file.Files; +import java.nio.file.Paths; import org.apache.cloudstack.utils.qemu.QemuImg; import org.apache.cloudstack.utils.qemu.QemuImg.PhysicalDiskFormat; @@ -95,14 +100,8 @@ public boolean connectPhysicalDisk(String volumeUuid, KVMStoragePool pool, Map/device/delete — the standard Linux kernel SCSI + * API for removing a single device without tearing down the entire iSCSI session. + * Once the kernel processes the delete, it also removes the by-path symlink. + * + * This is used instead of iscsiadm --logout when other LUNs on the same IQN are still + * active (ONTAP single-IQN-per-SVM model), since logout would tear down ALL LUNs. + */ + private void removeStaleScsiDevice(String host, int port, String iqn, String lun) { + String byPath = getByPath(host, port, "/" + iqn + "/" + lun); + Path byPathLink = Paths.get(byPath); + if (!Files.exists(byPathLink)) { + logger.debug("by-path entry for LUN " + lun + " already gone, nothing to remove"); + return; + } + try { + Path realDevice = byPathLink.toRealPath(); + String devName = realDevice.getFileName().toString(); + File deleteFile = new File("/sys/block/" + devName + "/device/delete"); + if (!deleteFile.exists()) { + logger.warn("sysfs delete entry not found for device " + devName + " — cannot remove stale SCSI device"); + return; + } + try (FileWriter fw = new FileWriter(deleteFile)) { + fw.write("1"); + } + logger.info("Removed stale SCSI device " + devName + " for LUN /" + iqn + "/" + lun + " via sysfs"); + } catch (Exception e) { + logger.warn("Failed to remove stale SCSI device for LUN /" + iqn + "/" + lun + ": " + e.getMessage()); + } + } + private boolean disconnectPhysicalDisk(String host, int port, String iqn, String lun) { - // use iscsiadm to log out of the iSCSI target and un-discover it + // Check if other LUNs on the same IQN target are still in use. + // ONTAP (and similar) uses a single IQN per SVM with multiple LUNs. + // Doing iscsiadm --logout tears down the ENTIRE target session, + // which would destroy access to ALL LUNs — not just the one being disconnected. + if (hasOtherActiveLuns(host, port, iqn, lun)) { + logger.info("Skipping iSCSI logout for /" + iqn + "/" + lun + + " — other LUNs on the same target are still active. Removing stale SCSI device for this LUN only."); + removeStaleScsiDevice(host, port, iqn, lun); + // After removing this LUN's device, re-check: if no other LUNs remain active, + // If it is the last one then must logout to clean up the iSCSI session entirely. + if (hasOtherActiveLuns(host, port, iqn, lun)) { + logger.info("Other LUNs still active after removing /" + iqn + "/" + lun + " — session kept alive."); + return true; + } + logger.info("No more active LUNs on target after removing /" + iqn + "/" + lun + " — proceeding with iSCSI logout."); + } + + // No other LUNs active on this target — safe to logout and delete the node record. // ex. sudo iscsiadm -m node -T iqn.2012-03.com.test:volume1 -p 192.168.233.10:3260 --logout Script iScsiAdmCmd = new Script(true, "iscsiadm", 0, logger); @@ -422,6 +571,19 @@ public KVMPhysicalDisk copyPhysicalDisk(KVMPhysicalDisk srcDisk, String destVolu try { QemuImg q = new QemuImg(timeout); q.convert(srcFile, destFile); + // Below fix is required when vendor depends on host based copy rather than storage CAN_CREATE_VOLUME_FROM_VOLUME capability + // When host based template copy is triggered , small size template sits in RAM(depending on host memory and RAM) and copy is marked successful and by the time flush to storage is triggered + // disconnectPhysicalDisk would disconnect the lun , hence template staying in RAM is not copied to storage lun. Below does flushing of data to storage and marking + // copy as successful once flush is complete. + Script flushCmd = new Script(true, "blockdev", 0, logger); + flushCmd.add("--flushbufs", destDisk.getPath()); + String flushResult = flushCmd.execute(); + if (flushResult != null) { + logger.warn("iSCSI copyPhysicalDisk: blockdev --flushbufs returned: {}", flushResult); + } + Script syncCmd = new Script(true, "sync", 0, logger); + syncCmd.execute(); + logger.info("iSCSI copyPhysicalDisk: flush/sync completed "); } catch (QemuImgException | LibvirtException ex) { String msg = "Failed to copy data from " + srcDisk.getPath() + " to " + destDisk.getPath() + ". The error was the following: " + ex.getMessage(); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java index 996398a286ff..d99847fd921a 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStoragePoolManager.java @@ -275,10 +275,10 @@ public boolean disconnectPhysicalDisksViaVmSpec(VirtualMachineTO vmSpec) { } public KVMStoragePool getStoragePool(StoragePoolType type, String uuid) { - return this.getStoragePool(type, uuid, false); + return this.getStoragePool(type, uuid, false, true); } - public KVMStoragePool getStoragePool(StoragePoolType type, String uuid, boolean refreshInfo) { + public synchronized KVMStoragePool getStoragePool(StoragePoolType type, String uuid, boolean refreshInfo, boolean addDetails) { StorageAdaptor adaptor = getStorageAdaptor(type); KVMStoragePool pool = null; @@ -293,10 +293,9 @@ public KVMStoragePool getStoragePool(StoragePoolType type, String uuid, boolean } } - if (pool instanceof LibvirtStoragePool) { + if (pool instanceof LibvirtStoragePool && addDetails) { LibvirtStoragePool libvirtPool = (LibvirtStoragePool) pool; addPoolDetails(uuid, libvirtPool); - ((LibvirtStoragePool) pool).setType(type); updatePoolTypeIfApplicable(libvirtPool, pool, type, uuid); } @@ -444,7 +443,7 @@ public boolean disconnectPhysicalDisk(StoragePoolType type, String poolUuid, Str return adaptor.disconnectPhysicalDisk(volPath, pool); } - public boolean deleteStoragePool(StoragePoolType type, String uuid) { + public synchronized boolean deleteStoragePool(StoragePoolType type, String uuid) { StorageAdaptor adaptor = getStorageAdaptor(type); if (type == StoragePoolType.NetworkFilesystem) { _haMonitor.removeStoragePool(uuid); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java index 009e1decee2b..11acb9546b53 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java @@ -84,6 +84,7 @@ import org.apache.cloudstack.storage.command.SnapshotAndCopyCommand; import org.apache.cloudstack.storage.command.SyncVolumePathCommand; import org.apache.cloudstack.storage.formatinspector.Qcow2Inspector; +import org.apache.cloudstack.storage.to.BackupDeltaTO; import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; import org.apache.cloudstack.storage.to.SnapshotObjectTO; import org.apache.cloudstack.storage.to.TemplateObjectTO; @@ -248,6 +249,7 @@ public class KVMStorageProcessor implements StorageProcessor { " \n" + ""; + public static final List poolTypesToDeleteChainInfo = Arrays.asList(StoragePoolType.Filesystem, StoragePoolType.NetworkFilesystem, StoragePoolType.SharedMountPoint); public KVMStorageProcessor(final KVMStoragePoolManager storagePoolMgr, final LibvirtComputingResource resource) { this.storagePoolMgr = storagePoolMgr; @@ -570,7 +572,11 @@ public Answer cloneVolumeFromBaseTemplate(final CopyCommand cmd) { final VolumeObjectTO newVol = new VolumeObjectTO(); newVol.setPath(vol.getName()); - newVol.setSize(volume.getSize()); + if (StoragePoolType.CLVM_NG.equals(primaryStore.getPoolType()) && vol != null && vol.getVirtualSize() > 0) { + newVol.setSize(vol.getVirtualSize()); + } else { + newVol.setSize(volume.getSize()); + } if (vol.getQemuEncryptFormat() != null) { newVol.setEncryptFormat(vol.getQemuEncryptFormat().toString()); } @@ -2315,23 +2321,24 @@ protected void rebaseSnapshot(SnapshotObjectTO snapshotObjectTO, KVMStoragePool logger.debug("Rebasing snapshot [{}] with parent [{}].", snapshotName, parentSnapshotPath); + long snapshotTimeoutInMillis = wait * 1000L; try { - QemuImg qemuImg = new QemuImg(wait); + QemuImg qemuImg = new QemuImg(snapshotTimeoutInMillis); qemuImg.rebase(snapshotFile, parentSnapshotFile, PhysicalDiskFormat.QCOW2.toString(), false); } catch (LibvirtException | QemuImgException e) { if (!StringUtils.contains(e.getMessage(), "Is another process using the image")) { logger.error("Exception while rebasing incremental snapshot [{}] due to: [{}].", snapshotName, e.getMessage(), e); throw new CloudRuntimeException(e); } - retryRebase(snapshotName, wait, e, snapshotFile, parentSnapshotFile); + retryRebase(snapshotName, snapshotTimeoutInMillis, e, snapshotFile, parentSnapshotFile); } } - private void retryRebase(String snapshotName, int wait, Exception e, QemuImgFile snapshotFile, QemuImgFile parentSnapshotFile) { + private void retryRebase(String snapshotName, long waitInMilliseconds, Exception e, QemuImgFile snapshotFile, QemuImgFile parentSnapshotFile) { logger.warn("Libvirt still has not released the lock, will wait [{}] milliseconds and try again later.", incrementalSnapshotRetryRebaseWait); try { Thread.sleep(incrementalSnapshotRetryRebaseWait); - QemuImg qemuImg = new QemuImg(wait); + QemuImg qemuImg = new QemuImg(waitInMilliseconds); qemuImg.rebase(snapshotFile, parentSnapshotFile, PhysicalDiskFormat.QCOW2.toString(), false); } catch (LibvirtException | QemuImgException | InterruptedException ex) { logger.error("Unable to rebase snapshot [{}].", snapshotName, ex); @@ -2488,7 +2495,7 @@ private SnapshotObjectTO takeFullVolumeSnapshotOfRunningVm(CreateObjectCommand c String convertResult = convertBaseFileToSnapshotFileInStorageDir(ObjectUtils.defaultIfNull(secondaryPool, primaryPool), disk, snapshotPath, directoryPath, volume, cmd.getWait()); - resource.mergeSnapshotIntoBaseFile(vm, diskLabel, diskPath, null, true, snapshotName, volume, conn); + resource.mergeDeltaIntoBaseFile(vm, diskLabel, diskPath, null, true, snapshotName, volume, conn); validateConvertResult(convertResult, snapshotPath); } catch (LibvirtException e) { @@ -2759,7 +2766,7 @@ private void convertTheBaseFileToSnapshot(KVMPhysicalDisk baseFile, String snaps QemuImgFile destFile = new QemuImgFile(snapshotPath); destFile.setFormat(PhysicalDiskFormat.QCOW2); - QemuImg q = new QemuImg(wait); + QemuImg q = new QemuImg(wait * 1000L); q.convert(srcFile, destFile, options, qemuObjects, qemuImageOpts, null, true); } @@ -2899,6 +2906,10 @@ public Answer deleteVolume(final DeleteCommand cmd) { } } pool.deletePhysicalDisk(vol.getPath(), vol.getFormat()); + if (vol.getChainInfo() != null && poolTypesToDeleteChainInfo.contains(pool.getType()) && vol.getFormat() == ImageFormat.QCOW2 && cmd.isDeleteChain()) { + logger.debug("Deleting leftover backup delta at [{}].", vol.getChainInfo()); + pool.deletePhysicalDisk(vol.getChainInfo(), vol.getFormat()); + } return new Answer(null); } catch (final CloudRuntimeException e) { logger.debug("Failed to delete volume: ", e); @@ -3469,6 +3480,20 @@ public Answer syncVolumePath(SyncVolumePathCommand cmd) { return new Answer(cmd, false, "Not currently applicable for KVMStorageProcessor"); } + @Override + public Answer deleteBackup(DeleteCommand cmd) { + BackupDeltaTO delta = (BackupDeltaTO)cmd.getData(); + logger.debug("Deleting backup delta [{}].", delta); + PrimaryDataStoreTO primaryStore = (PrimaryDataStoreTO)delta.getDataStore(); + KVMStoragePool pool = storagePoolMgr.getStoragePool(primaryStore.getPoolType(), primaryStore.getUuid()); + try { + pool.deletePhysicalDisk(delta.getPath(), delta.getFormat()); + } catch (CloudRuntimeException e) { + return new Answer(cmd, e); + } + return new Answer(cmd); + } + /** * Determine if migration is using host-local source pool. If so, return this host's storage as the template source, * rather than remote host's diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java index ed159c92790d..4bfac31b68f9 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/LibvirtStorageAdaptor.java @@ -24,7 +24,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -97,8 +96,7 @@ public class LibvirtStorageAdaptor implements StorageAdaptor { public static final int RBD_FEATURES = RBD_FEATURE_LAYERING + RBD_FEATURE_EXCLUSIVE_LOCK + RBD_FEATURE_OBJECT_MAP + RBD_FEATURE_FAST_DIFF + RBD_FEATURE_DEEP_FLATTEN; private int rbdOrder = 0; /* Order 0 means 4MB blocks (the default) */ - private static final Set poolTypesThatEnableCreateDiskFromTemplateBacking = new HashSet<>(Arrays.asList(StoragePoolType.NetworkFilesystem, - StoragePoolType.Filesystem)); + private static final Set QEMU_IMG_MANAGED_POOL_TYPES = Set.of(StoragePoolType.NetworkFilesystem, StoragePoolType.Filesystem, StoragePoolType.SharedMountPoint); public LibvirtStorageAdaptor(StorageLayer storage) { _storageLayer = storage; @@ -134,8 +132,8 @@ public KVMPhysicalDisk createDiskFromTemplateBacking(KVMPhysicalDisk template, S String volumeDesc = String.format("volume [%s], with template backing [%s], in pool [%s] (%s), with size [%s] and encryption is %s", name, template.getName(), destPool.getUuid(), destPool.getType(), size, passphrase != null && passphrase.length > 0); - if (!poolTypesThatEnableCreateDiskFromTemplateBacking.contains(destPool.getType())) { - logger.info(String.format("Skipping creation of %s due to pool type is none of the following types %s.", volumeDesc, poolTypesThatEnableCreateDiskFromTemplateBacking.stream() + if (!QEMU_IMG_MANAGED_POOL_TYPES.contains(destPool.getType())) { + logger.info(String.format("Skipping creation of %s due to pool type is none of the following types %s.", volumeDesc, QEMU_IMG_MANAGED_POOL_TYPES.stream() .map(type -> type.toString()).collect(Collectors.joining(", ")))); return null; @@ -961,7 +959,7 @@ public boolean deleteStoragePool(String uuid) { * * *

  • - * {@link StoragePoolType#NetworkFilesystem} and {@link StoragePoolType#Filesystem} + * {@link StoragePoolType#NetworkFilesystem}, {@link StoragePoolType#Filesystem} and {@link StoragePoolType#SharedMountPoint} *
      *
    • * If the format is {@link PhysicalDiskFormat#QCOW2} or {@link PhysicalDiskFormat#RAW}, utilizes QemuImg to create the physical disk through the method @@ -992,7 +990,7 @@ public KVMPhysicalDisk createPhysicalDisk(String name, KVMStoragePool pool, return (dataPool == null) ? createPhysicalDiskByLibVirt(name, pool, PhysicalDiskFormat.RAW, provisioningType, size) : createPhysicalDiskByQemuImg(name, pool, PhysicalDiskFormat.RAW, provisioningType, size, passphrase); - } else if (StoragePoolType.NetworkFilesystem.equals(poolType) || StoragePoolType.Filesystem.equals(poolType)) { + } else if (QEMU_IMG_MANAGED_POOL_TYPES.contains(poolType)) { switch (format) { case QCOW2: case RAW: @@ -1062,7 +1060,7 @@ private KVMPhysicalDisk createPhysicalDiskByQemuImg(String name, KVMStoragePool destFile.setFormat(format); destFile.setSize(size); Map options = new HashMap(); - if (List.of(StoragePoolType.NetworkFilesystem, StoragePoolType.Filesystem).contains(pool.getType())) { + if (QEMU_IMG_MANAGED_POOL_TYPES.contains(pool.getType())) { options.put(QemuImg.PREALLOCATION, QemuImg.PreallocationType.getPreallocationType(provisioningType).toString()); } @@ -1607,8 +1605,9 @@ to support snapshots(backuped) as qcow2 files. */ if (destPool.getType() == StoragePoolType.CLVM) { keepBitmaps = false; } - qemu.convert(srcFile, destFile, null, null, new QemuImageOptions(srcFile.getFormat(), srcFile.getFileName(), null), - null, false, keepBitmaps); + qemu.convert(srcFile, destFile, null, null, null, new QemuImageOptions(srcFile.getFormat(), srcFile.getFileName(), null), + null, false, keepBitmaps, false, + false, null, null); Map destInfo = qemu.info(destFile); Long virtualSize = Long.parseLong(destInfo.get(QemuImg.VIRTUAL_SIZE)); newDisk.setVirtualSize(virtualSize); diff --git a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/kvm/ha/KVMHostActivityChecker.java b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/kvm/ha/KVMHostActivityChecker.java index af7441c4fd29..96fd42d4d544 100644 --- a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/kvm/ha/KVMHostActivityChecker.java +++ b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/kvm/ha/KVMHostActivityChecker.java @@ -109,6 +109,7 @@ public Status getHostAgentStatus(Host host) { } Status hostStatusFromNeighbour = checkHostStatusWithNeighbourHosts(host); + logger.debug("{} status reported from itself: {} and neighbor: {}", host.toString(), hostStatusFromItself, hostStatusFromNeighbour); Status hostStatus = hostStatusFromItself; if (hostStatusFromNeighbour == Status.Up && (hostStatusFromItself == Status.Disconnected || hostStatusFromItself == Status.Down)) { hostStatus = Status.Disconnected; diff --git a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java index 1fec561dc890..cae6832999eb 100644 --- a/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java +++ b/plugins/hypervisors/kvm/src/main/java/org/apache/cloudstack/utils/qemu/QemuImg.java @@ -54,14 +54,15 @@ public class QemuImg { public static final String TARGET_ZERO_FLAG = "--target-is-zero"; public static final String PREALLOCATION = "preallocation"; public static final long QEMU_2_10 = 2010000; - public static final long QEMU_5_10 = 5010000; + public static final long QEMU_5_1 = 5001000; + public static final long QEMU_5_2 = 5002000; public static final int MIN_BITMAP_VERSION = 3; /* The qemu-img binary. We expect this to be in $PATH */ public String _qemuImgPath = "qemu-img"; private String cloudQemuImgPath = "cloud-qemu-img"; - private int timeout; + private long timeout; private boolean skipZero = false; private boolean skipTargetVolumeCreation = false; private boolean noCache = false; @@ -129,7 +130,7 @@ public enum BitmapOperation { * @param skipZeroIfSupported Don't write zeroes to target device during convert, if supported by qemu-img * @param noCache Ensure we flush writes to target disk (useful for block device targets) */ - public QemuImg(final int timeout, final boolean skipZeroIfSupported, final boolean noCache) throws LibvirtException { + public QemuImg(final long timeout, final boolean skipZeroIfSupported, final boolean noCache) throws LibvirtException { if (skipZeroIfSupported) { final Script s = new Script(_qemuImgPath, timeout); s.add("--help"); @@ -159,7 +160,7 @@ public QemuImg(final int timeout, final boolean skipZeroIfSupported, final boole * @param timeout * The timeout of scripts executed by this QemuImg object. */ - public QemuImg(final int timeout) throws LibvirtException, QemuImgException { + public QemuImg(final long timeout) throws LibvirtException, QemuImgException { this(timeout, false, false); } @@ -186,6 +187,12 @@ public QemuImg(final String qemuImgPath) throws LibvirtException { _qemuImgPath = qemuImgPath; } + /** + * Created for testing purposes + * */ + protected QemuImg() { + } + /* These are all methods supported by the qemu-img tool. */ /** @@ -392,7 +399,7 @@ public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, */ public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, final Map options, final List qemuObjects, final QemuImageOptions srcImageOpts, final String snapshotName, final boolean forceSourceFormat) throws QemuImgException { - convert(srcFile, destFile, options, qemuObjects, srcImageOpts, snapshotName, forceSourceFormat, false); + convert(srcFile, destFile, null, options, qemuObjects, srcImageOpts, snapshotName, forceSourceFormat, false, false, false, null, null); } protected Map getResizeOptionsFromConvertOptions(final Map options) { @@ -408,31 +415,41 @@ protected Map getResizeOptionsFromConvertOptions(final Map * This method is a facade for 'qemu-img convert' and converts a disk image or snapshot into a disk image with the specified filename and format. * * @param srcFile - * The source file. + * The source file. * @param destFile - * The destination file. + * The destination file. + * @param backingFile + * The destination's backing file. * @param options - * Options for the conversion. Takes a Map with key value - * pairs which are passed on to qemu-img without validation. + * Options for the conversion. Takes a Map with key value + * pairs which are passed on to qemu-img without validation. * @param qemuObjects - * Pass qemu Objects to create - see objects in the qemu main page. + * Pass qemu Objects to create - see objects in the qemu main page. * @param srcImageOpts - * pass qemu --image-opts to convert. + * pass qemu --image-opts to convert. * @param snapshotName - * If it is provided, conversion uses it as parameter. + * If it is provided, conversion uses it as parameter. * @param forceSourceFormat - * If true, specifies the source format in the conversion command. + * If true, specifies the source format in the conversion command. * @param keepBitmaps - * If true, copies the bitmaps to the destination image. + * If true, copies the bitmaps to the destination image. + * @param outOfOrderWrites + * If true, inform -W to convert + * @param compress + * If true, inform -c to convert + * @param coroutines + * If not null, inform -m and number of coroutines. By default, qemu uses 8 coroutines. + * @param rateLimit + * If not null, inform -r and rate limit in MB/s. By default, qemu does not limit the convert rate. * @return void */ - public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, - final Map options, final List qemuObjects, final QemuImageOptions srcImageOpts, final String snapshotName, final boolean forceSourceFormat, - boolean keepBitmaps) throws QemuImgException { + public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, QemuImgFile backingFile, final Map options, final List qemuObjects, + final QemuImageOptions srcImageOpts, final String snapshotName, final boolean forceSourceFormat, boolean keepBitmaps, boolean outOfOrderWrites, boolean compress, + Integer coroutines, Integer rateLimit) throws QemuImgException { Script script = new Script(_qemuImgPath, timeout); if (StringUtils.isNotBlank(snapshotName)) { String qemuPath = Script.runSimpleBashScript(getQemuImgPathScript); @@ -455,9 +472,28 @@ public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, script.add("-O"); script.add(destFile.getFormat().toString()); + addBackingFileToConvertCommand(script, backingFile); addScriptOptionsFromMap(options, script); addSnapshotToConvertCommand(srcFile.getFormat().toString(), snapshotName, forceSourceFormat, script, version); + if (outOfOrderWrites) { + script.add("-W"); + } + + if (rateLimit != null) { + script.add("-r"); + script.add(rateLimit + "M"); + } + + if (coroutines != null) { + script.add("-m"); + script.add(String.valueOf(coroutines)); + } + + if (compress) { + script.add("-c"); + } + if (noCache) { script.add("-t"); script.add("none"); @@ -484,7 +520,7 @@ public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, script.add(srcFile.getFileName()); } - if (this.version >= QEMU_5_10 && keepBitmaps && Qcow2Inspector.validateQcow2Version(srcFile.getFileName(), MIN_BITMAP_VERSION)) { + if (this.version >= QEMU_5_1 && keepBitmaps && Qcow2Inspector.validateQcow2Version(srcFile.getFileName(), MIN_BITMAP_VERSION)) { script.add("--bitmaps"); } @@ -500,6 +536,23 @@ public void convert(final QemuImgFile srcFile, final QemuImgFile destFile, } } + + protected void addBackingFileToConvertCommand(Script script, QemuImgFile backingFile) { + if (backingFile == null) { + return; + } + + script.add("-o"); + + String opts; + if (backingFile.getFormat() == null) { + opts = String.format("backing_file=%s", backingFile.getFileName()); + } else { + opts = String.format("backing_file=%s,backing_fmt=%s", backingFile.getFileName(), backingFile.getFormat().toString()); + } + script.add(opts); + } + /** * Qemu version 2.0.0 added (via commit ef80654d0dc1edf2dd2a51feff8cc3e1102a6583) the * flag "-l" to inform the snapshot name or ID @@ -871,11 +924,8 @@ public void commit(QemuImgFile file, QemuImgFile base, boolean skipEmptyingFiles throw new QemuImgException("File should not be null"); } - final Script s = new Script(_qemuImgPath, timeout); + final Script s = createScript(_qemuImgPath, timeout); s.add("commit"); - if (skipEmptyingFiles) { - s.add("-d"); - } if (file.getFormat() != null) { s.add("-f"); @@ -885,6 +935,8 @@ public void commit(QemuImgFile file, QemuImgFile base, boolean skipEmptyingFiles if (base != null) { s.add("-b"); s.add(base.getFileName()); + } else if (skipEmptyingFiles) { + s.add("-d"); } s.add(file.getFileName()); @@ -894,6 +946,13 @@ public void commit(QemuImgFile file, QemuImgFile base, boolean skipEmptyingFiles } } + /** + * This was created to facilitate testing + * */ + protected Script createScript(String path, long timeout) { + return new Script(path, timeout); + } + /** * Does qemu-img support --target-is-zero * @return boolean @@ -927,7 +986,10 @@ public boolean supportsImageFormat(QemuImg.PhysicalDiskFormat format) { } protected static boolean helpSupportsImageFormat(String text, QemuImg.PhysicalDiskFormat format) { - Pattern pattern = Pattern.compile("Supported\\sformats:[a-zA-Z0-9-_\\s]*?\\b" + format + "\\b", CASE_INSENSITIVE); + // QEMU >= 10.1.0 changed the qemu-img --help header from + // "Supported formats:" to "Supported image formats:", so the word + // "image" must be treated as optional here. + Pattern pattern = Pattern.compile("Supported\\s(image\\s)?formats:[a-zA-Z0-9-_\\s]*?\\b" + format + "\\b", CASE_INSENSITIVE); return pattern.matcher(text).find(); } @@ -1006,4 +1068,9 @@ private void removeBitmap(QemuImgFile srcFile, String bitmapName) throws QemuImg throw new QemuImgException(String.format("Exception while removing bitmap [%s] from file [%s]. Result is [%s].", srcFile.getFileName(), bitmapName, result)); } } + + public long getVersion() { + return this.version; + } + } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index ce431d9bab26..639f1dc2894c 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -52,7 +52,6 @@ import java.util.Random; import java.util.UUID; import java.util.Vector; -import java.util.concurrent.Semaphore; import javax.naming.ConfigurationException; import javax.xml.parsers.DocumentBuilderFactory; @@ -61,6 +60,7 @@ import javax.xml.xpath.XPathExpressionException; import javax.xml.xpath.XPathFactory; +import com.cloud.agent.api.CheckOnHostAnswer; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -2406,7 +2406,7 @@ public void testGetStorageStatsCommand() { final KVMStoragePool secondaryPool = Mockito.mock(KVMStoragePool.class); when(libvirtComputingResourceMock.getStoragePoolMgr()).thenReturn(storagePoolMgr); - when(storagePoolMgr.getStoragePool(command.getPooltype(), command.getStorageId(), true)).thenReturn(secondaryPool); + when(storagePoolMgr.getStoragePool(command.getPooltype(), command.getStorageId(), true, true)).thenReturn(secondaryPool); final LibvirtRequestWrapper wrapper = LibvirtRequestWrapper.getInstance(); assertNotNull(wrapper); @@ -2415,7 +2415,7 @@ public void testGetStorageStatsCommand() { assertTrue(answer.getResult()); verify(libvirtComputingResourceMock, times(1)).getStoragePoolMgr(); - verify(storagePoolMgr, times(1)).getStoragePool(command.getPooltype(), command.getStorageId(), true); + verify(storagePoolMgr, times(1)).getStoragePool(command.getPooltype(), command.getStorageId(), true, true); } @SuppressWarnings("unchecked") @@ -3139,7 +3139,9 @@ public void testCheckOnHostCommand() { assertNotNull(wrapper); final Answer answer = wrapper.execute(command, libvirtComputingResourceMock); - assertFalse(answer.getResult()); + assertTrue(answer.getResult()); + assertTrue(answer instanceof CheckOnHostAnswer); + assertFalse(((CheckOnHostAnswer)answer).isAlive()); verify(libvirtComputingResourceMock, times(1)).getMonitor(); } @@ -6685,10 +6687,12 @@ public void mergeSnapshotIntoBaseFileTestActiveAndDeleteFlags() throws Exception libvirtComputingResourceSpy.qcow2DeltaMergeTimeout = 10; try (MockedStatic libvirtUtilitiesHelperMockedStatic = Mockito.mockStatic(LibvirtUtilitiesHelper.class); - MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class)) { + MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class); + MockedStatic agentPropertiesFileHandlerMockedStatic = Mockito.mockStatic(AgentPropertiesFileHandler.class)) { + + agentPropertiesFileHandlerMockedStatic.when(() -> AgentPropertiesFileHandler.getPropertyValue(Mockito.any())).thenAnswer(invocation -> true); libvirtUtilitiesHelperMockedStatic.when(() -> LibvirtUtilitiesHelper.isLibvirtSupportingFlagDeleteOnCommandVirshBlockcommit(Mockito.any())).thenAnswer(invocation -> true); - Mockito.doReturn(new Semaphore(1)).when(libvirtComputingResourceSpy).getSemaphoreToWaitForMerge(); threadContextMockedStatic.when(() -> ThreadContext.get(Mockito.anyString())).thenReturn("logid"); @@ -6700,7 +6704,7 @@ public void mergeSnapshotIntoBaseFileTestActiveAndDeleteFlags() throws Exception String baseFilePath = "/file"; String snapshotName = "snap"; - libvirtComputingResourceSpy.mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(domainMock, diskLabel, baseFilePath, null, true, snapshotName, volumeObjectToMock, connMock); + libvirtComputingResourceSpy.mergeDeltaIntoBaseFile(domainMock, diskLabel, baseFilePath, null, true, snapshotName, volumeObjectToMock, connMock); Mockito.verify(domainMock, Mockito.times(1)).blockCommit(diskLabel, baseFilePath, null, 0, Domain.BlockCommitFlags.ACTIVE | Domain.BlockCommitFlags.DELETE); Mockito.verify(libvirtComputingResourceSpy, Mockito.times(1)).manuallyDeleteUnusedSnapshotFile(true, "/" + snapshotName); @@ -6710,10 +6714,12 @@ public void mergeSnapshotIntoBaseFileTestActiveAndDeleteFlags() throws Exception @Test public void mergeSnapshotIntoBaseFileTestActiveFlag() throws Exception { try (MockedStatic libvirtUtilitiesHelperMockedStatic = Mockito.mockStatic(LibvirtUtilitiesHelper.class); - MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class)) { + MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class); + MockedStatic agentPropertiesFileHandlerMockedStatic = Mockito.mockStatic(AgentPropertiesFileHandler.class)) { + + agentPropertiesFileHandlerMockedStatic.when(() -> AgentPropertiesFileHandler.getPropertyValue(Mockito.any())).thenAnswer(invocation -> true); libvirtUtilitiesHelperMockedStatic.when(() -> LibvirtUtilitiesHelper.isLibvirtSupportingFlagDeleteOnCommandVirshBlockcommit(Mockito.any())).thenAnswer(invocation -> false); - Mockito.doReturn(new Semaphore(1)).when(libvirtComputingResourceSpy).getSemaphoreToWaitForMerge(); threadContextMockedStatic.when(() -> ThreadContext.get(Mockito.anyString())).thenReturn("logid"); @@ -6725,7 +6731,7 @@ public void mergeSnapshotIntoBaseFileTestActiveFlag() throws Exception { String baseFilePath = "/file"; String snapshotName = "snap"; - libvirtComputingResourceSpy.mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(domainMock, diskLabel, baseFilePath, null, true, snapshotName, volumeObjectToMock, connMock); + libvirtComputingResourceSpy.mergeDeltaIntoBaseFile(domainMock, diskLabel, baseFilePath, null, true, snapshotName, volumeObjectToMock, connMock); Mockito.verify(domainMock, Mockito.times(1)).blockCommit(diskLabel, baseFilePath, null, 0, Domain.BlockCommitFlags.ACTIVE); Mockito.verify(libvirtComputingResourceSpy, Mockito.times(1)).manuallyDeleteUnusedSnapshotFile(false, "/" + snapshotName); @@ -6735,10 +6741,12 @@ public void mergeSnapshotIntoBaseFileTestActiveFlag() throws Exception { @Test public void mergeSnapshotIntoBaseFileTestDeleteFlag() throws Exception { try (MockedStatic libvirtUtilitiesHelperMockedStatic = Mockito.mockStatic(LibvirtUtilitiesHelper.class); - MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class)) { + MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class); + MockedStatic agentPropertiesFileHandlerMockedStatic = Mockito.mockStatic(AgentPropertiesFileHandler.class)) { + + agentPropertiesFileHandlerMockedStatic.when(() -> AgentPropertiesFileHandler.getPropertyValue(Mockito.any())).thenAnswer(invocation -> true); libvirtComputingResourceSpy.qcow2DeltaMergeTimeout = 10; libvirtUtilitiesHelperMockedStatic.when(() -> LibvirtUtilitiesHelper.isLibvirtSupportingFlagDeleteOnCommandVirshBlockcommit(Mockito.any())).thenReturn(true); - Mockito.doReturn(new Semaphore(1)).when(libvirtComputingResourceSpy).getSemaphoreToWaitForMerge(); threadContextMockedStatic.when(() -> ThreadContext.get(Mockito.anyString())).thenReturn("logid"); Mockito.doNothing().when(domainMock).addBlockJobListener(Mockito.any()); Mockito.doReturn(null).when(domainMock).getBlockJobInfo(Mockito.anyString(), Mockito.anyInt()); @@ -6749,7 +6757,7 @@ public void mergeSnapshotIntoBaseFileTestDeleteFlag() throws Exception { String baseFilePath = "/file"; String snapshotName = "snap"; - libvirtComputingResourceSpy.mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); + libvirtComputingResourceSpy.mergeDeltaIntoBaseFile(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); Mockito.verify(domainMock, Mockito.times(1)).blockCommit(diskLabel, baseFilePath, null, 0, Domain.BlockCommitFlags.DELETE); Mockito.verify(libvirtComputingResourceSpy, Mockito.times(1)).manuallyDeleteUnusedSnapshotFile(true, "/" + snapshotName); @@ -6759,10 +6767,12 @@ public void mergeSnapshotIntoBaseFileTestDeleteFlag() throws Exception { @Test public void mergeSnapshotIntoBaseFileTestNoFlags() throws Exception { try (MockedStatic libvirtUtilitiesHelperMockedStatic = Mockito.mockStatic(LibvirtUtilitiesHelper.class); - MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class)) { + MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class); + MockedStatic agentPropertiesFileHandlerMockedStatic = Mockito.mockStatic(AgentPropertiesFileHandler.class)) { + + agentPropertiesFileHandlerMockedStatic.when(() -> AgentPropertiesFileHandler.getPropertyValue(Mockito.any())).thenAnswer(invocation -> true); libvirtComputingResourceSpy.qcow2DeltaMergeTimeout = 10; libvirtUtilitiesHelperMockedStatic.when(() -> LibvirtUtilitiesHelper.isLibvirtSupportingFlagDeleteOnCommandVirshBlockcommit(Mockito.any())).thenReturn(false); - Mockito.doReturn(new Semaphore(1)).when(libvirtComputingResourceSpy).getSemaphoreToWaitForMerge(); threadContextMockedStatic.when(() -> ThreadContext.get(Mockito.anyString())).thenReturn("logid"); Mockito.doNothing().when(domainMock).addBlockJobListener(Mockito.any()); Mockito.doReturn(null).when(domainMock).getBlockJobInfo(Mockito.anyString(), Mockito.anyInt()); @@ -6773,7 +6783,7 @@ public void mergeSnapshotIntoBaseFileTestNoFlags() throws Exception { String baseFilePath = "/file"; String snapshotName = "snap"; - libvirtComputingResourceSpy.mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); + libvirtComputingResourceSpy.mergeDeltaIntoBaseFile(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); Mockito.verify(domainMock, Mockito.times(1)).blockCommit(diskLabel, baseFilePath, null, 0, 0); Mockito.verify(libvirtComputingResourceSpy, Mockito.times(1)).manuallyDeleteUnusedSnapshotFile(false, "/" + snapshotName); @@ -6786,20 +6796,20 @@ public void mergeSnapshotIntoBaseFileTestMergeFailsThrowException() throws Excep MockedStatic threadContextMockedStatic = Mockito.mockStatic(ThreadContext.class)) { libvirtComputingResourceSpy.qcow2DeltaMergeTimeout = 10; libvirtUtilitiesHelperMockedStatic.when(() -> LibvirtUtilitiesHelper.isLibvirtSupportingFlagDeleteOnCommandVirshBlockcommit(Mockito.any())).thenReturn(false); - Mockito.doReturn(new Semaphore(1)).when(libvirtComputingResourceSpy).getSemaphoreToWaitForMerge(); threadContextMockedStatic.when(() -> ThreadContext.get(Mockito.anyString())).thenReturn("logid"); + Mockito.doReturn(Boolean.TRUE).when(libvirtComputingResourceSpy).isLibvirtEventsEnabled(); Mockito.doNothing().when(domainMock).addBlockJobListener(Mockito.any()); Mockito.doReturn(null).when(domainMock).getBlockJobInfo(Mockito.anyString(), Mockito.anyInt()); Mockito.doNothing().when(domainMock).removeBlockJobListener(Mockito.any()); - Mockito.doReturn(blockCommitListenerMock).when(libvirtComputingResourceSpy).getBlockCommitListener(Mockito.any(), Mockito.any()); + Mockito.doReturn(blockCommitListenerMock).when(libvirtComputingResourceSpy).getBlockCommitListener(Mockito.any()); Mockito.doReturn("Failed").when(blockCommitListenerMock).getResult(); String diskLabel = "vda"; String baseFilePath = "/file"; String snapshotName = "snap"; - libvirtComputingResourceSpy.mergeSnapshotIntoBaseFileWithEventsAndConfigurableTimeout(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); + libvirtComputingResourceSpy.mergeDeltaIntoBaseFile(domainMock, diskLabel, baseFilePath, null, false, snapshotName, volumeObjectToMock, connMock); } } diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirTakeKbossBackupCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirTakeKbossBackupCommandWrapperTest.java new file mode 100644 index 000000000000..8354993e61a1 --- /dev/null +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirTakeKbossBackupCommandWrapperTest.java @@ -0,0 +1,377 @@ +// +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.backup.TakeKbossBackupAnswer; +import org.apache.cloudstack.backup.TakeKbossBackupCommand; +import org.apache.cloudstack.storage.to.BackupDeltaTO; +import org.apache.cloudstack.storage.to.DeltaMergeTreeTO; +import org.apache.cloudstack.storage.to.KbossTO; +import org.apache.cloudstack.storage.to.PrimaryDataStoreTO; +import org.apache.cloudstack.storage.to.VolumeObjectTO; +import org.apache.cloudstack.utils.qemu.QemuImgException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.libvirt.LibvirtException; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.storage.KVMStoragePool; +import com.cloud.hypervisor.kvm.storage.KVMStoragePoolManager; +import com.cloud.utils.Pair; +import com.cloud.utils.exception.BackupException; + +@RunWith(MockitoJUnitRunner.class) +public class LibvirTakeKbossBackupCommandWrapperTest { + + @Mock + private TakeKbossBackupCommand takeKbossBackupCommandMock; + + @Mock + private LibvirtComputingResource libvirtComputingResourceMock; + + @Mock + private KVMStoragePoolManager kvmStoragePoolManagerMock; + + @Mock + private KVMStoragePool kvmStoragePool1; + + @Mock + private KVMStoragePool kvmStoragePool2; + + @Mock + private KVMStoragePool kvmStoragePool3; + + @Mock + private KbossTO kbossTO1; + + @Mock + private KbossTO kbossTO2; + + @Mock + private VolumeObjectTO volumeObjectToMock1; + + @Mock + private VolumeObjectTO volumeObjectToMock2; + + @Mock + private DeltaMergeTreeTO deltaMergeTreeToMock; + + @Mock + private BackupDeltaTO backupDeltaTOMock; + + @Mock + private PrimaryDataStoreTO primaryDataStoreToMock; + + @Spy + @InjectMocks + private LibvirtTakeKbossBackupCommandWrapper libvirtTakeKbossBackupCommandWrapperSpy; + + private String volUuid1 = "uuid1"; + + private String volUuid2 = "uuid2"; + + private String deltaPath1 = "deltapath1"; + + private String deltaPath2 = "deltapath2"; + + private String secondaryUrl = "nfs://1.1.1.2:/mnt"; + + private String secondaryUrl2 = "nfs://2.2.2.2:/mnt2"; + + @Test + public void executeTestBackupException() { + doReturn(List.of()).when(takeKbossBackupCommandMock).getKbossTOs(); + doThrow(new BackupException("tst", false)).when(libvirtComputingResourceMock).createDiskOnlyVMSnapshotOfStoppedVm(any(), any()); + + TakeKbossBackupAnswer answer = (TakeKbossBackupAnswer)libvirtTakeKbossBackupCommandWrapperSpy.execute(takeKbossBackupCommandMock, libvirtComputingResourceMock); + + assertFalse(answer.getResult()); + assertFalse(answer.isVmConsistent()); + } + + @Test + public void executeTestSuccessStoppedVm() { + doReturn(List.of()).when(takeKbossBackupCommandMock).getKbossTOs(); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).backupVolumes(any(), any(), any(), any(), any(), any(), anyBoolean(), any()); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).cleanupVm(any(), any(), any(), any(), anyBoolean(), any()); + + TakeKbossBackupAnswer answer = (TakeKbossBackupAnswer)libvirtTakeKbossBackupCommandWrapperSpy.execute(takeKbossBackupCommandMock, libvirtComputingResourceMock); + + verify(libvirtComputingResourceMock).createDiskOnlyVMSnapshotOfStoppedVm(any(), any()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).backupVolumes(any(), any(), any(), any(), any(), any(), anyBoolean(), any()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).cleanupVm(any(), any(), any(), any(), anyBoolean(), any()); + assertTrue(answer.getResult()); + } + + @Test + public void executeTestSuccessRunningVm() { + doReturn(List.of()).when(takeKbossBackupCommandMock).getKbossTOs(); + doReturn(true).when(takeKbossBackupCommandMock).isRunningVM(); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).backupVolumes(any(), any(), any(), any(), any(), any(), anyBoolean(), any()); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).cleanupVm(any(), any(), any(), any(), anyBoolean(), any()); + + TakeKbossBackupAnswer answer = (TakeKbossBackupAnswer)libvirtTakeKbossBackupCommandWrapperSpy.execute(takeKbossBackupCommandMock, libvirtComputingResourceMock); + + verify(libvirtComputingResourceMock).createDiskOnlyVmSnapshotForRunningVm(any(), any(), any(), anyBoolean()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).backupVolumes(any(), any(), any(), any(), any(), any(), anyBoolean(), any()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).cleanupVm(any(), any(), any(), any(), anyBoolean(), any()); + assertTrue(answer.getResult()); + } + + @Test (expected = BackupException.class) + public void backupVolumesTestRecoverIfExceptionThrown() { + List> volumeTosAndNewPaths = new ArrayList<>(); + Map> mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize = new HashMap<>(); + String secondaryUrl = "nfs://1.1.1.2:/mnt"; + + doReturn(secondaryUrl).when(takeKbossBackupCommandMock).getImageStoreUrl(); + doThrow(new RuntimeException("odasij")).when(kbossTO1).getVolumeObjectTO(); + + libvirtTakeKbossBackupCommandWrapperSpy.backupVolumes(takeKbossBackupCommandMock, libvirtComputingResourceMock, kvmStoragePoolManagerMock, List.of(kbossTO1), + volumeTosAndNewPaths, "tst", false, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize); + + + verify(libvirtTakeKbossBackupCommandWrapperSpy).recoverPreviousVmStateAndDeletePartialBackup(libvirtComputingResourceMock, volumeTosAndNewPaths, "tst", false, + mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize, kvmStoragePoolManagerMock, secondaryUrl); + } + + @Test + public void backupVolumesTestHappyPath() { + setupKbossTos(); + List> volumeTosAndNewPaths = new ArrayList<>(); + Map> mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize = new HashMap<>(); + + doReturn(100).when(takeKbossBackupCommandMock).getWait(); + doReturn(secondaryUrl).when(takeKbossBackupCommandMock).getImageStoreUrl(); + Pair pair1 = new Pair<>("p1", 10L); + doReturn(pair1).when(libvirtTakeKbossBackupCommandWrapperSpy).copyBackupDeltaToSecondary(eq(kvmStoragePoolManagerMock), eq(kbossTO1), anyList(), + eq(secondaryUrl), anyInt()); + Pair pair2 = new Pair<>("p2", 13L); + doReturn(pair2).when(libvirtTakeKbossBackupCommandWrapperSpy).copyBackupDeltaToSecondary(eq(kvmStoragePoolManagerMock), eq(kbossTO2), anyList(), + eq(secondaryUrl), anyInt()); + + libvirtTakeKbossBackupCommandWrapperSpy.backupVolumes(takeKbossBackupCommandMock, libvirtComputingResourceMock, kvmStoragePoolManagerMock, List.of(kbossTO1, kbossTO2), + volumeTosAndNewPaths, "tst", false, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize); + + verify(libvirtTakeKbossBackupCommandWrapperSpy, never()).recoverPreviousVmStateAndDeletePartialBackup(libvirtComputingResourceMock, volumeTosAndNewPaths, "tst", false, + mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize, kvmStoragePoolManagerMock, secondaryUrl); + assertEquals(pair1, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize.get(volUuid1)); + assertEquals(pair2, mapVolumeUuidToDeltaPathOnSecondaryAndDeltaSize.get(volUuid2)); + } + + @Test + public void cleanupVmTestEndOfChain() { + setupKbossTos(); + Map mapVolumeUUidToNewVolumePath = new HashMap<>(); + String path1 = "path1"; + String path2 = "path2"; + String path3 = "path3"; + String vmName = "ttt"; + doReturn(path1).when(volumeObjectToMock1).getPath(); + doReturn(path2).when(volumeObjectToMock2).getPath(); + doReturn(deltaPath1).when(kbossTO1).getDeltaPathOnPrimary(); + doReturn(deltaPath2).when(kbossTO2).getDeltaPathOnPrimary(); + doReturn(deltaMergeTreeToMock).when(kbossTO1).getDeltaMergeTreeTO(); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).mergeBackupDelta(any(), any(), any(), any(), anyBoolean(), any(), anyBoolean()); + doReturn(true).when(takeKbossBackupCommandMock).isEndChain(); + doReturn(volumeObjectToMock1).when(deltaMergeTreeToMock).getChild(); + doReturn(backupDeltaTOMock).when(deltaMergeTreeToMock).getParent(); + doReturn(path3).when(backupDeltaTOMock).getPath(); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).endChainForVolume(libvirtComputingResourceMock, volumeObjectToMock1, vmName, true, volUuid1, path3); + + libvirtTakeKbossBackupCommandWrapperSpy.cleanupVm(takeKbossBackupCommandMock, libvirtComputingResourceMock, List.of(kbossTO1, kbossTO2), vmName, true, + mapVolumeUUidToNewVolumePath); + + verify(volumeObjectToMock1).setPath(deltaPath1); + verify(volumeObjectToMock2).setPath(deltaPath2); + verify(libvirtTakeKbossBackupCommandWrapperSpy).mergeBackupDelta(eq(libvirtComputingResourceMock), eq(deltaMergeTreeToMock), eq(volumeObjectToMock1), eq(vmName), eq(true), + eq(volUuid1), anyBoolean()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).endChainForVolume(libvirtComputingResourceMock, volumeObjectToMock1, vmName, true, volUuid1, path3); + assertEquals(path3, mapVolumeUUidToNewVolumePath.get(volUuid1)); + assertEquals(path2, mapVolumeUUidToNewVolumePath.get(volUuid2)); + } + + @Test + public void cleanupVmTestNotEndOfChainAndNotIsolated() { + setupKbossTos(); + Map mapVolumeUUidToNewVolumePath = new HashMap<>(); + String vmName = "ttt"; + doReturn(deltaPath1).when(kbossTO1).getDeltaPathOnPrimary(); + doReturn(deltaPath2).when(kbossTO2).getDeltaPathOnPrimary(); + + libvirtTakeKbossBackupCommandWrapperSpy.cleanupVm(takeKbossBackupCommandMock, libvirtComputingResourceMock, List.of(kbossTO1, kbossTO2), vmName, true, + mapVolumeUUidToNewVolumePath); + + verify(volumeObjectToMock1).setPath(deltaPath1); + verify(volumeObjectToMock2).setPath(deltaPath2); + assertEquals(deltaPath1, mapVolumeUUidToNewVolumePath.get(volUuid1)); + assertEquals(deltaPath2, mapVolumeUUidToNewVolumePath.get(volUuid2)); + } + + @Test + public void copyBackupDeltaToSecondaryTest() throws LibvirtException, QemuImgException { + String parentPath = "parentPath"; + String volumePath = "volPath"; + String parentBackupFullPath = "parentBackupFullPath"; + String backupDeltaFullPathOnPrimary1 = "backupDeltaFullPathOnPrimary1"; + String backupDeltaFullPathOnSecondary1 = "backupDeltaFullPathOnSecondary1"; + String randomPath1 = "random"; + String backupDeltaFullPathOnPrimary2 = "backupDeltaFullPathOnPrimary2"; + + doReturn(volumeObjectToMock1).when(kbossTO1).getVolumeObjectTO(); + doReturn(volumePath).when(volumeObjectToMock1).getPath(); + doReturn(volUuid1).when(volumeObjectToMock1).getUuid(); + doReturn(parentPath).when(kbossTO1).getPathBackupParentOnSecondary(); + doReturn(new ArrayList<>(List.of(deltaPath2))).when(kbossTO1).getVmSnapshotDeltaPaths(); + doReturn(deltaPath1).when(kbossTO1).getDeltaPathOnSecondary(); + doReturn(kvmStoragePool1).when(kvmStoragePoolManagerMock).getStoragePoolByURI(secondaryUrl); + doReturn(kvmStoragePool2).when(kvmStoragePoolManagerMock).getStoragePoolByURI(secondaryUrl2); + doReturn(primaryDataStoreToMock).when(volumeObjectToMock1).getDataStore(); + doReturn(kvmStoragePool3).when(kvmStoragePoolManagerMock).getStoragePool(any(), any()); + + doReturn(parentBackupFullPath).when(kvmStoragePool2).getLocalPathFor(parentPath); + doReturn(backupDeltaFullPathOnPrimary1).when(kvmStoragePool3).getLocalPathFor(deltaPath2); + doReturn(backupDeltaFullPathOnSecondary1).when(kvmStoragePool1).getLocalPathFor(deltaPath1); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).convertDeltaToSecondary(backupDeltaFullPathOnPrimary1, backupDeltaFullPathOnSecondary1, parentBackupFullPath, volUuid1, 100000); + + doReturn(randomPath1).when(libvirtTakeKbossBackupCommandWrapperSpy).getRelativePathOnSecondaryForBackup(anyLong(), anyLong(), any()); + doReturn("random2").when(kvmStoragePool1).getLocalPathFor(randomPath1); + doReturn(backupDeltaFullPathOnPrimary2).when(kvmStoragePool3).getLocalPathFor(volumePath); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).convertDeltaToSecondary(eq(backupDeltaFullPathOnPrimary2), eq("random2"), eq(backupDeltaFullPathOnSecondary1), + any(), anyInt()); + + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).commitTopDeltaOnBaseBackupOnSecondaryIfNeeded(randomPath1, deltaPath1, kvmStoragePool1, + backupDeltaFullPathOnSecondary1, 100000); + doNothing().when(libvirtTakeKbossBackupCommandWrapperSpy).removeTemporaryDeltas(any(), anyBoolean()); + + try(MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.size(any())).thenReturn(1000L); + Pair result = libvirtTakeKbossBackupCommandWrapperSpy.copyBackupDeltaToSecondary(kvmStoragePoolManagerMock, kbossTO1, List.of(secondaryUrl2), + secondaryUrl, 100000); + + assertEquals(deltaPath1, result.first()); + assertEquals(Long.valueOf(1000L), result.second()); + } + + verify(libvirtTakeKbossBackupCommandWrapperSpy).convertDeltaToSecondary(backupDeltaFullPathOnPrimary1, backupDeltaFullPathOnSecondary1, parentBackupFullPath, volUuid1, 100000); + verify(libvirtTakeKbossBackupCommandWrapperSpy).convertDeltaToSecondary(eq(backupDeltaFullPathOnPrimary2), eq("random2"), eq(backupDeltaFullPathOnSecondary1), + any(), anyInt()); + verify(libvirtTakeKbossBackupCommandWrapperSpy).commitTopDeltaOnBaseBackupOnSecondaryIfNeeded(randomPath1, deltaPath1, kvmStoragePool1, + backupDeltaFullPathOnSecondary1, 100000); + verify(libvirtTakeKbossBackupCommandWrapperSpy).removeTemporaryDeltas(any(), anyBoolean()); + } + + @Test + public void removeTemporaryDeltasTestResultTrue() { + ArrayList input = new ArrayList<>(List.of("a", "b")); + + try(MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + libvirtTakeKbossBackupCommandWrapperSpy.removeTemporaryDeltas(input, true); + + filesMockedStatic.verify(() -> Files.deleteIfExists(any()), Mockito.times(1)); + } + } + + @Test + public void removeTemporaryDeltasTestResultFalse() { + ArrayList input = new ArrayList<>(List.of("a", "b")); + + try(MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + libvirtTakeKbossBackupCommandWrapperSpy.removeTemporaryDeltas(input, false); + + filesMockedStatic.verify(() -> Files.deleteIfExists(any()), Mockito.times(2)); + } + } + + @Test + public void removeTemporaryDeltasTestExceptionIsIgnored() { + ArrayList input = new ArrayList<>(List.of("a", "b")); + + try(MockedStatic filesMockedStatic = Mockito.mockStatic(Files.class)) { + filesMockedStatic.when(() -> Files.deleteIfExists(any())).thenThrow(new IOException("das")); + libvirtTakeKbossBackupCommandWrapperSpy.removeTemporaryDeltas(input, false); + + filesMockedStatic.verify(() -> Files.deleteIfExists(any()), Mockito.times(2)); + } + } + + @Test (expected = BackupException.class) + public void mergeBackupDeltaTestThrowsException() throws LibvirtException, QemuImgException { + doThrow(new QemuImgException("a")).when(libvirtComputingResourceMock).mergeDeltaForRunningVm(any(), any(), any()); + + libvirtTakeKbossBackupCommandWrapperSpy.mergeBackupDelta(libvirtComputingResourceMock, deltaMergeTreeToMock, volumeObjectToMock1, "ttt", true, volUuid1, false); + } + + @Test + public void mergeBackupDeltaTestRunningVm() throws LibvirtException, QemuImgException { + libvirtTakeKbossBackupCommandWrapperSpy.mergeBackupDelta(libvirtComputingResourceMock, deltaMergeTreeToMock, volumeObjectToMock1, "ttt", true, volUuid1, false); + + verify(libvirtComputingResourceMock).mergeDeltaForRunningVm(deltaMergeTreeToMock, "ttt", volumeObjectToMock1); + } + + @Test + public void mergeBackupDeltaTestStoppedVm() throws LibvirtException, QemuImgException, IOException { + libvirtTakeKbossBackupCommandWrapperSpy.mergeBackupDelta(libvirtComputingResourceMock, deltaMergeTreeToMock, volumeObjectToMock1, "ttt", false, volUuid1, false); + + verify(libvirtComputingResourceMock).mergeDeltaForStoppedVm(deltaMergeTreeToMock); + } + + @Test + public void mergeBackupDeltaTestStoppedVmCountNewestDeltaAsGrandChild() throws LibvirtException, QemuImgException, IOException { + libvirtTakeKbossBackupCommandWrapperSpy.mergeBackupDelta(libvirtComputingResourceMock, deltaMergeTreeToMock, volumeObjectToMock1, "ttt", false, volUuid1, true); + + verify(deltaMergeTreeToMock).addGrandChild(volumeObjectToMock1); + verify(libvirtComputingResourceMock).mergeDeltaForStoppedVm(deltaMergeTreeToMock); + } + + private void setupKbossTos() { + doReturn(volumeObjectToMock1).when(kbossTO1).getVolumeObjectTO(); + doReturn(volUuid1).when(volumeObjectToMock1).getUuid(); + doReturn(volumeObjectToMock2).when(kbossTO2).getVolumeObjectTO(); + doReturn(volUuid2).when(volumeObjectToMock2).getUuid(); + } +} diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVmIpAddressCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVmIpAddressCommandWrapperTest.java index 511306854cf2..f104eadd18be 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVmIpAddressCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtGetVmIpAddressCommandWrapperTest.java @@ -52,6 +52,12 @@ public class LibvirtGetVmIpAddressCommandWrapperTest { " net4 2e:9b:60:dc:49:30 N/A N/A\n" + // " lxc5b7327203b6f 92:b2:77:0b:a9:20 N/A N/A\n"; + private static String VIRSH_DOMIF_OUTPUT_WINDOWS = " Name MAC address Protocol Address\n" + // + "-------------------------------------------------------------------------------\n" + // + " Ethernet Instance 0 02:0c:02:f9:00:80 ipv4 192.168.0.10/24\n" + // + " Loopback Pseudo-Interface 1 ipv6 ::1/128\n" + // + " - - ipv4 127.0.0.1/8\n"; + @Before public void setUp() { MockitoAnnotations.openMocks(this); @@ -118,7 +124,34 @@ public void testExecuteWithWindowsVm() { when(getVmIpAddressCommand.getVmNetworkCidr()).thenReturn("192.168.0.0/24"); when(getVmIpAddressCommand.getMacAddress()).thenReturn("02:0c:02:f9:00:80"); when(getVmIpAddressCommand.isWindows()).thenReturn(true); - when(Script.executePipedCommands(anyList(), anyLong())).thenReturn(new Pair<>(0, "192.168.0.10")); + when(Script.executePipedCommands(anyList(), anyLong())).thenReturn(new Pair<>(0, VIRSH_DOMIF_OUTPUT_WINDOWS)); + + Answer answer = commandWrapper.execute(getVmIpAddressCommand, libvirtComputingResource); + + assertTrue(answer.getResult()); + assertEquals("192.168.0.10", answer.getDetails()); + } finally { + if (scriptMock != null) + scriptMock.close(); + } + } + + + @Test + public void testExecuteWithWindowsVm2() { + LibvirtComputingResource libvirtComputingResource = mock(LibvirtComputingResource.class); + GetVmIpAddressCommand getVmIpAddressCommand = mock(GetVmIpAddressCommand.class); + LibvirtGetVmIpAddressCommandWrapper commandWrapper = new LibvirtGetVmIpAddressCommandWrapper(); + MockedStatic + + diff --git a/ui/src/components/view/charts/BarChart.vue b/ui/src/components/view/charts/BarChart.vue new file mode 100644 index 000000000000..a27069163d35 --- /dev/null +++ b/ui/src/components/view/charts/BarChart.vue @@ -0,0 +1,56 @@ +// 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. + + + + diff --git a/ui/src/components/view/stats/ResourceStatsLineChart.vue b/ui/src/components/view/stats/ResourceStatsLineChart.vue index 399e77bebd46..f1afbefd155d 100644 --- a/ui/src/components/view/stats/ResourceStatsLineChart.vue +++ b/ui/src/components/view/stats/ResourceStatsLineChart.vue @@ -217,7 +217,7 @@ export default { data: element.data.map(d => d.stat), hidden: this.hideLine(element.data.map(d => d.stat)), pointRadius: element.pointRadius, - fill: 'origin' + fill: element.fill || 'origin' } ) } diff --git a/ui/src/components/widgets/Status.vue b/ui/src/components/widgets/Status.vue index 1f6a1183e4a4..3b078b5fe3e3 100644 --- a/ui/src/components/widgets/Status.vue +++ b/ui/src/components/widgets/Status.vue @@ -153,6 +153,8 @@ export default { case 'primary': case 'managed': case 'yes': + case 'compressed': + case 'valid': status = 'success' break case 'alert': @@ -169,6 +171,9 @@ export default { case 'failed': case 'unmanaged': case 'no': + case 'notvalid': + case 'unabletovalidate': + case 'compressionerror': status = 'error' break case 'migrating': @@ -177,6 +182,8 @@ export default { case 'stopping': case 'upgrading': case 'inprogress': + case 'validating': + case 'compressing': status = 'processing' break case 'allocated': @@ -193,6 +200,7 @@ export default { case 'warning': case 'backup': case 'partiallyallocated': + case 'uncompressed': status = 'warning' break } diff --git a/ui/src/config/router.js b/ui/src/config/router.js index a48c3fef81e3..b9c60bcd0c21 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -218,9 +218,9 @@ export function asyncRouterMap () { generateRouterMap(compute), generateRouterMap(storage), - generateRouterMap(kms), generateRouterMap(network), generateRouterMap(image), + generateRouterMap(kms), generateRouterMap(event), generateRouterMap(project), generateRouterMap(user), diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js index 9262664d448e..b6e83b5eb438 100644 --- a/ui/src/config/section/compute.js +++ b/ui/src/config/section/compute.js @@ -301,6 +301,21 @@ export default { } } }, + { + api: 'finishBackupChain', + icon: 'vertical-align-middle-outlined', + label: 'label.backup.chain.finish', + dataView: true, + args: ['virtualmachineid'], + show: (record) => { + return ['Running', 'Stopped', 'BackupError'].includes(record.state) && record.backupofferingid && record.backupprovider === 'kboss' + }, + mapping: { + virtualmachineid: { + value: (record, params) => { return record.id } + } + } + }, { api: 'attachIso', icon: 'paper-clip-outlined', diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js index 2a83b25c002f..a2c12ce236ac 100644 --- a/ui/src/config/section/config.js +++ b/ui/src/config/section/config.js @@ -79,8 +79,8 @@ export default { icon: 'login-outlined', docHelp: 'adminguide/accounts.html#using-an-ldap-server-for-user-authentication', permission: ['listOauthProvider'], - columns: ['provider', 'enabled', 'description', 'clientid', 'secretkey', 'redirecturi'], - details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl'], + columns: ['provider', 'enabled', 'description', 'clientid', 'redirecturi', 'domainpath'], + details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl', 'domainpath'], actions: [ { api: 'registerOauthProvider', @@ -89,7 +89,7 @@ export default { listView: true, dataView: false, args: [ - 'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl' + 'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl', 'domainid' ], mapping: { provider: { @@ -103,7 +103,7 @@ export default { label: 'label.edit', dataView: true, popup: true, - args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl'] + args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl', 'domainid'] }, { api: 'updateOauthProvider', diff --git a/ui/src/config/section/extension.js b/ui/src/config/section/extension.js index 5904abae30b6..8bb3ee362f60 100644 --- a/ui/src/config/section/extension.js +++ b/ui/src/config/section/extension.js @@ -45,7 +45,7 @@ export default { return fields }, details: ['name', 'description', 'id', 'type', 'details', 'path', 'pathready', 'isuserdefined', 'orchestratorrequirespreparevm', 'reservedresourcedetails', 'created'], - filters: ['orchestrator'], + filters: ['Orchestrator', 'NetworkOrchestrator'], tabs: [{ name: 'details', component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) diff --git a/ui/src/config/section/infra/phynetworks.js b/ui/src/config/section/infra/phynetworks.js index 0863eff6ec0b..9b7a0a83f7f3 100644 --- a/ui/src/config/section/infra/phynetworks.js +++ b/ui/src/config/section/infra/phynetworks.js @@ -94,7 +94,7 @@ export default { icon: 'edit-outlined', label: 'label.update.physical.network', dataView: true, - args: ['vlan', 'tags'] + args: ['vlan', 'tags', 'externaldetails'] }, { api: 'addTrafficType', diff --git a/ui/src/config/section/kms.js b/ui/src/config/section/kms.js index 648a8064b5c6..3120fee54cc8 100644 --- a/ui/src/config/section/kms.js +++ b/ui/src/config/section/kms.js @@ -21,7 +21,7 @@ import store from '@/store' export default { name: 'kms', title: 'label.kms', - icon: 'hdd-outlined', + icon: 'lock-outlined', show: () => { return ['Admin'].includes(store.getters.userInfo.roletype) || store.getters.features.hashsmprofiles }, diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 50c2ff4250b0..30608a82a98c 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -18,7 +18,7 @@ import { shallowRef, defineAsyncComponent } from 'vue' import store from '@/store' import tungsten from '@/assets/icons/tungsten.svg?inline' -import { isAdmin } from '@/role' +import { isAdmin, isAdminOrDomainAdmin } from '@/role' import { isZoneCreated } from '@/utils/zone' import { vueProps } from '@/vue-app' @@ -49,7 +49,10 @@ export default { return fields }, details: () => { - const fields = ['name', 'id', 'description', 'type', 'traffictype', 'vpcid', 'vlan', 'cidr', 'ip6cidr', 'netmask', 'gateway', 'asnumber', 'aclname', 'ispersistent', 'restartrequired', 'reservediprange', 'redundantrouter', 'networkdomain', 'egressdefaultpolicy', 'zonename', 'account', 'domainpath', 'associatednetwork', 'associatednetworkid', 'ip4routing', 'ip6firewall', 'ip6routing', 'ip6routes', 'dns1', 'dns2', 'ip6dns1', 'ip6dns2', 'publicmtu', 'privatemtu'] + const fields = ['name', 'id', 'description', 'type', 'traffictype', 'vpcid', 'vlan', 'cidr', 'ip6cidr', 'netmask', 'gateway', 'asnumber', + 'aclname', 'ispersistent', 'restartrequired', 'reservediprange', 'redundantrouter', 'networkdomain', 'egressdefaultpolicy', 'zonename', + 'account', 'domainpath', 'associatednetwork', 'associatednetworkid', 'ip4routing', 'ip6firewall', 'ip6routing', 'ip6routes', + 'dns1', 'dns2', 'ip6dns1', 'ip6dns2', 'publicmtu', 'privatemtu', 'dnszone', 'dnssubdomain'] if (isAdmin()) { const vlanIndex = fields.findIndex(detail => detail === 'vlan') fields.splice(vlanIndex + 1, 0, 'broadcasturi') @@ -118,6 +121,14 @@ export default { name: 'network.permissions', component: shallowRef(defineAsyncComponent(() => import('@/views/network/NetworkPermissions.vue'))), show: (record, route, user) => { return 'listNetworkPermissions' in store.getters.apis && record.acltype === 'Account' && !('vpcid' in record) && (['Admin', 'DomainAdmin'].includes(user.roletype) || record.account === user.account) && !record.projectid } + }, { + name: 'custom.actions', + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction.vue'))), + show: (record) => { + return 'runCustomAction' in store.getters.apis && + 'listCustomActions' in store.getters.apis && + record.service && record.service.some(s => s.name === 'CustomAction') + } }, { name: 'events', @@ -207,6 +218,49 @@ export default { } } }, + { + api: 'runCustomAction', + icon: 'thunderbolt-outlined', + label: 'label.run.custom.action', + dataView: true, + show: (record) => { + return 'runCustomAction' in store.getters.apis && + 'listCustomActions' in store.getters.apis && + record.service && record.service.some(s => s.name === 'CustomAction') + }, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction.vue'))) + }, + { + api: 'associateDnsZoneToNetwork', + icon: 'link-outlined', + label: 'label.action.associate.dns.zone', + dataView: true, + show: (record, store) => { + return (record.type === 'Shared' && record.dnszone === undefined && + (record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype))) + }, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/AssociateDnsZone.vue'))) + }, + { + api: 'disassociateDnsZoneFromNetwork', + icon: 'disconnect-outlined', + label: 'label.action.disassociate.dns.zone', + message: 'message.action.disassociate.dns.zone', + dataView: true, + popup: true, + args: ['networkid'], + show: (record, store) => { + return record.dnszone !== undefined && record.type === 'Shared' && + (record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype)) + }, + mapping: { + networkid: { + value: (record) => { return record.id } + } + } + }, { api: 'deleteNetwork', icon: 'delete-outlined', @@ -1511,6 +1565,103 @@ export default { groupMap: (selection) => { return selection.map(x => { return { id: x } }) } } ] + }, + { + name: 'dnszone', + title: 'label.dns.zones', + icon: 'apartment-outlined', + permission: ['listDnsZones'], + columns: ['name', 'state', 'dnsservername', 'account', 'description'], + details: ['name', 'id', 'state', 'dnsservername', 'dnsserverid', 'account', 'domainpath', 'description'], + tabs: [{ + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'dns.records', + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/DnsRecordsTab.vue'))), + show: () => true + }], + actions: [ + { + api: 'createDnsZone', + icon: 'plus-outlined', + label: 'label.dns.create.zone', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/CreateDnsZone.vue'))), + show: () => { + return true + } + }, + { + api: 'updateDnsZone', + icon: 'edit-outlined', + label: 'label.dns.update.zone', + dataView: true, + popup: true, + show: (record, store) => { return record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype) }, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/UpdateDnsZone.vue'))) + }, + { + api: 'deleteDnsZone', + icon: 'delete-outlined', + label: 'label.dns.delete.zone', + message: 'message.action.delete.dns.zone', + dataView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/DeleteDnsZone.vue'))), + show: (record, store) => { return record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype) }, + groupAction: false + } + ] + }, + { + name: 'dnsserver', + title: 'label.dns.servers', + icon: 'cloud-server-outlined', + permission: ['listDnsServers'], + columns: ['name', 'url', 'provider', 'ispublic', 'port', 'nameservers', 'publicdomainsuffix'], + details: ['name', 'url', 'provider', 'ispublic', 'port', 'nameservers', 'publicdomainsuffix', 'domain', 'account'], + related: [{ + name: 'dnszone', + title: 'label.dns.zone', + param: 'dnsserverid' + }], + actions: [ + { + api: 'addDnsServer', + icon: 'plus-outlined', + label: 'label.dns.add.server', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/AddDnsServer.vue'))), + show: () => { + return true + } + }, + { + api: 'updateDnsServer', + icon: 'edit-outlined', + label: 'label.dns.update.server', + dataView: true, + popup: true, + show: (record, store) => { return record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype) }, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/UpdateDnsServer.vue'))) + }, + { + api: 'deleteDnsServer', + icon: 'delete-outlined', + label: 'label.dns.delete.server', + message: 'message.action.delete.dns.server', + dataView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/dns/DeleteDnsServer.vue'))), + show: (record, store) => { return record.account === store.userInfo.account || isAdminOrDomainAdmin(store.userInfo.roletype) }, + groupAction: false, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + } + ] } ] } diff --git a/ui/src/config/section/offering.js b/ui/src/config/section/offering.js index 9d7b743a70aa..afdf2605a6a8 100644 --- a/ui/src/config/section/offering.js +++ b/ui/src/config/section/offering.js @@ -367,7 +367,7 @@ export default { permission: ['listBackupOfferings'], searchFilters: ['zoneid', 'domainid'], columns: ['name', 'description', 'domain', 'zonename'], - details: ['name', 'id', 'description', 'externalid', 'domain', 'zone', 'allowuserdrivenbackups', 'created'], + details: ['name', 'id', 'description', 'externalid', 'domain', 'zone', 'allowuserdrivenbackups', 'created', 'backupofferingdetails'], related: [{ name: 'vm', title: 'label.instances', @@ -393,6 +393,13 @@ export default { listView: true, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/offering/ImportBackupOffering.vue'))) + }, { + api: 'createBackupOffering', + icon: 'plus-outlined', + label: 'label.create.backup.offering', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/offering/CreateBackupOffering.vue'))) }, { api: 'updateBackupOffering', icon: 'edit-outlined', diff --git a/ui/src/config/section/plugin/quota.js b/ui/src/config/section/plugin/quota.js index 630e42e4c042..162fb74f7e75 100644 --- a/ui/src/config/section/plugin/quota.js +++ b/ui/src/config/section/plugin/quota.js @@ -30,29 +30,38 @@ export default { title: 'label.quota.summary', icon: 'bars-outlined', permission: ['quotaSummary'], - columns: ['account', + customParamHandler: (params, query) => { return { ...params, ignoreproject: true } }, + tabs: [ + { + name: 'consumption', + component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaUsageTab.vue'))) + }, { - state: (record) => record.state.toLowerCase() + name: 'balance', + component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaBalanceTab.vue'))) }, { - quotastate: (record) => record.quotaenabled ? 'Enabled' : 'Disabled' - }, 'domain', 'currency', 'balance' + name: 'credits', + component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaCreditTab.vue'))) + } ], - columnNames: ['account', 'accountstate', 'quotastate', 'domain', 'currency', 'currentbalance'], - details: ['account', 'domain', 'state', 'currency', 'balance', 'quota', 'startdate', 'enddate'], - component: shallowRef(() => import('@/views/plugins/quota/QuotaSummary.vue')), - tabs: [ + columns: [ + 'account', { - name: 'details', - component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + field: 'state', + customTitle: 'accountState', + state: (record) => record.accountremoved || (record.projectid && record.projectremoved) ? 'disabled' : 'enabled' }, { - name: 'quota.statement.quota', - component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaUsage.vue'))) + field: 'quotastate', + customTitle: 'quotaState', + quotastate: (record) => !record.quotaenabled || record.accountremoved || (record.projectid && record.projectremoved) ? 'disabled' : 'enabled' }, + 'domain', + 'currency', { - name: 'quota.statement.balance', - component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/QuotaBalance.vue'))) + field: 'balance', + customTitle: 'quota.current.balance' } ], actions: [ @@ -61,16 +70,9 @@ export default { icon: 'plus-outlined', docHelp: 'plugins/quota.html#quota-credits', label: 'label.quota.add.credits', - dataView: true, - args: ['value', 'min_balance', 'quota_enforce'], - mapping: { - account: { - value: (record) => { return record.account } - }, - domainid: { - value: (record) => { return record.domainid } - } - } + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/quota/AddQuotaCredit.vue'))) } ] }, @@ -83,7 +85,7 @@ export default { customParamHandler: (params, query) => { params.listall = false - if (['all', 'removed'].includes(query.filter) || params.id) { + if (['all', 'removed'].includes(query.filter) || params.uuid) { params.listall = true } @@ -109,6 +111,11 @@ export default { field: 'tariffValue', customTitle: 'quota.tariff.value' }, + { + field: 'hasActivationRule', + customTitle: 'quota.tariff.hasactivationrule', + hasActivationRule: (record) => record.activationRule ? i18n.global.t('label.yes') : i18n.global.t('label.no') + }, { field: 'executionPosition', customTitle: 'quota.tariff.position', @@ -145,7 +152,11 @@ export default { field: 'endDate', customTitle: 'end.date' }, - 'removed' + 'removed', + { + field: 'activationRule', + customTitle: 'quota.tariff.activationrule' + } ], filters: ['all', 'active', 'removed'], searchFilters: ['usagetype'], @@ -173,13 +184,16 @@ export default { label: 'label.action.quota.tariff.remove', message: 'message.action.quota.tariff.remove', dataView: true, - show: (record) => !record.removed + show: (record) => !record.removed, + groupAction: true, + popup: true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } } ] }, { name: 'quotaemailtemplate', - title: 'label.templatetype', + title: 'label.emailtemplate', icon: 'mail-outlined', permission: ['quotaEmailTemplateList'], columns: ['templatetype', 'templatesubject', 'templatebody'], diff --git a/ui/src/config/section/storage.js b/ui/src/config/section/storage.js index e3cd875a6b85..702b782690af 100644 --- a/ui/src/config/section/storage.js +++ b/ui/src/config/section/storage.js @@ -18,6 +18,7 @@ import { shallowRef, defineAsyncComponent } from 'vue' import store from '@/store' import { isZoneCreated } from '@/utils/zone' +import { isAdmin } from '@/role' export default { name: 'storage', @@ -492,7 +493,7 @@ export default { icon: 'cloud-upload-outlined', permission: ['listBackups'], params: { listvmdetails: 'true' }, - columns: ['name', 'status', 'size', 'virtualsize', 'virtualmachinename', 'backupofferingname', 'intervaltype', 'type', 'created', 'account', 'domain', 'zone'], + columns: ['name', 'status', 'compressionstatus', 'validationstatus', 'size', 'virtualsize', 'virtualmachinename', 'backupofferingname', 'intervaltype', 'type', 'created', 'account', 'domain', 'zone'], details: ['name', 'description', 'virtualmachinename', 'id', 'intervaltype', 'type', 'externalid', 'size', 'virtualsize', 'volumes', 'backupofferingname', 'zone', 'account', 'domain', 'created'], searchFilters: () => { var filters = ['name', 'zoneid', 'domainid', 'account', 'backupofferingid', 'status'] @@ -516,7 +517,14 @@ export default { label: 'label.backup.restore', message: 'message.backup.restore', dataView: true, - show: (record) => { return record.status === 'BackedUp' } + show: (record) => { return record.status === 'BackedUp' }, + args: () => { + const fields = ['quickrestore'] + if (isAdmin()) { + fields.push('hostid') + } + return fields + } }, { api: 'restoreVolumeFromBackupAndAttachToVM', diff --git a/ui/src/core/lazy_lib/icons_use.js b/ui/src/core/lazy_lib/icons_use.js index 502eb5de0b63..43c6f822de31 100644 --- a/ui/src/core/lazy_lib/icons_use.js +++ b/ui/src/core/lazy_lib/icons_use.js @@ -183,6 +183,7 @@ import { UploadOutlined, VerticalAlignBottomOutlined, VerticalAlignTopOutlined, + VerticalAlignMiddleOutlined, WarningOutlined, WifiOutlined, SolutionOutlined @@ -358,6 +359,7 @@ export default { app.component('UploadOutlined', UploadOutlined) app.component('VerticalAlignBottomOutlined', VerticalAlignBottomOutlined) app.component('VerticalAlignTopOutlined', VerticalAlignTopOutlined) + app.component('VerticalAlignMiddleOutlined', VerticalAlignMiddleOutlined) app.component('WarningOutlined', WarningOutlined) app.component('WifiOutlined', WifiOutlined) app.component('renderIcon', renderIcon) diff --git a/ui/src/locales/index.js b/ui/src/locales/index.js index 6933e05206e1..929ed410a6d5 100644 --- a/ui/src/locales/index.js +++ b/ui/src/locales/index.js @@ -18,46 +18,54 @@ import { createI18n } from 'vue-i18n' import { vueProps } from '@/vue-app' +const FALLBACK_LANG = 'en' const loadedLanguage = [] const messages = {} export const i18n = createI18n({ - locale: 'en', - fallbackLocale: 'en', + locale: FALLBACK_LANG, + fallbackLocale: FALLBACK_LANG, silentTranslationWarn: true, messages: messages, silentFallbackWarn: true, warnHtmlInMessage: 'off' }) -export function loadLanguageAsync (lang) { - if (!lang) { - const locale = vueProps.$localStorage.get('LOCALE') - lang = (!locale || typeof locale === 'object') ? 'en' : locale +function applyMessages (lang, message) { + if (message && Object.keys(message).length > 0) { + i18n.global.setLocaleMessage(lang, message) + messages[lang] = message } - if (loadedLanguage.includes(lang)) { - return Promise.resolve(setLanguage(lang)) + if (!loadedLanguage.includes(lang)) { + loadedLanguage.push(lang) } +} +function fetchLocale (lang) { return fetch(`locales/${lang}.json?ts=${Date.now()}`) .then(response => response.json()) - .then(json => Promise.resolve(setLanguage(lang, json))) + .then(json => applyMessages(lang, json)) } -function setLanguage (lang, message) { - if (i18n) { - i18n.global.locale = lang - - if (message && Object.keys(message).length > 0) { - i18n.global.setLocaleMessage(lang, message) - } +export function loadLanguageAsync (lang) { + if (!lang) { + const locale = vueProps.$localStorage.get('LOCALE') + lang = (!locale || typeof locale === 'object') ? FALLBACK_LANG : locale } - if (!loadedLanguage.includes(lang)) { - loadedLanguage.push(lang) - } + // Always keep the fallback locale's messages loaded so $t() degrades + // to readable English instead of raw keys when a translation is missing. + const ensureFallback = loadedLanguage.includes(FALLBACK_LANG) + ? Promise.resolve() + : fetchLocale(FALLBACK_LANG) - if (message && Object.keys(message).length > 0) { - messages[lang] = message - } + const ensureTarget = (lang === FALLBACK_LANG || loadedLanguage.includes(lang)) + ? ensureFallback + : ensureFallback.then(() => fetchLocale(lang)) + + // Activate locale after messages are in place so the first render + // already has the translations and avoids a flash of raw keys. + return ensureTarget.then(() => { + i18n.global.locale = lang + }) } diff --git a/ui/src/permission.js b/ui/src/permission.js index 0b87de92c6b4..b0ed66365e68 100644 --- a/ui/src/permission.js +++ b/ui/src/permission.js @@ -93,7 +93,10 @@ router.beforeEach((to, from, next) => { return } store.commit('SET_LOGIN_FLAG', true) - store.commit('SET_MS_ID', Cookies.get('managementserverid')) + const MS_ID = Cookies.get('managementserverid') + if (MS_ID) { + store.commit('SET_MS_ID', MS_ID) + } } // store already loaded if (store.getters.passwordChangeRequired) { @@ -170,7 +173,7 @@ router.beforeEach((to, from, next) => { } } } else { - if (window.location.href.includes('verifyOauth') && to.name === undefined) { + if (window.location.search.includes('verifyOauth') && to.name !== 'VerifyOauth') { currentURL = new URL(window.location.href) urlParams = new URLSearchParams(currentURL.search) code = urlParams.get('code') diff --git a/ui/src/style/common/common.scss b/ui/src/style/common/common.scss new file mode 100644 index 000000000000..39dcb46457e7 --- /dev/null +++ b/ui/src/style/common/common.scss @@ -0,0 +1,37 @@ +// 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. + +.w-100 { + width: 100%; +} + +.mt-10 { + margin-top: 10px; +} + +.mb-10 { + margin-bottom: 10px; +} + +.m-20-0 { + margin: 20px 0; +} + +.dotted-underline { + text-decoration: underline dotted; + cursor: default; +} diff --git a/ui/src/style/vars.less b/ui/src/style/vars.less index de2d494c878f..133244473e2e 100644 --- a/ui/src/style/vars.less +++ b/ui/src/style/vars.less @@ -355,7 +355,7 @@ a { text-align: right; padding-top: 15px; - button { + button, a.ant-btn { margin-right: 5px; } } diff --git a/ui/src/utils/chart.js b/ui/src/utils/chart.js new file mode 100644 index 000000000000..a4e78d896163 --- /dev/null +++ b/ui/src/utils/chart.js @@ -0,0 +1,70 @@ +// 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. + +import { TIME_UNITS } from './units' + +export const defaultDisplayFormats = { + day: 'DD MMM YYYY', + week: 'DD MMM YYYY', + month: 'MMM YYYY', + quarter: 'MMM YYYY', + year: 'YYYY' +} + +export const getUnitToTimeCartesianAxis = (baseUnit, dataLength) => { + const maxLabels = 15 + if (dataLength <= maxLabels) { + return baseUnit + } + + const units = [ + 'millisecond', + 'second', + 'minute', + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year' + ] + + let index = units.indexOf(baseUnit) + + let unitToReturn = baseUnit + if (index >= 0 && index < units.length) { + let unitTime = 0 + for (index; index < units.length; index++) { + unitTime = TIME_UNITS[units[index]] + const nextUnitTime = TIME_UNITS[units[index + 1]] + + if ((dataLength / (nextUnitTime / unitTime)) <= maxLabels) { + return units[index + 1] + } + + unitToReturn = units[index] + } + } + + return unitToReturn +} + +export const getChartColorObject = (hexColor = '#1890FF') => ({ + backgroundColor: hexColor.concat('80'), + borderColor: hexColor, + borderWidth: 1.5 +}) diff --git a/ui/src/utils/date.js b/ui/src/utils/date.js index 216dfde1303b..bd2ea64e32fa 100644 --- a/ui/src/utils/date.js +++ b/ui/src/utils/date.js @@ -65,16 +65,21 @@ export function parseDateToDatePicker (value) { } export function toLocalDate ({ date, timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone }) { - if (usebrowsertimezone) { - // Since GMT+530 is returned as -330 (minutes to GMT) - timezoneoffset = new Date().getTimezoneOffset() / -60 - } + timezoneoffset = getTimezoneOffset({ timezoneoffset, usebrowsertimezone }) const milliseconds = Date.parse(date) // e.g. "Tue, 08 Jun 2010 19:13:49 GMT"; "Tue, 25 May 2010 12:07:01 UTC" return new Date(milliseconds + (timezoneoffset * 60 * 60 * 1000)) } +export function getTimezoneOffset ({ timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone }) { + if (!usebrowsertimezone) { + return timezoneoffset + } + // Since GMT+530 is returned as -330 (mins to GMT) + return new Date().getTimezoneOffset() / -60 +} + export function toLocaleDate ({ date, timezoneoffset = store.getters.timezoneoffset, usebrowsertimezone = store.getters.usebrowsertimezone, dateOnly = false, hourOnly = false }) { if (!date) { return null diff --git a/ui/src/utils/quota.js b/ui/src/utils/quota.js index b8adbb93518a..c23deb8d13b1 100644 --- a/ui/src/utils/quota.js +++ b/ui/src/utils/quota.js @@ -19,106 +19,135 @@ export const QUOTA_TYPES = [ { id: 1, - type: 'RUNNING_VM' + type: 'RUNNING_VM', + chartColor: '#1890ff' }, { id: 2, - type: 'ALLOCATED_VM' + type: 'ALLOCATED_VM', + chartColor: '#fadb14' }, { id: 3, - type: 'IP_ADDRESS' + type: 'IP_ADDRESS', + chartColor: '#ffd6e7' }, { id: 4, - type: 'NETWORK_BYTES_SENT' + type: 'NETWORK_BYTES_SENT', + chartColor: '#adc6ff' }, { id: 5, - type: 'NETWORK_BYTES_RECEIVED' + type: 'NETWORK_BYTES_RECEIVED', + chartColor: '#10239e' }, { id: 6, - type: 'VOLUME' + type: 'VOLUME', + chartColor: '#722ed1' }, { id: 7, - type: 'TEMPLATE' + type: 'TEMPLATE', + chartColor: '#08979c' }, { id: 8, - type: 'ISO' + type: 'ISO', + chartColor: '#87e8de' }, { id: 9, - type: 'SNAPSHOT' + type: 'SNAPSHOT', + chartColor: '#f5222d' }, { id: 10, - type: 'SECURITY_GROUP' + type: 'SECURITY_GROUP', + chartColor: '#d46b08' }, { id: 11, - type: 'LOAD_BALANCER_POLICY' + type: 'LOAD_BALANCER_POLICY', + chartColor: '#ffd666' }, { id: 12, - type: 'PORT_FORWARDING_RULE' + type: 'PORT_FORWARDING_RULE', + chartColor: '#7cb305' }, { id: 13, - type: 'NETWORK_OFFERING' + type: 'NETWORK_OFFERING', + chartColor: '#ffbb96' }, { id: 14, - type: 'VPN_USERS' + type: 'VPN_USERS', + chartColor: '#95de64' }, { id: 21, - type: 'VM_DISK_IO_READ' + type: 'VM_DISK_IO_READ', + chartColor: '#ffe7ba' }, { id: 22, - type: 'VM_DISK_IO_WRITE' + type: 'VM_DISK_IO_WRITE', + chartColor: '#5b8c00' }, { id: 23, - type: 'VM_DISK_BYTES_READ' + type: 'VM_DISK_BYTES_READ', + chartColor: '#0050b3' }, { id: 24, - type: 'VM_DISK_BYTES_WRITE' + type: 'VM_DISK_BYTES_WRITE', + chartColor: '#520339' }, { id: 25, - type: 'VM_SNAPSHOT' + type: 'VM_SNAPSHOT', + chartColor: '#9e1068' }, { id: 26, - type: 'VOLUME_SECONDARY' + type: 'VOLUME_SECONDARY', + chartColor: '#061178' }, { id: 27, - type: 'VM_SNAPSHOT_ON_PRIMARY' + type: 'VM_SNAPSHOT_ON_PRIMARY', + chartColor: '#ad2102' }, { id: 28, - type: 'BACKUP' + type: 'BACKUP', + chartColor: '#00474f' }, { id: 29, - type: 'BUCKET' + type: 'BUCKET', + chartColor: '#13a8a8' }, { id: 30, - type: 'NETWORK' + type: 'NETWORK', + chartColor: '#c75314' }, { id: 31, - type: 'VPC' + type: 'VPC', + chartColor: '#018391' } ] export const getQuotaTypes = () => { return QUOTA_TYPES.sort((a, b) => a.type.localeCompare(b.type)) } + +export const getQuotaTypeByName = (type) => { + return QUOTA_TYPES.find(quotaType => quotaType.type === type) +} diff --git a/ui/src/utils/ssvmProbe.js b/ui/src/utils/ssvmProbe.js new file mode 100644 index 000000000000..55690aea8981 --- /dev/null +++ b/ui/src/utils/ssvmProbe.js @@ -0,0 +1,30 @@ +// 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. + +const SSVM_PROBE_TIMEOUT_MS = 5000 +export async function probeSsvmCert (origin) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), SSVM_PROBE_TIMEOUT_MS) + try { + await fetch(origin, { method: 'HEAD', mode: 'no-cors', signal: controller.signal }) + return true + } catch (e) { + return false + } finally { + clearTimeout(timeoutId) + } +} diff --git a/ui/src/utils/units.js b/ui/src/utils/units.js new file mode 100644 index 000000000000..712b4d8426c3 --- /dev/null +++ b/ui/src/utils/units.js @@ -0,0 +1,28 @@ +// 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. + +export const TIME_UNITS = { + millisecond: 1, + second: 1000, + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + quarter: 91 * 24 * 60 * 60 * 1000, + year: 365 * 24 * 60 * 60 * 1000 +} diff --git a/ui/src/utils/util.js b/ui/src/utils/util.js index 3c51096ac53e..94fbaa14c0af 100644 --- a/ui/src/utils/util.js +++ b/ui/src/utils/util.js @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +import dayjs from 'dayjs' import semver from 'semver' export function timeFix () { @@ -79,13 +80,13 @@ export function getParsedVersion (version) { return version } -export function toCsv ({ keys = null, data = null, columnDelimiter = ',', lineDelimiter = '\n' }) { - if (data === null || !data.length) { +export function toCsv ({ keys = null, data = null, columnDelimiter = ',', lineDelimiter = '\n', headers = null, dateFormat = undefined }) { + if (data === null || !data.length || keys === null || !keys.filter(key => key !== null && key !== '').length) { return null } let result = '' - result += keys.join(columnDelimiter) + result += (headers || keys).join(columnDelimiter) result += lineDelimiter data.forEach(item => { @@ -93,7 +94,15 @@ export function toCsv ({ keys = null, data = null, columnDelimiter = ',', lineDe if (item[key] === undefined) { item[key] = '' } - result += typeof item[key] === 'string' && item[key].includes(columnDelimiter) ? `"${item[key]}"` : item[key] + + if (typeof item[key] === 'string' && item[key].includes(columnDelimiter)) { + result += `"${item[key]}"` + } else if (dateFormat && dayjs.isDayjs(item[key])) { + result += `"${item[key].format(dateFormat)}"` + } else { + result += item[key] + } + result += columnDelimiter }) result = result.slice(0, -1) @@ -103,6 +112,20 @@ export function toCsv ({ keys = null, data = null, columnDelimiter = ',', lineDe return result } +export function downloadDataAsCsv ({ data = null, keys = null, headers = null, columnDelimiter = ',', lineDelimiter = '\n', fileName = 'data', dateFormat = undefined }) { + const dataParsed = toCsv({ keys, data, columnDelimiter, lineDelimiter, headers, dateFormat }) + if (dataParsed === null) { + return + } + + const hiddenElement = document.createElement('a') + hiddenElement.href = 'data:text/csv;charset=utf-8,' + encodeURI(dataParsed) + hiddenElement.target = '_blank' + hiddenElement.download = `${fileName}.csv` + hiddenElement.click() + hiddenElement.remove() +} + export function isValidIPv4Cidr (rule, value) { return new Promise((resolve, reject) => { if (!value) { diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index 7ddec48b85cf..8b492d507a2e 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -512,7 +512,7 @@ :placeholder="field.description" /> col.dataIndex === 'hasActivationRule') + if (index >= 0) { + this.columns.splice(index, 1) + } + } + this.loading = true if (this.$route.path.startsWith('/cniconfiguration')) { params.forcks = true @@ -1160,6 +1167,14 @@ export default { if (this.$route.path.startsWith('/tungstenfirewallpolicy/')) { params.firewallpolicyuuid = this.$route.params.id } + if (this.apiName === 'quotaSummary' && params.id) { + params.accountid = params.id + delete params.id + } + if (this.apiName === 'quotaEmailTemplateList' && params.id) { + params.templatetype = params.id + delete params.id + } } if (this.$store.getters.listAllProjects && !this.projectView) { @@ -1206,7 +1221,11 @@ export default { break } - if ('id' in this.$route.params && this.$route.params.id !== params.id) { + const idFromRouteMatchesApiParameter = this.$route.params.id === params.id || + this.apiName === 'quotaSummary' && this.$route.params.id === params.accountid || + this.apiName === 'quotaEmailTemplateList' && this.$route.params.id === params.templatetype + + if ('id' in this.$route.params && !idFromRouteMatchesApiParameter) { console.log('DEBUG - Discarding API response as its `id` does not match the uuid on the browser path') return } @@ -1248,6 +1267,16 @@ export default { }) } + if (this.apiName === 'listBackups') { + const kbossFields = ['compressionstatus', 'validationstatus'] + const hasKbossData = this.items.some(backup => kbossFields.some(field => backup[field])) + if (!hasKbossData) { + this.columns = this.columns.filter(col => !kbossFields.includes(col.dataIndex)) + this.allColumns = this.allColumns.filter(col => !kbossFields.includes(col.dataIndex)) + this.selectedColumns = this.selectedColumns.filter(key => !kbossFields.includes(key)) + } + } + for (let idx = 0; idx < this.items.length; idx++) { this.items[idx].key = idx for (const key in customRender) { @@ -1534,6 +1563,10 @@ export default { params.isofilter = 'executable' } else if (possibleApi === 'listHosts') { params.type = 'routing' + if (this.currentAction?.api === 'restoreBackup') { + params.resourcestate = 'enabled' + params.state = 'up' + } } else if (possibleApi === 'listNetworkOfferings' && this.resource) { if (this.resource.type) { params.guestiptype = this.resource.type diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index 202ae20f16fd..9dcc8b4f34c2 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -27,6 +27,7 @@ > + + + + + + + +
      + Enter your domain to see domain-specific providers +
      +
      + + + +
      +
      + +
      +
      + No OAuth providers configured for this domain + Enter your domain to see available providers +
      +
      - + -
      -

      or

      -
      -
      - - - -
      @@ -251,6 +284,18 @@ export default { githubclientid: '', keycloakclientid: '', keycloakauthorizeurl: '', + oauthGoogleProvider: false, + oauthGithubProvider: false, + oauthKeycloakProvider: false, + oauthGoogleClientId: '', + oauthGithubClientId: '', + oauthKeycloakClientId: '', + oauthGoogleRedirectUri: '', + oauthGithubRedirectUri: '', + oauthKeycloakRedirectUri: '', + oauthKeycloakAuthorizeUrl: '', + oauthLoading: false, + oauthDomainQueried: false, loginType: 0, state: { time: 60, @@ -285,6 +330,7 @@ export default { server: (this.server.apiHost || '') + this.server.apiBase, username: this.$route.query?.username || '', domain: this.$route.query?.domain || '', + oauthDomain: '', project: null }) this.rules = reactive({}) @@ -327,30 +373,7 @@ export default { this.form.idp = this.idps[0].id || '' } }) - getAPI('listOauthProvider', {}).then(response => { - if (response) { - const oauthproviders = response.listoauthproviderresponse.oauthprovider || [] - oauthproviders.forEach(item => { - if (item.provider === 'google') { - this.googleprovider = item.enabled - this.googleclientid = item.clientid - this.googleredirecturi = item.redirecturi - } - if (item.provider === 'github') { - this.githubprovider = item.enabled - this.githubclientid = item.clientid - this.githubredirecturi = item.redirecturi - } - if (item.provider === 'keycloak') { - this.keycloakprovider = item.enabled - this.keycloakclientid = item.clientid - this.keycloakredirecturi = item.redirecturi - this.keycloakauthorizeurl = item.authorizeurl - } - }) - this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider - } - }) + this.fetchOauthProviders() postAPI('forgotPassword', {}).then(response => { this.forgotPasswordEnabled = response.forgotpasswordresponse.enabled }).catch((err) => { @@ -361,6 +384,74 @@ export default { } }) }, + fetchOauthProviders (domain) { + const params = {} + if (domain) { + params.domain = domain + this.oauthLoading = true + } + getAPI('listOauthProvider', params).then(response => { + if (response) { + const oauthproviders = response.listoauthproviderresponse.oauthprovider || [] + if (!domain) { + oauthproviders.forEach(item => { + if (item.provider === 'google') { + this.googleprovider = item.enabled + this.googleclientid = item.clientid + this.googleredirecturi = item.redirecturi + } + if (item.provider === 'github') { + this.githubprovider = item.enabled + this.githubclientid = item.clientid + this.githubredirecturi = item.redirecturi + } + if (item.provider === 'keycloak') { + this.keycloakprovider = item.enabled + this.keycloakclientid = item.clientid + this.keycloakredirecturi = item.redirecturi + this.keycloakauthorizeurl = item.authorizeurl + } + }) + const totalCount = response.listoauthproviderresponse.count || 0 + this.socialLogin = totalCount > 0 + this.oauthGithubProvider = this.githubprovider + this.oauthGoogleProvider = this.googleprovider + this.oauthKeycloakProvider = this.keycloakprovider + this.oauthGithubClientId = this.githubclientid + this.oauthGoogleClientId = this.googleclientid + this.oauthKeycloakClientId = this.keycloakclientid + this.oauthGithubRedirectUri = this.githubredirecturi + this.oauthGoogleRedirectUri = this.googleredirecturi + this.oauthKeycloakRedirectUri = this.keycloakredirecturi + this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + } else { + this.oauthGithubProvider = false + this.oauthGoogleProvider = false + this.oauthKeycloakProvider = false + oauthproviders.forEach(item => { + if (item.provider === 'google') { + this.oauthGoogleProvider = item.enabled + this.oauthGoogleClientId = item.clientid + this.oauthGoogleRedirectUri = item.redirecturi + } + if (item.provider === 'github') { + this.oauthGithubProvider = item.enabled + this.oauthGithubClientId = item.clientid + this.oauthGithubRedirectUri = item.redirecturi + } + if (item.provider === 'keycloak') { + this.oauthKeycloakProvider = item.enabled + this.oauthKeycloakClientId = item.clientid + this.oauthKeycloakRedirectUri = item.redirecturi + this.oauthKeycloakAuthorizeUrl = item.authorizeurl + } + }) + } + } + }).finally(() => { + this.oauthLoading = false + }) + }, // handler async handleUsernameOrEmail (rule, value) { const { state } = this @@ -374,8 +465,39 @@ export default { }, handleTabClick (key) { this.customActiveKey = key + if (key === 'oauth') { + this.oauthGithubProvider = this.githubprovider + this.oauthGoogleProvider = this.googleprovider + this.oauthKeycloakProvider = this.keycloakprovider + this.oauthGithubClientId = this.githubclientid + this.oauthGoogleClientId = this.googleclientid + this.oauthKeycloakClientId = this.keycloakclientid + this.oauthGithubRedirectUri = this.githubredirecturi + this.oauthGoogleRedirectUri = this.googleredirecturi + this.oauthKeycloakRedirectUri = this.keycloakredirecturi + this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + } this.setRules() }, + handleOauthDomainSubmit () { + const domain = this.form.oauthDomain + if (domain) { + this.oauthDomainQueried = true + this.fetchOauthProviders(domain) + } else { + this.oauthDomainQueried = false + this.oauthGithubProvider = this.githubprovider + this.oauthGoogleProvider = this.googleprovider + this.oauthKeycloakProvider = this.keycloakprovider + this.oauthGithubClientId = this.githubclientid + this.oauthGoogleClientId = this.googleclientid + this.oauthKeycloakClientId = this.keycloakclientid + this.oauthGithubRedirectUri = this.githubredirecturi + this.oauthGoogleRedirectUri = this.googleredirecturi + this.oauthKeycloakRedirectUri = this.keycloakredirecturi + this.oauthKeycloakAuthorizeUrl = this.keycloakauthorizeurl + } + }, handleGithubProviderAndDomain () { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'github') @@ -390,8 +512,8 @@ export default { }, handleDomain () { const values = toRaw(this.form) - const domain = this.getLoginDomain(values.domain) - this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', domain) + const domain = this.customActiveKey === 'oauth' ? values.oauthDomain : values.domain + this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', this.getLoginDomain(domain)) }, getLoginDomain (domain) { if (this.$config.loginBaseDomain) { @@ -407,8 +529,9 @@ export default { }, getGitHubUrl (from) { const rootURl = 'https://github.com/login/oauth/authorize' + const clientId = this.customActiveKey === 'oauth' ? this.oauthGithubClientId : this.githubclientid const options = { - client_id: this.githubclientid, + client_id: clientId, scope: 'user:email', state: 'cloudstack' } @@ -419,9 +542,11 @@ export default { }, getGoogleUrl (from) { const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth' + const redirectUri = this.customActiveKey === 'oauth' ? this.oauthGoogleRedirectUri : this.googleredirecturi + const clientId = this.customActiveKey === 'oauth' ? this.oauthGoogleClientId : this.googleclientid const options = { - redirect_uri: this.googleredirecturi, - client_id: this.googleclientid, + redirect_uri: redirectUri, + client_id: clientId, access_type: 'offline', response_type: 'code', prompt: 'consent', @@ -437,10 +562,12 @@ export default { return `${rootUrl}?${qs.toString()}` }, getKeycloakUrl (from) { - const rootURl = this.keycloakauthorizeurl + const rootURl = this.customActiveKey === 'oauth' ? this.oauthKeycloakAuthorizeUrl : this.keycloakauthorizeurl + const redirectUri = this.customActiveKey === 'oauth' ? this.oauthKeycloakRedirectUri : this.keycloakredirecturi + const clientId = this.customActiveKey === 'oauth' ? this.oauthKeycloakClientId : this.keycloakclientid const options = { - redirect_uri: this.keycloakredirecturi, - client_id: this.keycloakclientid, + redirect_uri: redirectUri, + client_id: clientId, response_type: 'code', scope: 'openid email', state: 'cloudstack' @@ -562,12 +689,21 @@ export default { diff --git a/ui/src/views/image/RegisterOrUploadIso.vue b/ui/src/views/image/RegisterOrUploadIso.vue index e8ce5add61b8..eb19379dc8e2 100644 --- a/ui/src/views/image/RegisterOrUploadIso.vue +++ b/ui/src/views/image/RegisterOrUploadIso.vue @@ -19,11 +19,27 @@
      - + {{ $t('message.upload.file.processing') }} +
      + +
      + {{ $t('label.cancel') }} + + {{ $t('label.ssvm.open.cert.page') }} + + + {{ $t('label.retry.upload') }} + +
      +
      1) { @@ -502,6 +533,7 @@ export default { fileList.forEach(file => { formData.append('files[]', file) }) + this.uploading = true this.uploadPercentage = 0 axios.post(this.uploadParams.postURL, formData, @@ -529,6 +561,8 @@ export default { description: `${this.$t('message.upload.iso.failed.description')} - ${e}`, duration: 0 }) + }).finally(() => { + this.uploading = false }) }, handleSubmit (e) { @@ -583,18 +617,18 @@ export default { } params.format = 'ISO' this.loading = true - getAPI('getUploadParamsForIso', params).then(json => { + getAPI('getUploadParamsForIso', params).then(async json => { this.uploadParams = (json.postuploadisoresponse && json.postuploadisoresponse.getuploadparams) ? json.postuploadisoresponse.getuploadparams : '' - const response = this.handleUpload() if (this.userdataid !== null) { this.linkUserdataToTemplate(this.userdataid, json.postuploadisoresponse.iso[0].id) } - if (response === 'upload successful') { - this.$notification.success({ - message: this.$t('message.success.upload'), - description: this.$t('message.success.upload.iso.description') - }) + this.ssvmOrigin = new URL(this.uploadParams.postURL).origin + const trusted = await probeSsvmCert(this.ssvmOrigin) + if (!trusted) { + this.ssvmCertUntrusted = true + return } + this.handleUpload() }).catch(error => { this.$notifyError(error) }).finally(() => { diff --git a/ui/src/views/image/RegisterOrUploadTemplate.vue b/ui/src/views/image/RegisterOrUploadTemplate.vue index 00b060727939..8a3626a70b1e 100644 --- a/ui/src/views/image/RegisterOrUploadTemplate.vue +++ b/ui/src/views/image/RegisterOrUploadTemplate.vue @@ -19,11 +19,27 @@
      - + {{ $t('message.upload.file.processing') }} +
      + +
      + {{ $t('label.cancel') }} + + {{ $t('label.ssvm.open.cert.page') }} + + + {{ $t('label.retry.upload') }} + +
      +
      { formData.append('files[]', file) }) + this.uploading = true this.uploadPercentage = 0 axios.post(this.uploadParams.postURL, formData, @@ -678,6 +709,8 @@ export default { this.closeAction() }).catch(e => { this.$notifyError(e) + }).finally(() => { + this.uploading = false }) }, fetchCustomHypervisorName () { @@ -706,6 +739,7 @@ export default { } this.loading = true getAPI('listExtensions', { + type: 'Orchestrator' }).then(response => { this.extensionsList = response.listextensionsresponse.extension || [] }).catch(error => { @@ -1175,12 +1209,18 @@ export default { duration: 0 }) } - getAPI('getUploadParamsForTemplate', params).then(json => { + getAPI('getUploadParamsForTemplate', params).then(async json => { this.uploadParams = (json.postuploadtemplateresponse && json.postuploadtemplateresponse.getuploadparams) ? json.postuploadtemplateresponse.getuploadparams : '' - this.handleUpload() if (this.userdataid !== null) { this.linkUserdataToTemplate(this.userdataid, json.postuploadtemplateresponse.template[0].id) } + this.ssvmOrigin = new URL(this.uploadParams.postURL).origin + const trusted = await probeSsvmCert(this.ssvmOrigin) + if (!trusted) { + this.ssvmCertUntrusted = true + return + } + this.handleUpload() }).catch(error => { this.$notifyError(error) }).finally(() => { diff --git a/ui/src/views/infra/AddPrimaryStorage.vue b/ui/src/views/infra/AddPrimaryStorage.vue index 48dd363e0df0..7d189032f098 100644 --- a/ui/src/views/infra/AddPrimaryStorage.vue +++ b/ui/src/views/infra/AddPrimaryStorage.vue @@ -242,7 +242,7 @@
      -
      +
      @@ -431,6 +431,26 @@ export default { this.zoneLoading = false }) }, + isVpcCoreProvider (providerName, serviceName) { + if (serviceName === 'Firewall') { + return ['VpcVirtualRouter'].includes(providerName) + } + if (['VpcVirtualRouter', 'Netscaler', 'BigSwitchBcf', 'ConfigDrive'].includes(providerName)) { + return true + } + return serviceName === 'Connectivity' && ['NiciraNvp', 'Ovs', 'JuniperContrailVpcRouter'].includes(providerName) + }, + isBuiltInNetworkProvider (providerName) { + const builtInProviders = [ + 'VirtualRouter', 'JuniperContrailRouter', 'JuniperContrailVpcRouter', 'JuniperSRX', 'PaloAlto', + 'F5BigIp', 'Netscaler', 'ExternalDhcpServer', 'ExternalGateWay', 'ElasticLoadBalancerVm', + 'SecurityGroupProvider', 'VpcVirtualRouter', 'None', 'NiciraNvp', 'InternalLbVm', 'CiscoVnmc', + 'Ovs', 'Opendaylight', 'BrocadeVcs', 'GloboDns', 'BigSwitchBcf', 'ConfigDrive', 'Tungsten', + 'Nsx', 'Netris', 'BaremetalDhcpProvider', 'BaremetalPxeProvider', 'BaremetalUserdataProvider', + 'StratosphereSsp' + ] + return builtInProviders.includes(providerName) + }, fetchSupportedServiceData () { var services = [] if (this.provider === 'NSX') { @@ -520,82 +540,54 @@ export default { provider: [{ name: 'VpcVirtualRouter' }] }) } else { - services.push({ - name: 'Dhcp', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'ConfigDrive' } - ] - }) - services.push({ - name: 'Dns', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'ConfigDrive' } - ] - }) - services.push({ - name: 'Lb', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'InternalLbVm' } - ] - }) - services.push({ - name: 'Gateway', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'BigSwitchBcf' } - ] - }) - services.push({ - name: 'StaticNat', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'BigSwitchBcf' } - ] - }) - services.push({ - name: 'SourceNat', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'BigSwitchBcf' } - ] - }) - services.push({ - name: 'NetworkACL', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'BigSwitchBcf' } - ] - }) - services.push({ - name: 'PortForwarding', - provider: [{ name: 'VpcVirtualRouter' }] - }) - services.push({ - name: 'UserData', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'ConfigDrive' } - ] - }) - services.push({ - name: 'Vpn', - provider: [ - { name: 'VpcVirtualRouter' }, - { name: 'BigSwitchBcf' } - ] - }) - services.push({ - name: 'Connectivity', - provider: [ - { name: 'BigSwitchBcf' }, - { name: 'NiciraNvp' }, - { name: 'Ovs' }, - { name: 'JuniperContrailVpcRouter' } - ] + this.supportedServices = [] + this.supportedServiceLoading = true + getAPI('listSupportedNetworkServices').then(json => { + const vpcServices = ['Dhcp', 'Dns', 'Lb', 'Gateway', 'StaticNat', 'SourceNat', 'NetworkACL', 'PortForwarding', 'UserData', 'Vpn', 'Connectivity', 'CustomAction', 'Firewall'] + services = (json?.listsupportednetworkservicesresponse?.networkservice || []) + .filter(service => vpcServices.includes(service.name)) + .map(service => { + const providerMap = {} + const providers = [...(service.provider || []), ...(service.name === 'Lb' ? [{ name: 'InternalLbVm' }] : [])] + .map(provider => { + const providerName = provider.name === 'VirtualRouter' ? 'VpcVirtualRouter' : provider.name + const isExtension = !this.isBuiltInNetworkProvider(providerName) + const enabled = providerName === 'InternalLbVm' + ? service.name === 'Lb' + : this.isVpcCoreProvider(providerName, service.name) || isExtension + return { + name: providerName, + description: providerName, + displaytext: isExtension ? `${providerName} (${this.$t('label.extension')})` : providerName, + enabled + } + }) + .filter(provider => { + if (providerMap[provider.name]) { + return false + } + providerMap[provider.name] = true + return true + }) + return { + ...service, + description: service.name, + provider: providers + } + }) + + this.supportedServices = [] + if (this.networkmode === 'ROUTED') { + services = services.filter(service => !['SourceNat', 'StaticNat', 'Lb', 'PortForwarding', 'Vpn', 'Firewall'].includes(service.name)) + } + this.supportedServices = services + }).catch(error => { + this.supportedServices = [] + this.$notifyError(error) + }).finally(() => { + this.supportedServiceLoading = false }) + return } this.supportedServices = [] if (this.networkmode === 'ROUTED') { @@ -647,7 +639,7 @@ export default { if (service === 'SourceNat') { this.sourceNatServiceChecked = checked } - if (checked && provider != null & provider !== undefined) { + if (checked && provider != null && provider !== undefined) { this.selectedServiceProviderMap[service] = provider } else { delete this.selectedServiceProviderMap[service] diff --git a/ui/src/views/offering/CloneVpcOffering.vue b/ui/src/views/offering/CloneVpcOffering.vue index cecc0c600b57..6adbfafad611 100644 --- a/ui/src/views/offering/CloneVpcOffering.vue +++ b/ui/src/views/offering/CloneVpcOffering.vue @@ -523,6 +523,7 @@ export default { return [ { name: 'Dhcp', provider: [{ name: 'VpcVirtualRouter' }, { name: 'ConfigDrive' }] }, { name: 'Dns', provider: [{ name: 'VpcVirtualRouter' }, { name: 'ConfigDrive' }] }, + { name: 'Firewall', provider: [{ name: 'VpcVirtualRouter' }] }, { name: 'Lb', provider: [{ name: 'VpcVirtualRouter' }, { name: 'InternalLbVm' }] }, { name: 'Gateway', provider: [{ name: 'VpcVirtualRouter' }, { name: 'BigSwitchBcf' }] }, { name: 'StaticNat', provider: [{ name: 'VpcVirtualRouter' }, { name: 'BigSwitchBcf' }] }, diff --git a/ui/src/views/offering/CreateBackupOffering.vue b/ui/src/views/offering/CreateBackupOffering.vue new file mode 100644 index 000000000000..c9e22949f061 --- /dev/null +++ b/ui/src/views/offering/CreateBackupOffering.vue @@ -0,0 +1,297 @@ +// 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. + + + + + + diff --git a/ui/src/views/plugins/quota/AddQuotaCredit.vue b/ui/src/views/plugins/quota/AddQuotaCredit.vue new file mode 100644 index 000000000000..51a2d84bb803 --- /dev/null +++ b/ui/src/views/plugins/quota/AddQuotaCredit.vue @@ -0,0 +1,163 @@ +// 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. + + + + + + diff --git a/ui/src/views/plugins/quota/CreateQuotaTariff.vue b/ui/src/views/plugins/quota/CreateQuotaTariff.vue index 18086aca2af0..3262c61b7351 100644 --- a/ui/src/views/plugins/quota/CreateQuotaTariff.vue +++ b/ui/src/views/plugins/quota/CreateQuotaTariff.vue @@ -62,7 +62,6 @@ @@ -85,7 +84,6 @@ @@ -94,7 +92,6 @@ @@ -61,7 +60,6 @@ @@ -70,7 +68,6 @@ - - - - - - - - - -
      - {{ $t('label.cancel') }} - {{ $t('label.ok') }} -
      -
      -
      - - - - - diff --git a/ui/src/views/plugins/quota/EmailTemplateDetails.vue b/ui/src/views/plugins/quota/EmailTemplateDetails.vue index 0644fa55b69d..a048cdd7188d 100644 --- a/ui/src/views/plugins/quota/EmailTemplateDetails.vue +++ b/ui/src/views/plugins/quota/EmailTemplateDetails.vue @@ -63,9 +63,14 @@ import { postAPI } from '@/api' export default { name: 'EmailTemplateDetails', + props: { + resource: { + type: Object, + required: true + } + }, data () { return { - resource: {}, formModel: { templatesubject: null, templatebody: null @@ -74,29 +79,10 @@ export default { } }, created () { - this.fetchData() + this.formModel.templatesubject = this.resource.templatesubject || null + this.formModel.templatebody = this.resource.templatebody || null }, methods: { - fetchData () { - this.loading = true - const params = {} - params.templatetype = this.$route.params.id - - postAPI('quotaEmailTemplateList', params).then(json => { - const listTemplates = json.quotaemailtemplatelistresponse.quotaemailtemplate || [] - this.resource = listTemplates && listTemplates.length > 0 ? listTemplates[0] : {} - this.preFillDataValues() - }).catch(e => { - this.$notifyError(e) - }).finally(() => { - this.loading = false - }) - }, - preFillDataValues () { - console.log(this.resource) - this.formModel.templatesubject = this.resource.templatesubject || null - this.formModel.templatebody = this.resource.templatebody || null - }, handleSubmit () { if (this.loading) return const params = {} diff --git a/ui/src/views/plugins/quota/FilterQuotaDataByPeriodView.vue b/ui/src/views/plugins/quota/FilterQuotaDataByPeriodView.vue new file mode 100644 index 000000000000..3e4839b21aa2 --- /dev/null +++ b/ui/src/views/plugins/quota/FilterQuotaDataByPeriodView.vue @@ -0,0 +1,161 @@ +// 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. + + + + + + diff --git a/ui/src/views/plugins/quota/QuotaBalance.vue b/ui/src/views/plugins/quota/QuotaBalance.vue deleted file mode 100644 index f1cf640f6aaa..000000000000 --- a/ui/src/views/plugins/quota/QuotaBalance.vue +++ /dev/null @@ -1,173 +0,0 @@ -// 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. - - - - diff --git a/ui/src/views/plugins/quota/QuotaBalanceTab.vue b/ui/src/views/plugins/quota/QuotaBalanceTab.vue new file mode 100644 index 000000000000..46c2e03d8fe9 --- /dev/null +++ b/ui/src/views/plugins/quota/QuotaBalanceTab.vue @@ -0,0 +1,204 @@ +// 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. + + + + + + diff --git a/ui/src/views/plugins/quota/QuotaCreditTab.vue b/ui/src/views/plugins/quota/QuotaCreditTab.vue new file mode 100644 index 000000000000..3ae32a02d526 --- /dev/null +++ b/ui/src/views/plugins/quota/QuotaCreditTab.vue @@ -0,0 +1,230 @@ +// 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. + + + + + + diff --git a/ui/src/views/plugins/quota/QuotaSummary.vue b/ui/src/views/plugins/quota/QuotaSummary.vue deleted file mode 100644 index 5bfa72a8abae..000000000000 --- a/ui/src/views/plugins/quota/QuotaSummary.vue +++ /dev/null @@ -1,65 +0,0 @@ -// 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. - - - - diff --git a/ui/src/views/plugins/quota/QuotaSummaryResource.vue b/ui/src/views/plugins/quota/QuotaSummaryResource.vue deleted file mode 100644 index c6acf30676fc..000000000000 --- a/ui/src/views/plugins/quota/QuotaSummaryResource.vue +++ /dev/null @@ -1,98 +0,0 @@ -// 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. - - - - - - diff --git a/ui/src/views/plugins/quota/QuotaUsage.vue b/ui/src/views/plugins/quota/QuotaUsage.vue deleted file mode 100644 index 6c55a728bd1c..000000000000 --- a/ui/src/views/plugins/quota/QuotaUsage.vue +++ /dev/null @@ -1,158 +0,0 @@ -// 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. - - - - diff --git a/ui/src/views/plugins/quota/QuotaUsageTab.vue b/ui/src/views/plugins/quota/QuotaUsageTab.vue new file mode 100644 index 000000000000..cefc169c8d4a --- /dev/null +++ b/ui/src/views/plugins/quota/QuotaUsageTab.vue @@ -0,0 +1,731 @@ +// 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. + + + + + + diff --git a/ui/src/views/setting/ConfigurationValue.vue b/ui/src/views/setting/ConfigurationValue.vue index 5a6f0b025cac..0979c3c03cbe 100644 --- a/ui/src/views/setting/ConfigurationValue.vue +++ b/ui/src/views/setting/ConfigurationValue.vue @@ -295,6 +295,9 @@ export default { params[this.scopeKey] = this.resource?.id } postAPI('updateConfiguration', params).then(json => { + // The updateConfiguration response can return a stale value for + // non-global scopes (server-side issue). Trust the value the user + // just submitted — the API returned success, so we know it was persisted. const apiRecord = json.updateconfigurationresponse.configuration configRecordEntry = { ...apiRecord, value: String(newValue) } this.editableValue = this.getEditableValue(configRecordEntry) diff --git a/ui/src/views/storage/CreateVMFromBackup.vue b/ui/src/views/storage/CreateVMFromBackup.vue index 891e8fe96420..50fee4ea5429 100644 --- a/ui/src/views/storage/CreateVMFromBackup.vue +++ b/ui/src/views/storage/CreateVMFromBackup.vue @@ -35,6 +35,12 @@ + + + +