265 lines
9.2 KiB
Go
265 lines
9.2 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
"browser.local/platform/validator"
|
|
)
|
|
|
|
const (
|
|
maxMetricSamplesPerServer = 1000
|
|
maxBackupsPerServer = 100
|
|
maxBackupBytesPerServer = int64(4 * 1024 * 1024 * 1024)
|
|
)
|
|
|
|
func (svc *CoreService) IngestMetricBatch(batch domain.MetricBatchIngest) (domain.MetricBatchIngestResult, error) {
|
|
batch = domain.CopyMetricBatchIngest(batch)
|
|
if len(batch.Samples) == 0 || len(batch.Samples) > 256 {
|
|
return domain.MetricBatchIngestResult{}, validationError("metric batch must contain between 1 and 256 samples")
|
|
}
|
|
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
|
|
return domain.MetricBatchIngestResult{}, err
|
|
}
|
|
latest := svc.now()
|
|
for index, sample := range batch.Samples {
|
|
instance, err := svc.store.ServerInstances().Get(sample.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.MetricBatchIngestResult{}, err
|
|
}
|
|
if instance.RunEndpointID != batch.RunEndpointID {
|
|
return domain.MetricBatchIngestResult{}, validationError("metric sample server must belong to runEndpointId")
|
|
}
|
|
sample.RunEndpointID = batch.RunEndpointID
|
|
if sample.ID == "" {
|
|
sample.ID = fmt.Sprintf("metric:%s:%d:%d", sample.ServerInstanceID, sample.CollectedAt.UnixNano(), index)
|
|
}
|
|
if sample.CollectedAt.IsZero() {
|
|
sample.CollectedAt = svc.now()
|
|
}
|
|
if err := validator.ValidateMetricSample(sample); err != nil {
|
|
return domain.MetricBatchIngestResult{}, err
|
|
}
|
|
if err := svc.store.MetricSamples().Create(sample); err != nil {
|
|
if !errors.Is(err, repo.ErrDuplicate) {
|
|
return domain.MetricBatchIngestResult{}, err
|
|
}
|
|
existing, getErr := svc.store.MetricSamples().Get(sample.ID)
|
|
if getErr != nil || existing.ServerInstanceID != sample.ServerInstanceID || existing.CollectedAt != sample.CollectedAt {
|
|
return domain.MetricBatchIngestResult{}, validationError("metric sample id conflicts with persisted sample")
|
|
}
|
|
}
|
|
if sample.CollectedAt.After(latest) {
|
|
latest = sample.CollectedAt
|
|
}
|
|
if err := svc.pruneMetricSamples(sample.ServerInstanceID); err != nil {
|
|
return domain.MetricBatchIngestResult{}, err
|
|
}
|
|
}
|
|
return domain.MetricBatchIngestResult{Accepted: true, AcceptedCount: len(batch.Samples), LatestAt: latest, ServerTime: svc.now()}, nil
|
|
}
|
|
|
|
func (svc *CoreService) ListMetricSamplesForSession(sessionID string, filter domain.MetricSampleFilter) ([]domain.MetricSample, error) {
|
|
if err := validator.ValidateMetricSampleFilter(filter); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
|
return nil, validationError("serverInstanceId is required")
|
|
}
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := svc.store.MetricSamples().List(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) })
|
|
limit := filter.Limit
|
|
if limit == 0 {
|
|
limit = 100
|
|
}
|
|
if len(items) > limit {
|
|
items = items[len(items)-limit:]
|
|
}
|
|
for _, sample := range items {
|
|
if sample.ServerInstanceID != instance.ID || sample.RunEndpointID != instance.RunEndpointID {
|
|
return nil, ErrForbidden
|
|
}
|
|
}
|
|
return domain.CopyMetricSamples(items), nil
|
|
}
|
|
|
|
func (svc *CoreService) CreateBackupForSession(sessionID string, record domain.BackupRecord) (domain.BackupRecord, error) {
|
|
instance, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
artifact, err := svc.store.Artifacts().Get(record.ArtifactID)
|
|
if err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
if err := svc.validateBackupArtifactOwner(instance, artifact); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
stamp := svc.now()
|
|
if record.ID == "" {
|
|
record.ID = fmt.Sprintf("backup:%s:%d", instance.ID, stamp.UnixNano())
|
|
}
|
|
if record.State == "" {
|
|
record.State = domain.BackupStatePending
|
|
}
|
|
if record.Checksum == "" {
|
|
record.Checksum = artifact.Checksum
|
|
}
|
|
if record.SizeBytes == 0 {
|
|
record.SizeBytes = artifact.SizeBytes
|
|
}
|
|
if record.CreatedAt.IsZero() {
|
|
record.CreatedAt = stamp
|
|
}
|
|
record.UpdatedAt = stamp
|
|
if record.State == domain.BackupStateAvailable && artifact.State != domain.ArtifactStateAvailable {
|
|
return domain.BackupRecord{}, validationError("backup artifact must be available")
|
|
}
|
|
if err := validator.ValidateBackupRecord(record); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
if err := svc.store.Backups().Create(record); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
user, err := svc.GetCurrentUser(sessionID)
|
|
if err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
if err := svc.recordAuditEvent(user.ID, "backup.create", "server-instance", instance.ID, domain.AuditResultQueued, "created bounded backup record with artifact checksum"); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
if err := svc.pruneBackups(instance.ID); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
return domain.CopyBackupRecord(record), nil
|
|
}
|
|
|
|
func (svc *CoreService) GetBackupForSession(sessionID string, backupID string) (domain.BackupRecord, error) {
|
|
record, err := svc.store.Backups().Get(strings.TrimSpace(backupID))
|
|
if err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
if _, err := svc.GetServerInstanceForSession(sessionID, record.ServerInstanceID); err != nil {
|
|
return domain.BackupRecord{}, err
|
|
}
|
|
return domain.CopyBackupRecord(record), nil
|
|
}
|
|
|
|
func (svc *CoreService) ListBackupsForSession(sessionID string, filter domain.BackupFilter) ([]domain.BackupRecord, error) {
|
|
if err := validator.ValidateBackupFilter(filter); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(filter.ServerInstanceID) == "" {
|
|
return nil, validationError("serverInstanceId is required")
|
|
}
|
|
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := svc.store.Backups().List(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.After(items[j].CreatedAt) })
|
|
if len(items) > validator.MaxBackupRecordsPerQuery {
|
|
items = items[:validator.MaxBackupRecordsPerQuery]
|
|
}
|
|
return domain.CopyBackupRecords(items), nil
|
|
}
|
|
|
|
func (svc *CoreService) RecoverIncompleteBackups() error {
|
|
items, err := svc.store.Backups().List(domain.BackupFilter{State: domain.BackupStatePending})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, record := range items {
|
|
record.State = domain.BackupStateFailed
|
|
record.RecoveryStatus = "recoverable-after-interrupted-transfer"
|
|
record.UpdatedAt = svc.now()
|
|
if err := svc.store.Backups().Update(record); err != nil {
|
|
return err
|
|
}
|
|
if err := svc.recordAuditEvent("platform-recovery", "backup.recover", "server-instance", record.ServerInstanceID, domain.AuditResultFailed, "marked interrupted backup recoverable without exposing storage details"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error {
|
|
items, err := svc.store.MetricSamples().List(domain.MetricSampleFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil || len(items) <= maxMetricSamplesPerServer {
|
|
return err
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CollectedAt.Before(items[j].CollectedAt) })
|
|
for _, sample := range items[:len(items)-maxMetricSamplesPerServer] {
|
|
if err := svc.store.MetricSamples().Delete(sample.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return svc.recordAuditEvent("platform-retention", "metrics.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "pruned oldest metric samples to bounded retention")
|
|
}
|
|
|
|
func (svc *CoreService) pruneBackups(serverInstanceID string) error {
|
|
items, err := svc.store.Backups().List(domain.BackupFilter{ServerInstanceID: serverInstanceID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
|
|
total := int64(0)
|
|
for _, record := range items {
|
|
if record.State != domain.BackupStateExpired {
|
|
total += record.SizeBytes
|
|
}
|
|
}
|
|
pruned := false
|
|
for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer {
|
|
record := items[0]
|
|
items = items[1:]
|
|
if record.State != domain.BackupStateExpired {
|
|
total -= record.SizeBytes
|
|
}
|
|
record.State = domain.BackupStateExpired
|
|
record.RecoveryStatus = "retention-expired"
|
|
record.UpdatedAt = svc.now()
|
|
if err := svc.store.Backups().Update(record); err != nil {
|
|
return err
|
|
}
|
|
pruned = true
|
|
}
|
|
if pruned {
|
|
return svc.recordAuditEvent("platform-retention", "backup.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "expired oldest backup records to bounded retention")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *CoreService) validateBackupArtifactOwner(instance domain.ServerInstance, artifact domain.Artifact) error {
|
|
switch artifact.OwnerKind {
|
|
case domain.ArtifactOwnerKindServerInstance:
|
|
if artifact.OwnerID != instance.ID {
|
|
return ErrForbidden
|
|
}
|
|
case domain.ArtifactOwnerKindJob:
|
|
job, err := svc.store.Jobs().Get(artifact.OwnerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if job.ServerInstanceID != instance.ID || job.RunEndpointID != instance.RunEndpointID {
|
|
return ErrForbidden
|
|
}
|
|
default:
|
|
return ErrForbidden
|
|
}
|
|
return nil
|
|
}
|