Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \

RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata
apk add --no-cache acl attr cifs-utils davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata

ENTRYPOINT ["/sbin/tini", "-s", "--"]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type Props = {
const defaultValuesForType = {
directory: { backend: "directory" as const, path: "/" },
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const, mapToContainerUidGid: false },
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
rclone: { backend: "rclone" as const, path: "/" },
sftp: { backend: "sftp" as const, port: 22, path: "/", skipHostKeyCheck: false },
Expand Down
26 changes: 26 additions & 0 deletions app/client/modules/volumes/components/volume-forms/smb-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,32 @@ export const SMBForm = ({ form }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="mapToContainerUidGid"
defaultValue={false}
render={({ field }) => (
<FormItem>
<FormLabel>Ownership Mapping</FormLabel>
<FormControl>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
className="rounded border-gray-300"
/>
<span className="text-sm">Map all files to container user/group</span>
</div>
</FormControl>
<FormDescription>
Keep the old behavior by forcing the SMB mount to present every file and directory as owned by the
container user and group instead of using server reported ownership.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="readOnly"
Expand Down
3 changes: 2 additions & 1 deletion app/server/modules/lifecycle/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { v00002 } from "./migrations/00002-isolate-restic-passwords";
import { v00003 } from "./migrations/00003-assign-organization";
import { v00004 } from "./migrations/00004-concat-path-name";
import { v00005 } from "./migrations/00005-split-backup-include-paths";
import { v00006 } from "./migrations/00006-map-smb-files-to-container-uid-gid";
import { sql } from "drizzle-orm";
import { appMetadataTable, usersTable } from "../../db/schema";
import { db } from "../../db/db";
Expand Down Expand Up @@ -38,7 +39,7 @@ type MigrationEntity = {
dependsOn?: string[];
};

const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005];
const registry: MigrationEntity[] = [v00001, v00002, v00003, v00004, v00005, v00006];

export const runMigrations = async () => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { logger } from "@zerobyte/core/node";
import { db } from "../../../db/db";
import { volumesTable } from "../../../db/schema";
import { toMessage } from "~/server/utils/errors";

const execute = async () => {
const errors: Array<{ name: string; error: string }> = [];
const volumes = await db.query.volumesTable.findMany();
let migratedCount = 0;

for (const volume of volumes) {
if (volume.type !== "smb" || volume.config.backend !== "smb" || volume.config.mapToContainerUidGid !== undefined) {
continue;
}

try {
await db
.update(volumesTable)
.set({
config: { ...volume.config, mapToContainerUidGid: true },
updatedAt: Date.now(),
})
.where(eq(volumesTable.id, volume.id));

migratedCount += 1;
} catch (error) {
errors.push({
name: `volume:${volume.id}`,
error: toMessage(error),
});
}
}

logger.info(`Migration 00006-map-smb-files-to-container-uid-gid updated ${migratedCount} SMB volumes.`);

return { success: errors.length === 0, errors };
};

export const v00006 = {
execute,
id: "00006-map-smb-files-to-container-uid-gid",
type: "maintenance" as const,
};
47 changes: 47 additions & 0 deletions app/test/backend-integration/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
FROM oven/bun:1.3.13-alpine@sha256:4de475389889577f346c636f956b42a5c31501b654664e9ae5726f94d7bb5349

ARG RESTIC_VERSION="0.18.1"
ARG RCLONE_VERSION="1.74.0"
ARG SHOUTRRR_VERSION="0.14.3"
ARG TARGETARCH

WORKDIR /app

RUN apk update --no-cache && \
apk upgrade --no-cache && \
apk add --no-cache acl attr cifs-utils curl bzip2 unzip tar davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini tzdata

COPY ./package.json ./bun.lock ./
COPY ./packages/core/package.json ./packages/core/package.json
COPY ./packages/contracts/package.json ./packages/contracts/package.json
COPY ./apps/agent/package.json ./apps/agent/package.json
COPY ./apps/docs/package.json ./apps/docs/package.json

RUN VITE_GIT_HOOKS=0 bun install --frozen-lockfile

COPY . .

RUN bun run build:backend-integration

RUN echo "Building for ${TARGETARCH}" && if [ "${TARGETARCH}" = "arm64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_arm64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-arm64.zip"; \
unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_arm64v8_${SHOUTRRR_VERSION}.tar.gz"; \
elif [ "${TARGETARCH}" = "amd64" ]; then \
curl -fL -o restic.bz2 "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2"; \
curl -fL -o rclone.zip "https://github.com/rclone/rclone/releases/download/v${RCLONE_VERSION}/rclone-v${RCLONE_VERSION}-linux-amd64.zip"; \
unzip rclone.zip; \
curl -fL -o shoutrrr.tar.gz "https://github.com/nicholas-fedor/shoutrrr/releases/download/v${SHOUTRRR_VERSION}/shoutrrr_linux_amd64_${SHOUTRRR_VERSION}.tar.gz"; \
else \
echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; \
exit 1; \
fi

RUN bzip2 -d restic.bz2 && install -m 0755 restic /usr/local/bin/restic
RUN mv rclone-v*-linux-*/rclone /usr/local/bin/rclone && chmod +x /usr/local/bin/rclone
RUN tar -xzf shoutrrr.tar.gz && install -m 0755 shoutrrr /usr/local/bin/shoutrrr

ENTRYPOINT ["/sbin/tini", "-s", "--"]

CMD ["bun", ".output/backend-integration/index.js"]
26 changes: 26 additions & 0 deletions app/test/backend-integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Backend Integration

This runner executes isolated source-level integration tests for Zerobyte volume backends and repository backends inside a Docker container that shares the same runtime tooling as the main image.

## What it verifies

For each scenario the runner will:

1. Mount the configured volume backend into an isolated temp workspace.
2. Read fixture files directly from the mounted filesystem.
3. Verify mounted file ownership and permission bits.
4. Run a restic backup directly against the configured repository backend.
5. Inspect the created snapshot with `restic ls` and verify snapshot metadata.
6. Restore the snapshot into an isolated temp directory.
7. Verify restored content, ownership, and permission bits.
8. Unmount and clean up local test artifacts.

## Bootstrap a Debian target

This folder includes `setup-target.sh`, which connects to a VM host and configures:

- NFS export at `/srv/zerobyte-backend-integration/fixtures`
- Samba share `//<host>/zerobyte-backend-integration`
- WebDAV endpoint at `http://<host>/zerobyte-backend-integration`
- SFTP access for both fixture reads and a reusable restic repository
- A generated local keypair, `known_hosts`, password files, and a ready-to-run config under `artifacts/192.168.2.41/`
2 changes: 2 additions & 0 deletions app/test/backend-integration/artifacts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
18 changes: 18 additions & 0 deletions app/test/backend-integration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
function ensureIntegrationEnv(): void {
process.env.NODE_ENV ??= "production";
process.env.LOG_LEVEL ??= "warn";
process.env.BASE_URL ??= "http://localhost:4096";
process.env.TRUSTED_ORIGINS ??= process.env.BASE_URL;
process.env.APP_SECRET ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
}

async function main(): Promise<void> {
ensureIntegrationEnv();

const { runBackendIntegration } = await import("./runner");
await runBackendIntegration();
}

await main();

export {};
34 changes: 34 additions & 0 deletions app/test/backend-integration/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "$0")" && pwd)"
repo_root="$(cd "$script_dir/../../.." && pwd)"
default_config_path="$script_dir/artifacts/192.168.2.41/config.generated.json"
config_path="${1:-$default_config_path}"
image_tag="zerobyte-backend-integration"

if [[ ! -f "$config_path" ]]; then
if [[ "$config_path" == "$default_config_path" ]]; then
echo "Generated config not found: $config_path" >&2
echo "Run the target bootstrap first:" >&2
echo " bash app/test/backend-integration/setup-target.sh" >&2
else
echo "Config file not found: $config_path" >&2
fi
exit 1
fi

if command -v realpath >/dev/null 2>&1; then
config_path="$(realpath "$config_path")"
else
config_dir="$(cd "$(dirname "$config_path")" && pwd)"
config_path="$config_dir/$(basename "$config_path")"
fi

docker build -f "$script_dir/Dockerfile" -t "$image_tag" "$repo_root"
docker run --rm \
--cap-add SYS_ADMIN \
--device /dev/fuse:/dev/fuse \
-e ZEROBYTE_INTEGRATION_CONFIG=/config/config.json \
-v "$config_path:/config/config.json:ro" \
"$image_tag"
Loading
Loading