mirror of
https://github.com/Portabase/agent.git
synced 2026-10-15 09:01:44 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92817beea3 | |||
| 0aeb88f645 | |||
| df34765e0f | |||
| a1a84bb76d | |||
| 7e9b276685 | |||
| c4f4cd86cd | |||
| 1e4a07964a | |||
| d174d86d2d | |||
| bbca053a49 | |||
| 9502f1a032 | |||
| d592710821 | |||
| b30f5964be | |||
| 496614b067 | |||
| 84f223c5be | |||
| e1e649791e | |||
| 82c9b0aee0 | |||
| 33b298a4e6 |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
|
||||||
|
<circle cx="75" cy="75" r="70" fill="none" stroke="#9ca3af" stroke-width="4" stroke-dasharray="10 8"/>
|
||||||
|
<path d="M75 45v60M45 75h60" stroke="#9ca3af" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 290 B |
+1
-1
@@ -27,5 +27,5 @@ keywords:
|
|||||||
- self-hosted
|
- self-hosted
|
||||||
- portabase
|
- portabase
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.21.2
|
version: 1.22.0
|
||||||
date-released: '2026-02-24'
|
date-released: '2026-02-24'
|
||||||
|
|||||||
Generated
+2
-1
@@ -3642,7 +3642,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portabase-agent"
|
name = "portabase-agent"
|
||||||
version = "1.21.2"
|
version = "1.22.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
@@ -3679,6 +3679,7 @@ dependencies = [
|
|||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"redis",
|
"redis",
|
||||||
"reqwest 0.13.5",
|
"reqwest 0.13.5",
|
||||||
|
"rustls 0.23.44",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "portabase-agent"
|
name = "portabase-agent"
|
||||||
version = "1.21.2"
|
version = "1.22.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
redis = { version = "1.0.2", features = ["aio", "tokio-comp"] }
|
redis = { version = "1.0.2", features = ["aio", "tokio-comp"] }
|
||||||
@@ -60,6 +61,7 @@ postgres = "0.19.12"
|
|||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
bollard = "0.20.0"
|
bollard = "0.20.0"
|
||||||
|
rustls = "0.23"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2024 Portabase
|
||||||
|
|
||||||
|
Licensed 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.
|
||||||
@@ -63,7 +63,65 @@ You have 4 ways to install Portabase Agent:
|
|||||||
|
|
||||||
[](https://github.com/Portabase/agent-rust/graphs/contributors)
|
[](https://github.com/Portabase/agent-rust/graphs/contributors)
|
||||||
|
|
||||||
[](https://www.buymeacoffee.com/portabase)
|
## Become a Sponsor
|
||||||
|
|
||||||
|
🙏 Thank you to the incredible sponsors for supporting this project! Your contributions help keep Portabase running and growing. If you'd like to join and become a sponsor, please visit the [sponsorship page](https://github.com/sponsors/Portabase) and be part of something great! 🚀
|
||||||
|
|
||||||
|
### 💎 Platinum Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/sponsors/Portabase">
|
||||||
|
<img src="/.github/assets/plus-circle.svg" height="180" alt="Become a platinum sponsor"/>
|
||||||
|
<br />
|
||||||
|
Become a platinum sponsor
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🥇 Gold Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/sponsors/Portabase">
|
||||||
|
<img src="/.github/assets/plus-circle.svg" height="150" alt="Become a gold sponsor"/>
|
||||||
|
<br />
|
||||||
|
Become a gold sponsor
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🥈 Silver Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/sponsors/Portabase">
|
||||||
|
<img src="/.github/assets/plus-circle.svg" height="100" alt="Become a silver sponsor"/>
|
||||||
|
<br />
|
||||||
|
Become a silver sponsor
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### 🤝 Community Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/sponsors/Portabase">
|
||||||
|
<img src="/.github/assets/plus-circle.svg" height="80" alt="Become a community sponsor"/>
|
||||||
|
<br />
|
||||||
|
Become a community sponsor
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -12,16 +12,16 @@ services:
|
|||||||
- ./databases.json:/config/config.json
|
- ./databases.json:/config/config.json
|
||||||
#- ./databases.toml:/config/config.toml
|
#- ./databases.toml:/config/config.toml
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# - cargo-target:/app/target
|
# - cargo-target:/app/target
|
||||||
- databases_sqlite-data:/sqlite-data/workspace/data
|
- databases_sqlite-data:/sqlite-data/workspace/data
|
||||||
- ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
|
- ./scripts/sqlite/test-db:/sqlite-data-2/workspace/data
|
||||||
# - /bigdisk:/scratch
|
# - /bigdisk:/scratch
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: development
|
APP_ENV: development
|
||||||
LOG: debug
|
LOG: debug
|
||||||
TZ: "Europe/Paris"
|
TZ: "Europe/Paris"
|
||||||
# TMPDIR: /scratch
|
# TMPDIR: /scratch
|
||||||
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiYTQyNjQzNTQtZGE3Ni00OWFkLWJkYjctZDVjMjMwYzhjYmViIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
EDGE_KEY: "eyJzZXJ2ZXJVcmwiOiJodHRwOi8vbG9jYWxob3N0Ojg4ODciLCJhZ2VudElkIjoiMjcyMWFiNGQtZDkzNi00NmFkLWE1MWEtOWVhYTk3ZjJhMjAwIiwibWFzdGVyS2V5QjY0IjoiQlhWM1hvbEM2NTZTVjdkTmdjV1BHUWxrKytycExJNmxHRGk3Q1BCNWllbz0ifQ=="
|
||||||
#CHUNK_SIZE_MB: "1"
|
#CHUNK_SIZE_MB: "1"
|
||||||
#POOLING: 1
|
#POOLING: 1
|
||||||
#RETRY_ATTEMPTS: 3
|
#RETRY_ATTEMPTS: 3
|
||||||
@@ -40,7 +40,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
cargo-registry:
|
cargo-registry:
|
||||||
cargo-git:
|
cargo-git:
|
||||||
# cargo-target:
|
# cargo-target:
|
||||||
databases_sqlite-data:
|
databases_sqlite-data:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::format::MssqlFormat;
|
||||||
use crate::services::backup::logger::JobLogger;
|
use crate::services::backup::logger::JobLogger;
|
||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::DatabaseConfig;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -13,7 +14,12 @@ pub async fn run(
|
|||||||
logger: Arc<JobLogger>,
|
logger: Arc<JobLogger>,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
tokio::task::spawn_blocking(move || -> Result<PathBuf> {
|
||||||
logger.log("info", format!("Starting MSSQL backup for database {}", cfg.name));
|
let (format, unknown_format) = MssqlFormat::from_config(&cfg);
|
||||||
|
if let Some(v) = unknown_format {
|
||||||
|
logger.log("warn", format!("Unknown backup_format '{}' for {}, falling back to 'dacpac'", v, cfg.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log("info", format!("Starting MSSQL {} backup for database {}", &format.extension()[1..], cfg.name));
|
||||||
|
|
||||||
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
|
let file_path = backup_dir.join(format!("{}{}", cfg.generated_id, file_extension));
|
||||||
let connection_string = format!(
|
let connection_string = format!(
|
||||||
@@ -24,8 +30,18 @@ pub async fn run(
|
|||||||
logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display()));
|
logger.log("info", format!("MSSQL backup: {}:{}/{} → {}", cfg.host, cfg.port, cfg.database, file_path.display()));
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let output = Command::new("sqlpackage")
|
let mut command = Command::new("sqlpackage");
|
||||||
.arg("/a:Export")
|
match format {
|
||||||
|
MssqlFormat::Dacpac => command.args([
|
||||||
|
"/a:Extract",
|
||||||
|
"/p:ExtractAllTableData=True",
|
||||||
|
"/p:ExtractReferencedServerScopedElements=False",
|
||||||
|
"/p:IgnorePermissions=False",
|
||||||
|
"/p:IgnoreUserLoginMappings=False",
|
||||||
|
]),
|
||||||
|
MssqlFormat::Bacpac => command.arg("/a:Export"),
|
||||||
|
};
|
||||||
|
let output = command
|
||||||
.arg(format!("/scs:{}", connection_string))
|
.arg(format!("/scs:{}", connection_string))
|
||||||
.arg(format!("/tf:{}", file_path.display()))
|
.arg(format!("/tf:{}", file_path.display()))
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::DatabaseConfig;
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use tiberius::{AuthMethod, Client, Config};
|
use tiberius::{AuthMethod, Client, Config};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||||
|
|
||||||
pub async fn build_client(cfg: &DatabaseConfig) -> Result<Client<Compat<TcpStream>>> {
|
const DROP_DATABASE_SQL: &str = "DECLARE @q nvarchar(258) = QUOTENAME(@P1);
|
||||||
|
IF DB_ID(@P1) IS NOT NULL
|
||||||
|
BEGIN
|
||||||
|
IF DB_ID(@P1) <= 4
|
||||||
|
THROW 50000, N'Refusing to restore over a system database; nothing was dropped', 1;
|
||||||
|
IF ISNULL(HAS_PERMS_BY_NAME(NULL, 'DATABASE', 'CREATE DATABASE'), 0) = 0
|
||||||
|
THROW 50000, N'Restore needs CREATE DATABASE permission (dbcreator) to recreate the target database; nothing was dropped', 1;
|
||||||
|
DECLARE @single bit = 0;
|
||||||
|
IF CAST(SERVERPROPERTY('EngineEdition') AS int) <> 5
|
||||||
|
BEGIN
|
||||||
|
EXEC(N'ALTER DATABASE ' + @q + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE');
|
||||||
|
SET @single = 1;
|
||||||
|
END
|
||||||
|
BEGIN TRY
|
||||||
|
EXEC(N'DROP DATABASE ' + @q);
|
||||||
|
END TRY
|
||||||
|
BEGIN CATCH
|
||||||
|
IF @single = 1 AND DB_ID(@P1) IS NOT NULL
|
||||||
|
EXEC(N'ALTER DATABASE ' + @q + N' SET MULTI_USER');
|
||||||
|
THROW;
|
||||||
|
END CATCH
|
||||||
|
END";
|
||||||
|
|
||||||
|
pub async fn build_client(cfg: &DatabaseConfig, database: Option<&str>) -> Result<Client<Compat<TcpStream>>> {
|
||||||
let mut config = Config::new();
|
let mut config = Config::new();
|
||||||
config.host(&cfg.host);
|
config.host(&cfg.host);
|
||||||
config.port(cfg.port);
|
config.port(cfg.port);
|
||||||
|
if let Some(database) = database {
|
||||||
|
config.database(database);
|
||||||
|
}
|
||||||
config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password));
|
config.authentication(AuthMethod::sql_server(&cfg.username, &cfg.password));
|
||||||
config.trust_cert();
|
config.trust_cert();
|
||||||
|
|
||||||
@@ -16,3 +42,14 @@ pub async fn build_client(cfg: &DatabaseConfig) -> Result<Client<Compat<TcpStrea
|
|||||||
let client = Client::connect(config, tcp.compat_write()).await?;
|
let client = Client::connect(config, tcp.compat_write()).await?;
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn drop_database(cfg: &DatabaseConfig) -> Result<()> {
|
||||||
|
let mut client = build_client(cfg, Some("master"))
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to connect to master for {}", cfg.name))?;
|
||||||
|
client
|
||||||
|
.execute(DROP_DATABASE_SQL, &[&cfg.database.as_str()])
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Failed to drop MSSQL database {}", cfg.database))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::format::MssqlFormat;
|
||||||
use super::{backup, ping, restore};
|
use super::{backup, ping, restore};
|
||||||
use crate::domain::factory::Database;
|
use crate::domain::factory::Database;
|
||||||
use crate::services::backup::logger::JobLogger;
|
use crate::services::backup::logger::JobLogger;
|
||||||
@@ -21,7 +22,7 @@ impl MssqlDatabase {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Database for MssqlDatabase {
|
impl Database for MssqlDatabase {
|
||||||
fn file_extension(&self) -> &'static str {
|
fn file_extension(&self) -> &'static str {
|
||||||
".bacpac"
|
MssqlFormat::from_config(&self.cfg).0.extension()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ping(&self) -> Result<bool> {
|
async fn ping(&self) -> Result<bool> {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use crate::services::config::DatabaseConfig;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MssqlFormat {
|
||||||
|
Dacpac,
|
||||||
|
Bacpac,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MssqlFormat {
|
||||||
|
pub fn from_config(cfg: &DatabaseConfig) -> (Self, Option<String>) {
|
||||||
|
match cfg.options.get("backup_format").and_then(|v| v.as_str()) {
|
||||||
|
None | Some("dacpac") => (Self::Dacpac, None),
|
||||||
|
Some("bacpac") => (Self::Bacpac, None),
|
||||||
|
Some(other) => (Self::Dacpac, Some(other.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_path(path: &Path) -> Option<Self> {
|
||||||
|
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
|
||||||
|
"dacpac" => Some(Self::Dacpac),
|
||||||
|
"bacpac" => Some(Self::Bacpac),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extension(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Dacpac => ".dacpac",
|
||||||
|
Self::Bacpac => ".bacpac",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod database;
|
pub mod database;
|
||||||
|
pub(crate) mod format;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod ping;
|
mod ping;
|
||||||
mod backup;
|
mod backup;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use tracing::{error, info};
|
|||||||
pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
|
pub async fn run(cfg: DatabaseConfig) -> Result<bool> {
|
||||||
info!("Running ping for MSSQL database {}", cfg.name);
|
info!("Running ping for MSSQL database {}", cfg.name);
|
||||||
|
|
||||||
match super::connection::build_client(&cfg).await {
|
match super::connection::build_client(&cfg, None).await {
|
||||||
Ok(mut client) => match client.simple_query("SELECT 1").await {
|
Ok(mut client) => match client.simple_query("SELECT 1").await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("MSSQL ping succeeded for {}", cfg.name);
|
info!("MSSQL ping succeeded for {}", cfg.name);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::format::MssqlFormat;
|
||||||
use crate::services::backup::logger::JobLogger;
|
use crate::services::backup::logger::JobLogger;
|
||||||
use crate::services::config::DatabaseConfig;
|
use crate::services::config::DatabaseConfig;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -7,9 +8,23 @@ use std::sync::Arc;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogger>) -> Result<()> {
|
pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogger>) -> Result<()> {
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name));
|
||||||
logger.log("debug", format!("Starting MSSQL restore for database {}", cfg.name));
|
|
||||||
|
|
||||||
|
let Some(format) = MssqlFormat::from_path(&restore_file) else {
|
||||||
|
let message = format!(
|
||||||
|
"Unsupported MSSQL backup file {}: expected .bacpac or .dacpac",
|
||||||
|
restore_file.display()
|
||||||
|
);
|
||||||
|
logger.log("error", message.clone());
|
||||||
|
anyhow::bail!(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log("info", format!("Replacing MSSQL database {} (drop if it exists, then recreate from the backup)", cfg.database));
|
||||||
|
let start = Instant::now();
|
||||||
|
super::connection::drop_database(&cfg).await?;
|
||||||
|
logger.log_command(format!("DROP DATABASE IF EXISTS [{}]", cfg.database), None, Some(0), Some(start.elapsed().as_millis() as f64));
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
let connection_string = format!(
|
let connection_string = format!(
|
||||||
"Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False",
|
"Server=tcp:{},{};Database={};User Id={};Password={};TrustServerCertificate=True;Encrypt=False",
|
||||||
cfg.host, cfg.port, cfg.database, cfg.username, cfg.password
|
cfg.host, cfg.port, cfg.database, cfg.username, cfg.password
|
||||||
@@ -24,8 +39,12 @@ pub async fn run(cfg: DatabaseConfig, restore_file: PathBuf, logger: Arc<JobLogg
|
|||||||
));
|
));
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let output = Command::new("sqlpackage")
|
let mut command = Command::new("sqlpackage");
|
||||||
.arg("/a:Import")
|
match format {
|
||||||
|
MssqlFormat::Dacpac => command.args(["/a:Publish", "/p:AllowIncompatiblePlatform=True"]),
|
||||||
|
MssqlFormat::Bacpac => command.arg("/a:Import"),
|
||||||
|
};
|
||||||
|
let output = command
|
||||||
.arg(format!("/tcs:{}", connection_string))
|
.arg(format!("/tcs:{}", connection_string))
|
||||||
.arg(format!("/sf:{}", restore_file.display()))
|
.arg(format!("/sf:{}", restore_file.display()))
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
+2
-1
@@ -15,9 +15,10 @@ use utils::task_manager::scheduler;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||||
|
|
||||||
logging::init_logger();
|
logging::init_logger();
|
||||||
|
|
||||||
// Remove all locks on startup
|
|
||||||
if let Err(e) = FileLock::clean_startup().await {
|
if let Err(e) = FileLock::clean_startup().await {
|
||||||
eprintln!("Failed to clean locks on startup: {:?}", e);
|
eprintln!("Failed to clean locks on startup: {:?}", e);
|
||||||
}
|
}
|
||||||
|
|||||||
+342
-3
@@ -1,8 +1,10 @@
|
|||||||
use crate::domain::factory::DatabaseFactory;
|
use crate::domain::factory::DatabaseFactory;
|
||||||
|
use crate::services::backup::logger::JobLogger;
|
||||||
use crate::services::config::{DatabaseConfig, DbType};
|
use crate::services::config::{DatabaseConfig, DbType};
|
||||||
use crate::tests::init_tracing_for_test;
|
use crate::tests::init_tracing_for_test;
|
||||||
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
|
use crate::utils::compress::{compress_to_tar_gz_large, decompress_large_tar_gz};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tiberius::{AuthMethod, Client, Config};
|
use tiberius::{AuthMethod, Client, Config};
|
||||||
@@ -10,7 +12,7 @@ use testcontainers::runners::AsyncRunner;
|
|||||||
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
||||||
use testcontainers::core::IntoContainerPort;
|
use testcontainers::core::IntoContainerPort;
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio_util::compat::TokioAsyncWriteCompatExt;
|
use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
const SA_PASSWORD: &str = "Test!Str0ng1";
|
const SA_PASSWORD: &str = "Test!Str0ng1";
|
||||||
@@ -61,6 +63,43 @@ fn make_config(host: String, port: u16, database: &str, generated_id: &str) -> D
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn start() -> (ContainerAsync<GenericImage>, String, u16) {
|
||||||
|
let container = start_container().await;
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
let host = container.get_host().await.unwrap().to_string();
|
||||||
|
let port = container.get_host_port_ipv4(1433).await.unwrap();
|
||||||
|
(container, host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_as(host: &str, port: u16, database: &str, user: &str, password: &str) -> Client<Compat<TcpStream>> {
|
||||||
|
let mut config = Config::new();
|
||||||
|
config.host(host);
|
||||||
|
config.port(port);
|
||||||
|
config.database(database);
|
||||||
|
config.authentication(AuthMethod::sql_server(user, password));
|
||||||
|
config.trust_cert();
|
||||||
|
|
||||||
|
let tcp = TcpStream::connect(config.get_addr()).await.unwrap();
|
||||||
|
tcp.set_nodelay(true).unwrap();
|
||||||
|
Client::connect(config, tcp.compat_write()).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exec(host: &str, port: u16, database: &str, sql: &str) {
|
||||||
|
let mut client = connect_as(host, port, database, "sa", SA_PASSWORD).await;
|
||||||
|
client.simple_query(sql).await.unwrap().into_results().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scalar(host: &str, port: u16, database: &str, user: &str, password: &str, sql: &str) -> i32 {
|
||||||
|
let mut client = connect_as(host, port, database, user, password).await;
|
||||||
|
let row = client.simple_query(sql).await.unwrap().into_row().await.unwrap().unwrap();
|
||||||
|
row.get::<i32, _>(0).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_backup_format(mut config: DatabaseConfig, format: &str) -> DatabaseConfig {
|
||||||
|
config.options.insert("backup_format".to_string(), serde_json::json!(format));
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mssql_ping_test() {
|
async fn mssql_ping_test() {
|
||||||
init_tracing_for_test();
|
init_tracing_for_test();
|
||||||
@@ -102,8 +141,8 @@ async fn mssql_backup_test() {
|
|||||||
"backup file should be non-empty"
|
"backup file should be non-empty"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
file_path.extension().and_then(|e| e.to_str()) == Some("bacpac"),
|
file_path.extension().and_then(|e| e.to_str()) == Some("dacpac"),
|
||||||
"backup file should have .bacpac extension"
|
"backup file should have .dacpac extension"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,3 +192,303 @@ async fn mssql_backup_restore_test() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_backup_bacpac_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "bacpacdb").await;
|
||||||
|
|
||||||
|
let config = with_backup_format(
|
||||||
|
make_config(host, port, "bacpacdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d6"),
|
||||||
|
"bacpac",
|
||||||
|
);
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = DatabaseFactory::create_for_backup(config)
|
||||||
|
.await
|
||||||
|
.backup(temp_dir.path(), Arc::new(JobLogger::new()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(file_path.metadata().unwrap().len() > 0);
|
||||||
|
assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("bacpac"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_backup_windows_principal_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "windowsdb").await;
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"windowsdb",
|
||||||
|
"CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \
|
||||||
|
CREATE USER [NT AUTHORITY\\SYSTEM] FOR LOGIN [NT AUTHORITY\\SYSTEM];",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let config = make_config(host, port, "windowsdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d7");
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
let bacpac = DatabaseFactory::create_for_backup(with_backup_format(config.clone(), "bacpac"))
|
||||||
|
.await
|
||||||
|
.backup(temp_dir.path(), Arc::new(JobLogger::new()))
|
||||||
|
.await;
|
||||||
|
assert!(bacpac.unwrap_err().to_string().contains("SQL71627"));
|
||||||
|
|
||||||
|
let file_path = DatabaseFactory::create_for_backup(config)
|
||||||
|
.await
|
||||||
|
.backup(temp_dir.path(), Arc::new(JobLogger::new()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some("dacpac"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_over_existing(format: &str, generated_id: &str) {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "appdb").await;
|
||||||
|
exec(&host, port, "master", "CREATE LOGIN app_login WITH PASSWORD = 'App!Str0ng1'").await;
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"appdb",
|
||||||
|
"CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \
|
||||||
|
INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \
|
||||||
|
CREATE USER app_user FOR LOGIN app_login; \
|
||||||
|
GRANT SELECT ON dbo.users TO app_user;",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let config = with_backup_format(make_config(host.clone(), port, "appdb", generated_id), format);
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = DatabaseFactory::create_for_backup(config.clone())
|
||||||
|
.await
|
||||||
|
.backup(temp_dir.path(), Arc::new(JobLogger::new()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(file_path.extension().and_then(|e| e.to_str()), Some(format));
|
||||||
|
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"appdb",
|
||||||
|
"DELETE FROM dbo.users WHERE id = 1; INSERT dbo.users VALUES (4, 'd@x'); CREATE TABLE dbo.extra (id INT);",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
DatabaseFactory::create_for_restore(config, &file_path)
|
||||||
|
.await
|
||||||
|
.restore(&file_path, Arc::new(JobLogger::new()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3);
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users WHERE id IN (1, 2, 3)").await,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "appdb", "sa", SA_PASSWORD, "SELECT CASE WHEN OBJECT_ID('dbo.extra') IS NULL THEN 1 ELSE 0 END").await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "appdb", "app_login", "App!Str0ng1", "SELECT COUNT(*) FROM dbo.users").await,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_over_existing_dacpac_test() {
|
||||||
|
restore_over_existing("dacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d8").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_over_existing_bacpac_test() {
|
||||||
|
restore_over_existing("bacpac", "5a445eb4-c2c6-4bde-a423-ee1385dcf6d9").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_rejects_unknown_extension_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "keepdb").await;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("backup.dump");
|
||||||
|
std::fs::write(&file_path, b"not a package").unwrap();
|
||||||
|
|
||||||
|
let config = make_config(host.clone(), port, "keepdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6da");
|
||||||
|
let result = DatabaseFactory::create_for_restore(config, &file_path)
|
||||||
|
.await
|
||||||
|
.restore(&file_path, Arc::new(JobLogger::new()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.unwrap_err().to_string().contains("expected .bacpac or .dacpac"));
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('keepdb') IS NULL THEN 0 ELSE 1 END").await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_refuses_without_create_database_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "ownerdb").await;
|
||||||
|
exec(&host, port, "master", "CREATE LOGIN owner_login WITH PASSWORD = 'Owner!Str0ng1'").await;
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"ownerdb",
|
||||||
|
"CREATE TABLE dbo.users (id INT PRIMARY KEY, email NVARCHAR(100) NOT NULL); \
|
||||||
|
INSERT dbo.users VALUES (1, 'a@x'), (2, 'b@x'), (3, 'c@x'); \
|
||||||
|
CREATE USER owner_user FOR LOGIN owner_login; \
|
||||||
|
ALTER ROLE db_owner ADD MEMBER owner_user;",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let config = make_config(host.clone(), port, "ownerdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6db");
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = DatabaseFactory::create_for_backup(config.clone())
|
||||||
|
.await
|
||||||
|
.backup(temp_dir.path(), Arc::new(JobLogger::new()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut owner_config = config;
|
||||||
|
owner_config.username = "owner_login".to_string();
|
||||||
|
owner_config.password = "Owner!Str0ng1".to_string();
|
||||||
|
let result = DatabaseFactory::create_for_restore(owner_config, &file_path)
|
||||||
|
.await
|
||||||
|
.restore(&file_path, Arc::new(JobLogger::new()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(format!("{:#}", result.unwrap_err()).contains("CREATE DATABASE"));
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('ownerdb') IS NULL THEN 0 ELSE 1 END").await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(scalar(&host, port, "ownerdb", "sa", SA_PASSWORD, "SELECT COUNT(*) FROM dbo.users").await, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_failed_drop_keeps_multi_user_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
create_user_database(&host, port, "alterdb").await;
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"master",
|
||||||
|
"CREATE LOGIN alter_login WITH PASSWORD = 'Alter!Str0ng1'; GRANT CREATE ANY DATABASE TO alter_login;",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
exec(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"alterdb",
|
||||||
|
"CREATE TABLE dbo.t (id INT PRIMARY KEY); INSERT dbo.t VALUES (1); \
|
||||||
|
CREATE USER alter_user FOR LOGIN alter_login; GRANT ALTER TO alter_user;",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("backup.dacpac");
|
||||||
|
std::fs::write(&file_path, b"not a package").unwrap();
|
||||||
|
|
||||||
|
let mut config = make_config(host.clone(), port, "alterdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dc");
|
||||||
|
config.username = "alter_login".to_string();
|
||||||
|
config.password = "Alter!Str0ng1".to_string();
|
||||||
|
let result = DatabaseFactory::create_for_restore(config, &file_path)
|
||||||
|
.await
|
||||||
|
.restore(&file_path, Arc::new(JobLogger::new()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(!format!("{:#}", result.unwrap_err()).contains("nothing was dropped"));
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('alterdb') IS NULL THEN 0 ELSE 1 END").await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scalar(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"master",
|
||||||
|
"sa",
|
||||||
|
SA_PASSWORD,
|
||||||
|
"SELECT CASE WHEN DATABASEPROPERTYEX('alterdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END"
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mssql_restore_refuses_system_database_test() {
|
||||||
|
init_tracing_for_test();
|
||||||
|
let (_container, host, port) = start().await;
|
||||||
|
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("backup.dacpac");
|
||||||
|
std::fs::write(&file_path, b"not a package").unwrap();
|
||||||
|
|
||||||
|
let config = make_config(host.clone(), port, "msdb", "5a445eb4-c2c6-4bde-a423-ee1385dcf6dd");
|
||||||
|
let result = DatabaseFactory::create_for_restore(config, &file_path)
|
||||||
|
.await
|
||||||
|
.restore(&file_path, Arc::new(JobLogger::new()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(format!("{:#}", result.unwrap_err()).contains("system database"));
|
||||||
|
assert_eq!(
|
||||||
|
scalar(&host, port, "master", "sa", SA_PASSWORD, "SELECT CASE WHEN DB_ID('msdb') IS NULL THEN 0 ELSE 1 END").await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scalar(
|
||||||
|
&host,
|
||||||
|
port,
|
||||||
|
"master",
|
||||||
|
"sa",
|
||||||
|
SA_PASSWORD,
|
||||||
|
"SELECT CASE WHEN DATABASEPROPERTYEX('msdb', 'UserAccess') = 'MULTI_USER' THEN 1 ELSE 0 END"
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
mod format_tests {
|
||||||
|
use super::{make_config, with_backup_format};
|
||||||
|
use crate::domain::mssql::format::MssqlFormat;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_config_defaults_to_dacpac() {
|
||||||
|
let cfg = make_config("localhost".into(), 1433, "db", "00000000-0000-0000-0000-000000000000");
|
||||||
|
assert_eq!(MssqlFormat::from_config(&cfg), (MssqlFormat::Dacpac, None));
|
||||||
|
assert_eq!(
|
||||||
|
MssqlFormat::from_config(&with_backup_format(cfg.clone(), "dacpac")),
|
||||||
|
(MssqlFormat::Dacpac, None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MssqlFormat::from_config(&with_backup_format(cfg.clone(), "bacpac")),
|
||||||
|
(MssqlFormat::Bacpac, None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MssqlFormat::from_config(&with_backup_format(cfg, "zip")),
|
||||||
|
(MssqlFormat::Dacpac, Some("zip".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_path_reads_extension() {
|
||||||
|
assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.dacpac")), Some(MssqlFormat::Dacpac));
|
||||||
|
assert_eq!(MssqlFormat::from_path(Path::new("/tmp/a.BACPAC")), Some(MssqlFormat::Bacpac));
|
||||||
|
assert_eq!(MssqlFormat::from_path(Path::new("/tmp/backup.dump")), None);
|
||||||
|
assert_eq!(MssqlFormat::from_path(Path::new("/tmp/noext")), None);
|
||||||
|
assert_eq!(MssqlFormat::Dacpac.extension(), ".dacpac");
|
||||||
|
assert_eq!(MssqlFormat::Bacpac.extension(), ".bacpac");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user