diff --git a/internal/core/services/instance.go b/internal/core/services/instance.go index 43133992f..5d42af005 100644 --- a/internal/core/services/instance.go +++ b/internal/core/services/instance.go @@ -246,6 +246,10 @@ func (s *InstanceService) LaunchInstance(ctx context.Context, params ports.Launc // Rollback quota reservation on enqueue failure _ = s.tenantSvc.DecrementUsage(ctx, tenantID, "vcpus", it.VCPUs) _ = s.tenantSvc.DecrementUsage(ctx, tenantID, "memory", it.MemoryMB/1024) + // Rollback database record creation + if delErr := s.repo.Delete(ctx, inst.ID); delErr != nil { + s.logger.Error("failed to delete instance record after enqueue failure", "instance_id", inst.ID, "error", delErr) + } return nil, errors.Wrap(errors.Internal, "failed to enqueue provisioning task", err) } @@ -313,6 +317,10 @@ func (s *InstanceService) LaunchInstanceWithOptions(ctx context.Context, opts po if err := s.taskQueue.Enqueue(ctx, "provision_queue", job); err != nil { s.logger.Error("failed to enqueue provision job", "instance_id", inst.ID, "error", err) + // Rollback database record creation + if delErr := s.repo.Delete(ctx, inst.ID); delErr != nil { + s.logger.Error("failed to delete instance record after enqueue failure", "instance_id", inst.ID, "error", delErr) + } return nil, errors.Wrap(errors.Internal, "failed to enqueue provisioning task", err) } diff --git a/internal/core/services/instance_test.go b/internal/core/services/instance_test.go index fe1b8b789..55628ca97 100644 --- a/internal/core/services/instance_test.go +++ b/internal/core/services/instance_test.go @@ -47,13 +47,17 @@ func (r *FaultyInstanceRepository) Create(ctx context.Context, instance *domain. } type InMemoryTaskQueue struct { - jobs []string - mu sync.Mutex + jobs []string + mu sync.Mutex + ShouldFail bool } func (q *InMemoryTaskQueue) Enqueue(ctx context.Context, queueName string, payload interface{}) error { q.mu.Lock() defer q.mu.Unlock() + if q.ShouldFail { + return fmt.Errorf("simulated enqueue failure") + } q.jobs = append(q.jobs, fmt.Sprintf("%v", payload)) return nil } @@ -334,7 +338,7 @@ func TestInstanceServiceLaunchDBFailure(t *testing.T) { assert.Contains(t, err.Error(), "simulated database failure") // Verify no junk in DB (using real repo to check) - list, err := realRepo.List(ctx) + list, err := realRepo.List(ctx, nil) require.NoError(t, err) assert.Empty(t, list) } @@ -413,7 +417,7 @@ func TestInstanceServiceLaunchConcurrency(t *testing.T) { } // Verify all created - list, err := repo.List(ctx) + list, err := repo.List(ctx, nil) require.NoError(t, err) assert.Len(t, list, concurrency) @@ -709,3 +713,100 @@ func TestLaunchInstanceWithOptions(t *testing.T) { _ = compute.DeleteInstance(ctx, inst.ContainerID) } } + +func TestInstanceServiceLaunchEnqueueFailure(t *testing.T) { + db := setupDB(t) + ctx := setupTestUser(t, db) + + repo := postgres.NewInstanceRepository(db) + vpcRepo := postgres.NewVpcRepository(db) + subnetRepo := postgres.NewSubnetRepository(db) + volumeRepo := postgres.NewVolumeRepository(db) + itRepo := postgres.NewInstanceTypeRepository(db) + + compute := noop.NewNoopComputeBackend() + + defaultType := &domain.InstanceType{ID: testInstanceType, Name: "Basic 2", VCPUs: 1, MemoryMB: 128, DiskGB: 1} + _, err := itRepo.Create(ctx, defaultType) + require.NoError(t, err) + + rbacSvc := new(MockRBACService) + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) + + eventSvc := services.NewEventService(services.EventServiceParams{ + Repo: postgres.NewEventRepository(db), + RBACSvc: rbacSvc, + Publisher: nil, + Logger: slog.Default(), + }) + auditSvc := services.NewAuditService(services.AuditServiceParams{ + Repo: postgres.NewAuditRepository(db), + RBACSvc: rbacSvc, + }) + + // Create a task queue that fails + taskQueue := &InMemoryTaskQueue{ShouldFail: true} + + sshKeySvc, err := services.NewSSHKeyService(services.SSHKeyServiceParams{ + Repo: postgres.NewSSHKeyRepo(db), + RBACSvc: rbacSvc, + }) + require.NoError(t, err) + + tenantSvc := services.NewTenantService(services.TenantServiceParams{ + Repo: postgres.NewTenantRepo(db), + UserRepo: postgres.NewUserRepo(db), + RBACSvc: rbacSvc, + Logger: slog.Default(), + }) + + svc := services.NewInstanceService(services.InstanceServiceParams{ + Repo: repo, + VpcRepo: vpcRepo, + SubnetRepo: subnetRepo, + VolumeRepo: volumeRepo, + InstanceTypeRepo: itRepo, + RBAC: rbacSvc, + Compute: compute, + EventSvc: eventSvc, + AuditSvc: auditSvc, + TaskQueue: taskQueue, + Logger: slog.Default(), + TenantSvc: tenantSvc, + SSHKeySvc: sshKeySvc, + }) + + // Attempt LaunchInstance + name := "enqueue-fail-integration" + _, err = svc.LaunchInstance(ctx, coreports.LaunchParams{ + Name: name, + Image: testImage, + InstanceType: testInstanceType, + }) + + // Verify Failure + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + // Verify instance not in DB + inst, err := repo.GetByName(ctx, name) + require.Error(t, err) + assert.Nil(t, inst) + + // Attempt LaunchInstanceWithOptions + optsName := "enqueue-fail-opts-integration" + opts := coreports.CreateInstanceOptions{ + Name: optsName, + ImageName: testImage, + } + _, err = svc.LaunchInstanceWithOptions(ctx, opts) + + // Verify Failure + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + // Verify instance not in DB + instOpts, err := repo.GetByName(ctx, optsName) + require.Error(t, err) + assert.Nil(t, instOpts) +} diff --git a/internal/core/services/instance_unit_test.go b/internal/core/services/instance_unit_test.go index 003564842..6578975cc 100644 --- a/internal/core/services/instance_unit_test.go +++ b/internal/core/services/instance_unit_test.go @@ -220,6 +220,160 @@ func testInstanceServiceLaunchInstanceUnit(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "quota exceeded") }) + + t.Run("EnqueueFailure", func(t *testing.T) { + params := ports.LaunchParams{ + Name: "enqueue-fail-inst", + Image: "alpine", + InstanceType: "t2.micro", + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + typeRepo.On("GetByID", mock.Anything, "t2.micro").Return(&domain.InstanceType{ + ID: "t2.micro", VCPUs: 1, MemoryMB: 1024, + }, nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "instances", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == params.Name && i.UserID == userID + })).Return(nil).Once() + + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(nil).Once() + + _, err := svc.LaunchInstance(ctx, params) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + repo.AssertExpectations(t) + tenantSvc.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("EnqueueAndRollbackFailure", func(t *testing.T) { + params := ports.LaunchParams{ + Name: "enqueue-fail-inst-rollback-fail", + Image: "alpine", + InstanceType: "t2.micro", + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + typeRepo.On("GetByID", mock.Anything, "t2.micro").Return(&domain.InstanceType{ + ID: "t2.micro", VCPUs: 1, MemoryMB: 1024, + }, nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "instances", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("CheckQuota", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("IncrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == params.Name && i.UserID == userID + })).Return(nil).Once() + + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "vcpus", 1).Return(nil).Once() + tenantSvc.On("DecrementUsage", mock.Anything, tenantID, "memory", 1).Return(nil).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(errors.New("delete error")).Once() + + _, err := svc.LaunchInstance(ctx, params) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + assert.NotContains(t, err.Error(), "delete error") + + repo.AssertExpectations(t) + tenantSvc.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("LaunchInstanceWithOptions_Success", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-success", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(nil).Once() + + inst, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.NoError(t, err) + assert.NotNil(t, inst) + assert.Equal(t, opts.Name, inst.Name) + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("LaunchInstanceWithOptions_EnqueueFailure", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-fail", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(nil).Once() + + _, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) + + t.Run("LaunchInstanceWithOptions_EnqueueAndRollbackFailure", func(t *testing.T) { + opts := ports.CreateInstanceOptions{ + Name: "opts-fail-rollback-fail", + ImageName: "alpine", + Ports: []string{"80:80"}, + } + + rbacSvc.On("Authorize", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() + var createdID uuid.UUID + repo.On("Create", mock.Anything, mock.MatchedBy(func(i *domain.Instance) bool { + createdID = i.ID + return i.Name == opts.Name + })).Return(nil).Once() + taskQueue.On("Enqueue", mock.Anything, "provision_queue", mock.Anything).Return(errors.New("enqueue error")).Once() + repo.On("Delete", mock.Anything, mock.MatchedBy(func(id uuid.UUID) bool { + return id == createdID + })).Return(errors.New("delete error")).Once() + + _, err := svc.LaunchInstanceWithOptions(ctx, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to enqueue provisioning task") + assert.NotContains(t, err.Error(), "delete error") + + repo.AssertExpectations(t) + taskQueue.AssertExpectations(t) + }) } func testInstanceServiceLifecycleUnit(t *testing.T) { diff --git a/internal/core/services/lb_worker.go b/internal/core/services/lb_worker.go index 2c2556934..c3d1461e5 100644 --- a/internal/core/services/lb_worker.go +++ b/internal/core/services/lb_worker.go @@ -94,7 +94,7 @@ func (w *LBWorker) processCreatingLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.deployLB(gCtx, lb) } if len(lbs) < w.batchLimit { @@ -115,7 +115,7 @@ func (w *LBWorker) processDeletingLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.cleanupLB(gCtx, lb) } if len(lbs) < w.batchLimit { @@ -174,7 +174,7 @@ func (w *LBWorker) processActiveLBs(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) targets, err := w.lbRepo.ListTargets(gCtx, lb.ID) if err != nil { continue @@ -201,7 +201,7 @@ func (w *LBWorker) processHealthChecks(ctx context.Context) { return } for _, lb := range lbs { - gCtx := appcontext.WithUserID(ctx, lb.UserID) + gCtx := appcontext.WithTenantID(ctx, lb.TenantID) w.checkLBHealth(gCtx, lb) } if len(lbs) < w.batchLimit { diff --git a/internal/core/services/lb_worker_test.go b/internal/core/services/lb_worker_test.go index 710bea2fa..50712990b 100644 --- a/internal/core/services/lb_worker_test.go +++ b/internal/core/services/lb_worker_test.go @@ -9,11 +9,23 @@ import ( "time" "github.com/google/uuid" + appcontext "github.com/poyrazk/thecloud/internal/core/context" "github.com/poyrazk/thecloud/internal/core/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +// tenantMatcher returns a matcher that verifies the context carries the expected TenantID. +func tenantMatcher(expectedTenantID uuid.UUID) func(any) bool { + return func(arg any) bool { + ctx, ok := arg.(context.Context) + if !ok { + return false + } + return appcontext.TenantIDFromContext(ctx) == expectedTenantID + } +} + type mockDialer struct { err error } @@ -162,19 +174,19 @@ func TestLBWorkerProcessCreatingLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() - userID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: userID, - Status: domain.LBStatusCreating, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusCreating, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusCreating), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{}, nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{}, nil).Once() proxy.On("DeployProxy", mock.Anything, lb, []*domain.LBTarget{}).Return("http://lb-url", nil) - lbRepo.On("Update", mock.Anything, mock.MatchedBy(func(l *domain.LoadBalancer) bool { + lbRepo.On("Update", mock.MatchedBy(tenantMatcher(tenantID)), mock.MatchedBy(func(l *domain.LoadBalancer) bool { return l.ID == lbID && l.Status == domain.LBStatusActive - })).Return(nil) + })).Return(nil).Once() worker.processCreatingLBs(ctx) @@ -191,15 +203,16 @@ func TestLBWorkerProcessDeletingLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusDeleted, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusDeleted, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusDeleted), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - proxy.On("RemoveProxy", mock.Anything, lbID).Return(nil) - lbRepo.On("Delete", mock.Anything, lbID).Return(nil) + proxy.On("RemoveProxy", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return(nil).Once() + lbRepo.On("Delete", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return(nil).Once() worker.processDeletingLBs(ctx) @@ -216,19 +229,21 @@ func TestLBWorkerProcessActiveLBs(t *testing.T) { ctx := context.Background() lbID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusActive, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusActive, } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusActive), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{}, nil) - proxy.On("UpdateProxyConfig", mock.Anything, lb, []*domain.LBTarget{}).Return(nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{}, nil).Once() + proxy.On("UpdateProxyConfig", mock.MatchedBy(tenantMatcher(tenantID)), lb, []*domain.LBTarget{}).Return(nil).Once() worker.processActiveLBs(ctx) proxy.AssertExpectations(t) + lbRepo.AssertExpectations(t) } func TestLBWorkerProcessHealthChecks(t *testing.T) { @@ -241,10 +256,11 @@ func TestLBWorkerProcessHealthChecks(t *testing.T) { ctx := context.Background() lbID := uuid.New() instID := uuid.New() + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: lbID, - UserID: uuid.New(), - Status: domain.LBStatusActive, + ID: lbID, + TenantID: tenantID, + Status: domain.LBStatusActive, } target := &domain.LBTarget{ @@ -259,8 +275,8 @@ func TestLBWorkerProcessHealthChecks(t *testing.T) { } lbRepo.On("ListByStatus", mock.Anything, string(domain.LBStatusActive), 100, 0).Return([]*domain.LoadBalancer{lb}, nil) - lbRepo.On("ListTargets", mock.Anything, lbID).Return([]*domain.LBTarget{target}, nil) - instRepo.On("GetByID", mock.Anything, instID).Return(inst, nil) + lbRepo.On("ListTargets", mock.MatchedBy(tenantMatcher(tenantID)), lbID).Return([]*domain.LBTarget{target}, nil).Once() + instRepo.On("GetByID", mock.MatchedBy(tenantMatcher(tenantID)), instID).Return(inst, nil).Once() // Since we are mocking, we cannot easily mock net.Dial from within isPortOpen since it's hardcoded. // But we can test that it TRIES to update if health status changes. @@ -300,9 +316,11 @@ func TestLBWorkerCheckTargetHealthUpdates(t *testing.T) { ctx := context.Background() lbID := uuid.New() instID := uuid.New() + tenantID := uuid.New() + ctx = appcontext.WithTenantID(ctx, tenantID) - instRepo.On("GetByID", ctx, instID).Return(&domain.Instance{ID: instID, Ports: "8080:80"}, nil) - lbRepo.On("UpdateTargetHealth", ctx, lbID, instID, "healthy").Return(nil).Once() + instRepo.On("GetByID", mock.MatchedBy(tenantMatcher(tenantID)), instID).Return(&domain.Instance{ID: instID, Ports: "8080:80"}, nil) + lbRepo.On("UpdateTargetHealth", mock.MatchedBy(tenantMatcher(tenantID)), lbID, instID, "healthy").Return(nil).Once() changed := worker.checkTargetHealth(ctx, &domain.LoadBalancer{ID: lbID}, &domain.LBTarget{ InstanceID: instID, diff --git a/internal/core/services/loadbalancer.go b/internal/core/services/loadbalancer.go index 64ccfd4af..52224911d 100644 --- a/internal/core/services/loadbalancer.go +++ b/internal/core/services/loadbalancer.go @@ -183,8 +183,9 @@ func (s *LBService) Delete(ctx context.Context, idOrName string) error { return err } + lbCtx := appcontext.WithTenantID(ctx, lb.TenantID) lb.Status = domain.LBStatusDeleted - if err := s.lbRepo.Update(ctx, lb); err != nil { + if err := s.lbRepo.Update(lbCtx, lb); err != nil { return err } diff --git a/internal/core/services/vpc.go b/internal/core/services/vpc.go index 154bd44f3..71308990c 100644 --- a/internal/core/services/vpc.go +++ b/internal/core/services/vpc.go @@ -371,7 +371,9 @@ func (s *VpcService) cascadeDeleteDependencies(ctx context.Context, vpcID uuid.U } for _, lb := range lbs { if lb.VpcID == vpcID { - if err := s.lbRepo.Delete(ctx, lb.ID); err != nil { + // Use the LB's own TenantID for deletion (not the VPC's) + lbCtx := appcontext.WithTenantID(ctx, lb.TenantID) + if err := s.lbRepo.Delete(lbCtx, lb.ID); err != nil { delErrs = append(delErrs, fmt.Errorf("load balancer %s: %w", lb.ID, err)) } } diff --git a/internal/repositories/postgres/lb_repo.go b/internal/repositories/postgres/lb_repo.go index bd2c16e8e..fc7e0bcce 100644 --- a/internal/repositories/postgres/lb_repo.go +++ b/internal/repositories/postgres/lb_repo.go @@ -24,11 +24,11 @@ func NewLBRepository(db DB) *LBRepository { func (r *LBRepository) Create(ctx context.Context, lb *domain.LoadBalancer) error { query := ` - INSERT INTO load_balancers (id, user_id, idempotency_key, name, vpc_id, port, algorithm, ip, status, version, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO load_balancers (id, user_id, tenant_id, idempotency_key, name, vpc_id, port, algorithm, ip, status, version, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ` _, err := r.db.Exec(ctx, query, - lb.ID, lb.UserID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt, + lb.ID, lb.UserID, lb.TenantID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt, ) if err != nil { // Check for unique constraint violation on idempotency_key @@ -38,47 +38,47 @@ func (r *LBRepository) Create(ctx context.Context, lb *domain.LoadBalancer) erro } func (r *LBRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE id = $1 AND user_id = $2 + WHERE id = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, id, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, id, tenantID)) } func (r *LBRepository) GetByName(ctx context.Context, name string) (*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE name = $1 AND user_id = $2 + WHERE name = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, name, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, name, tenantID)) } func (r *LBRepository) GetByIdempotencyKey(ctx context.Context, key string) (*domain.LoadBalancer, error) { if key == "" { return nil, errors.New(errors.NotFound, "idempotency key empty") } - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, idempotency_key, name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, idempotency_key, name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE idempotency_key = $1 AND user_id = $2 + WHERE idempotency_key = $1 AND tenant_id = $2 ` - return r.scanLB(r.db.QueryRow(ctx, query, key, userID)) + return r.scanLB(r.db.QueryRow(ctx, query, key, tenantID)) } func (r *LBRepository) List(ctx context.Context) ([]*domain.LoadBalancer, error) { - userID := appcontext.UserIDFromContext(ctx) + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers - WHERE user_id = $1 + WHERE tenant_id = $1 ORDER BY created_at DESC ` - rows, err := r.db.Query(ctx, query, userID) + rows, err := r.db.Query(ctx, query, tenantID) if err != nil { return nil, errors.Wrap(errors.Internal, "failed to list load balancers", err) } @@ -87,7 +87,7 @@ func (r *LBRepository) List(ctx context.Context) ([]*domain.LoadBalancer, error) func (r *LBRepository) ListAll(ctx context.Context) ([]*domain.LoadBalancer, error) { query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers ORDER BY created_at DESC ` @@ -100,7 +100,7 @@ func (r *LBRepository) ListAll(ctx context.Context) ([]*domain.LoadBalancer, err func (r *LBRepository) ListByStatus(ctx context.Context, status string, limit, offset int) ([]*domain.LoadBalancer, error) { query := ` - SELECT id, user_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at + SELECT id, user_id, tenant_id, COALESCE(idempotency_key, ''), name, vpc_id, port, algorithm, COALESCE(ip, ''), status, version, created_at FROM load_balancers WHERE status = $1 ORDER BY created_at DESC @@ -117,7 +117,7 @@ func (r *LBRepository) scanLB(row pgx.Row) (*domain.LoadBalancer, error) { var lb domain.LoadBalancer var status string err := row.Scan( - &lb.ID, &lb.UserID, &lb.IdempotencyKey, &lb.Name, &lb.VpcID, &lb.Port, &lb.Algorithm, &lb.IP, &status, &lb.Version, &lb.CreatedAt, + &lb.ID, &lb.UserID, &lb.TenantID, &lb.IdempotencyKey, &lb.Name, &lb.VpcID, &lb.Port, &lb.Algorithm, &lb.IP, &status, &lb.Version, &lb.CreatedAt, ) if err != nil { if stdlib_errors.Is(err, pgx.ErrNoRows) { @@ -143,12 +143,13 @@ func (r *LBRepository) scanLBs(rows pgx.Rows) ([]*domain.LoadBalancer, error) { } func (r *LBRepository) Update(ctx context.Context, lb *domain.LoadBalancer) error { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` UPDATE load_balancers SET name = $1, port = $2, algorithm = $3, ip = $4, status = $5, version = version + 1 - WHERE id = $6 AND version = $7 AND user_id = $8 + WHERE id = $6 AND version = $7 AND tenant_id = $8 ` - cmd, err := r.db.Exec(ctx, query, lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID) + cmd, err := r.db.Exec(ctx, query, lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to update load balancer", err) } @@ -160,9 +161,9 @@ func (r *LBRepository) Update(ctx context.Context, lb *domain.LoadBalancer) erro } func (r *LBRepository) Delete(ctx context.Context, id uuid.UUID) error { - userID := appcontext.UserIDFromContext(ctx) - query := `DELETE FROM load_balancers WHERE id = $1 AND user_id = $2` - cmd, err := r.db.Exec(ctx, query, id, userID) + tenantID := appcontext.TenantIDFromContext(ctx) + query := `DELETE FROM load_balancers WHERE id = $1 AND tenant_id = $2` + cmd, err := r.db.Exec(ctx, query, id, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to delete load balancer", err) } @@ -188,8 +189,13 @@ func (r *LBRepository) AddTarget(ctx context.Context, target *domain.LBTarget) e } func (r *LBRepository) RemoveTarget(ctx context.Context, lbID, instanceID uuid.UUID) error { - query := `DELETE FROM lb_targets WHERE lb_id = $1 AND instance_id = $2` - cmd, err := r.db.Exec(ctx, query, lbID, instanceID) + tenantID := appcontext.TenantIDFromContext(ctx) + query := ` + DELETE FROM lb_targets + WHERE lb_id = $1 AND instance_id = $2 + AND lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $3) + ` + cmd, err := r.db.Exec(ctx, query, lbID, instanceID, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to remove load balancer target", err) } @@ -200,12 +206,13 @@ func (r *LBRepository) RemoveTarget(ctx context.Context, lbID, instanceID uuid.U } func (r *LBRepository) ListTargets(ctx context.Context, lbID uuid.UUID) ([]*domain.LBTarget, error) { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` - SELECT id, lb_id, instance_id, port, weight, health - FROM lb_targets - WHERE lb_id = $1 + SELECT t.id, t.lb_id, t.instance_id, t.port, t.weight, t.health + FROM lb_targets t + WHERE t.lb_id = $1 AND t.lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $2) ` - rows, err := r.db.Query(ctx, query, lbID) + rows, err := r.db.Query(ctx, query, lbID, tenantID) if err != nil { return nil, errors.Wrap(errors.Internal, "failed to list load balancer targets", err) } @@ -213,12 +220,14 @@ func (r *LBRepository) ListTargets(ctx context.Context, lbID uuid.UUID) ([]*doma } func (r *LBRepository) UpdateTargetHealth(ctx context.Context, lbID, instanceID uuid.UUID, health string) error { + tenantID := appcontext.TenantIDFromContext(ctx) query := ` UPDATE lb_targets SET health = $1 WHERE lb_id = $2 AND instance_id = $3 + AND lb_id IN (SELECT id FROM load_balancers WHERE tenant_id = $4) ` - _, err := r.db.Exec(ctx, query, health, lbID, instanceID) + _, err := r.db.Exec(ctx, query, health, lbID, instanceID, tenantID) if err != nil { return errors.Wrap(errors.Internal, "failed to update target health", err) } diff --git a/internal/repositories/postgres/lb_repo_test.go b/internal/repositories/postgres/lb_repo_test.go index c539c0f72..01192ba91 100644 --- a/internal/repositories/postgres/lb_repo_test.go +++ b/internal/repositories/postgres/lb_repo_test.go @@ -17,7 +17,7 @@ import ( ) const ( - lbQueryPattern = "SELECT id, user_id, COALESCE.+idempotency_key.+name, vpc_id, port, algorithm, COALESCE.+ip.+status, version, created_at FROM load_balancers" + lbQueryPattern = "SELECT id, user_id, tenant_id, COALESCE.+idempotency_key.+name, vpc_id, port, algorithm, COALESCE.+ip.+status, version, created_at FROM load_balancers" errDbMessage = "db error" errNotFound = "not found" ) @@ -32,6 +32,7 @@ func TestLBRepositoryCreate(t *testing.T) { lb := &domain.LoadBalancer{ ID: uuid.New(), UserID: uuid.New(), + TenantID: uuid.New(), IdempotencyKey: "key-1", Name: "lb-1", VpcID: uuid.New(), @@ -43,7 +44,7 @@ func TestLBRepositoryCreate(t *testing.T) { } mock.ExpectExec("INSERT INTO load_balancers"). - WithArgs(lb.ID, lb.UserID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt). + WithArgs(lb.ID, lb.UserID, lb.TenantID, lb.IdempotencyKey, lb.Name, lb.VpcID, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.Version, lb.CreatedAt). WillReturnResult(pgxmock.NewResult("INSERT", 1)) err = repo.Create(context.Background(), lb) @@ -74,14 +75,14 @@ func TestLBRepositoryGetByID(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) now := time.Now() mock.ExpectQuery(lbQueryPattern). - WithArgs(id, userID). - WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(id, userID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + WithArgs(id, tenantID). + WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). + AddRow(id, uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lb, err := repo.GetByID(ctx, id) require.NoError(t, err) @@ -96,11 +97,11 @@ func TestLBRepositoryGetByID(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectQuery(lbQueryPattern). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnError(pgx.ErrNoRows) lb, err := repo.GetByID(ctx, id) @@ -117,14 +118,14 @@ func TestLBRepositoryList(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) now := time.Now() mock.ExpectQuery(lbQueryPattern). - WithArgs(userID). - WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). - AddRow(uuid.New(), userID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) + WithArgs(tenantID). + WillReturnRows(pgxmock.NewRows([]string{"id", "user_id", "tenant_id", "idempotency_key", "name", "vpc_id", "port", "algorithm", "ip", "status", "version", "created_at"}). + AddRow(uuid.New(), uuid.New(), tenantID, "key-1", "lb-1", uuid.New(), 80, "round_robin", "10.0.0.1", string(domain.LBStatusActive), 1, now)) lbs, err := repo.List(ctx) require.NoError(t, err) @@ -137,11 +138,11 @@ func TestLBRepositoryList(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectQuery(lbQueryPattern). - WithArgs(userID). + WithArgs(tenantID). WillReturnError(errors.New(errDbMessage)) list, err := repo.List(ctx) @@ -157,21 +158,23 @@ func TestLBRepositoryUpdate(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) + tenantID := uuid.New() lb := &domain.LoadBalancer{ ID: uuid.New(), - UserID: uuid.New(), + TenantID: tenantID, Name: "lb-updated", Port: 8080, Algorithm: "least_conn", Status: domain.LBStatusActive, Version: 1, } + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("UPDATE load_balancers"). - WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID). + WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.TenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 1)) - err = repo.Update(context.Background(), lb) + err = repo.Update(ctx, lb) require.NoError(t, err) assert.Equal(t, 2, lb.Version) }) @@ -182,17 +185,19 @@ func TestLBRepositoryUpdate(t *testing.T) { defer mock.Close() repo := NewLBRepository(mock) + tenantID := uuid.New() lb := &domain.LoadBalancer{ - ID: uuid.New(), - UserID: uuid.New(), - Version: 1, + ID: uuid.New(), + TenantID: tenantID, + Version: 1, } + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("UPDATE load_balancers"). - WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.UserID). + WithArgs(lb.Name, lb.Port, lb.Algorithm, lb.IP, lb.Status, lb.ID, lb.Version, lb.TenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 0)) - err = repo.Update(context.Background(), lb) + err = repo.Update(ctx, lb) require.Error(t, err) var theCloudErr *theclouderrors.Error if errors.As(err, &theCloudErr) { @@ -209,11 +214,11 @@ func TestLBRepositoryDelete(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("DELETE FROM load_balancers"). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 1)) err = repo.Delete(ctx, id) @@ -227,11 +232,11 @@ func TestLBRepositoryDelete(t *testing.T) { repo := NewLBRepository(mock) id := uuid.New() - userID := uuid.New() - ctx := appcontext.WithUserID(context.Background(), userID) + tenantID := uuid.New() + ctx := appcontext.WithTenantID(context.Background(), tenantID) mock.ExpectExec("DELETE FROM load_balancers"). - WithArgs(id, userID). + WithArgs(id, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 0)) err = repo.Delete(ctx, id) @@ -274,12 +279,13 @@ func TestLBRepositoryRemoveTarget(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() mock.ExpectExec("DELETE FROM lb_targets"). - WithArgs(lbID, instanceID). + WithArgs(lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 1)) - err = repo.RemoveTarget(context.Background(), lbID, instanceID) + err = repo.RemoveTarget(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID) require.NoError(t, err) }) @@ -291,12 +297,13 @@ func TestLBRepositoryRemoveTarget(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() mock.ExpectExec("DELETE FROM lb_targets"). - WithArgs(lbID, instanceID). + WithArgs(lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("DELETE", 0)) - err = repo.RemoveTarget(context.Background(), lbID, instanceID) + err = repo.RemoveTarget(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID) require.Error(t, err) }) } @@ -309,13 +316,14 @@ func TestLBRepositoryListTargets(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() + tenantID := uuid.New() - mock.ExpectQuery("SELECT id, lb_id, instance_id, port, weight, health FROM lb_targets"). - WithArgs(lbID). + mock.ExpectQuery("SELECT t.id, t.lb_id, t.instance_id, t.port, t.weight, t.health FROM lb_targets t"). + WithArgs(lbID, tenantID). WillReturnRows(pgxmock.NewRows([]string{"id", "lb_id", "instance_id", "port", "weight", "health"}). AddRow(uuid.New(), lbID, uuid.New(), 80, 1, "healthy")) - targets, err := repo.ListTargets(context.Background(), lbID) + targets, err := repo.ListTargets(appcontext.WithTenantID(context.Background(), tenantID), lbID) require.NoError(t, err) assert.Len(t, targets, 1) }) @@ -330,13 +338,14 @@ func TestLBRepositoryUpdateTargetHealth(t *testing.T) { repo := NewLBRepository(mock) lbID := uuid.New() instanceID := uuid.New() + tenantID := uuid.New() health := "unhealthy" mock.ExpectExec("UPDATE lb_targets"). - WithArgs(health, lbID, instanceID). + WithArgs(health, lbID, instanceID, tenantID). WillReturnResult(pgxmock.NewResult("UPDATE", 1)) - err = repo.UpdateTargetHealth(context.Background(), lbID, instanceID, health) + err = repo.UpdateTargetHealth(appcontext.WithTenantID(context.Background(), tenantID), lbID, instanceID, health) require.NoError(t, err) }) }