// Package app provides application management services. package app import ( "context" "crypto/rand" "database/sql" "fmt" "log/slog" "time" "github.com/google/uuid" "github.com/oklog/ulid/v2" "go.uber.org/fx" "git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/ssh" ) // ServiceParams contains dependencies for Service. type ServiceParams struct { fx.In Logger *logger.Logger Database *database.Database } // Service provides app management functionality. type Service struct { log *slog.Logger db *database.Database params *ServiceParams } // New creates a new app Service. func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { return &Service{ log: params.Logger.Get(), db: params.Database, params: ¶ms, }, nil } // CreateAppInput contains the input for creating an app. type CreateAppInput struct { Name string RepoURL string Branch string DockerfilePath string DockerNetwork string NtfyTopic string SlackWebhook string } // CreateApp creates a new application with generated SSH keys and webhook secret. func (svc *Service) CreateApp( ctx context.Context, input CreateAppInput, ) (*models.App, error) { // Generate SSH key pair keyPair, err := ssh.GenerateKeyPair() if err != nil { return nil, fmt.Errorf("failed to generate SSH key pair: %w", err) } // Create app with ULID app := models.NewApp(svc.db) app.ID = ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() app.Name = input.Name app.RepoURL = input.RepoURL app.Branch = input.Branch if app.Branch == "" { app.Branch = "main" } app.DockerfilePath = input.DockerfilePath if app.DockerfilePath == "" { app.DockerfilePath = "Dockerfile" } app.WebhookSecret = uuid.New().String() app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret) app.SSHPrivateKey = keyPair.PrivateKey app.SSHPublicKey = keyPair.PublicKey app.Status = models.AppStatusPending if input.DockerNetwork != "" { app.DockerNetwork = sql.NullString{String: input.DockerNetwork, Valid: true} } if input.NtfyTopic != "" { app.NtfyTopic = sql.NullString{String: input.NtfyTopic, Valid: true} } if input.SlackWebhook != "" { app.SlackWebhook = sql.NullString{String: input.SlackWebhook, Valid: true} } saveErr := app.Save(ctx) if saveErr != nil { return nil, fmt.Errorf("failed to save app: %w", saveErr) } svc.log.Info("app created", "id", app.ID, "name", app.Name) return app, nil } // UpdateAppInput contains the input for updating an app. type UpdateAppInput struct { Name string RepoURL string Branch string DockerfilePath string DockerNetwork string NtfyTopic string SlackWebhook string } // UpdateApp updates an existing application. func (svc *Service) UpdateApp( ctx context.Context, app *models.App, input UpdateAppInput, ) error { app.Name = input.Name app.RepoURL = input.RepoURL app.Branch = input.Branch app.DockerfilePath = input.DockerfilePath app.DockerNetwork = sql.NullString{ String: input.DockerNetwork, Valid: input.DockerNetwork != "", } app.NtfyTopic = sql.NullString{ String: input.NtfyTopic, Valid: input.NtfyTopic != "", } app.SlackWebhook = sql.NullString{ String: input.SlackWebhook, Valid: input.SlackWebhook != "", } saveErr := app.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save app: %w", saveErr) } svc.log.Info("app updated", "id", app.ID, "name", app.Name) return nil } // DeleteApp deletes an application and its related data. func (svc *Service) DeleteApp(ctx context.Context, app *models.App) error { // Related data is deleted by CASCADE deleteErr := app.Delete(ctx) if deleteErr != nil { return fmt.Errorf("failed to delete app: %w", deleteErr) } svc.log.Info("app deleted", "id", app.ID, "name", app.Name) return nil } // GetApp retrieves an app by ID. func (svc *Service) GetApp(ctx context.Context, appID string) (*models.App, error) { app, err := models.FindApp(ctx, svc.db, appID) if err != nil { return nil, fmt.Errorf("failed to find app: %w", err) } return app, nil } // GetAppByWebhookSecret retrieves an app by webhook secret. func (svc *Service) GetAppByWebhookSecret( ctx context.Context, secret string, ) (*models.App, error) { app, err := models.FindAppByWebhookSecret(ctx, svc.db, secret) if err != nil { return nil, fmt.Errorf("failed to find app by webhook secret: %w", err) } return app, nil } // ListApps returns all apps. func (svc *Service) ListApps(ctx context.Context) ([]*models.App, error) { apps, err := models.AllApps(ctx, svc.db) if err != nil { return nil, fmt.Errorf("failed to list apps: %w", err) } return apps, nil } // AddEnvVar adds an environment variable to an app. func (svc *Service) AddEnvVar( ctx context.Context, appID, key, value string, ) error { envVar := models.NewEnvVar(svc.db) envVar.AppID = appID envVar.Key = key envVar.Value = value saveErr := envVar.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save env var: %w", saveErr) } return nil } // DeleteEnvVar deletes an environment variable. func (svc *Service) DeleteEnvVar(ctx context.Context, envVarID int64) error { envVar, err := models.FindEnvVar(ctx, svc.db, envVarID) if err != nil { return fmt.Errorf("failed to find env var: %w", err) } if envVar == nil { return nil } deleteErr := envVar.Delete(ctx) if deleteErr != nil { return fmt.Errorf("failed to delete env var: %w", deleteErr) } return nil } // AddLabel adds a label to an app. func (svc *Service) AddLabel( ctx context.Context, appID, key, value string, ) error { label := models.NewLabel(svc.db) label.AppID = appID label.Key = key label.Value = value saveErr := label.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save label: %w", saveErr) } return nil } // DeleteLabel deletes a label. func (svc *Service) DeleteLabel(ctx context.Context, labelID int64) error { label, err := models.FindLabel(ctx, svc.db, labelID) if err != nil { return fmt.Errorf("failed to find label: %w", err) } if label == nil { return nil } deleteErr := label.Delete(ctx) if deleteErr != nil { return fmt.Errorf("failed to delete label: %w", deleteErr) } return nil } // AddVolume adds a volume mount to an app. func (svc *Service) AddVolume( ctx context.Context, appID, hostPath, containerPath string, readonly bool, ) error { volume := models.NewVolume(svc.db) volume.AppID = appID volume.HostPath = hostPath volume.ContainerPath = containerPath volume.ReadOnly = readonly saveErr := volume.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save volume: %w", saveErr) } return nil } // DeleteVolume deletes a volume mount. func (svc *Service) DeleteVolume(ctx context.Context, volumeID int64) error { volume, err := models.FindVolume(ctx, svc.db, volumeID) if err != nil { return fmt.Errorf("failed to find volume: %w", err) } if volume == nil { return nil } deleteErr := volume.Delete(ctx) if deleteErr != nil { return fmt.Errorf("failed to delete volume: %w", deleteErr) } return nil } // UpdateAppStatus updates the status of an app. func (svc *Service) UpdateAppStatus( ctx context.Context, app *models.App, status models.AppStatus, ) error { app.Status = status saveErr := app.Save(ctx) if saveErr != nil { return fmt.Errorf("failed to save app status: %w", saveErr) } return nil }