Compare commits

..

10 Commits

Author SHA1 Message Date
Rostislav Dugin
09fc893979 Merge pull request #494 from databasus/develop
FEATURE (storages): Add uptime banner
2026-04-03 11:15:15 +03:00
Rostislav Dugin
8c8c712d97 FEATURE (storages): Add uptime banner 2026-04-03 11:09:11 +03:00
Rostislav Dugin
7a44d0150f Merge pull request #493 from databasus/develop
FEATURE (storage): Add storages warning
2026-04-03 09:38:31 +03:00
Rostislav Dugin
4e1cee2aa2 FEATURE (storage): Add storages warning 2026-04-03 09:36:45 +03:00
Rostislav Dugin
5d685e0a39 Merge pull request #492 from databasus/develop
FIX (backups): Save metadata file to storage before marking backup as…
2026-04-02 09:48:39 +03:00
Rostislav Dugin
18b8178608 FIX (backups): Save metadata file to storage before marking backup as COMPLETED to fix flaky test race condition 2026-04-02 09:48:05 +03:00
Rostislav Dugin
981d560768 Merge pull request #491 from databasus/develop
Develop
2026-04-02 09:05:03 +03:00
Rostislav Dugin
02d9cda86f FEATURE (storages): Add configurable S3 storage class to allow cheaper storage tiers like ONEZONE_IA 2026-04-02 09:00:13 +03:00
Rostislav Dugin
cefedb6ddd FIX (mariadb): Disable wsrep replication during restore to fix "Maximum writeset size exceeded" on Galera Cluster 2026-04-02 08:46:48 +03:00
Rostislav Dugin
27d891fb34 FIX (docker): Use -k /tmp for PostgreSQL socket directory to fix lock file permission denied on NAS systems 2026-04-02 08:26:07 +03:00
20 changed files with 242 additions and 32 deletions

View File

@@ -312,8 +312,6 @@ if [ "\$CURRENT_UID" != "\$PUID" ]; then
usermod -o -u "\$PUID" postgres
fi
chown -R postgres:postgres /var/run/postgresql
# PostgreSQL 17 binary paths
PG_BIN="/usr/lib/postgresql/17/bin"
@@ -426,7 +424,12 @@ fi
# Function to start PostgreSQL and wait for it to be ready
start_postgres() {
echo "Starting PostgreSQL..."
gosu postgres \$PG_BIN/postgres -D /databasus-data/pgdata -p 5437 &
# -k /tmp: create Unix socket and lock file in /tmp instead of /var/run/postgresql/.
# On NAS systems (e.g. TrueNAS Scale), the ZFS-backed Docker overlay filesystem
# ignores chown/chmod on directories from image layers, so PostgreSQL gets
# "Permission denied" when creating .s.PGSQL.5437.lock in /var/run/postgresql/.
# All internal connections use TCP (-h localhost), so the socket location does not matter.
gosu postgres \$PG_BIN/postgres -D /databasus-data/pgdata -p 5437 -k /tmp &
POSTGRES_PID=\$!
echo "Waiting for PostgreSQL to be ready..."

View File

@@ -280,7 +280,6 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
return
}
backup.Status = backups_core.BackupStatusCompleted
backup.BackupDurationMs = time.Since(start).Milliseconds()
// Update backup with encryption metadata if provided
@@ -297,12 +296,6 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
backup.Encryption = backupMetadata.Encryption
}
if err := n.backupRepository.Save(backup); err != nil {
n.logger.Error("Failed to save backup", "error", err)
return
}
// Save metadata file to storage
if backupMetadata != nil {
metadataJSON, err := json.Marshal(backupMetadata)
if err != nil {
@@ -335,6 +328,13 @@ func (n *BackuperNode) MakeBackup(backupID uuid.UUID, isCallNotifier bool) {
}
}
backup.Status = backups_core.BackupStatusCompleted
if err := n.backupRepository.Save(backup); err != nil {
n.logger.Error("Failed to save backup", "error", err)
return
}
// Update database last backup time
now := time.Now().UTC()
if updateErr := n.databaseService.SetLastBackupTime(databaseID, now); updateErr != nil {

View File

@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-backend/internal/config"
audit_logs "databasus-backend/internal/features/audit_logs"
@@ -1516,14 +1517,14 @@ func Test_MakeBackup_VerifyBackupAndMetadataFilesExistInStorage(t *testing.T) {
encryptor := encryption.GetFieldEncryptor()
backupFile, err := backupStorage.GetFile(encryptor, backup.FileName)
assert.NoError(t, err)
require.NoError(t, err)
backupFile.Close()
metadataFile, err := backupStorage.GetFile(encryptor, backup.FileName+".metadata")
assert.NoError(t, err)
require.NoError(t, err)
metadataContent, err := io.ReadAll(metadataFile)
assert.NoError(t, err)
require.NoError(t, err)
metadataFile.Close()
var storageMetadata backups_common.BackupMetadata

View File

@@ -70,6 +70,14 @@ func (uc *RestoreMariadbBackupUsecase) Execute(
"--verbose",
}
// Disable Galera Cluster replication for the restore session to prevent
// "Maximum writeset size exceeded" errors on large restores.
// wsrep_on is available in MariaDB 10.1+ (all builds with Galera support).
// On non-Galera instances the variable still exists but is a no-op.
if mdb.Version != tools.MariadbVersion55 {
args = append(args, "--init-command=SET SESSION wsrep_on=OFF")
}
if !config.GetEnv().IsCloud {
args = append(args, "--max-allowed-packet=1G")
}
@@ -379,6 +387,13 @@ func (uc *RestoreMariadbBackupUsecase) handleMariadbRestoreError(
)
}
if containsIgnoreCase(stderrStr, "writeset size exceeded") {
return fmt.Errorf(
"MariaDB Galera Cluster writeset size limit exceeded. Try increasing wsrep_max_ws_size on your cluster nodes. stderr: %s",
stderrStr,
)
}
return errors.New(errorMsg)
}

View File

@@ -110,6 +110,18 @@ func Test_Storage_BasicOperations(t *testing.T) {
S3Endpoint: "http://" + s3Container.endpoint,
},
},
{
name: "S3Storage_WithStorageClass",
storage: &s3_storage.S3Storage{
StorageID: uuid.New(),
S3Bucket: s3Container.bucketName,
S3Region: s3Container.region,
S3AccessKey: s3Container.accessKey,
S3SecretKey: s3Container.secretKey,
S3Endpoint: "http://" + s3Container.endpoint,
S3StorageClass: s3_storage.S3StorageClassStandard,
},
},
{
name: "NASStorage",
storage: &nas_storage.NASStorage{

View File

@@ -0,0 +1,13 @@
package s3_storage
type S3StorageClass string
const (
S3StorageClassDefault S3StorageClass = ""
S3StorageClassStandard S3StorageClass = "STANDARD"
S3StorageClassStandardIA S3StorageClass = "STANDARD_IA"
S3StorageClassOnezoneIA S3StorageClass = "ONEZONE_IA"
S3StorageClassIntelligentTiering S3StorageClass = "INTELLIGENT_TIERING"
S3StorageClassReducedRedundancy S3StorageClass = "REDUCED_REDUNDANCY"
S3StorageClassGlacierIR S3StorageClass = "GLACIER_IR"
)

View File

@@ -43,9 +43,10 @@ type S3Storage struct {
S3SecretKey string `json:"s3SecretKey" gorm:"not null;type:text;column:s3_secret_key"`
S3Endpoint string `json:"s3Endpoint" gorm:"type:text;column:s3_endpoint"`
S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"`
S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"`
SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"`
S3Prefix string `json:"s3Prefix" gorm:"type:text;column:s3_prefix"`
S3UseVirtualHostedStyle bool `json:"s3UseVirtualHostedStyle" gorm:"default:false;column:s3_use_virtual_hosted_style"`
SkipTLSVerify bool `json:"skipTLSVerify" gorm:"default:false;column:skip_tls_verify"`
S3StorageClass S3StorageClass `json:"s3StorageClass" gorm:"type:text;column:s3_storage_class;default:''"`
}
func (s *S3Storage) TableName() string {
@@ -76,7 +77,7 @@ func (s *S3Storage) SaveFile(
ctx,
s.S3Bucket,
objectKey,
minio.PutObjectOptions{},
s.putObjectOptions(),
)
if err != nil {
return fmt.Errorf("failed to initiate multipart upload: %w", err)
@@ -151,15 +152,16 @@ func (s *S3Storage) SaveFile(
if err != nil {
return err
}
opts := s.putObjectOptions()
opts.SendContentMd5 = true
_, err = client.PutObject(
ctx,
s.S3Bucket,
objectKey,
bytes.NewReader([]byte{}),
0,
minio.PutObjectOptions{
SendContentMd5: true,
},
opts,
)
if err != nil {
return fmt.Errorf("failed to upload empty file: %w", err)
@@ -173,7 +175,7 @@ func (s *S3Storage) SaveFile(
objectKey,
uploadID,
parts,
minio.PutObjectOptions{},
s.putObjectOptions(),
)
if err != nil {
_ = coreClient.AbortMultipartUpload(ctx, s.S3Bucket, objectKey, uploadID)
@@ -350,6 +352,7 @@ func (s *S3Storage) Update(incoming *S3Storage) {
s.S3Endpoint = incoming.S3Endpoint
s.S3UseVirtualHostedStyle = incoming.S3UseVirtualHostedStyle
s.SkipTLSVerify = incoming.SkipTLSVerify
s.S3StorageClass = incoming.S3StorageClass
if incoming.S3AccessKey != "" {
s.S3AccessKey = incoming.S3AccessKey
@@ -363,6 +366,12 @@ func (s *S3Storage) Update(incoming *S3Storage) {
// otherwise we will have to transfer all the data to the new prefix
}
func (s *S3Storage) putObjectOptions() minio.PutObjectOptions {
return minio.PutObjectOptions{
StorageClass: string(s.S3StorageClass),
}
}
func (s *S3Storage) buildObjectKey(fileName string) string {
if s.S3Prefix == "" {
return fileName

View File

@@ -0,0 +1,11 @@
-- +goose Up
-- +goose StatementBegin
ALTER TABLE s3_storages
ADD COLUMN s3_storage_class TEXT NOT NULL DEFAULT '';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE s3_storages
DROP COLUMN s3_storage_class;
-- +goose StatementEnd

View File

@@ -3,6 +3,7 @@ export { type Storage } from './models/Storage';
export { StorageType } from './models/StorageType';
export { type LocalStorage } from './models/LocalStorage';
export { type S3Storage } from './models/S3Storage';
export { S3StorageClass, S3StorageClassLabels } from './models/S3StorageClass';
export { type NASStorage } from './models/NASStorage';
export { getStorageLogoFromType } from './models/getStorageLogoFromType';
export { getStorageNameFromType } from './models/getStorageNameFromType';

View File

@@ -7,4 +7,5 @@ export interface S3Storage {
s3Prefix?: string;
s3UseVirtualHostedStyle?: boolean;
skipTLSVerify?: boolean;
s3StorageClass?: string;
}

View File

@@ -0,0 +1,19 @@
export enum S3StorageClass {
DEFAULT = '',
STANDARD = 'STANDARD',
STANDARD_IA = 'STANDARD_IA',
ONEZONE_IA = 'ONEZONE_IA',
INTELLIGENT_TIERING = 'INTELLIGENT_TIERING',
REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY',
GLACIER_IR = 'GLACIER_IR',
}
export const S3StorageClassLabels: Record<S3StorageClass, string> = {
[S3StorageClass.DEFAULT]: 'Default (Standard)',
[S3StorageClass.STANDARD]: 'Standard',
[S3StorageClass.STANDARD_IA]: 'Standard - Infrequent Access',
[S3StorageClass.ONEZONE_IA]: 'One Zone - Infrequent Access',
[S3StorageClass.INTELLIGENT_TIERING]: 'Intelligent Tiering',
[S3StorageClass.REDUCED_REDUNDANCY]: 'Reduced Redundancy',
[S3StorageClass.GLACIER_IR]: 'Glacier Instant Retrieval',
};

View File

@@ -558,7 +558,7 @@ export const BackupsComponent = ({
<div className="mt-5" />
{database.postgresql?.backupType !== PostgresBackupType.WAL_V1 && (
<div className="flex">
<div className="flex items-center">
<Button
onClick={makeBackup}
className="mr-1"
@@ -569,6 +569,17 @@ export const BackupsComponent = ({
<span className="md:hidden">Backup now</span>
<span className="hidden md:inline">Make backup right now</span>
</Button>
{!IS_CLOUD && (
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="inline-flex h-8 items-center rounded-md border border-blue-600 px-[15px] text-sm leading-none transition-colors hover:bg-blue-50 dark:!text-white dark:hover:bg-blue-900/30"
>
Get 24x7 uptime and 2x independent backups copy
</a>
)}
</div>
)}

View File

@@ -1,6 +1,8 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Modal, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { IS_CLOUD } from '../../../constants';
import { storageApi } from '../../../entity/storages';
import type { Storage } from '../../../entity/storages';
import type { UserProfile } from '../../../entity/users';
@@ -100,6 +102,31 @@ export const StoragesComponent = ({
>
{storages.length >= 5 && isCanManageStorages && addStorageButton}
{!IS_CLOUD && (
<div className="mb-3 rounded bg-yellow-50 p-3 shadow dark:bg-yellow-900/30">
<div className="mb-1 flex items-center gap-1.5 text-sm font-bold text-yellow-700 dark:text-yellow-400">
<ExclamationCircleOutlined />
Self-hosted notice
</div>
<div className="text-sm !text-yellow-600 dark:!text-yellow-500">
Do not forget to backup the storage itself as it contains all your backups.
<br /> Or you can use cloud{"'"}s build-in{' '}
<u>unlimited storage with double reservation</u>. We care about security,
maintainance and 24x7 uptime
</div>
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="mt-2 block w-full rounded-md !bg-green-600 px-4 py-1.5 text-center text-sm font-medium !text-white transition-colors hover:!bg-green-700 dark:!bg-green-700 dark:hover:!bg-green-800"
>
Use cloud storage from $9
</a>
</div>
)}
{storages.map((storage) => (
<StorageCardComponent
key={storage.id}

View File

@@ -1,4 +1,4 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { ExclamationCircleOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input, Select, Switch, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
@@ -14,6 +14,7 @@ import { ToastHelper } from '../../../../shared/toast';
import { EditAzureBlobStorageComponent } from './storages/EditAzureBlobStorageComponent';
import { EditFTPStorageComponent } from './storages/EditFTPStorageComponent';
import { EditGoogleDriveStorageComponent } from './storages/EditGoogleDriveStorageComponent';
import { EditLocalStorageComponent } from './storages/EditLocalStorageComponent';
import { EditNASStorageComponent } from './storages/EditNASStorageComponent';
import { EditRcloneStorageComponent } from './storages/EditRcloneStorageComponent';
import { EditS3StorageComponent } from './storages/EditS3StorageComponent';
@@ -486,6 +487,35 @@ export function EditStorageComponent({
}}
/>
)}
{storage?.type === StorageType.LOCAL && <EditLocalStorageComponent />}
</div>
<div>
{!IS_CLOUD && (
<div className="mb-3 rounded bg-yellow-50 p-3 shadow dark:bg-yellow-900/30">
<div className="mb-1 flex items-center gap-1.5 text-sm font-bold text-yellow-700 dark:text-yellow-400">
<ExclamationCircleOutlined />
Self-hosted notice
</div>
<div className="text-sm !text-yellow-600 dark:!text-yellow-500">
Do not forget to backup the storage itself as it contains all your backups.
<br /> Or you can use cloud{"'"}s build-in{' '}
<u>unlimited storage with double reservation</u>. We care about security, maintainance
and 24x7 uptime for you
</div>
<a
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
className="mt-2 block w-full rounded-md !bg-green-600 px-4 py-1.5 text-center text-sm font-medium !text-white transition-colors hover:!bg-green-700 dark:!bg-green-700 dark:hover:!bg-green-800"
>
Use cloud storage from $9
</a>
</div>
)}
</div>
<div className="mt-3 flex">

View File

@@ -0,0 +1,14 @@
import { ExclamationCircleOutlined } from '@ant-design/icons';
export function EditLocalStorageComponent() {
return (
<>
<div className="max-w-[360px] text-yellow-600 dark:text-yellow-400">
<ExclamationCircleOutlined /> Be careful: with local storage you may run out of ROM memory.
It is recommended to use S3 or unlimited storages
</div>
<div className="mb-5" />
</>
);
}

View File

@@ -1,8 +1,8 @@
import { DownOutlined, InfoCircleOutlined, UpOutlined } from '@ant-design/icons';
import { Checkbox, Input, Tooltip } from 'antd';
import { Checkbox, Input, Select, Tooltip } from 'antd';
import { useEffect, useState } from 'react';
import type { Storage } from '../../../../../entity/storages';
import { S3StorageClass, S3StorageClassLabels, type Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
@@ -20,7 +20,8 @@ export function EditS3StorageComponent({
const hasAdvancedValues =
!!storage?.s3Storage?.s3Prefix ||
!!storage?.s3Storage?.s3UseVirtualHostedStyle ||
!!storage?.s3Storage?.skipTLSVerify;
!!storage?.s3Storage?.skipTLSVerify ||
!!storage?.s3Storage?.s3StorageClass;
const [showAdvanced, setShowAdvanced] = useState(hasAdvancedValues);
useEffect(() => {
@@ -278,6 +279,40 @@ export function EditS3StorageComponent({
</Tooltip>
</div>
</div>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
<div className="mb-1 min-w-[110px] sm:mb-0">Storage class</div>
<div className="flex items-center">
<Select
value={storage?.s3Storage?.s3StorageClass || S3StorageClass.DEFAULT}
options={Object.entries(S3StorageClassLabels).map(([value, label]) => ({
value,
label,
}))}
onChange={(value) => {
if (!storage?.s3Storage) return;
setStorage({
...storage,
s3Storage: {
...storage.s3Storage,
s3StorageClass: value,
},
});
setUnsaved();
}}
size="small"
className="w-[250px] max-w-[250px]"
/>
<Tooltip
className="cursor-pointer"
title="S3 storage class for uploaded objects. Leave as default for Standard. Some providers offer cheaper classes like One Zone IA. Do not use Glacier/Deep Archive — files must be immediately accessible for restores."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
</>
)}

View File

@@ -1,4 +1,4 @@
import type { Storage } from '../../../../../entity/storages';
import { S3StorageClass, S3StorageClassLabels, type Storage } from '../../../../../entity/storages';
interface Props {
storage: Storage;
@@ -52,6 +52,14 @@ export function ShowS3StorageComponent({ storage }: Props) {
Enabled
</div>
)}
{storage?.s3Storage?.s3StorageClass && (
<div className="mb-1 flex items-center">
<div className="min-w-[110px]">Storage Class</div>
{S3StorageClassLabels[storage.s3Storage.s3StorageClass as S3StorageClass] ||
storage.s3Storage.s3StorageClass}
</div>
)}
</>
);
}

View File

@@ -34,12 +34,12 @@ export function AuthNavbarComponent() {
{!IS_CLOUD && (
<a
className="!text-black !underline !decoration-blue-600 !decoration-2 underline-offset-2 hover:opacity-80 dark:!text-gray-200"
className="!text-black hover:opacity-80 dark:!text-gray-200"
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}

View File

@@ -232,12 +232,12 @@ export const MainScreenComponent = () => {
{!IS_CLOUD && (
<a
className="!text-black !underline !decoration-blue-600 !decoration-2 underline-offset-2 hover:opacity-80 dark:!text-gray-200"
className="!text-black hover:opacity-80 dark:!text-gray-200"
href="https://databasus.com/cloud"
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}

View File

@@ -211,7 +211,7 @@ export const SidebarComponent = ({
target="_blank"
rel="noreferrer"
>
Cloud (from $9)
Cloud
</a>
)}