upaas/internal/service/app/app.go
sneak 5fb0b111fc Use ULID for app IDs and Docker label for container lookup
- 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.
2025-12-29 16:06:40 +07:00

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: &params,
}, 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
}