mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb53bb07b | ||
|
|
6ac04270b9 | ||
|
|
b0510d7c21 | ||
|
|
dc5f271882 | ||
|
|
8f718771c9 | ||
|
|
d8eea05dca | ||
|
|
b2a94274d7 | ||
|
|
77c2712ebb | ||
|
|
a9dc29f82c | ||
|
|
c934a45dca | ||
|
|
d4acdf2826 | ||
|
|
49753c4fc0 | ||
|
|
c6aed6b36d | ||
|
|
3060b4266a | ||
|
|
ebeb597f17 | ||
|
|
4783784325 | ||
|
|
bd41433bdb | ||
|
|
a9073787d2 | ||
|
|
0890bf8f09 | ||
|
|
f8c11e8802 | ||
|
|
e798d82fc1 | ||
|
|
81a01585ee |
@@ -272,6 +272,15 @@ window.__RUNTIME_CONFIG__ = {
|
||||
};
|
||||
JSEOF
|
||||
|
||||
# Inject analytics script if provided (only if not already injected)
|
||||
if [ -n "\${ANALYTICS_SCRIPT:-}" ]; then
|
||||
if ! grep -q "rybbit.databasus.com" /app/ui/build/index.html 2>/dev/null; then
|
||||
echo "Injecting analytics script..."
|
||||
sed -i "s#</head># \${ANALYTICS_SCRIPT}\\
|
||||
</head>#" /app/ui/build/index.html
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure proper ownership of data directory
|
||||
echo "Setting up data directory permissions..."
|
||||
mkdir -p /databasus-data/pgdata
|
||||
|
||||
@@ -103,6 +103,16 @@ func (s *BackupsScheduler) IsSchedulerRunning() bool {
|
||||
return s.lastBackupTime.After(time.Now().UTC().Add(-schedulerHealthcheckThreshold))
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) IsBackupNodesAvailable() bool {
|
||||
nodes, err := s.backupNodesRegistry.GetAvailableNodes()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get available nodes for health check", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return len(nodes) > 0
|
||||
}
|
||||
|
||||
func (s *BackupsScheduler) StartBackup(databaseID uuid.UUID, isCallNotifier bool) {
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(databaseID)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
users_services "databasus-backend/internal/features/users/services"
|
||||
users_testing "databasus-backend/internal/features/users/testing"
|
||||
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
|
||||
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
|
||||
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
|
||||
"databasus-backend/internal/util/encryption"
|
||||
test_utils "databasus-backend/internal/util/testing"
|
||||
@@ -1969,6 +1970,143 @@ func Test_TransferSystemStorage_TransferBlocked(t *testing.T) {
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceB, router)
|
||||
}
|
||||
|
||||
func Test_DeleteWorkspace_SystemStoragesFromAnotherWorkspaceNotRemovedAndWorkspaceDeletedSuccessfully(
|
||||
t *testing.T,
|
||||
) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", admin, router)
|
||||
workspaceD := workspaces_testing.CreateTestWorkspace("Workspace D", admin, router)
|
||||
|
||||
// Create a system storage in workspace A
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspaceA.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "Test System Storage " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
var savedSystemStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedSystemStorage,
|
||||
)
|
||||
assert.True(t, savedSystemStorage.IsSystem)
|
||||
assert.Equal(t, workspaceA.ID, savedSystemStorage.WorkspaceID)
|
||||
|
||||
// Create a regular storage in workspace D
|
||||
regularStorage := createNewStorage(workspaceD.ID)
|
||||
var savedRegularStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*regularStorage,
|
||||
http.StatusOK,
|
||||
&savedRegularStorage,
|
||||
)
|
||||
assert.False(t, savedRegularStorage.IsSystem)
|
||||
assert.Equal(t, workspaceD.ID, savedRegularStorage.WorkspaceID)
|
||||
|
||||
// Delete workspace D
|
||||
workspaces_testing.DeleteWorkspace(workspaceD, admin.Token, router)
|
||||
|
||||
// Verify system storage from workspace A still exists
|
||||
repository := &StorageRepository{}
|
||||
systemStorageAfterDeletion, err := repository.FindByID(savedSystemStorage.ID)
|
||||
assert.NoError(t, err, "System storage should still exist after workspace D deletion")
|
||||
assert.NotNil(t, systemStorageAfterDeletion)
|
||||
assert.Equal(t, savedSystemStorage.ID, systemStorageAfterDeletion.ID)
|
||||
assert.True(t, systemStorageAfterDeletion.IsSystem)
|
||||
assert.Equal(t, workspaceA.ID, systemStorageAfterDeletion.WorkspaceID)
|
||||
|
||||
// Verify regular storage from workspace D was deleted
|
||||
regularStorageAfterDeletion, err := repository.FindByID(savedRegularStorage.ID)
|
||||
assert.Error(t, err, "Regular storage should be deleted with workspace D")
|
||||
assert.Nil(t, regularStorageAfterDeletion)
|
||||
|
||||
// Cleanup
|
||||
deleteStorage(t, router, savedSystemStorage.ID, admin.Token)
|
||||
workspaces_testing.RemoveTestWorkspace(workspaceA, router)
|
||||
}
|
||||
|
||||
func Test_DeleteWorkspace_WithOwnSystemStorage_ReturnsForbidden(t *testing.T) {
|
||||
router := createRouter()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
admin := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
|
||||
workspaceA := workspaces_testing.CreateTestWorkspace("Workspace A", admin, router)
|
||||
|
||||
// Create a system storage assigned to workspace A
|
||||
systemStorage := &Storage{
|
||||
WorkspaceID: workspaceA.ID,
|
||||
Type: StorageTypeLocal,
|
||||
Name: "System Storage in A " + uuid.New().String(),
|
||||
IsSystem: true,
|
||||
LocalStorage: &local_storage.LocalStorage{},
|
||||
}
|
||||
|
||||
var savedSystemStorage Storage
|
||||
test_utils.MakePostRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
"/api/v1/storages",
|
||||
"Bearer "+admin.Token,
|
||||
*systemStorage,
|
||||
http.StatusOK,
|
||||
&savedSystemStorage,
|
||||
)
|
||||
assert.True(t, savedSystemStorage.IsSystem)
|
||||
assert.Equal(t, workspaceA.ID, savedSystemStorage.WorkspaceID)
|
||||
|
||||
// Attempt to delete workspace A - should fail because it has a system storage
|
||||
resp := workspaces_testing.MakeAPIRequest(
|
||||
router,
|
||||
"DELETE",
|
||||
"/api/v1/workspaces/"+workspaceA.ID.String(),
|
||||
"Bearer "+admin.Token,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.Code, "Workspace deletion should fail")
|
||||
assert.Contains(
|
||||
t,
|
||||
resp.Body.String(),
|
||||
"system storage cannot be deleted due to workspace deletion",
|
||||
"Error message should indicate system storage prevents deletion",
|
||||
)
|
||||
|
||||
// Verify workspace still exists
|
||||
workspaceRepo := &workspaces_repositories.WorkspaceRepository{}
|
||||
workspaceAfterFailedDeletion, err := workspaceRepo.GetWorkspaceByID(workspaceA.ID)
|
||||
assert.NoError(t, err, "Workspace should still exist after failed deletion")
|
||||
assert.NotNil(t, workspaceAfterFailedDeletion)
|
||||
assert.Equal(t, workspaceA.ID, workspaceAfterFailedDeletion.ID)
|
||||
|
||||
// Verify system storage still exists
|
||||
repository := &StorageRepository{}
|
||||
storageAfterFailedDeletion, err := repository.FindByID(savedSystemStorage.ID)
|
||||
assert.NoError(t, err, "System storage should still exist after failed deletion")
|
||||
assert.NotNil(t, storageAfterFailedDeletion)
|
||||
assert.Equal(t, savedSystemStorage.ID, storageAfterFailedDeletion.ID)
|
||||
assert.True(t, storageAfterFailedDeletion.IsSystem)
|
||||
|
||||
// Cleanup: Delete system storage first, then workspace can be deleted
|
||||
deleteStorage(t, router, savedSystemStorage.ID, admin.Token)
|
||||
workspaces_testing.DeleteWorkspace(workspaceA, admin.Token, router)
|
||||
|
||||
// Verify workspace was successfully deleted after storage removal
|
||||
_, err = workspaceRepo.GetWorkspaceByID(workspaceA.ID)
|
||||
assert.Error(t, err, "Workspace should be deleted after storage was removed")
|
||||
}
|
||||
|
||||
func createRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -1983,6 +2121,7 @@ func createRouter() *gin.Engine {
|
||||
}
|
||||
|
||||
audit_logs.SetupDependencies()
|
||||
SetupDependencies()
|
||||
GetStorageService().SetStorageDatabaseCounter(&mockStorageDatabaseCounter{})
|
||||
|
||||
return router
|
||||
|
||||
@@ -25,6 +25,32 @@ func (s *StorageService) SetStorageDatabaseCounter(storageDatabaseCounter Storag
|
||||
s.storageDatabaseCounter = storageDatabaseCounter
|
||||
}
|
||||
|
||||
func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
|
||||
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get storages for workspace deletion: %w", err)
|
||||
}
|
||||
|
||||
for _, storage := range storages {
|
||||
if storage.IsSystem && storage.WorkspaceID != workspaceID {
|
||||
// skip system storage from another workspace
|
||||
continue
|
||||
}
|
||||
|
||||
if storage.IsSystem && storage.WorkspaceID == workspaceID {
|
||||
return fmt.Errorf(
|
||||
"system storage cannot be deleted due to workspace deletion, please transfer or remove storage first",
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.storageRepository.Delete(storage); err != nil {
|
||||
return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageService) SaveStorage(
|
||||
user *users_models.User,
|
||||
workspaceID uuid.UUID,
|
||||
@@ -351,18 +377,3 @@ func (s *StorageService) TransferStorageToWorkspace(
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageService) OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error {
|
||||
storages, err := s.storageRepository.FindByWorkspaceID(workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get storages for workspace deletion: %w", err)
|
||||
}
|
||||
|
||||
for _, storage := range storages {
|
||||
if err := s.storageRepository.Delete(storage); err != nil {
|
||||
return fmt.Errorf("failed to delete storage %s: %w", storage.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ func (s *HealthcheckService) performHealthCheck() error {
|
||||
if !s.backupBackgroundService.IsSchedulerRunning() {
|
||||
return errors.New("backups are not running for more than 5 minutes")
|
||||
}
|
||||
|
||||
if !s.backupBackgroundService.IsBackupNodesAvailable() {
|
||||
return errors.New("no backup nodes available")
|
||||
}
|
||||
}
|
||||
|
||||
if config.GetEnv().IsProcessingNode {
|
||||
|
||||
@@ -71,6 +71,7 @@ func tryInitVictoriaLogs() *VictoriaLogsWriter {
|
||||
|
||||
// Try to get config - this may fail early in startup
|
||||
url := getVictoriaLogsURL()
|
||||
username := getVictoriaLogsUsername()
|
||||
password := getVictoriaLogsPassword()
|
||||
|
||||
if url == "" {
|
||||
@@ -78,7 +79,7 @@ func tryInitVictoriaLogs() *VictoriaLogsWriter {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NewVictoriaLogsWriter(url, password)
|
||||
return NewVictoriaLogsWriter(url, username, password)
|
||||
}
|
||||
|
||||
func ensureEnvLoaded() {
|
||||
@@ -126,6 +127,10 @@ func getVictoriaLogsURL() string {
|
||||
return os.Getenv("VICTORIA_LOGS_URL")
|
||||
}
|
||||
|
||||
func getVictoriaLogsUsername() string {
|
||||
return os.Getenv("VICTORIA_LOGS_USERNAME")
|
||||
}
|
||||
|
||||
func getVictoriaLogsPassword() string {
|
||||
return os.Getenv("VICTORIA_LOGS_PASSWORD")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type logEntry struct {
|
||||
|
||||
type VictoriaLogsWriter struct {
|
||||
url string
|
||||
username string
|
||||
password string
|
||||
httpClient *http.Client
|
||||
logChannel chan logEntry
|
||||
@@ -33,11 +34,12 @@ type VictoriaLogsWriter struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewVictoriaLogsWriter(url, password string) *VictoriaLogsWriter {
|
||||
func NewVictoriaLogsWriter(url, username, password string) *VictoriaLogsWriter {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writer := &VictoriaLogsWriter{
|
||||
url: url,
|
||||
username: username,
|
||||
password: password,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -149,9 +151,9 @@ func (w *VictoriaLogsWriter) sendHTTP(entries []logEntry) error {
|
||||
// Set headers
|
||||
req.Header.Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
// Set Basic Auth (password as username, empty password)
|
||||
// Set Basic Auth (username:password)
|
||||
if w.password != "" {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(w.password + ":"))
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(w.username + ":" + w.password))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ export const EditMariaDbSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -425,7 +425,7 @@ export const EditMongoDbSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -353,7 +353,7 @@ export const EditMySqlSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -514,7 +514,7 @@ export const EditPostgreSqlSpecificDataComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isConnectionFailed && (
|
||||
{isConnectionFailed && !IS_CLOUD && (
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
If your database uses IP whitelist, make sure Databasus server IP is added to the allowed
|
||||
list.
|
||||
|
||||
@@ -218,7 +218,7 @@ export function SettingsComponent({ contentHeight }: Props) {
|
||||
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
Read more about settings you can{' '}
|
||||
<a
|
||||
href="https://databasus.com/access-management/#global-settings"
|
||||
href="https://databasus.com/access-management#global-settings"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="!text-blue-600"
|
||||
|
||||
@@ -227,23 +227,22 @@ export const StorageComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!storage.isSystem ||
|
||||
(user.role === UserRole.ADMIN && (
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
{(!storage.isSystem || user.role === UserRole.ADMIN) && (
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
|
||||
{!isEditSettings && isCanManageStorages ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('settings')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!isEditSettings && isCanManageStorages ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('settings')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditSettings && isCanManageStorages ? (
|
||||
|
||||
@@ -193,9 +193,18 @@ export function EditStorageComponent({
|
||||
id: undefined as unknown as string,
|
||||
workspaceId,
|
||||
name: '',
|
||||
type: StorageType.LOCAL,
|
||||
type: IS_CLOUD ? StorageType.S3 : StorageType.LOCAL,
|
||||
isSystem: false,
|
||||
localStorage: {},
|
||||
localStorage: IS_CLOUD ? undefined : {},
|
||||
s3Storage: IS_CLOUD
|
||||
? {
|
||||
s3Bucket: '',
|
||||
s3Region: '',
|
||||
s3AccessKey: '',
|
||||
s3SecretKey: '',
|
||||
s3Endpoint: '',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}, [editingStorage]);
|
||||
|
||||
@@ -101,7 +101,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
width: 350,
|
||||
render: (message: string) => <span className="text-xs text-gray-900">{message}</span>,
|
||||
render: (message: string) => (
|
||||
<span className="text-xs text-gray-900 dark:text-white">{message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
@@ -111,7 +113,9 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
render: (workspaceId: string | undefined) => (
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${
|
||||
workspaceId ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'
|
||||
workspaceId
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{workspaceId || '-'}
|
||||
@@ -127,7 +131,7 @@ export function UserAuditLogsSidebarComponent({ user }: Props) {
|
||||
const date = dayjs(createdAt);
|
||||
const timeFormat = getUserTimeFormat();
|
||||
return (
|
||||
<span className="text-xs text-gray-700">
|
||||
<span className="text-xs text-gray-700 dark:text-white">
|
||||
{`${date.format(timeFormat.format)} (${date.fromNow()})`}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user