gogcli/internal/tracking/deploy.go
2026-01-09 01:53:42 +01:00

202 lines
5.3 KiB
Go

package tracking
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
type DeployLogger interface {
Printf(format string, args ...any)
}
type DeployOptions struct {
WorkerDir string
WorkerName string
DatabaseName string
TrackingKey string
AdminKey string
}
var (
errWranglerNotFound = errors.New("wrangler not found in PATH")
errWorkerConfigMissing = errors.New("worker dir missing wrangler.toml")
errParseDatabaseIDInfo = errors.New("failed to parse database_id from wrangler d1 info output")
errParseDatabaseIDCreate = errors.New("failed to parse database_id from wrangler d1 create output")
)
func DefaultWorkerName(account string) string {
sanitized := SanitizeWorkerName(account)
if sanitized == "" {
return "gog-email-tracker"
}
return "gog-email-tracker-" + sanitized
}
func SanitizeWorkerName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
re := regexp.MustCompile(`[^a-z0-9-]+`)
name = re.ReplaceAllString(name, "-")
name = strings.Trim(name, "-")
for strings.Contains(name, "--") {
name = strings.ReplaceAll(name, "--", "-")
}
if len(name) > 63 {
name = strings.Trim(name[:63], "-")
}
return name
}
func DeployWorker(ctx context.Context, logger DeployLogger, opts DeployOptions) (string, error) {
if _, err := exec.LookPath("wrangler"); err != nil {
return "", errWranglerNotFound
}
workerDir := filepath.Clean(opts.WorkerDir)
if _, err := os.Stat(filepath.Join(workerDir, "wrangler.toml")); err != nil {
return "", fmt.Errorf("%w: %s", errWorkerConfigMissing, workerDir)
}
if logger != nil {
logger.Printf("deploy\tstarting (worker=%s, db=%s)", opts.WorkerName, opts.DatabaseName)
}
dbID, err := ensureD1Database(ctx, workerDir, opts.DatabaseName)
if err != nil {
return "", err
}
if runErr := runWranglerCommand(ctx, workerDir, nil, "d1", "execute", opts.DatabaseName, "--file", "schema.sql", "--remote"); runErr != nil {
return "", runErr
}
if runErr := runWranglerCommand(ctx, workerDir, strings.NewReader(opts.TrackingKey+"\n"), "secret", "put", "TRACKING_KEY", "--name", opts.WorkerName); runErr != nil {
return "", runErr
}
if runErr := runWranglerCommand(ctx, workerDir, strings.NewReader(opts.AdminKey+"\n"), "secret", "put", "ADMIN_KEY", "--name", opts.WorkerName); runErr != nil {
return "", runErr
}
configPath, err := writeWranglerConfig(workerDir, opts.WorkerName, opts.DatabaseName, dbID)
if err != nil {
return "", err
}
defer os.Remove(configPath)
if runErr := runWranglerCommand(ctx, workerDir, nil, "deploy", "--config", configPath, "--name", opts.WorkerName); runErr != nil {
return "", runErr
}
if logger != nil {
logger.Printf("deploy\tok")
}
return dbID, nil
}
func ensureD1Database(ctx context.Context, workerDir, dbName string) (string, error) {
out, err := runWranglerCommandOutput(ctx, workerDir, nil, "d1", "create", dbName)
if err != nil {
outInfo, infoErr := runWranglerCommandOutput(ctx, workerDir, nil, "d1", "info", dbName)
if infoErr != nil {
return "", err
}
id := parseDatabaseID(outInfo)
if id == "" {
return "", errParseDatabaseIDInfo
}
return id, nil
}
id := parseDatabaseID(out)
if id == "" {
return "", errParseDatabaseIDCreate
}
return id, nil
}
func parseDatabaseID(out string) string {
patterns := []*regexp.Regexp{
regexp.MustCompile(`database_id\s*=\s*\"([^\"]+)\"`),
regexp.MustCompile(`database_id\s*:\s*\"?([a-zA-Z0-9-]+)\"?`),
regexp.MustCompile(`Database ID:\s*([a-zA-Z0-9-]+)`),
}
for _, re := range patterns {
if match := re.FindStringSubmatch(out); len(match) > 1 {
return match[1]
}
}
return ""
}
func writeWranglerConfig(workerDir, workerName, dbName, dbID string) (string, error) {
templatePath := filepath.Join(workerDir, "wrangler.toml")
// #nosec G304 -- path is derived from the configured worker dir
data, err := os.ReadFile(templatePath)
if err != nil {
return "", fmt.Errorf("read wrangler.toml: %w", err)
}
content := string(data)
content = replaceTomlString(content, "name", workerName)
content = replaceTomlString(content, "database_name", dbName)
content = replaceTomlString(content, "database_id", dbID)
tmpFile, err := os.CreateTemp("", "gog-wrangler-*.toml")
if err != nil {
return "", fmt.Errorf("create temp wrangler config: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.WriteString(content); err != nil {
return "", fmt.Errorf("write temp wrangler config: %w", err)
}
return tmpFile.Name(), nil
}
func replaceTomlString(content, key, value string) string {
re := regexp.MustCompile(fmt.Sprintf(`(?m)^%s\s*=\s*\".*\"\s*$`, regexp.QuoteMeta(key)))
return re.ReplaceAllString(content, fmt.Sprintf(`%s = \"%s\"`, key, value))
}
func runWranglerCommand(ctx context.Context, dir string, stdin io.Reader, args ...string) error {
_, err := runWranglerCommandOutput(ctx, dir, stdin, args...)
return err
}
func runWranglerCommandOutput(ctx context.Context, dir string, stdin io.Reader, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "wrangler", args...)
cmd.Dir = dir
cmd.Stdin = stdin
cmd.Env = append(os.Environ(), "WRANGLER_SEND_METRICS=false")
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("wrangler %s failed: %w\n%s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return string(out), nil
}