- Replace UUID with ULID for app ID generation (lexicographically sortable) - Remove container_id column from apps table (migration 002) - Add upaas.id Docker label to identify containers by app ID - Implement FindContainerByAppID in Docker client to query by label - Update handlers and deploy service to use label-based container lookup - Show system-managed upaas.id label in UI with editing disabled Container association is now determined dynamically via Docker label rather than stored in the database, making the system more resilient to container recreation or external changes.
330 lines
7.3 KiB
Go
330 lines
7.3 KiB
Go
// 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.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
|
|
}
|