Compare commits
5 Commits
main
...
b8a920d0e7
Author | SHA1 | Date | |
---|---|---|---|
b8a920d0e7
|
|||
fa49939a4d
|
|||
c802c76ace
|
|||
9f6fad8466
|
|||
571e952897
|
@@ -1,19 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
end_of_line = lf
|
|
||||||
indent_size = 4
|
|
||||||
indent_style = space
|
|
||||||
insert_final_newline = false
|
|
||||||
max_line_length = 120
|
|
||||||
tab_width = 4
|
|
||||||
|
|
||||||
[{*.yaml,*.yml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[src/test/**/*]
|
|
||||||
ktlint_standard_no-wildcard-imports = disabled
|
|
||||||
|
|
||||||
[{*.kt,*.kts}]
|
|
||||||
ij_kotlin_packages_to_use_import_on_demand = org.junit.jupiter.api,aws.sdk.kotlin.services.s3,kotlinx.coroutines,java.io,ziputils
|
|
13
.github/workflows/publish.yaml
vendored
13
.github/workflows/publish.yaml
vendored
@@ -1,8 +1,11 @@
|
|||||||
name: Publish Workflow
|
name: Publish Workflow
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- v*
|
- main
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
name: Publish library
|
name: Publish library
|
||||||
@@ -22,12 +25,6 @@ jobs:
|
|||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/gradle-build-action@v2
|
||||||
|
|
||||||
- name: Setup AWS Credentials
|
|
||||||
run: |
|
|
||||||
mkdir ~/.aws
|
|
||||||
echo ${{ secrets.aws_config }} | base64 -d > ~/.aws/config
|
|
||||||
echo ${{ secrets.aws_credentials }} | base64 -d > ~/.aws/credentials
|
|
||||||
|
|
||||||
- name: Run checks
|
- name: Run checks
|
||||||
run: ./gradlew check
|
run: ./gradlew check
|
||||||
|
|
||||||
|
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -25,11 +25,5 @@ jobs:
|
|||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/gradle-build-action@v2
|
uses: gradle/gradle-build-action@v2
|
||||||
|
|
||||||
- name: Setup AWS Credentials
|
|
||||||
run: |
|
|
||||||
mkdir ~/.aws
|
|
||||||
echo ${{ secrets.aws_config }} | base64 -d > ~/.aws/config
|
|
||||||
echo ${{ secrets.aws_credentials }} | base64 -d > ~/.aws/credentials
|
|
||||||
|
|
||||||
- name: Run checks
|
- name: Run checks
|
||||||
run: ./gradlew check
|
run: ./gradlew check
|
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
|||||||
s3backup-tool
|
|
124
.idea/uiDesigner.xml
generated
124
.idea/uiDesigner.xml
generated
@@ -1,124 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Palette2">
|
|
||||||
<group name="Swing">
|
|
||||||
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="Button" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="RadioButton" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="CheckBox" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
|
|
||||||
<initial-values>
|
|
||||||
<property name="text" value="Label" />
|
|
||||||
</initial-values>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
|
|
||||||
<preferred-size width="150" height="-1" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
|
|
||||||
<preferred-size width="150" height="50" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
|
||||||
<preferred-size width="200" height="200" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
|
|
||||||
<preferred-size width="200" height="200" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
|
|
||||||
<preferred-size width="-1" height="20" />
|
|
||||||
</default-constraints>
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
|
|
||||||
</item>
|
|
||||||
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
|
|
||||||
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
|
|
||||||
</item>
|
|
||||||
</group>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
89
README.md
89
README.md
@@ -3,91 +3,38 @@ This is a small backup utility for uploading/restoring a local directory to/from
|
|||||||
an AWS S3 bucket.
|
an AWS S3 bucket.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
This tool is released as a JAR in the [releases page](https://git.koval.net/cyclane/teamcity-executors-test-task/releases).
|
This tool is released as a JAR in the [release page](https://git.koval.net/cyclane/teamcity-executors-test-task/releases).
|
||||||
Use `java -jar s3backup-tool-<version>.jar --help` for more detailed usage instructions.
|
Use `java -jar <backup-jar-name>.jar --help` for more detailed usage instructions.
|
||||||
|
|
||||||
### --help
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool [<options>] <command> [<args>]...
|
|
||||||
|
|
||||||
A simple AWS S3 backup tool. This tool assumes credentials are properly configured using aws-cli.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
create Create a backup of a file or directory.
|
|
||||||
restore Restore a backup from AWS S3.
|
|
||||||
restore-file Restore a single file from a backup from AWS S3.
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Subcommands
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool create [<options>] <source> <bucket>
|
|
||||||
|
|
||||||
Create a backup of a file or directory.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<source> File or directory to backup
|
|
||||||
<bucket> Name of S3 bucket to backup to
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool restore [<options>] <bucket> <backupkey> <destination>
|
|
||||||
|
|
||||||
Restore a backup from AWS S3.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<bucket> Name of S3 bucket to restore the backup from
|
|
||||||
<backupkey> The S3 key of the backup to restore
|
|
||||||
<destination> Directory to restore to
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
Usage: s3backup-tool restore-file [<options>] <bucket> <backupkey> <filepath> <destination>
|
|
||||||
|
|
||||||
Restore a single file from a backup from AWS S3.
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit
|
|
||||||
|
|
||||||
Arguments:
|
|
||||||
<bucket> Name of S3 bucket to restore the backup from
|
|
||||||
<backupkey> The S3 key of the backup to restore
|
|
||||||
<filepath> File path within the backup
|
|
||||||
<destination> Directory to restore to
|
|
||||||
```
|
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
1. The test task is not interested in re-implementations of common libraries (AWS SDK, Clikt, Gradle Shadow, ...)
|
1. This test task is not interested in re-implementations of common libraries (AWS SDK, Clikt, Gradle Shadow, ...)
|
||||||
2. The test task is more interested in Kotlin JVM (not Kotlin Native).
|
2. The last part (restoration of a single file) should be optimised so that only the part of the blob required for this
|
||||||
|
file is downloaded.
|
||||||
|
3. Only this tool is ever used to create backups, so S3 object keys are in the expected format, and ZIP files do not have
|
||||||
|
a comment in the *end of central directory* record (making it a predictable length of 22 bytes).
|
||||||
|
- EOCD64 should similarly not have a comment.
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
- Backups may be large, so we want to use **multipart uploads** if possible (< 100mb is recommended).
|
- Backups may be large, so we want to use multipart uploads if possible (< 100mb is recommended).
|
||||||
https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
|
https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
|
||||||
- The Java SDK has high-level support for this via [S3TransferManager](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/transfer/s3/S3TransferManager.html),
|
- The Java SDK has high-level support for this via [S3TransferManager](https://sdk.amazonaws.com/java/api/latest/software/amazon/awssdk/transfer/s3/S3TransferManager.html),
|
||||||
but unfortunately when the content is too small, the HTTP `Content-Length` is not automatically calculated resulting
|
but unfortunately when the content is too small, the HTTP `Content-Length` is not automatically calculated resulting
|
||||||
in an error response from the API.
|
in an error response from the API.
|
||||||
- I'm not sure whether this is intended behaviour or a bug, but decided to manually implement multipart uploads using
|
- I'm not sure whether this is intended behaviour or a bug, but decided to manually implement multipart uploads using
|
||||||
the Kotlin SDK instead anyway.
|
the Kotlin SDK instead anyway.
|
||||||
- **ZIP files** are used so that the backups can be stored in a very common format which also provides compression.
|
- **Note**: I could have just used a temporary file (with a known `Content-Length`), but I wanted to play around with
|
||||||
- Allows future expansion to allow for encryption as well.
|
streams and kotlin concurrency a bit, which is why I went with the more scalable way using streams.
|
||||||
- [Java ZIP specification](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/zip/package-summary.html):
|
- Zip files are used so that the backups can be stored in a very common format which also provides compression.
|
||||||
|
- Java zip specification: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/zip/package-summary.html
|
||||||
- ZIP64 implementation is optional, but possible, so we'll handle it.
|
- ZIP64 implementation is optional, but possible, so we'll handle it.
|
||||||
- The End of Central Directory record is also useful for locating the exact positions of files in the blob, so that
|
- The End of Central Directory record is also useful for locating the exact positions of files in the blob, so that
|
||||||
single files can be downloaded using the HTTP `Range` header.
|
single files can be downloaded using the HTTP `Range` header.
|
||||||
- End of Central Directory comment must be blank. Otherwise, the EOCD length is unpredictable, and so we
|
- End of Central Directory comment must be blank (assumption 3). Otherwise, the EOCD length is unpredictable and so we
|
||||||
cannot use just a single request the HTTP `Range` header to get the entire EOCD.
|
cannot use just a single request the HTTP `Range` header to get the entire EOCD.
|
||||||
- *Alternative*: use S3 object tags to store the EOCD size, fallback to 22 bytes otherwise. This could be
|
- Alternative: use S3 object tags to store the EOCD offset, but this way the blob itself would no longer contain all
|
||||||
interesting if we want the backup tool to be able to import existing ZIPs (which could potentially have a comment),
|
the data required by this backup tool.
|
||||||
but that is beyond the scope of the instructions.
|
- Alternative: store the EOCD offset in the EOCD comment or the beginning of the file, but this makes a similar, but
|
||||||
- Only the `BackupClient` is tested, since by testing it, all other (internal) classes are functions are tested as well.
|
more strict assumption anyway.
|
||||||
|
|
||||||
## Instructions
|
## Instructions
|
||||||
Create a backup utility that copies files to AWS S3. The utility should take a local directory with files and put it into AWS S3 in the form of one blob file. The reverse behavior should also be possible. We should be able to specify what backup we want to restore and where it should put the files on the local system. The utility should be able to restore one individual file from a backup.
|
Create a backup utility that copies files to AWS S3. The utility should take a local directory with files and put it into AWS S3 in the form of one blob file. The reverse behavior should also be possible. We should be able to specify what backup we want to restore and where it should put the files on the local system. The utility should be able to restore one individual file from a backup.
|
@@ -1,7 +1,7 @@
|
|||||||
|
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
application
|
|
||||||
kotlin("jvm") version "1.9.21"
|
kotlin("jvm") version "1.9.21"
|
||||||
id("org.jlleitschuh.gradle.ktlint") version "12.0.3"
|
|
||||||
id("com.github.johnrengelman.shadow") version "8.1.1"
|
id("com.github.johnrengelman.shadow") version "8.1.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,9 +14,8 @@ repositories {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("aws.sdk.kotlin:s3:1.0.25")
|
implementation("aws.sdk.kotlin:s3:1.0.25")
|
||||||
implementation("org.slf4j:slf4j-simple:2.0.9") // AWS SDK wants a SLF4J provider.
|
implementation("org.slf4j:slf4j-simple:2.0.9")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||||
implementation("com.github.ajalt.clikt:clikt:4.2.1")
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
testImplementation("org.jetbrains.kotlin:kotlin-test")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +25,8 @@ tasks.test {
|
|||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(17)
|
jvmToolchain(17)
|
||||||
}
|
}
|
||||||
application {
|
tasks.jar {
|
||||||
mainClass.set("MainKt")
|
manifest {
|
||||||
|
attributes("Main-Class" to "backup.MainKt")
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,4 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||||
}
|
}
|
||||||
rootProject.name = "s3backup-tool"
|
rootProject.name = "teamcity-executors-test-task"
|
||||||
|
|
||||||
|
@@ -1,80 +0,0 @@
|
|||||||
import aws.sdk.kotlin.services.s3.S3Client
|
|
||||||
import backup.BackupClient
|
|
||||||
import com.github.ajalt.clikt.core.CliktCommand
|
|
||||||
import com.github.ajalt.clikt.core.subcommands
|
|
||||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
|
||||||
import com.github.ajalt.clikt.parameters.arguments.help
|
|
||||||
import com.github.ajalt.clikt.parameters.types.file
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlin.system.exitProcess
|
|
||||||
|
|
||||||
fun main(args: Array<String>) =
|
|
||||||
runBlocking {
|
|
||||||
S3Client.fromEnvironment().use { s3 ->
|
|
||||||
S3BackupTool()
|
|
||||||
.subcommands(
|
|
||||||
Create(s3),
|
|
||||||
Restore(s3),
|
|
||||||
RestoreFile(s3),
|
|
||||||
)
|
|
||||||
.main(args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class S3BackupTool : CliktCommand(
|
|
||||||
help = "A simple AWS S3 backup tool. This tool assumes credentials are properly configured using aws-cli.",
|
|
||||||
) {
|
|
||||||
override fun run() {
|
|
||||||
shortHelp(currentContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Create(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Create a backup of a file or directory.",
|
|
||||||
) {
|
|
||||||
val source by argument().file(mustExist = true).help("File or directory to backup")
|
|
||||||
val bucket by argument().help("Name of S3 bucket to backup to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
val backupKey = BackupClient(s3, bucket).upload(source)
|
|
||||||
echo("Successfully created backup with key '$backupKey'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Restore(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Restore a backup from AWS S3.",
|
|
||||||
) {
|
|
||||||
val bucket by argument().help("Name of S3 bucket to restore the backup from")
|
|
||||||
val backupKey by argument().help("The S3 key of the backup to restore")
|
|
||||||
val destination by argument().file(mustExist = true).help("Directory to restore to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
if (!destination.isDirectory) {
|
|
||||||
echo("Destination must be an existing directory", err = true)
|
|
||||||
exitProcess(1)
|
|
||||||
}
|
|
||||||
BackupClient(s3, bucket).restore(destination.toPath(), backupKey)
|
|
||||||
echo("Successfully restored backup '$backupKey' to '$destination'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RestoreFile(val s3: S3Client) : CliktCommand(
|
|
||||||
help = "Restore a single file from a backup from AWS S3.",
|
|
||||||
) {
|
|
||||||
val bucket by argument().help("Name of S3 bucket to restore the backup from")
|
|
||||||
val backupKey by argument().help("The S3 key of the backup to restore")
|
|
||||||
val filePath by argument().help("File path within the backup")
|
|
||||||
val destination by argument().file(mustExist = true).help("Directory to restore to")
|
|
||||||
|
|
||||||
override fun run() =
|
|
||||||
runBlocking {
|
|
||||||
if (!destination.isDirectory) {
|
|
||||||
echo("Destination must be an existing directory", err = true)
|
|
||||||
exitProcess(1)
|
|
||||||
}
|
|
||||||
BackupClient(s3, bucket).restoreFile(destination.toPath(), backupKey, filePath)
|
|
||||||
echo("Successfully restored '$filePath' from backup '$backupKey' to '$destination'")
|
|
||||||
}
|
|
||||||
}
|
|
@@ -30,19 +30,17 @@ import kotlin.io.path.createDirectory
|
|||||||
class BackupClient(
|
class BackupClient(
|
||||||
private val s3: S3Client,
|
private val s3: S3Client,
|
||||||
private val bucketName: String,
|
private val bucketName: String,
|
||||||
private val bufSize: Int = 1024 * 1024 * 32,
|
private val bufSize: Int = 1024 * 1024 * 100
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Upload a file/directory backup to AWS S3.
|
* Upload a file/directory backup to AWS S3.
|
||||||
* @param file The File object for the file or directory.
|
* @param file The File object for the file or directory.
|
||||||
*/
|
*/
|
||||||
suspend fun upload(file: File) =
|
suspend fun upload(file: File) = coroutineScope {
|
||||||
coroutineScope {
|
val backupKey = "${file.name}/${Instant.now()}.zip"
|
||||||
val backupKey = "${file.canonicalFile.name}/${Instant.now()}.zip"
|
|
||||||
PipedInputStream().use { inputStream ->
|
PipedInputStream().use { inputStream ->
|
||||||
val outputStream = PipedOutputStream(inputStream)
|
val outputStream = PipedOutputStream(inputStream)
|
||||||
val zipper =
|
val zipper = launch(Dispatchers.IO) {
|
||||||
launch(Dispatchers.IO) {
|
|
||||||
file.compressToZip(outputStream)
|
file.compressToZip(outputStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +50,7 @@ class BackupClient(
|
|||||||
// Large upload, use multipart
|
// Large upload, use multipart
|
||||||
// TODO: multipart uploads can be asynchronous, which would improve
|
// TODO: multipart uploads can be asynchronous, which would improve
|
||||||
// performance a little bit for big uploads.
|
// performance a little bit for big uploads.
|
||||||
val upload =
|
val upload = s3.createMultipartUpload {
|
||||||
s3.createMultipartUpload {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
}
|
}
|
||||||
@@ -62,8 +59,7 @@ class BackupClient(
|
|||||||
var number = 1
|
var number = 1
|
||||||
var bytesRead = initialRead
|
var bytesRead = initialRead
|
||||||
while (bytesRead > 0) {
|
while (bytesRead > 0) {
|
||||||
val part =
|
val part = s3.uploadPart {
|
||||||
s3.uploadPart {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
partNumber = number
|
partNumber = number
|
||||||
@@ -78,8 +74,7 @@ class BackupClient(
|
|||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
uploadId = upload.uploadId
|
uploadId = upload.uploadId
|
||||||
multipartUpload =
|
multipartUpload = CompletedMultipartUpload {
|
||||||
CompletedMultipartUpload {
|
|
||||||
parts = uploadParts
|
parts = uploadParts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,19 +104,15 @@ class BackupClient(
|
|||||||
* @param destination The destination directory path for the backup contents.
|
* @param destination The destination directory path for the backup contents.
|
||||||
* @param backupKey The S3 key of the backup.
|
* @param backupKey The S3 key of the backup.
|
||||||
*/
|
*/
|
||||||
suspend fun restore(
|
suspend fun restore(destination: Path, backupKey: String) = coroutineScope {
|
||||||
destination: Path,
|
val req = GetObjectRequest {
|
||||||
backupKey: String,
|
|
||||||
) = coroutineScope {
|
|
||||||
val req =
|
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
}
|
}
|
||||||
s3.getObject(req) { resp ->
|
s3.getObject(req) { resp ->
|
||||||
ZipInputStream(
|
ZipInputStream(
|
||||||
resp.body?.toInputStream()
|
resp.body?.toInputStream()
|
||||||
?: throw IOException("S3 response is missing body"),
|
?: throw IOException("S3 response is missing body")
|
||||||
).use { zipStream ->
|
).use { zipStream ->
|
||||||
zipStream.decompress { destination.resolve(it) }
|
zipStream.decompress { destination.resolve(it) }
|
||||||
}
|
}
|
||||||
@@ -134,14 +125,9 @@ class BackupClient(
|
|||||||
* @param backupKey The S3 key of the backup.
|
* @param backupKey The S3 key of the backup.
|
||||||
* @param fileName The full name of the file to restore (including directories if it was under a subdirectory).
|
* @param fileName The full name of the file to restore (including directories if it was under a subdirectory).
|
||||||
*/
|
*/
|
||||||
suspend fun restoreFile(
|
suspend fun restoreFile(destination: Path, backupKey: String, fileName: String) = coroutineScope {
|
||||||
destination: Path,
|
|
||||||
backupKey: String,
|
|
||||||
fileName: String,
|
|
||||||
) = coroutineScope {
|
|
||||||
// For byte ranges refer to https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.3.9.TXT
|
// For byte ranges refer to https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.3.9.TXT
|
||||||
val eocdReq =
|
val eocdReq = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
// Assumption: EOCD has an empty comment
|
// Assumption: EOCD has an empty comment
|
||||||
@@ -149,17 +135,14 @@ class BackupClient(
|
|||||||
// in which case this function would error anyway, so it should be fine to have this edge-case.
|
// in which case this function would error anyway, so it should be fine to have this edge-case.
|
||||||
range = "bytes=-${EndOfCentralDirectoryRecord.SIZE + EndOfCentralDirectoryLocator.SIZE}"
|
range = "bytes=-${EndOfCentralDirectoryRecord.SIZE + EndOfCentralDirectoryLocator.SIZE}"
|
||||||
}
|
}
|
||||||
val eocdBytes =
|
val eocdBytes = s3.getObject(eocdReq) { resp ->
|
||||||
s3.getObject(eocdReq) { resp ->
|
|
||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
bytes
|
bytes
|
||||||
}
|
}
|
||||||
val eocd = EndOfCentralDirectoryRecord.fromByteArray(eocdBytes, EndOfCentralDirectoryLocator.SIZE)
|
val eocd = EndOfCentralDirectoryRecord.fromByteArray(eocdBytes, EndOfCentralDirectoryLocator.SIZE)
|
||||||
val eocd64 =
|
val eocd64 = if (eocd.eocd64Required()) {
|
||||||
if (eocd.eocd64Required()) {
|
|
||||||
val locator = EndOfCentralDirectoryLocator.fromByteArray(eocdBytes, 0)
|
val locator = EndOfCentralDirectoryLocator.fromByteArray(eocdBytes, 0)
|
||||||
val eocd64Req =
|
val eocd64Req = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
range = "bytes=${locator.endOfCentralDirectory64Offset}-"
|
range = "bytes=${locator.endOfCentralDirectory64Offset}-"
|
||||||
@@ -168,25 +151,18 @@ class BackupClient(
|
|||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
EndOfCentralDirectoryRecord64.fromByteArray(bytes, 0)
|
EndOfCentralDirectoryRecord64.fromByteArray(bytes, 0)
|
||||||
}
|
}
|
||||||
} else {
|
} else null
|
||||||
null
|
val cenOffset = if (eocd.centralDirectoryOffset == 0xffffffffU && eocd64 != null) {
|
||||||
}
|
|
||||||
val cenOffset =
|
|
||||||
if (eocd.centralDirectoryOffset == 0xffffffffU && eocd64 != null) {
|
|
||||||
eocd64.centralDirectoryOffset
|
eocd64.centralDirectoryOffset
|
||||||
} else {
|
} else eocd.centralDirectoryOffset.toULong()
|
||||||
eocd.centralDirectoryOffset.toULong()
|
val censReq = GetObjectRequest {
|
||||||
}
|
|
||||||
val censReq =
|
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
// We only know where to fetch until if we've also fetched EOCD64 (which isn't always the case).
|
// We only know where to fetch until if we've also fetched EOCD64 (which isn't always the case).
|
||||||
// So just over-fetch a little bit, these headers aren't that big anyway.
|
// So just over-fetch a little bit, these headers aren't that big anyway.
|
||||||
range = "bytes=$cenOffset-"
|
range = "bytes=${cenOffset}-"
|
||||||
}
|
}
|
||||||
val cen =
|
val cen = s3.getObject(censReq) { resp ->
|
||||||
s3.getObject(censReq) { resp ->
|
|
||||||
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
val bytes = resp.body?.toByteArray() ?: throw IOException("S3 response is missing body")
|
||||||
var p = 0
|
var p = 0
|
||||||
while (p < bytes.size) {
|
while (p < bytes.size) {
|
||||||
@@ -194,37 +170,36 @@ class BackupClient(
|
|||||||
val cen = CentralDirectoryFileHeader.fromByteArray(bytes, p)
|
val cen = CentralDirectoryFileHeader.fromByteArray(bytes, p)
|
||||||
p += cen.size
|
p += cen.size
|
||||||
if (cen.fileName == fileName) return@getObject cen
|
if (cen.fileName == fileName) return@getObject cen
|
||||||
} catch (_: InvalidDataException) {
|
} catch (_: InvalidSignatureException) {
|
||||||
return@getObject null
|
return@getObject null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
} ?: throw FileNotFoundException("File '$fileName' not found in backup")
|
} ?: throw FileNotFoundException("File '${fileName}' not found in backup")
|
||||||
|
|
||||||
val localHeaderOffset =
|
val localHeaderOffset = cen.extraFieldRecords.firstNotNullOfOrNull {
|
||||||
cen.extraFieldRecords.firstNotNullOfOrNull {
|
|
||||||
if (it is Zip64ExtraFieldRecord && it.localHeaderOffset != null) it else null
|
if (it is Zip64ExtraFieldRecord && it.localHeaderOffset != null) it else null
|
||||||
}?.localHeaderOffset ?: cen.localHeaderOffset.toULong()
|
}?.localHeaderOffset ?: cen.localHeaderOffset.toULong()
|
||||||
val compressedSize =
|
val compressedSize = cen.extraFieldRecords.firstNotNullOfOrNull {
|
||||||
cen.extraFieldRecords.firstNotNullOfOrNull {
|
|
||||||
if (it is Zip64ExtraFieldRecord && it.compressedSize != null) it else null
|
if (it is Zip64ExtraFieldRecord && it.compressedSize != null) it else null
|
||||||
}?.compressedSize ?: cen.compressedSize.toULong()
|
}?.compressedSize ?: cen.compressedSize.toULong()
|
||||||
val req =
|
val req = GetObjectRequest {
|
||||||
GetObjectRequest {
|
|
||||||
bucket = bucketName
|
bucket = bucketName
|
||||||
key = backupKey
|
key = backupKey
|
||||||
range = "bytes=$localHeaderOffset-${
|
range = "bytes=${localHeaderOffset}-${
|
||||||
// Over-fetch a bit so that ZipInputStream can see the next header (otherwise it EOFs, even though
|
// Add CEN min size (46 bytes) so that the next CEN / LOC header is seen by the ZipInputStream
|
||||||
// it knows exactly how much data is should read, so not sure why it reads ahead).
|
// and so it can see the current entry has stopped.
|
||||||
|
// Note: yes ZipInputStream should know the exact content length from the LOC, but it was still sending
|
||||||
|
// EOF errors. Perhaps due to fetching multiples of a power of two, or something else. But this helps.
|
||||||
localHeaderOffset + cen.size.toULong() + compressedSize + CentralDirectoryFileHeader.SIZE.toULong()
|
localHeaderOffset + cen.size.toULong() + compressedSize + CentralDirectoryFileHeader.SIZE.toULong()
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
s3.getObject(req) { resp ->
|
s3.getObject(req) { resp ->
|
||||||
ZipInputStream(
|
ZipInputStream(
|
||||||
resp.body?.toInputStream()
|
resp.body?.toInputStream()
|
||||||
?: throw IOException("S3 response is missing body"),
|
?: throw IOException("S3 response is missing body")
|
||||||
).use { zipStream ->
|
).use { zipStream ->
|
||||||
zipStream.decompress(limit = 1) { name -> destination.resolve(name.takeLastWhile { it != '/' }) }
|
zipStream.decompress { name -> destination.resolve(name.takeLastWhile { it != '/' }) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +210,7 @@ class BackupClient(
|
|||||||
* @param number The part number that was used for this part upload.
|
* @param number The part number that was used for this part upload.
|
||||||
* @return The CompletedPart object.
|
* @return The CompletedPart object.
|
||||||
*/
|
*/
|
||||||
internal fun UploadPartResponse.toCompletedPart(number: Int): CompletedPart {
|
private fun UploadPartResponse.toCompletedPart(number: Int): CompletedPart {
|
||||||
val part = this
|
val part = this
|
||||||
return CompletedPart {
|
return CompletedPart {
|
||||||
partNumber = number
|
partNumber = number
|
||||||
@@ -252,24 +227,20 @@ internal fun UploadPartResponse.toCompletedPart(number: Int): CompletedPart {
|
|||||||
* @param n The number of items to take.
|
* @param n The number of items to take.
|
||||||
* @return A ByteArray of the first `n` items.
|
* @return A ByteArray of the first `n` items.
|
||||||
*/
|
*/
|
||||||
internal fun ByteArray.take(n: Int) =
|
private fun ByteArray.take(n: Int) =
|
||||||
if (n == size) {
|
if (n == size) this // No copy
|
||||||
this // No copy
|
else asList().subList(0, n).toByteArray() // TODO: One copy (toByteArray()), not sure how to do 0 copies here
|
||||||
} else {
|
|
||||||
asList().subList(0, n).toByteArray() // TODO: One copy (toByteArray()), not sure how to do 0 copies here
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compress a file or directory as a ZIP file to an `OutputStream`.
|
* Compress a file or directory as a ZIP file to an `OutputStream`.
|
||||||
* @param outputStream The `OutputStream` to write the ZIP file contents to.
|
* @param outputStream The `OutputStream` to write the ZIP file contents to.
|
||||||
*/
|
*/
|
||||||
internal fun File.compressToZip(outputStream: OutputStream) =
|
private fun File.compressToZip(outputStream: OutputStream) = ZipOutputStream(outputStream).use { zipStream ->
|
||||||
ZipOutputStream(outputStream).use { zipStream ->
|
val parentDir = this.absoluteFile.parent + "/"
|
||||||
val parentDir = this.canonicalFile.parent + "/"
|
|
||||||
val fileQueue = ArrayDeque<File>()
|
val fileQueue = ArrayDeque<File>()
|
||||||
fileQueue.add(this)
|
fileQueue.add(this)
|
||||||
fileQueue.forEach { subFile ->
|
fileQueue.forEach { subFile ->
|
||||||
val path = subFile.canonicalPath.removePrefix(parentDir)
|
val path = subFile.absolutePath.removePrefix(parentDir)
|
||||||
val subFiles = subFile.listFiles()
|
val subFiles = subFile.listFiles()
|
||||||
if (subFiles != null) { // Is a directory
|
if (subFiles != null) { // Is a directory
|
||||||
val entry = ZipEntry("$path/")
|
val entry = ZipEntry("$path/")
|
||||||
@@ -292,13 +263,11 @@ internal fun File.compressToZip(outputStream: OutputStream) =
|
|||||||
* @param bufSize The buffer size to use for writing the decompressed files.
|
* @param bufSize The buffer size to use for writing the decompressed files.
|
||||||
* @param entryNameToPath A function to convert ZIP entry names to destination `Path`s.
|
* @param entryNameToPath A function to convert ZIP entry names to destination `Path`s.
|
||||||
*/
|
*/
|
||||||
internal fun ZipInputStream.decompress(
|
private fun ZipInputStream.decompress(
|
||||||
bufSize: Int = 1024 * 1024,
|
bufSize: Int = 1024 * 1024,
|
||||||
limit: Int? = null,
|
entryNameToPath: (String) -> Path
|
||||||
entryNameToPath: (String) -> Path,
|
|
||||||
) {
|
) {
|
||||||
var entry = this.nextEntry
|
var entry = this.nextEntry
|
||||||
var count = 1
|
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
val path = entryNameToPath(entry.name)
|
val path = entryNameToPath(entry.name)
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
@@ -314,9 +283,6 @@ internal fun ZipInputStream.decompress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyZipAttributes(entry, path)
|
applyZipAttributes(entry, path)
|
||||||
// This is here, not in while loop, since we do not want to read more from the input stream.
|
|
||||||
// But this.nextEntry will read from the input stream.
|
|
||||||
if (limit != null && count++ >= limit) return
|
|
||||||
entry = this.nextEntry
|
entry = this.nextEntry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,10 +292,7 @@ internal fun ZipInputStream.decompress(
|
|||||||
* @param entry The `ZipEntry` to set attributes of.
|
* @param entry The `ZipEntry` to set attributes of.
|
||||||
* @param path The `Path` of the file to get the attributes from.
|
* @param path The `Path` of the file to get the attributes from.
|
||||||
*/
|
*/
|
||||||
internal fun setZipAttributes(
|
private fun setZipAttributes(entry: ZipEntry, path: Path) {
|
||||||
entry: ZipEntry,
|
|
||||||
path: Path,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java).readAttributes()
|
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java).readAttributes()
|
||||||
entry.setCreationTime(attrs.creationTime())
|
entry.setCreationTime(attrs.creationTime())
|
||||||
@@ -344,10 +307,7 @@ internal fun setZipAttributes(
|
|||||||
* @param entry The `ZipEntry` to get the attributes from.
|
* @param entry The `ZipEntry` to get the attributes from.
|
||||||
* @param path The `Path` of the file to set the attributes of.
|
* @param path The `Path` of the file to set the attributes of.
|
||||||
*/
|
*/
|
||||||
internal fun applyZipAttributes(
|
private fun applyZipAttributes(entry: ZipEntry, path: Path) {
|
||||||
entry: ZipEntry,
|
|
||||||
path: Path,
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java)
|
val attrs = Files.getFileAttributeView(path, BasicFileAttributeView::class.java)
|
||||||
attrs.setTimes(entry.lastModifiedTime, entry.lastAccessTime, entry.creationTime)
|
attrs.setTimes(entry.lastModifiedTime, entry.lastAccessTime, entry.creationTime)
|
||||||
|
10
src/main/kotlin/backup/main.kt
Normal file
10
src/main/kotlin/backup/main.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import aws.sdk.kotlin.services.s3.S3Client
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
|
fun main() = runBlocking {
|
||||||
|
S3Client.fromEnvironment().use { s3 ->
|
||||||
|
val backupClient = BackupClient(s3, "teamcity-executors-test-task", 1024 * 1024 * 10)
|
||||||
|
}
|
||||||
|
}
|
@@ -15,7 +15,7 @@ internal class CentralDirectoryFileHeader(
|
|||||||
val disk: UShort,
|
val disk: UShort,
|
||||||
val localHeaderOffset: UInt,
|
val localHeaderOffset: UInt,
|
||||||
val fileName: String,
|
val fileName: String,
|
||||||
val extraFieldRecords: List<ExtraFieldRecord>,
|
val extraFieldRecords: List<ExtraFieldRecord>
|
||||||
) {
|
) {
|
||||||
val size: Int
|
val size: Int
|
||||||
get() = SIZE + nameLength.toInt() + extraFieldLength.toInt() + commentLength.toInt()
|
get() = SIZE + nameLength.toInt() + extraFieldLength.toInt() + commentLength.toInt()
|
||||||
@@ -32,85 +32,65 @@ internal class CentralDirectoryFileHeader(
|
|||||||
* @return A `CentralDirectoryFileHeader`.
|
* @return A `CentralDirectoryFileHeader`.
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): CentralDirectoryFileHeader {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): CentralDirectoryFileHeader {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("CEN must be at least 46 bytes")
|
throw InvalidDataException("CEN must be at least 46 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, 46).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, 46).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
|
|
||||||
val extraFieldRecords = mutableListOf<ExtraFieldRecord>()
|
val extraFieldRecords = mutableListOf<ExtraFieldRecord>()
|
||||||
val nameLength = buf.getShort(offset + 28).toUShort()
|
val nameLength = buf.getShort(offset + 28).toUShort()
|
||||||
buf.position(offset + 20)
|
buf.position(offset + 20)
|
||||||
val cen =
|
val cen = CentralDirectoryFileHeader(
|
||||||
CentralDirectoryFileHeader(
|
|
||||||
compressedSize = buf.getInt().toUInt(),
|
compressedSize = buf.getInt().toUInt(),
|
||||||
uncompressedSize = buf.getInt().toUInt(),
|
uncompressedSize = buf.getInt().toUInt(),
|
||||||
nameLength =
|
nameLength = nameLength
|
||||||
nameLength
|
|
||||||
.also { buf.position(offset + 30) },
|
.also { buf.position(offset + 30) },
|
||||||
extraFieldLength = buf.getShort().toUShort(),
|
extraFieldLength = buf.getShort().toUShort(),
|
||||||
commentLength = buf.getShort().toUShort(),
|
commentLength = buf.getShort().toUShort(),
|
||||||
disk =
|
disk = buf.getShort().toUShort()
|
||||||
buf.getShort().toUShort()
|
|
||||||
.also { buf.position(offset + 42) },
|
.also { buf.position(offset + 42) },
|
||||||
localHeaderOffset = buf.getInt().toUInt(),
|
localHeaderOffset = buf.getInt().toUInt(),
|
||||||
fileName = String(data.sliceArray(offset + SIZE..<offset + SIZE + nameLength.toInt())),
|
fileName = String(data.sliceArray(offset + SIZE..<offset + SIZE + nameLength.toInt())),
|
||||||
extraFieldRecords = extraFieldRecords,
|
extraFieldRecords = extraFieldRecords
|
||||||
)
|
)
|
||||||
if (data.size - offset < cen.size) {
|
if (data.size - offset < cen.size) {
|
||||||
throw InvalidDataException("CEN is too short")
|
throw InvalidDataException("CEN is too short")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse extra field records
|
// Parse extra field records
|
||||||
val extraFieldsBuf =
|
val extraFieldsBuf = ByteBuffer.wrap(
|
||||||
ByteBuffer.wrap(
|
data, offset + SIZE + cen.nameLength.toInt(), cen.extraFieldLength.toInt()
|
||||||
data,
|
|
||||||
offset + SIZE + cen.nameLength.toInt(),
|
|
||||||
cen.extraFieldLength.toInt(),
|
|
||||||
).order(ByteOrder.LITTLE_ENDIAN)
|
).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
while (extraFieldsBuf.remaining() > 0) {
|
while (extraFieldsBuf.remaining() > 0) {
|
||||||
val id = extraFieldsBuf.getShort().toUShort()
|
val id = extraFieldsBuf.getShort().toUShort()
|
||||||
val size = extraFieldsBuf.getShort().toUShort()
|
val size = extraFieldsBuf.getShort().toUShort()
|
||||||
extraFieldRecords.add(
|
extraFieldRecords.add(when (id) {
|
||||||
when (id) {
|
|
||||||
Zip64ExtraFieldRecord.ID -> {
|
Zip64ExtraFieldRecord.ID -> {
|
||||||
Zip64ExtraFieldRecord(
|
Zip64ExtraFieldRecord(
|
||||||
size,
|
size,
|
||||||
if (cen.uncompressedSize == 0xffffffffU) {
|
if (cen.uncompressedSize == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.compressedSize == 0xffffffffU) {
|
if (cen.compressedSize == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.localHeaderOffset == 0xffffffffU) {
|
if (cen.localHeaderOffset == 0xffffffffU) {
|
||||||
extraFieldsBuf.getLong().toULong()
|
extraFieldsBuf.getLong().toULong()
|
||||||
} else {
|
} else null,
|
||||||
null
|
|
||||||
},
|
|
||||||
if (cen.disk == 0xffffU.toUShort()) {
|
if (cen.disk == 0xffffU.toUShort()) {
|
||||||
extraFieldsBuf.getInt().toUInt()
|
extraFieldsBuf.getInt().toUInt()
|
||||||
} else {
|
} else null
|
||||||
null
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
extraFieldsBuf.position(extraFieldsBuf.position() + size.toInt())
|
extraFieldsBuf.position(extraFieldsBuf.position() + size.toInt())
|
||||||
ExtraFieldRecord(id, size)
|
ExtraFieldRecord(id, size)
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cen
|
return cen
|
||||||
|
@@ -7,12 +7,11 @@ import java.nio.ByteOrder
|
|||||||
* Represents a partial ZIP64 end of central directory locator.
|
* Represents a partial ZIP64 end of central directory locator.
|
||||||
*/
|
*/
|
||||||
internal class EndOfCentralDirectoryLocator(
|
internal class EndOfCentralDirectoryLocator(
|
||||||
val endOfCentralDirectory64Offset: ULong,
|
val endOfCentralDirectory64Offset: ULong
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x07064b50U
|
const val SIGNATURE = 0x07064b50U
|
||||||
const val SIZE = 20
|
const val SIZE = 20
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `EndOfCentralDirectoryLocator` from raw byte data.
|
* Create `EndOfCentralDirectoryLocator` from raw byte data.
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD locator.
|
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD locator.
|
||||||
@@ -21,16 +20,13 @@ internal class EndOfCentralDirectoryLocator(
|
|||||||
* @return A `EndOfCentralDirectoryLocator`.
|
* @return A `EndOfCentralDirectoryLocator`.
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryLocator {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryLocator {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD64 locator must be at least 20 bytes")
|
throw InvalidDataException("EOCD64 locator must be at least 20 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 8)
|
buf.position(offset + 8)
|
||||||
return EndOfCentralDirectoryLocator(buf.getLong().toULong())
|
return EndOfCentralDirectoryLocator(buf.getLong().toULong())
|
||||||
|
@@ -7,14 +7,14 @@ import java.nio.ByteOrder
|
|||||||
* Represents a partial ZIP end of central directory record.
|
* Represents a partial ZIP end of central directory record.
|
||||||
*/
|
*/
|
||||||
internal class EndOfCentralDirectoryRecord(
|
internal class EndOfCentralDirectoryRecord(
|
||||||
val centralDirectoryOffset: UInt,
|
val centralDirectoryOffset: UInt
|
||||||
) {
|
) {
|
||||||
fun eocd64Required(): Boolean = centralDirectoryOffset == 0xffffffffU
|
fun eocd64Required(): Boolean =
|
||||||
|
centralDirectoryOffset == 0xffffffffU
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x06054b50U
|
const val SIGNATURE = 0x06054b50U
|
||||||
const val SIZE = 22
|
const val SIZE = 22
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `EndOfCentralDirectoryRecord` from raw byte data.
|
* Create `EndOfCentralDirectoryRecord` from raw byte data.
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD64.
|
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD64.
|
||||||
@@ -23,20 +23,17 @@ internal class EndOfCentralDirectoryRecord(
|
|||||||
* @return A `EndOfCentralDirectoryRecord`.
|
* @return A `EndOfCentralDirectoryRecord`.
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryRecord {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryRecord {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD must be at least 22 bytes")
|
throw InvalidDataException("EOCD must be at least 22 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 16)
|
buf.position(offset + 16)
|
||||||
return EndOfCentralDirectoryRecord(
|
return EndOfCentralDirectoryRecord(
|
||||||
centralDirectoryOffset = buf.getInt().toUInt(),
|
centralDirectoryOffset = buf.getInt().toUInt()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -7,12 +7,11 @@ import java.nio.ByteOrder
|
|||||||
* Represents a partial ZIP64 end of central directory record.
|
* Represents a partial ZIP64 end of central directory record.
|
||||||
*/
|
*/
|
||||||
internal class EndOfCentralDirectoryRecord64(
|
internal class EndOfCentralDirectoryRecord64(
|
||||||
val centralDirectoryOffset: ULong,
|
val centralDirectoryOffset: ULong
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val SIGNATURE = 0x06064b50U
|
const val SIGNATURE = 0x06064b50U
|
||||||
const val SIZE = 56
|
const val SIZE = 56
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create `EndOfCentralDirectoryRecord64` from raw byte data.
|
* Create `EndOfCentralDirectoryRecord64` from raw byte data.
|
||||||
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD.
|
* @throws InvalidDataException Provided `ByteArray` is not a supported EOCD.
|
||||||
@@ -21,20 +20,17 @@ internal class EndOfCentralDirectoryRecord64(
|
|||||||
* @return A `EndOfCentralDirectoryRecord64`.
|
* @return A `EndOfCentralDirectoryRecord64`.
|
||||||
*/
|
*/
|
||||||
@Throws(InvalidDataException::class)
|
@Throws(InvalidDataException::class)
|
||||||
fun fromByteArray(
|
fun fromByteArray(data: ByteArray, offset: Int): EndOfCentralDirectoryRecord64 {
|
||||||
data: ByteArray,
|
|
||||||
offset: Int,
|
|
||||||
): EndOfCentralDirectoryRecord64 {
|
|
||||||
if (data.size - offset < SIZE) {
|
if (data.size - offset < SIZE) {
|
||||||
throw InvalidDataException("EOCD64 must be at least 56 bytes")
|
throw InvalidDataException("EOCD64 must be at least 56 bytes")
|
||||||
}
|
}
|
||||||
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
val buf = ByteBuffer.wrap(data, offset, SIZE).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
if (buf.getInt().toUInt() != SIGNATURE) {
|
if (buf.getInt().toUInt() != SIGNATURE) {
|
||||||
throw InvalidDataException("Invalid signature")
|
throw InvalidSignatureException("Invalid signature")
|
||||||
}
|
}
|
||||||
buf.position(offset + 48)
|
buf.position(offset + 48)
|
||||||
return EndOfCentralDirectoryRecord64(
|
return EndOfCentralDirectoryRecord64(
|
||||||
centralDirectoryOffset = buf.getLong().toULong(),
|
centralDirectoryOffset = buf.getLong().toULong()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
src/main/kotlin/ziputils/Exceptions.kt
Normal file
11
src/main/kotlin/ziputils/Exceptions.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package ziputils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an invalid raw byte data exception.
|
||||||
|
*/
|
||||||
|
class InvalidDataException(message: String): Exception(message)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an invalid raw byte signature exception.
|
||||||
|
*/
|
||||||
|
class InvalidSignatureException(message: String): Exception(message)
|
@@ -5,5 +5,5 @@ package ziputils
|
|||||||
*/
|
*/
|
||||||
internal open class ExtraFieldRecord(
|
internal open class ExtraFieldRecord(
|
||||||
val id: UShort,
|
val id: UShort,
|
||||||
val size: UShort,
|
val size: UShort
|
||||||
)
|
)
|
@@ -1,6 +0,0 @@
|
|||||||
package ziputils
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an invalid raw byte data exception.
|
|
||||||
*/
|
|
||||||
class InvalidDataException(message: String) : Exception(message)
|
|
@@ -8,7 +8,7 @@ internal class Zip64ExtraFieldRecord(
|
|||||||
val uncompressedSize: ULong?,
|
val uncompressedSize: ULong?,
|
||||||
val compressedSize: ULong?,
|
val compressedSize: ULong?,
|
||||||
val localHeaderOffset: ULong?,
|
val localHeaderOffset: ULong?,
|
||||||
val disk: UInt?,
|
val disk: UInt?
|
||||||
): ExtraFieldRecord(ID, size) {
|
): ExtraFieldRecord(ID, size) {
|
||||||
companion object {
|
companion object {
|
||||||
const val ID: UShort = 0x0001U
|
const val ID: UShort = 0x0001U
|
||||||
|
@@ -1,165 +0,0 @@
|
|||||||
package backup
|
|
||||||
|
|
||||||
import aws.sdk.kotlin.services.s3.*
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.jupiter.api.*
|
|
||||||
import org.junit.jupiter.api.Assertions.*
|
|
||||||
import java.io.File
|
|
||||||
import kotlin.io.path.*
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
val bucketName = System.getenv("BACKUP_BUCKET") ?: "teamcity-executors-test-task"
|
|
||||||
|
|
||||||
class BackupClientTest {
|
|
||||||
lateinit var s3: S3Client
|
|
||||||
lateinit var backupClient: BackupClient
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun `before each`() =
|
|
||||||
runBlocking {
|
|
||||||
s3 = S3Client.fromEnvironment {}
|
|
||||||
backupClient = BackupClient(s3, bucketName, 1024 * 1024 * 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
fun `after each`() {
|
|
||||||
s3.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@TestFactory
|
|
||||||
fun `round-trip tests`() =
|
|
||||||
listOf(
|
|
||||||
"empty directory" to {
|
|
||||||
listOf(
|
|
||||||
Path("_test").createDirectory(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"single file" to {
|
|
||||||
listOf(
|
|
||||||
Path("_test.txt").apply { writeText("Hello World!") },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"directory structure" to {
|
|
||||||
listOf(
|
|
||||||
Path("_test").createDirectory(),
|
|
||||||
Path("_test/a.txt").apply { writeText("This is file A!\nAnother line here.") },
|
|
||||||
Path("_test/folder").createDirectory(),
|
|
||||||
Path("_test/another-folder").createDirectory(),
|
|
||||||
Path("_test/another-folder/b").apply { writeText("This is file B\n") },
|
|
||||||
Path("_test/another-folder/c.txt").createFile(),
|
|
||||||
Path("_test/README.md").apply { writeText("# This is a test directory structure.") },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"single large file" to {
|
|
||||||
val bytes = ByteArray(1024 * 1024 * 32)
|
|
||||||
Random.nextBytes(bytes)
|
|
||||||
listOf(
|
|
||||||
Path("_test.txt").apply { writeBytes(bytes) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
"large directory structure" to {
|
|
||||||
val bytes1 = ByteArray(1024 * 1024 * 32)
|
|
||||||
val bytes2 = ByteArray(1024 * 1024 * 48)
|
|
||||||
listOf(
|
|
||||||
Path("_test").createDirectory(),
|
|
||||||
Path("_test/a.txt").apply { writeBytes(bytes1) },
|
|
||||||
Path("_test/folder").createDirectory(),
|
|
||||||
Path("_test/another-folder").createDirectory(),
|
|
||||||
Path("_test/another-folder/b").apply { writeText("This is file B\n") },
|
|
||||||
Path("_test/another-folder/c.txt").createFile(),
|
|
||||||
Path("_test/README.md").apply { writeBytes(bytes2) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
).map { (name, pathsGen) ->
|
|
||||||
DynamicTest.dynamicTest(name) {
|
|
||||||
val paths = pathsGen()
|
|
||||||
val backupKey =
|
|
||||||
assertDoesNotThrow("should upload files") {
|
|
||||||
runBlocking {
|
|
||||||
backupClient.upload(paths.first().toFile())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val restoreDir = Path("_test_restore").createDirectory()
|
|
||||||
assertDoesNotThrow("should recover files") {
|
|
||||||
runBlocking {
|
|
||||||
backupClient.restore(restoreDir, backupKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
paths.size,
|
|
||||||
restoreDir.toFile().countEntries() - 1,
|
|
||||||
"number of files in backup restore should be equal to original",
|
|
||||||
)
|
|
||||||
val individualRestoreDir = Path("_test_restore_individual").createDirectory()
|
|
||||||
paths.forEach { path ->
|
|
||||||
if (path.isDirectory()) {
|
|
||||||
individualRestoreDir.resolve(path).createDirectory()
|
|
||||||
} else {
|
|
||||||
assertDoesNotThrow("should recover file '$path'") {
|
|
||||||
runBlocking {
|
|
||||||
backupClient.restoreFile(
|
|
||||||
individualRestoreDir.resolve(path).parent,
|
|
||||||
backupKey,
|
|
||||||
path.toString(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
paths.size,
|
|
||||||
individualRestoreDir.toFile().countEntries() - 1,
|
|
||||||
"number of files in individual backup restore should be equal to original",
|
|
||||||
)
|
|
||||||
paths.asReversed().forEach { path ->
|
|
||||||
val restorePath = restoreDir.resolve(path)
|
|
||||||
val individualRestorePath = individualRestoreDir.resolve(path)
|
|
||||||
if (path.isDirectory()) {
|
|
||||||
assertTrue(restorePath.exists(), "'$path' should exist in backup")
|
|
||||||
assertTrue(restorePath.isDirectory(), "'$path' should be a directory in backup")
|
|
||||||
assertTrue(individualRestorePath.exists(), "'$path' should exist in backup (individual)")
|
|
||||||
assertTrue(
|
|
||||||
individualRestorePath.isDirectory(),
|
|
||||||
"'$path' should be a directory in backup (individual)",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
val originalBytes = path.toFile().readBytes().asList()
|
|
||||||
assertEquals(
|
|
||||||
originalBytes,
|
|
||||||
restorePath.toFile().readBytes().asList(),
|
|
||||||
"File contents of '$path' should equal",
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
originalBytes,
|
|
||||||
individualRestorePath.toFile().readBytes().asList(),
|
|
||||||
"File contents of '$path' should equal (individual)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// cleanup
|
|
||||||
path.deleteExisting()
|
|
||||||
restorePath.deleteExisting()
|
|
||||||
individualRestorePath.deleteExisting()
|
|
||||||
}
|
|
||||||
// cleanup
|
|
||||||
restoreDir.deleteExisting()
|
|
||||||
individualRestoreDir.deleteExisting()
|
|
||||||
runBlocking {
|
|
||||||
s3.deleteObject {
|
|
||||||
bucket = bucketName
|
|
||||||
key = backupKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun File.countEntries(): Int {
|
|
||||||
val queue = ArrayDeque<File>()
|
|
||||||
queue.add(this)
|
|
||||||
return queue.count { file ->
|
|
||||||
if (file.isDirectory) {
|
|
||||||
queue.addAll(file.listFiles()!!) // shouldn't ever be null, since we know it's a directory
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
Reference in New Issue
Block a user