diff --git a/Dockerfile b/Dockerfile index 70cc64b..4f425ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # Build stage -FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder # golang:1.25-alpine +FROM golang:1.25-alpine AS builder RUN apk add --no-cache git make gcc musl-dev # Install golangci-lint v2 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1 -RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0 +RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest +RUN go install golang.org/x/tools/cmd/goimports@latest WORKDIR /src COPY go.mod go.sum ./ @@ -20,7 +20,7 @@ RUN make check RUN make build # Runtime stage -FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 # alpine:3.19 +FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli diff --git a/README.md b/README.md index 2583fee..83ff0bd 100644 --- a/README.md +++ b/README.md @@ -176,8 +176,39 @@ docker run -d \ upaas ``` -**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path -that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly. +### Docker Compose + +```yaml +services: + upaas: + build: . + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${HOST_DATA_DIR}:/var/lib/upaas + environment: + - UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR} + # Optional: uncomment to enable debug logging + # - DEBUG=true + # Optional: Sentry error reporting + # - SENTRY_DSN=https://... + # Optional: Prometheus metrics auth + # - METRICS_USERNAME=prometheus + # - METRICS_PASSWORD=secret +``` + +**Important**: Set `HOST_DATA_DIR` to an **absolute path** on the Docker host before running +`docker compose up`. Relative paths will not work because docker-compose may not run on the same +machine as µPaaS. This value is used both for the bind mount and passed to µPaaS as +`UPAAS_HOST_DATA_DIR` so it can create correct bind mounts during builds. + +Example: +```bash +export HOST_DATA_DIR=/srv/upaas-data +docker compose up -d +``` Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e06e49f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,20 +0,0 @@ -services: - upaas: - build: . - restart: unless-stopped - ports: - - "8080:8080" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - upaas-data:/var/lib/upaas - # environment: - # Optional: uncomment to enable debug logging - # - DEBUG=true - # Optional: Sentry error reporting - # - SENTRY_DSN=https://... - # Optional: Prometheus metrics auth - # - METRICS_USERNAME=prometheus - # - METRICS_PASSWORD=secret - -volumes: - upaas-data: diff --git a/internal/database/migrations.go b/internal/database/migrations.go index 6e9b923..03a69fa 100644 --- a/internal/database/migrations.go +++ b/internal/database/migrations.go @@ -113,9 +113,9 @@ func (d *Database) applyMigration(ctx context.Context, filename string) error { return fmt.Errorf("failed to record migration: %w", err) } - commitErr := transaction.Commit() - if commitErr != nil { - return fmt.Errorf("failed to commit migration: %w", commitErr) + err = transaction.Commit() + if err != nil { + return fmt.Errorf("failed to commit migration: %w", err) } return nil diff --git a/internal/database/testing.go b/internal/database/testing.go deleted file mode 100644 index df2e737..0000000 --- a/internal/database/testing.go +++ /dev/null @@ -1,41 +0,0 @@ -package database - -import ( - "log/slog" - "os" - "testing" - - "git.eeqj.de/sneak/upaas/internal/config" - "git.eeqj.de/sneak/upaas/internal/logger" -) - -// NewTestDatabase creates an in-memory Database for testing. -// It runs migrations so all tables are available. -func NewTestDatabase(t *testing.T) *Database { - t.Helper() - - tmpDir := t.TempDir() - - cfg := &config.Config{ - DataDir: tmpDir, - } - - log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - logWrapper := logger.NewForTest(log) - - db, err := New(nil, Params{ - Logger: logWrapper, - Config: cfg, - }) - if err != nil { - t.Fatalf("failed to create test database: %v", err) - } - - t.Cleanup(func() { - if db.database != nil { - _ = db.database.Close() - } - }) - - return db -} diff --git a/internal/docker/client.go b/internal/docker/client.go index 38cc198..a4a4352 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -14,7 +14,7 @@ import ( "strconv" "strings" - "github.com/docker/docker/api/types" + dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" @@ -116,7 +116,7 @@ type BuildImageOptions struct { func (c *Client) BuildImage( ctx context.Context, opts BuildImageOptions, -) (string, error) { +) (ImageID, error) { if c.docker == nil { return "", ErrNotConnected } @@ -188,7 +188,7 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) { func (c *Client) CreateContainer( ctx context.Context, opts CreateContainerOptions, -) (string, error) { +) (ContainerID, error) { if c.docker == nil { return "", ErrNotConnected } @@ -241,18 +241,18 @@ func (c *Client) CreateContainer( return "", fmt.Errorf("failed to create container: %w", err) } - return resp.ID, nil + return ContainerID(resp.ID), nil } // StartContainer starts a container. -func (c *Client) StartContainer(ctx context.Context, containerID string) error { +func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error { if c.docker == nil { return ErrNotConnected } c.log.Info("starting container", "id", containerID) - err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{}) + err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{}) if err != nil { return fmt.Errorf("failed to start container: %w", err) } @@ -261,7 +261,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID string) error { } // StopContainer stops a container. -func (c *Client) StopContainer(ctx context.Context, containerID string) error { +func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error { if c.docker == nil { return ErrNotConnected } @@ -270,7 +270,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error { timeout := stopTimeoutSeconds - err := c.docker.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) + err := c.docker.ContainerStop(ctx, string(containerID), container.StopOptions{Timeout: &timeout}) if err != nil { return fmt.Errorf("failed to stop container: %w", err) } @@ -281,7 +281,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID string) error { // RemoveContainer removes a container. func (c *Client) RemoveContainer( ctx context.Context, - containerID string, + containerID ContainerID, force bool, ) error { if c.docker == nil { @@ -290,7 +290,7 @@ func (c *Client) RemoveContainer( c.log.Info("removing container", "id", containerID, "force", force) - err := c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) + err := c.docker.ContainerRemove(ctx, string(containerID), container.RemoveOptions{Force: force}) if err != nil { return fmt.Errorf("failed to remove container: %w", err) } @@ -301,7 +301,7 @@ func (c *Client) RemoveContainer( // ContainerLogs returns the logs for a container. func (c *Client) ContainerLogs( ctx context.Context, - containerID string, + containerID ContainerID, tail string, ) (string, error) { if c.docker == nil { @@ -314,7 +314,7 @@ func (c *Client) ContainerLogs( Tail: tail, } - reader, err := c.docker.ContainerLogs(ctx, containerID, opts) + reader, err := c.docker.ContainerLogs(ctx, string(containerID), opts) if err != nil { return "", fmt.Errorf("failed to get container logs: %w", err) } @@ -337,13 +337,13 @@ func (c *Client) ContainerLogs( // IsContainerRunning checks if a container is running. func (c *Client) IsContainerRunning( ctx context.Context, - containerID string, + containerID ContainerID, ) (bool, error) { if c.docker == nil { return false, ErrNotConnected } - inspect, err := c.docker.ContainerInspect(ctx, containerID) + inspect, err := c.docker.ContainerInspect(ctx, string(containerID)) if err != nil { return false, fmt.Errorf("failed to inspect container: %w", err) } @@ -354,13 +354,13 @@ func (c *Client) IsContainerRunning( // IsContainerHealthy checks if a container is healthy. func (c *Client) IsContainerHealthy( ctx context.Context, - containerID string, + containerID ContainerID, ) (bool, error) { if c.docker == nil { return false, ErrNotConnected } - inspect, err := c.docker.ContainerInspect(ctx, containerID) + inspect, err := c.docker.ContainerInspect(ctx, string(containerID)) if err != nil { return false, fmt.Errorf("failed to inspect container: %w", err) } @@ -378,7 +378,7 @@ const LabelUpaasID = "upaas.id" // ContainerInfo contains basic information about a container. type ContainerInfo struct { - ID string + ID ContainerID Running bool } @@ -413,7 +413,7 @@ func (c *Client) FindContainerByAppID( ctr := containers[0] return &ContainerInfo{ - ID: ctr.ID, + ID: ContainerID(ctr.ID), Running: ctr.State == "running", }, nil } @@ -482,8 +482,8 @@ func (c *Client) CloneRepo( // RemoveImage removes a Docker image by ID or tag. // It returns nil if the image was successfully removed or does not exist. -func (c *Client) RemoveImage(ctx context.Context, imageID string) error { - _, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{ +func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error { + _, err := c.docker.ImageRemove(ctx, string(imageID), image.RemoveOptions{ Force: true, PruneChildren: true, }) @@ -497,7 +497,7 @@ func (c *Client) RemoveImage(ctx context.Context, imageID string) error { func (c *Client) performBuild( ctx context.Context, opts BuildImageOptions, -) (string, error) { +) (ImageID, error) { // Create tar archive of build context tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) if err != nil { @@ -512,7 +512,7 @@ func (c *Client) performBuild( }() // Build image - resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{ + resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{ Dockerfile: opts.DockerfilePath, Tags: opts.Tags, Remove: true, @@ -542,7 +542,7 @@ func (c *Client) performBuild( return "", fmt.Errorf("failed to inspect image: %w", inspectErr) } - return inspect.ID, nil + return ImageID(inspect.ID), nil } return "", nil @@ -603,22 +603,22 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu } }() - containerID, err := c.createGitContainer(ctx, cfg) + gitContainerID, err := c.createGitContainer(ctx, cfg) if err != nil { return nil, err } defer func() { - _ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}) + _ = c.docker.ContainerRemove(ctx, string(gitContainerID), container.RemoveOptions{Force: true}) }() - return c.runGitClone(ctx, containerID) + return c.runGitClone(ctx, gitContainerID) } func (c *Client) createGitContainer( ctx context.Context, cfg *cloneConfig, -) (string, error) { +) (ContainerID, error) { gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no" // Build the git command using environment variables to avoid shell injection. @@ -675,16 +675,16 @@ func (c *Client) createGitContainer( return "", fmt.Errorf("failed to create git container: %w", err) } - return resp.ID, nil + return ContainerID(resp.ID), nil } -func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) { - err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{}) +func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) { + err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{}) if err != nil { return nil, fmt.Errorf("failed to start git container: %w", err) } - statusCh, errCh := c.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) + statusCh, errCh := c.docker.ContainerWait(ctx, string(containerID), container.WaitConditionNotRunning) select { case err := <-errCh: diff --git a/internal/docker/types.go b/internal/docker/types.go new file mode 100644 index 0000000..178035e --- /dev/null +++ b/internal/docker/types.go @@ -0,0 +1,7 @@ +package docker + +// ImageID is a Docker image identifier (ID or tag). +type ImageID string + +// ContainerID is a Docker container identifier. +type ContainerID string diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 28382de..0a9ee77 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -72,7 +72,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid nameErr := validateAppName(name) if nameErr != nil { data["Error"] = "Invalid app name: " + nameErr.Error() - _ = tmpl.ExecuteTemplate(writer, "app_new.html", data) + h.renderTemplate(writer, tmpl, "app_new.html", data) return } @@ -228,7 +228,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid "App": application, "Error": "Invalid app name: " + nameErr.Error(), }, request) - _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) + h.renderTemplate(writer, tmpl, "app_edit.html", data) return } @@ -239,7 +239,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid "App": application, "Error": "Invalid repository URL: " + repoURLErr.Error(), }, request) - _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) + h.renderTemplate(writer, tmpl, "app_edit.html", data) return } diff --git a/internal/logger/testing.go b/internal/logger/testing.go deleted file mode 100644 index 595733b..0000000 --- a/internal/logger/testing.go +++ /dev/null @@ -1,11 +0,0 @@ -package logger - -import "log/slog" - -// NewForTest creates a Logger wrapping the given slog.Logger, for use in tests. -func NewForTest(log *slog.Logger) *Logger { - return &Logger{ - log: log, - level: new(slog.LevelVar), - } -} diff --git a/internal/models/deployment.go b/internal/models/deployment.go index 492ba82..73a9feb 100644 --- a/internal/models/deployment.go +++ b/internal/models/deployment.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "git.eeqj.de/sneak/upaas/internal/database" @@ -76,7 +77,11 @@ func (d *Deployment) Reload(ctx context.Context) error { return d.scan(row) } +// maxLogSize is the maximum size of deployment logs stored in the database (1MB). +const maxLogSize = 1 << 20 + // AppendLog appends a log line to the deployment logs. +// If the total log size exceeds maxLogSize, the oldest lines are truncated. func (d *Deployment) AppendLog(ctx context.Context, line string) error { var currentLogs string @@ -84,7 +89,22 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error { currentLogs = d.Logs.String } - d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true} + newLogs := currentLogs + line + "\n" + + if len(newLogs) > maxLogSize { + // Keep the most recent logs that fit within the limit. + // Find a newline after the truncation point to avoid partial lines. + truncateAt := len(newLogs) - maxLogSize + idx := strings.Index(newLogs[truncateAt:], "\n") + + if idx >= 0 { + newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:] + } else { + newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:] + } + } + + d.Logs = sql.NullString{String: newLogs, Valid: true} return d.Save(ctx) } diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 6764736..1608fd8 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -251,8 +251,8 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) { } // GetBuildDir returns the build directory path for an app. -func (svc *Service) GetBuildDir(appID string) string { - return filepath.Join(svc.config.DataDir, "builds", appID) +func (svc *Service) GetBuildDir(appName string) string { + return filepath.Join(svc.config.DataDir, "builds", appName) } // GetLogFilePath returns the path to the log file for a deployment. @@ -417,7 +417,7 @@ func (svc *Service) executeRollback( svc.removeOldContainer(ctx, app, deployment) - rollbackOpts, err := svc.buildContainerOptions(ctx, app, previousImageID) + rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID)) if err != nil { svc.failDeployment(bgCtx, app, deployment, err) @@ -431,8 +431,8 @@ func (svc *Service) executeRollback( return fmt.Errorf("failed to create rollback container: %w", err) } - deployment.ContainerID = sql.NullString{String: containerID, Valid: true} - _ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID) + deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true} + _ = deployment.AppendLog(bgCtx, "Rollback container created: "+string(containerID)) startErr := svc.docker.StartContainer(ctx, containerID) if startErr != nil { @@ -514,7 +514,7 @@ func (svc *Service) buildImageWithTimeout( ctx context.Context, app *models.App, deployment *models.Deployment, -) (string, error) { +) (docker.ImageID, error) { buildCtx, cancel := context.WithTimeout(ctx, buildTimeout) defer cancel() @@ -539,7 +539,7 @@ func (svc *Service) deployContainerWithTimeout( ctx context.Context, app *models.App, deployment *models.Deployment, - imageID string, + imageID docker.ImageID, ) error { deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) defer cancel() @@ -667,7 +667,7 @@ func (svc *Service) checkCancelled( bgCtx context.Context, app *models.App, deployment *models.Deployment, - imageID string, + imageID docker.ImageID, ) error { if !errors.Is(deployCtx.Err(), context.Canceled) { return nil @@ -687,7 +687,7 @@ func (svc *Service) cleanupCancelledDeploy( ctx context.Context, app *models.App, deployment *models.Deployment, - imageID string, + imageID docker.ImageID, ) { // Clean up the intermediate Docker image if one was built if imageID != "" { @@ -695,11 +695,11 @@ func (svc *Service) cleanupCancelledDeploy( if removeErr != nil { svc.log.Error("failed to remove image from cancelled deploy", "error", removeErr, "app", app.Name, "image", imageID) - _ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID+": "+removeErr.Error()) + _ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+string(imageID)+": "+removeErr.Error()) } else { svc.log.Info("cleaned up image from cancelled deploy", "app", app.Name, "image", imageID) - _ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID) + _ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+string(imageID)) } } @@ -816,7 +816,7 @@ func (svc *Service) buildImage( ctx context.Context, app *models.App, deployment *models.Deployment, -) (string, error) { +) (docker.ImageID, error) { workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment) if err != nil { return "", err @@ -850,8 +850,8 @@ func (svc *Service) buildImage( return "", fmt.Errorf("failed to build image: %w", err) } - deployment.ImageID = sql.NullString{String: imageID, Valid: true} - _ = deployment.AppendLog(ctx, "Image built: "+imageID) + deployment.ImageID = sql.NullString{String: string(imageID), Valid: true} + _ = deployment.AppendLog(ctx, "Image built: "+string(imageID)) return imageID, nil } @@ -1009,15 +1009,15 @@ func (svc *Service) removeOldContainer( svc.log.Warn("failed to remove old container", "error", removeErr) } - _ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12]) + _ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12])) } func (svc *Service) createAndStartContainer( ctx context.Context, app *models.App, deployment *models.Deployment, - imageID string, -) (string, error) { + imageID docker.ImageID, +) (docker.ContainerID, error) { containerOpts, err := svc.buildContainerOptions(ctx, app, imageID) if err != nil { svc.failDeployment(ctx, app, deployment, err) @@ -1038,8 +1038,8 @@ func (svc *Service) createAndStartContainer( return "", fmt.Errorf("failed to create container: %w", err) } - deployment.ContainerID = sql.NullString{String: containerID, Valid: true} - _ = deployment.AppendLog(ctx, "Container created: "+containerID) + deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true} + _ = deployment.AppendLog(ctx, "Container created: "+string(containerID)) startErr := svc.docker.StartContainer(ctx, containerID) if startErr != nil { @@ -1062,7 +1062,7 @@ func (svc *Service) createAndStartContainer( func (svc *Service) buildContainerOptions( ctx context.Context, app *models.App, - imageID string, + imageID docker.ImageID, ) (docker.CreateContainerOptions, error) { envVars, err := app.GetEnvVars(ctx) if err != nil { @@ -1096,7 +1096,7 @@ func (svc *Service) buildContainerOptions( return docker.CreateContainerOptions{ Name: "upaas-" + app.Name, - Image: imageID, + Image: string(imageID), Env: envMap, Labels: buildLabelMap(app, labels), Volumes: buildVolumeMounts(volumes), @@ -1146,9 +1146,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping { func (svc *Service) updateAppRunning( ctx context.Context, app *models.App, - imageID string, + imageID docker.ImageID, ) error { - app.ImageID = sql.NullString{String: imageID, Valid: true} + app.ImageID = sql.NullString{String: string(imageID), Valid: true} app.Status = models.AppStatusRunning saveErr := app.Save(ctx) diff --git a/internal/service/deploy/deploy_container_test.go b/internal/service/deploy/deploy_container_test.go deleted file mode 100644 index 29ba3ef..0000000 --- a/internal/service/deploy/deploy_container_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package deploy_test - -import ( - "context" - "log/slog" - "os" - "testing" - - "git.eeqj.de/sneak/upaas/internal/database" - "git.eeqj.de/sneak/upaas/internal/models" - "git.eeqj.de/sneak/upaas/internal/service/deploy" -) - -func TestBuildContainerOptionsUsesImageID(t *testing.T) { - t.Parallel() - - db := database.NewTestDatabase(t) - - app := models.NewApp(db) - app.Name = "myapp" - - err := app.Save(context.Background()) - if err != nil { - t.Fatalf("failed to save app: %v", err) - } - - log := slog.New(slog.NewTextHandler(os.Stderr, nil)) - svc := deploy.NewTestService(log) - - const expectedImageID = "sha256:abc123def456" - - opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID) - if err != nil { - t.Fatalf("buildContainerOptions returned error: %v", err) - } - - if opts.Image != expectedImageID { - t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image) - } - - if opts.Name != "upaas-myapp" { - t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name) - } -} diff --git a/internal/service/deploy/export_test.go b/internal/service/deploy/export_test.go index 8d48715..bd90daa 100644 --- a/internal/service/deploy/export_test.go +++ b/internal/service/deploy/export_test.go @@ -10,7 +10,6 @@ import ( "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/docker" - "git.eeqj.de/sneak/upaas/internal/models" ) // NewTestService creates a Service with minimal dependencies for testing. @@ -81,12 +80,3 @@ func (svc *Service) CleanupCancelledDeploy( func (svc *Service) GetBuildDirExported(appName string) string { return svc.GetBuildDir(appName) } - -// BuildContainerOptionsExported exposes buildContainerOptions for testing. -func (svc *Service) BuildContainerOptionsExported( - ctx context.Context, - app *models.App, - imageID string, -) (docker.CreateContainerOptions, error) { - return svc.buildContainerOptions(ctx, app, imageID) -} diff --git a/internal/service/webhook/types.go b/internal/service/webhook/types.go new file mode 100644 index 0000000..69ce0d5 --- /dev/null +++ b/internal/service/webhook/types.go @@ -0,0 +1,7 @@ +package webhook + +// UnparsedURL is a URL stored as a plain string without parsing. +// Use this instead of string when the value is known to be a URL +// but should not be parsed into a net/url.URL (e.g. webhook URLs, +// compare URLs from external payloads). +type UnparsedURL string diff --git a/internal/service/webhook/webhook.go b/internal/service/webhook/webhook.go index dbdd23d..7934c96 100644 --- a/internal/service/webhook/webhook.go +++ b/internal/service/webhook/webhook.go @@ -47,24 +47,24 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) { // //nolint:tagliatelle // Field names match Gitea API (snake_case) type GiteaPushPayload struct { - Ref string `json:"ref"` - Before string `json:"before"` - After string `json:"after"` - CompareURL string `json:"compare_url"` + Ref string `json:"ref"` + Before string `json:"before"` + After string `json:"after"` + CompareURL UnparsedURL `json:"compare_url"` Repository struct { - FullName string `json:"full_name"` - CloneURL string `json:"clone_url"` - SSHURL string `json:"ssh_url"` - HTMLURL string `json:"html_url"` + FullName string `json:"full_name"` + CloneURL UnparsedURL `json:"clone_url"` + SSHURL string `json:"ssh_url"` + HTMLURL UnparsedURL `json:"html_url"` } `json:"repository"` Pusher struct { Username string `json:"username"` Email string `json:"email"` } `json:"pusher"` Commits []struct { - ID string `json:"id"` - URL string `json:"url"` - Message string `json:"message"` + ID string `json:"id"` + URL UnparsedURL `json:"url"` + Message string `json:"message"` Author struct { Name string `json:"name"` Email string `json:"email"` @@ -104,7 +104,7 @@ func (svc *Service) HandleWebhook( event.EventType = eventType event.Branch = branch event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""} - event.CommitURL = sql.NullString{String: commitURL, Valid: commitURL != ""} + event.CommitURL = sql.NullString{String: string(commitURL), Valid: commitURL != ""} event.Payload = sql.NullString{String: string(payload), Valid: true} event.Matched = matched event.Processed = false @@ -168,7 +168,7 @@ func extractBranch(ref string) string { // extractCommitURL extracts the commit URL from the webhook payload. // Prefers the URL from the head commit, falls back to constructing from repo URL. -func extractCommitURL(payload GiteaPushPayload) string { +func extractCommitURL(payload GiteaPushPayload) UnparsedURL { // Try to find the URL from the head commit (matching After SHA) for _, commit := range payload.Commits { if commit.ID == payload.After && commit.URL != "" { @@ -178,7 +178,7 @@ func extractCommitURL(payload GiteaPushPayload) string { // Fall back to constructing URL from repo HTML URL if payload.Repository.HTMLURL != "" && payload.After != "" { - return payload.Repository.HTMLURL + "/commit/" + payload.After + return UnparsedURL(string(payload.Repository.HTMLURL) + "/commit/" + payload.After) } return "" diff --git a/static/js/alpine.min.js b/static/js/alpine.min.js index 2a6849c..6bde2c4 100644 --- a/static/js/alpine.min.js +++ b/static/js/alpine.min.js @@ -1,5 +1,3046 @@ -(()=>{var nt=!1,it=!1,G=[],ot=-1;function Ut(e){In(e)}function In(e){G.includes(e)||G.push(e),$n()}function Wt(e){let t=G.indexOf(e);t!==-1&&t>ot&&G.splice(t,1)}function $n(){!it&&!nt&&(nt=!0,queueMicrotask(Ln))}function Ln(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),F(i))},i},()=>{t()}]}function Oe(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>F(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function re(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Re(e){Xt.push(e)}function Te(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function pe(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){jn(),ut.disconnect(),ft=!1}var de=[];function jn(){let e=ut.takeRecords();de.push(()=>e.length>0&&mt(e));let t=de.length;queueMicrotask(()=>{if(de.length===t)for(;de.length>0;)de.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return pe(),t}var pt=!1,Ce=[];function rr(){pt=!0}function nr(){pt=!1,mt(Ce),Ce=[]}function mt(e){if(pt){Ce=Ce.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Me(e){return k(B(e))}function D(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function k(e){return new Proxy({objects:e},Fn)}var Fn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Bn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Bn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function ne(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Ne(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>zn(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function zn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function K(e,t){let r=Hn(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Hn(e){let[t,r]=_t(e),n={interceptor:Ne,...t};return re(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){ie(i,e,t)}}function ie(...e){return sr(...e)}var sr=Kn;function ar(e){sr=e}function Kn(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} +(() => { + var nt = !1, + it = !1, + G = [], + ot = -1; + function Ut(e) { + In(e); + } + function In(e) { + (G.includes(e) || G.push(e), $n()); + } + function Wt(e) { + let t = G.indexOf(e); + t !== -1 && t > ot && G.splice(t, 1); + } + function $n() { + !it && !nt && ((nt = !0), queueMicrotask(Ln)); + } + function Ln() { + ((nt = !1), (it = !0)); + for (let e = 0; e < G.length; e++) (G[e](), (ot = e)); + ((G.length = 0), (ot = -1), (it = !1)); + } + var R, + N, + F, + at, + st = !0; + function Gt(e) { + ((st = !1), e(), (st = !0)); + } + function Jt(e) { + ((R = e.reactive), + (F = e.release), + (N = (t) => + e.effect(t, { + scheduler: (r) => { + st ? Ut(r) : r(); + }, + })), + (at = e.raw)); + } + function ct(e) { + N = e; + } + function Yt(e) { + let t = () => {}; + return [ + (n) => { + let i = N(n); + return ( + e._x_effects || + ((e._x_effects = new Set()), + (e._x_runEffects = () => { + e._x_effects.forEach((o) => o()); + })), + e._x_effects.add(i), + (t = () => { + i !== void 0 && (e._x_effects.delete(i), F(i)); + }), + i + ); + }, + () => { + t(); + }, + ]; + } + function Oe(e, t) { + let r = !0, + n, + i = N(() => { + let o = e(); + (JSON.stringify(o), + r + ? (n = o) + : queueMicrotask(() => { + (t(o, n), (n = o)); + }), + (r = !1)); + }); + return () => F(i); + } + var Xt = [], + Zt = [], + Qt = []; + function er(e) { + Qt.push(e); + } + function re(e, t) { + typeof t == "function" + ? (e._x_cleanups || (e._x_cleanups = []), e._x_cleanups.push(t)) + : ((t = e), Zt.push(t)); + } + function Re(e) { + Xt.push(e); + } + function Te(e, t, r) { + (e._x_attributeCleanups || (e._x_attributeCleanups = {}), + e._x_attributeCleanups[t] || (e._x_attributeCleanups[t] = []), + e._x_attributeCleanups[t].push(r)); + } + function lt(e, t) { + e._x_attributeCleanups && + Object.entries(e._x_attributeCleanups).forEach(([r, n]) => { + (t === void 0 || t.includes(r)) && + (n.forEach((i) => i()), delete e._x_attributeCleanups[r]); + }); + } + function tr(e) { + for (e._x_effects?.forEach(Wt); e._x_cleanups?.length; ) + e._x_cleanups.pop()(); + } + var ut = new MutationObserver(mt), + ft = !1; + function pe() { + (ut.observe(document, { + subtree: !0, + childList: !0, + attributes: !0, + attributeOldValue: !0, + }), + (ft = !0)); + } + function dt() { + (jn(), ut.disconnect(), (ft = !1)); + } + var de = []; + function jn() { + let e = ut.takeRecords(); + de.push(() => e.length > 0 && mt(e)); + let t = de.length; + queueMicrotask(() => { + if (de.length === t) for (; de.length > 0; ) de.shift()(); + }); + } + function m(e) { + if (!ft) return e(); + dt(); + let t = e(); + return (pe(), t); + } + var pt = !1, + Ce = []; + function rr() { + pt = !0; + } + function nr() { + ((pt = !1), mt(Ce), (Ce = [])); + } + function mt(e) { + if (pt) { + Ce = Ce.concat(e); + return; + } + let t = [], + r = new Set(), + n = new Map(), + i = new Map(); + for (let o = 0; o < e.length; o++) + if ( + !e[o].target._x_ignoreMutationObserver && + (e[o].type === "childList" && + (e[o].removedNodes.forEach((s) => { + s.nodeType === 1 && s._x_marker && r.add(s); + }), + e[o].addedNodes.forEach((s) => { + if (s.nodeType === 1) { + if (r.has(s)) { + r.delete(s); + return; + } + s._x_marker || t.push(s); + } + })), + e[o].type === "attributes") + ) { + let s = e[o].target, + a = e[o].attributeName, + c = e[o].oldValue, + l = () => { + (n.has(s) || n.set(s, []), + n + .get(s) + .push({ name: a, value: s.getAttribute(a) })); + }, + u = () => { + (i.has(s) || i.set(s, []), i.get(s).push(a)); + }; + s.hasAttribute(a) && c === null + ? l() + : s.hasAttribute(a) + ? (u(), l()) + : u(); + } + (i.forEach((o, s) => { + lt(s, o); + }), + n.forEach((o, s) => { + Xt.forEach((a) => a(s, o)); + })); + for (let o of r) + t.some((s) => s.contains(o)) || Zt.forEach((s) => s(o)); + for (let o of t) o.isConnected && Qt.forEach((s) => s(o)); + ((t = null), (r = null), (n = null), (i = null)); + } + function Me(e) { + return k(B(e)); + } + function D(e, t, r) { + return ( + (e._x_dataStack = [t, ...B(r || e)]), + () => { + e._x_dataStack = e._x_dataStack.filter((n) => n !== t); + } + ); + } + function B(e) { + return e._x_dataStack + ? e._x_dataStack + : typeof ShadowRoot == "function" && e instanceof ShadowRoot + ? B(e.host) + : e.parentNode + ? B(e.parentNode) + : []; + } + function k(e) { + return new Proxy({ objects: e }, Fn); + } + var Fn = { + ownKeys({ objects: e }) { + return Array.from(new Set(e.flatMap((t) => Object.keys(t)))); + }, + has({ objects: e }, t) { + return t == Symbol.unscopables + ? !1 + : e.some( + (r) => + Object.prototype.hasOwnProperty.call(r, t) || + Reflect.has(r, t), + ); + }, + get({ objects: e }, t, r) { + return t == "toJSON" + ? Bn + : Reflect.get(e.find((n) => Reflect.has(n, t)) || {}, t, r); + }, + set({ objects: e }, t, r, n) { + let i = + e.find((s) => Object.prototype.hasOwnProperty.call(s, t)) || + e[e.length - 1], + o = Object.getOwnPropertyDescriptor(i, t); + return o?.set && o?.get + ? o.set.call(n, r) || !0 + : Reflect.set(i, t, r); + }, + }; + function Bn() { + return Reflect.ownKeys(this).reduce( + (t, r) => ((t[r] = Reflect.get(this, r)), t), + {}, + ); + } + function ne(e) { + let t = (n) => typeof n == "object" && !Array.isArray(n) && n !== null, + r = (n, i = "") => { + Object.entries(Object.getOwnPropertyDescriptors(n)).forEach( + ([o, { value: s, enumerable: a }]) => { + if ( + a === !1 || + s === void 0 || + (typeof s == "object" && s !== null && s.__v_skip) + ) + return; + let c = i === "" ? o : `${i}.${o}`; + typeof s == "object" && s !== null && s._x_interceptor + ? (n[o] = s.initialize(e, c, o)) + : t(s) && + s !== n && + !(s instanceof Element) && + r(s, c); + }, + ); + }; + return r(e); + } + function Ne(e, t = () => {}) { + let r = { + initialValue: void 0, + _x_interceptor: !0, + initialize(n, i, o) { + return e( + this.initialValue, + () => zn(n, i), + (s) => ht(n, i, s), + i, + o, + ); + }, + }; + return ( + t(r), + (n) => { + if (typeof n == "object" && n !== null && n._x_interceptor) { + let i = r.initialize.bind(r); + r.initialize = (o, s, a) => { + let c = n.initialize(o, s, a); + return ((r.initialValue = c), i(o, s, a)); + }; + } else r.initialValue = n; + return r; + } + ); + } + function zn(e, t) { + return t.split(".").reduce((r, n) => r[n], e); + } + function ht(e, t, r) { + if ((typeof t == "string" && (t = t.split(".")), t.length === 1)) + e[t[0]] = r; + else { + if (t.length === 0) throw error; + return (e[t[0]] || (e[t[0]] = {}), ht(e[t[0]], t.slice(1), r)); + } + } + var ir = {}; + function y(e, t) { + ir[e] = t; + } + function K(e, t) { + let r = Hn(t); + return ( + Object.entries(ir).forEach(([n, i]) => { + Object.defineProperty(e, `$${n}`, { + get() { + return i(t, r); + }, + enumerable: !1, + }); + }), + e + ); + } + function Hn(e) { + let [t, r] = _t(e), + n = { interceptor: Ne, ...t }; + return (re(e, r), n); + } + function or(e, t, r, ...n) { + try { + return r(...n); + } catch (i) { + ie(i, e, t); + } + } + function ie(...e) { + return sr(...e); + } + var sr = Kn; + function ar(e) { + sr = e; + } + function Kn(e, t, r = void 0) { + ((e = Object.assign(e ?? { message: "No error message given." }, { + el: t, + expression: r, + })), + console.warn( + `Alpine Expression Error: ${e.message} -${r?'Expression: "'+r+`" +${ + r + ? 'Expression: "' + + r + + `" -`:""}`,t),setTimeout(()=>{throw e},0)}var oe=!0;function De(e){let t=oe;oe=!1;let r=e();return oe=t,r}function T(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return cr(...e)}var cr=xt;function lr(e){cr=e}var ur;function fr(e){ur=e}function xt(e,t){let r={};K(r,e);let n=[r,...B(e)],i=typeof t=="function"?Vn(n,t):Un(n,t,e);return or.bind(null,e,t,i)}function Vn(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!oe){me(r,t,k([n,...e]),i);return}let s=t.apply(k([n,...e]),i);me(r,s)}}var gt={};function qn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return ie(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Un(e,t,r){let n=qn(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=k([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>ie(u,r,t));n.finished?(me(i,n.result,c,s,r),n.result=void 0):l.then(u=>{me(i,u,c,s,r)}).catch(u=>ie(u,r,t)).finally(()=>n.result=void 0)}}}function me(e,t,r,n,i){if(oe&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>me(e,s,r,n)).catch(s=>ie(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function dr(...e){return ur(...e)}function pr(e,t,r={}){let n={};K(n,e);let i=[n,...B(e)],o=k([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&oe?l.apply(o,s):l}}var wt="x-";function C(e=""){return wt+e}function mr(e){wt=e}var ke={};function d(e,t){return ke[e]=t,{before(r){if(!ke[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=J.indexOf(r);J.splice(n>=0?n:J.indexOf("DEFAULT"),0,e)}}}function hr(e){return Object.keys(ke).includes(e)}function _e(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(xr((o,s)=>n[o]=s)).filter(br).map(Gn(n,r)).sort(Jn).map(o=>Wn(e,o))}function Et(e){return Array.from(e).map(xr()).filter(t=>!br(t))}var yt=!1,he=new Map,_r=Symbol();function gr(e){yt=!0;let t=Symbol();_r=t,he.set(t,[]);let r=()=>{for(;he.get(t).length;)he.get(t).shift()();he.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:z,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:T.bind(T,e)},()=>t.forEach(a=>a())]}function Wn(e,t){let r=()=>{},n=ke[t.type]||r,[i,o]=_t(e);Te(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?he.get(_r).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function xr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=yr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var yr=[];function se(e){yr.push(e)}function br({name:e}){return wr().test(e)}var wr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function Gn(e,t){return({name:r,value:n})=>{let i=r.match(wr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",J=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Jn(e,t){let r=J.indexOf(e.type)===-1?bt:e.type,n=J.indexOf(t.type)===-1?bt:t.type;return J.indexOf(r)-J.indexOf(n)}function Y(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function P(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>P(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)P(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Er=!1;function vr(){Er&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Er=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `