diff --git a/Makefile b/Makefile index 8fb79cc..3930415 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ INSTALL_DIR ?= $(or $(dir $(shell which $(BINARY_NAME) 2>/dev/null)),/usr/local/ deps: work go mod tidy -build: deps +build: go build -o $(BINARY_NAME) . install: work build diff --git a/cmd/backup.go b/cmd/backup.go deleted file mode 100644 index 32f107e..0000000 --- a/cmd/backup.go +++ /dev/null @@ -1,71 +0,0 @@ -package cmd - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" -) - -var backupCmd = &cobra.Command{ - Use: "backup [code|db|files]", - Short: "Backup Drupal database", - Long: `Create a backup of the Drupal database. - -This creates a gzipped SQL dump of the database to /tmp/db.tar.gz in the container. -Cache tables are excluded from the dump for efficiency, but their structure is preserved. - -Optional argument can be "code", "db", or "files" to pass as a flag to drush archive:dump. - -Example: - sitectl drupal backup # Backup code, database, and files to /tmp/backup.tar.gz - sitectl drupal backup code # Backup with --code flag - sitectl drupal backup db # Backup with --db flag - sitectl drupal backup files # Backup with --files flag - sitectl drupal backup --context prod # Backup production database`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - var extraFlag string - destinationName := "archive" - if len(args) > 0 { - arg := args[0] - if arg != "code" && arg != "db" && arg != "files" { - return fmt.Errorf("invalid argument: must be 'code', 'db', or 'files'") - } - extraFlag = "--" + arg - destinationName = arg - } - - _, cli, containerName, err := getDrupalContainerFromFlags(cmd) - if err != nil { - return err - } - defer cli.Close() - - cmdArgs := []string{ - "drush", - "archive:dump", - "-y", - "--skip-tables-list=cache,cache_*,watchdog", - "--structure-tables-list=cache,cache_*,watchdog", - "--debug", - "--overwrite", - fmt.Sprintf("--destination=/tmp/%s-%d.tar.gz", destinationName, time.Now().Unix()), - "--exclude-code-paths=web/sites/default/settings.php", - } - if extraFlag != "" { - cmdArgs = append(cmdArgs, extraFlag) - } - - exitCode, err := cli.ExecInteractive(cmd.Context(), containerName, cmdArgs) - if err != nil { - return err - } - - if exitCode != 0 { - return fmt.Errorf("non-zero exit code from command: %d", exitCode) - } - - return nil - }, -} diff --git a/cmd/drush.go b/cmd/drush.go index 4bd9c6f..92d3b1f 100644 --- a/cmd/drush.go +++ b/cmd/drush.go @@ -3,7 +3,7 @@ package cmd import ( "fmt" - "github.com/kballard/go-shellquote" + "github.com/libops/sitectl/pkg/docker" "github.com/spf13/cobra" ) @@ -30,17 +30,22 @@ Examples: sitectl drush sqlq "SHOW TABLES" # Run SQL query sitectl drush --context prod status # Check status on prod context`, RunE: func(cmd *cobra.Command, args []string) error { - filteredArgs, _, cli, containerName, err := getDrupalContainer(cmd, args) + filteredArgs, ctx, cli, containerName, err := getDrupalContainer(cmd, args) if err != nil { return err } defer cli.Close() - // Build the drush command with arguments - drushCmd := []string{"bash", "-c", fmt.Sprintf("drush %s", shellquote.Join(filteredArgs...))} - - // Execute the command interactively using SDK helper - exitCode, err := cli.ExecInteractive(cmd.Context(), containerName, drushCmd) + drushArgs := append([]string{"drush"}, filteredArgs...) + exitCode, err := cli.Exec(cmd.Context(), docker.ExecOptions{ + Container: containerName, + Cmd: drushArgs, + WorkingDir: ctx.EffectiveDrupalContainerRoot(), + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + }) if err != nil { return err } diff --git a/cmd/extensions.go b/cmd/extensions.go index 9afbecb..4ad04ef 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -12,7 +12,6 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/kballard/go-shellquote" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" "github.com/libops/sitectl/pkg/plugin" @@ -110,7 +109,7 @@ func init() { componentExtensionCmd.AddCommand(componentExtensionReconcileCmd) componentExtensionCmd.AddCommand(componentExtensionSetCmd) - debugExtensionCmd.Flags().StringVar(&drupalRootfsPath, "drupal-rootfs", "drupal/rootfs/var/www/drupal", "Drupal rootfs path override") + debugExtensionCmd.Flags().StringVar(&drupalRootfsPath, "drupal-rootfs", "", "Drupal rootfs path override") } func renderDrupalDebug(runCtx context.Context) (string, error) { @@ -130,8 +129,12 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { } defer files.Close() - slog.Debug("resolving drupal root", "plugin", "drupal", "rootfs", drupalRootfsPath) - drupalRoot := resolveDrupalRoot(files, ctx.ProjectDir, drupalRootfsPath) + rootfs := strings.TrimSpace(drupalRootfsPath) + if rootfs == "" { + rootfs = ctx.EffectiveDrupalRootfs() + } + slog.Debug("resolving drupal root", "plugin", "drupal", "rootfs", rootfs) + drupalRoot := ctx.ResolveProjectPath(rootfs) slog.Debug("resolved drupal root", "plugin", "drupal", "drupal_root", drupalRoot) configDir := filepath.Join(drupalRoot, "config", "sync") body := []string{ @@ -182,14 +185,14 @@ func renderDrupalDebug(runCtx context.Context) (string, error) { } func renderCachePageSummary(runCtx context.Context) (string, error) { - _, cli, containerName, err := getDrupalContainerForSDK(runCtx) + ctx, cli, containerName, err := getDrupalContainerForSDK(runCtx) if err != nil { return "", err } defer cli.Close() query := "SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = 'cache_page';" - output, err := execDrupalCommandCapture(runCtx, cli, containerName, []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}) + output, err := execDrupalCommandCapture(runCtx, cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}) if err != nil { return "", err } @@ -234,17 +237,15 @@ func getDrupalContainerForSDK(runCtx context.Context) (ctx *config.Context, cli return ctx, cli, containerName, nil } -func execDrupalCommandCapture(runCtx context.Context, cli *docker.DockerClient, containerName string, cmd []string) (string, error) { +func execDrupalCommandCapture(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot string, cmd []string) (string, error) { slog.Debug(strings.Join(cmd, " "), "plugin", "drupal", "container", containerName) var stdout bytes.Buffer var stderr bytes.Buffer - wrappedCmd := []string{"bash", "-lc", fmt.Sprintf("cd /var/www/drupal && %s", shellquote.Join(cmd...))} - exitCode, err := cli.Exec(runCtx, docker.ExecOptions{ Container: containerName, - Cmd: wrappedCmd, - WorkingDir: "/var/www/drupal", + Cmd: cmd, + WorkingDir: containerRoot, AttachStdout: true, AttachStderr: true, Stdout: &stdout, @@ -291,26 +292,6 @@ func humanBytes(size int64) string { return fmt.Sprintf("%.1f%ciB", float64(size)/float64(div), "KMGTPE"[exp]) } -func resolveDrupalRoot(files *plugin.FileAccessor, projectDir, drupalRootPath string) string { - candidates := []string{} - if trimmed := strings.TrimSpace(drupalRootPath); trimmed != "" { - if filepath.IsAbs(trimmed) { - candidates = append(candidates, filepath.Clean(trimmed)) - } else { - candidates = append(candidates, filepath.Join(projectDir, trimmed)) - } - } - if strings.TrimSpace(projectDir) != "" { - candidates = append(candidates, projectDir) - } - for _, candidate := range candidates { - if _, err := files.ReadFile(filepath.Join(candidate, "config", "sync", "core.extension.yml")); err == nil { - return candidate - } - } - return "" -} - func readCoreExtension(runCtx context.Context, files *plugin.FileAccessor, path string) ([]string, []string, error) { data, err := files.ReadFileContext(runCtx, path) if err != nil { diff --git a/cmd/extensions_test.go b/cmd/extensions_test.go index 8b0920a..17bef3d 100644 --- a/cmd/extensions_test.go +++ b/cmd/extensions_test.go @@ -70,7 +70,7 @@ func TestReadCoreExtensionMissingFileReturnsNilSlices(t *testing.T) { } } -func TestResolveDrupalRootFindsConfiguredRootfs(t *testing.T) { +func TestDrupalRootUsesConfiguredRootfs(t *testing.T) { projectDir := t.TempDir() drupalRoot := filepath.Join(projectDir, "drupal", "rootfs", "var", "www", "drupal") configDir := filepath.Join(drupalRoot, "config", "sync") @@ -81,37 +81,18 @@ func TestResolveDrupalRootFindsConfiguredRootfs(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) - if err != nil { - t.Fatalf("NewFileAccessor() error = %v", err) - } - defer files.Close() - - got := resolveDrupalRoot(files, projectDir, "drupal/rootfs/var/www/drupal") + got := (&config.Context{ProjectDir: projectDir, DrupalRootfs: "drupal/rootfs/var/www/drupal"}).ResolveProjectPath((&config.Context{ProjectDir: projectDir, DrupalRootfs: "drupal/rootfs/var/www/drupal"}).EffectiveDrupalRootfs()) if got != drupalRoot { - t.Fatalf("resolveDrupalRoot() = %q, want %q", got, drupalRoot) + t.Fatalf("drupal root = %q, want %q", got, drupalRoot) } } -func TestResolveDrupalRootFallsBackToProjectDir(t *testing.T) { +func TestDrupalRootUsesProjectDirWhenConfigured(t *testing.T) { projectDir := t.TempDir() - configDir := filepath.Join(projectDir, "config", "sync") - if err := os.MkdirAll(configDir, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - if err := os.WriteFile(filepath.Join(configDir, "core.extension.yml"), []byte("module: {}\ntheme: {}\n"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - files, err := plugin.NewFileAccessor(&config.Context{DockerHostType: config.ContextLocal}) - if err != nil { - t.Fatalf("NewFileAccessor() error = %v", err) - } - defer files.Close() - - got := resolveDrupalRoot(files, projectDir, "drupal/rootfs/var/www/drupal") + ctx := &config.Context{ProjectDir: projectDir, DrupalRootfs: "."} + got := ctx.ResolveProjectPath(ctx.EffectiveDrupalRootfs()) if got != projectDir { - t.Fatalf("resolveDrupalRoot() = %q, want %q", got, projectDir) + t.Fatalf("drupal root = %q, want %q", got, projectDir) } } diff --git a/cmd/root.go b/cmd/root.go index 6f17363..9e10ee3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + pluginjobs "github.com/libops/sitectl-drupal/pkg/jobs" "github.com/libops/sitectl/pkg/plugin" ) @@ -11,16 +12,16 @@ var ( func init() { loginCmd.Flags().Uint("uid", 1, "Drupal user ID to provide a direct login link for") - - backupCmd.Flags().StringVarP(drupalServiceName, "drupal-service", "d", "drupal", "The name of the drupal service in docker compose") } // RegisterCommands registers all drupal commands with the plugin SDK func RegisterCommands(s *plugin.SDK) { sdk = s - sdk.AddCommand(backupCmd) + pluginjobs.Register(s) + sdk.AddCommand(sdk.GetMetadataCommand()) sdk.AddCommand(componentExtensionCmd) sdk.AddCommand(debugExtensionCmd) sdk.AddCommand(drushCmd) sdk.AddCommand(loginCmd) + sdk.AddCommand(syncCmd) } diff --git a/cmd/sync.go b/cmd/sync.go new file mode 100644 index 0000000..ef82d64 --- /dev/null +++ b/cmd/sync.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "time" + + pluginjobs "github.com/libops/sitectl-drupal/pkg/jobs" + "github.com/libops/sitectl/pkg/config" + corejob "github.com/libops/sitectl/pkg/job" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var ( + syncSourceContext string + syncTargetContext string + syncDrupalRootfs string + syncFresh bool + syncBackupDir string + syncYolo bool +) + +var syncCmd = &cobra.Command{ + Use: "sync", + Short: "Sync Drupal artifacts between contexts", +} + +var syncDatabaseCmd = &cobra.Command{ + Use: "database", + Aliases: []string{"db"}, + Short: "Sync the Drupal database from one context to another", + RunE: func(cmd *cobra.Command, args []string) error { + progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Database", "Resolving contexts") + defer progress.Close() + + sourceCtx, targetCtx, err := corejob.ResolveContextPair(syncSourceContext, syncTargetContext) + if err != nil { + return err + } + + workDir, cleanupWorkDir, err := corejob.MakeTempWorkDir("sitectl-drupal-sync-db-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer cleanupWorkDir() + + progress.Report("Syncing Drupal Database", fmt.Sprintf("Resolving source artifact from %s", sourceCtx.Name)) + sourceArtifactPath, err := resolveSourceDBArtifact(cmd, sourceCtx) + if err != nil { + return err + } + + progress.Report("Syncing Drupal Database", fmt.Sprintf("Staging artifact from %s to %s", sourceCtx.Name, targetCtx.Name)) + targetHostPath, cleanupTarget, err := corejob.StageArtifactBetweenContexts( + cmd.Context(), + sourceCtx, + targetCtx, + sourceArtifactPath, + workDir, + "drupal.sql.gz", + "sitectl-drupal-sync", + ) + if err != nil { + return fmt.Errorf("download database artifact from %q: %w", sourceCtx.Name, err) + } + defer cleanupTarget() + + progress.Report("Syncing Drupal Database", fmt.Sprintf("Importing into %s", targetCtx.Name)) + if !syncYolo { + progress.Close() + } + if err := pluginjobs.RunDBImport(cmd, targetCtx, targetHostPath, syncYolo); err != nil { + return fmt.Errorf("import database into %q: %w", targetCtx.Name, err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Database synced from %s to %s\n", sourceCtx.Name, targetCtx.Name) + return nil + }, +} + +var syncConfigCmd = &cobra.Command{ + Use: "config", + Short: "Sync the Drupal config/sync directory from one context to another", + RunE: func(cmd *cobra.Command, args []string) error { + progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Config", "Resolving contexts") + defer progress.Close() + + sourceCtx, targetCtx, err := corejob.ResolveContextPair(syncSourceContext, syncTargetContext) + if err != nil { + return err + } + + workDir, cleanupWorkDir, err := corejob.MakeTempWorkDir("sitectl-drupal-sync-config-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer cleanupWorkDir() + + artifactName := corejob.SyncArtifactName("sitectl-drupal-sync", "config.tar.gz") + sourceHostPath := filepath.ToSlash(filepath.Join("/tmp", artifactName)) + + progress.Report("Syncing Drupal Config", fmt.Sprintf("Exporting config from %s", sourceCtx.Name)) + if err := pluginjobs.RunConfigExport(cmd, sourceCtx, sourceHostPath); err != nil { + return fmt.Errorf("export config from %q: %w", sourceCtx.Name, err) + } + defer corejob.RemoveContextHostPath(cmd.Context(), sourceCtx, sourceHostPath) + + progress.Report("Syncing Drupal Config", fmt.Sprintf("Staging artifact from %s to %s", sourceCtx.Name, targetCtx.Name)) + targetHostPath, cleanupTarget, err := corejob.StageArtifactBetweenContexts( + cmd.Context(), + sourceCtx, + targetCtx, + sourceHostPath, + workDir, + "config.tar.gz", + "sitectl-drupal-sync", + ) + if err != nil { + return fmt.Errorf("stage config artifact from %q to %q: %w", sourceCtx.Name, targetCtx.Name, err) + } + defer cleanupTarget() + + progress.Report("Syncing Drupal Config", fmt.Sprintf("Importing into %s", targetCtx.Name)) + if err := pluginjobs.RunConfigImport(cmd, targetCtx, targetHostPath, syncDrupalRootfs); err != nil { + return fmt.Errorf("import config into %q: %w", targetCtx.Name, err) + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Config synced from %s to %s\n", sourceCtx.Name, targetCtx.Name) + return nil + }, +} + +func init() { + syncDatabaseCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context") + syncDatabaseCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context") + syncDatabaseCmd.Flags().BoolVar(&syncFresh, "fresh", false, "Always run a fresh source database backup instead of reusing today/yesterday if available") + syncDatabaseCmd.Flags().StringVar(&syncBackupDir, "backup-dir", "/tmp/sitectl-drupal-jobs/db-backup", "Source host directory used to cache database backup artifacts for sync") + syncDatabaseCmd.Flags().BoolVar(&syncYolo, "yolo", false, "Apply destructive database changes without confirmation") + must(syncDatabaseCmd.MarkFlagRequired("source")) + must(syncDatabaseCmd.MarkFlagRequired("target")) + + syncConfigCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context") + syncConfigCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context") + syncConfigCmd.Flags().StringVar(&syncDrupalRootfs, "drupal-rootfs", "", "Drupal rootfs relative to the target context project dir") + must(syncConfigCmd.MarkFlagRequired("source")) + must(syncConfigCmd.MarkFlagRequired("target")) + + syncCmd.AddCommand(syncDatabaseCmd) + syncCmd.AddCommand(syncConfigCmd) +} + +func must(err error) { + if err != nil { + panic(err) + } +} + +func resolveSourceDBArtifact(cmd *cobra.Command, ctx *config.Context) (string, error) { + return corejob.ResolveRecentArtifact(ctx, syncBackupDir, "drupal.sql.gz", syncFresh, time.Now().UTC(), func(path string) error { + if err := pluginjobs.RunDBBackup(cmd, ctx, path); err != nil { + return fmt.Errorf("run source db-backup job on %q: %w", ctx.Name, err) + } + return nil + }) +} diff --git a/cmd/uli.go b/cmd/uli.go index b5ca92c..7ce6fe3 100644 --- a/cmd/uli.go +++ b/cmd/uli.go @@ -23,7 +23,7 @@ Examples: sitectl drush uli # Login as admin (user 1) sitectl drush uli --uid=2 # Login as user ID 2`, RunE: func(cmd *cobra.Command, args []string) error { - _, cli, containerName, err := getDrupalContainerFromFlags(cmd) + ctx, cli, containerName, err := getDrupalContainerFromFlags(cmd) if err != nil { return err } @@ -36,11 +36,12 @@ Examples: // Capture output to get the URL var stdout, stderr bytes.Buffer - drushCmd := []string{"bash", "-c", fmt.Sprintf("drush uli --uid=%d", uid)} + drushCmd := []string{"drush", "uli", fmt.Sprintf("--uid=%d", uid)} exitCode, err := cli.Exec(cmd.Context(), docker.ExecOptions{ Container: containerName, Cmd: drushCmd, + WorkingDir: ctx.EffectiveDrupalContainerRoot(), AttachStdout: true, AttachStderr: true, Stdout: &stdout, diff --git a/go.mod b/go.mod index 1efc12e..4603841 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,7 @@ go 1.25.8 require ( charm.land/lipgloss/v2 v2.0.2 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/libops/sitectl v0.12.0 + github.com/libops/sitectl v0.13.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 @@ -40,6 +39,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/fs v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect diff --git a/go.sum b/go.sum index b148654..950368a 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/libops/sitectl v0.12.0 h1:xkpqskZnbfoZusyi0aVECRKluwwLDMYVcuForhMCMM0= -github.com/libops/sitectl v0.12.0/go.mod h1:QykPh7hrFKFBA1mp9euyKEWP1x5xB6bmEq6zT/tzeTU= +github.com/libops/sitectl v0.13.0 h1:3htPRrEn5bJGJrhPOwd8/s8+C75bJWko2n3HCMiMKBg= +github.com/libops/sitectl v0.13.0/go.mod h1:QykPh7hrFKFBA1mp9euyKEWP1x5xB6bmEq6zT/tzeTU= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= diff --git a/pkg/jobs/register.go b/pkg/jobs/register.go new file mode 100644 index 0000000..df03188 --- /dev/null +++ b/pkg/jobs/register.go @@ -0,0 +1,450 @@ +package jobs + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/docker" + corejob "github.com/libops/sitectl/pkg/job" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var ( + sdk *plugin.SDK + drupalService = "drupal" +) + +func Register(s *plugin.SDK) { + sdk = s + sdk.RegisterContextJob(corejob.Spec{Name: "db-backup", Description: "Export a Drupal database backup artifact"}, &dbBackupJob{}) + sdk.RegisterContextJob(corejob.Spec{Name: "db-import", Description: "Import a Drupal database backup artifact"}, &dbImportJob{}) + sdk.RegisterContextJob(corejob.Spec{Name: "config-export", Description: "Export Drupal config to a tar.gz artifact"}, &configExportJob{}) + sdk.RegisterContextJob(corejob.Spec{Name: "config-import", Description: "Import Drupal config from a tar.gz artifact"}, &configImportJob{}) +} + +type dbBackupJob struct { + Output string +} + +func (j *dbBackupJob) BindFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&j.Output, "output", "", "Absolute output path on the host for the context this job runs on") +} + +func (j *dbBackupJob) Run(cmd *cobra.Command, ctx *config.Context) error { + if strings.TrimSpace(j.Output) == "" { + return fmt.Errorf("--output is required") + } + return RunDBBackup(cmd, ctx, j.Output) +} + +type dbImportJob struct { + Input string + Yolo bool +} + +func (j *dbImportJob) BindFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&j.Input, "input", "", "Absolute input path on the host for the context this job runs on") + cmd.Flags().BoolVar(&j.Yolo, "yolo", false, "Apply destructive database changes without confirmation") +} + +func (j *dbImportJob) Run(cmd *cobra.Command, ctx *config.Context) error { + if strings.TrimSpace(j.Input) == "" { + return fmt.Errorf("--input is required") + } + return RunDBImport(cmd, ctx, j.Input, j.Yolo) +} + +type configExportJob struct { + Output string +} + +func (j *configExportJob) BindFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&j.Output, "output", "", "Absolute output path on the host for the context this job runs on") +} + +func (j *configExportJob) Run(cmd *cobra.Command, ctx *config.Context) error { + if strings.TrimSpace(j.Output) == "" { + return fmt.Errorf("--output is required") + } + return RunConfigExport(cmd, ctx, j.Output) +} + +type configImportJob struct { + Input string + DrupalRootfs string +} + +func (j *configImportJob) BindFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&j.Input, "input", "", "Absolute input path on the host for the context this job runs on") + cmd.Flags().StringVar(&j.DrupalRootfs, "drupal-rootfs", j.DrupalRootfs, "Drupal rootfs relative to the context project dir") +} + +func (j *configImportJob) Run(cmd *cobra.Command, ctx *config.Context) error { + if strings.TrimSpace(j.Input) == "" { + return fmt.Errorf("--input is required") + } + return RunConfigImport(cmd, ctx, j.Input, j.DrupalRootfs) +} + +func RunDBBackup(cmd *cobra.Command, ctx *config.Context, outputPath string) error { + if err := corejob.EnsurePathAbsentOnContext(ctx, outputPath); err != nil { + return err + } + _, cli, containerName, err := getDrupalContainerForContext(cmd.Context(), ctx) + if err != nil { + return err + } + defer cli.Close() + + tempFile, err := os.CreateTemp("", "sitectl-drupal-db-backup-*.sql.gz") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + defer tempFile.Close() + + gzipWriter := gzip.NewWriter(tempFile) + defer gzipWriter.Close() + + var stderr bytes.Buffer + exitCode, err := cli.Exec(cmd.Context(), docker.ExecOptions{ + Container: containerName, + Cmd: []string{"drush", "sql-dump", "-y", "--skip-tables-list=cache,cache_*,watchdog", "--structure-tables-list=cache,cache_*,watchdog", "--debug"}, + WorkingDir: ctx.EffectiveDrupalContainerRoot(), + AttachStdout: true, + AttachStderr: true, + Stdout: gzipWriter, + Stderr: &stderr, + }) + if err != nil { + return err + } + if exitCode != 0 { + return fmt.Errorf("drupal sql-dump failed with exit code %d: %s", exitCode, strings.TrimSpace(stderr.String())) + } + if err := gzipWriter.Close(); err != nil { + return err + } + if err := tempFile.Close(); err != nil { + return err + } + return ctx.UploadFile(tempPath, outputPath) +} + +func RunDBImport(cmd *cobra.Command, ctx *config.Context, inputPath string, yolo bool) error { + if !yolo { + ok, err := confirmDatabaseReplacement(ctx.Name, "Drupal", inputPath) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("database import cancelled") + } + } + + _, cli, containerName, err := getDrupalContainerForContext(cmd.Context(), ctx) + if err != nil { + return err + } + defer cli.Close() + + tempFile, err := os.CreateTemp("", "sitectl-drupal-db-import-*.sql.gz") + if err != nil { + return err + } + tempPath := tempFile.Name() + tempFile.Close() + defer os.Remove(tempPath) + + if err := corejob.DownloadContextFile(ctx, inputPath, tempPath); err != nil { + return err + } + inputFile, err := os.Open(tempPath) + if err != nil { + return err + } + defer inputFile.Close() + + gzipReader, err := gzip.NewReader(inputFile) + if err != nil { + return err + } + defer gzipReader.Close() + + var stderr bytes.Buffer + exitCode, err := cli.Exec(cmd.Context(), docker.ExecOptions{ + Container: containerName, + Cmd: []string{"drush", "sql:cli"}, + WorkingDir: ctx.EffectiveDrupalContainerRoot(), + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Stdin: gzipReader, + Stdout: io.Discard, + Stderr: &stderr, + }) + if err != nil { + return err + } + if exitCode != 0 { + return fmt.Errorf("drupal sql import failed with exit code %d: %s", exitCode, strings.TrimSpace(stderr.String())) + } + _, err = execDrupalCommandCapture(cmd.Context(), cli, containerName, ctx.EffectiveDrupalContainerRoot(), []string{"drush", "cr", "-y"}) + return err +} + +func RunConfigExport(cmd *cobra.Command, ctx *config.Context, outputPath string) error { + if err := corejob.EnsurePathAbsentOnContext(ctx, outputPath); err != nil { + return err + } + _, cli, containerName, err := getDrupalContainerForContext(cmd.Context(), ctx) + if err != nil { + return err + } + defer cli.Close() + + containerRoot := ctx.EffectiveDrupalContainerRoot() + if _, err := execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cex", "-y"}); err != nil { + return err + } + + tempFile, err := os.CreateTemp("", "sitectl-drupal-config-export-*.tar.gz") + if err != nil { + return err + } + tempPath := tempFile.Name() + defer os.Remove(tempPath) + defer tempFile.Close() + + var stderr bytes.Buffer + exitCode, err := cli.Exec(cmd.Context(), docker.ExecOptions{ + Container: containerName, + Cmd: []string{"tar", "-czf", "-", "config/sync"}, + WorkingDir: containerRoot, + AttachStdout: true, + AttachStderr: true, + Stdout: tempFile, + Stderr: &stderr, + }) + if err != nil { + return err + } + if exitCode != 0 { + return fmt.Errorf("drupal config export failed with exit code %d: %s", exitCode, strings.TrimSpace(stderr.String())) + } + if err := tempFile.Close(); err != nil { + return err + } + return ctx.UploadFile(tempPath, outputPath) +} + +func RunConfigImport(cmd *cobra.Command, ctx *config.Context, inputPath, drupalRootfs string) error { + configDir, err := resolveContextDrupalConfigDir(ctx, drupalRootfs) + if err != nil { + return err + } + if err := corejob.EnsureDirOnContext(ctx, configDir); err != nil { + return err + } + _, cli, containerName, err := getDrupalContainerForContext(cmd.Context(), ctx) + if err != nil { + return err + } + defer cli.Close() + + tempFile, err := os.CreateTemp("", "sitectl-drupal-config-import-*.tar.gz") + if err != nil { + return err + } + tempPath := tempFile.Name() + tempFile.Close() + defer os.Remove(tempPath) + + if err := corejob.DownloadContextFile(ctx, inputPath, tempPath); err != nil { + return err + } + tempDir, err := os.MkdirTemp("", "sitectl-drupal-config-import-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + if err := extractTarGz(tempPath, tempDir); err != nil { + return err + } + + files, err := ctx.NewFileAccessor() + if err != nil { + return err + } + defer files.Close() + + if err := files.RemoveAll(configDir); err != nil { + return err + } + if err := files.MkdirAll(configDir); err != nil { + return err + } + if err := uploadDirectory(files, filepath.Join(tempDir, "config", "sync"), configDir); err != nil { + return err + } + + containerRoot := ctx.EffectiveDrupalContainerRoot() + if _, err := execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cim", "-y"}); err != nil { + return err + } + _, err = execDrupalCommandCapture(cmd.Context(), cli, containerName, containerRoot, []string{"drush", "cr", "-y"}) + return err +} + +func getDrupalContainerForContext(runCtx context.Context, ctx *config.Context) (*config.Context, *docker.DockerClient, string, error) { + cli, err := docker.GetDockerCli(ctx) + if err != nil { + return nil, nil, "", err + } + + containerName, err := cli.GetContainerNameContext(runCtx, ctx, drupalService) + if err != nil { + cli.Close() + return nil, nil, "", err + } + if strings.TrimSpace(containerName) == "" { + cli.Close() + return nil, nil, "", fmt.Errorf("unable to find drupal service %q for context %q", drupalService, ctx.Name) + } + + return ctx, cli, containerName, nil +} + +func execDrupalCommandCapture(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot string, command []string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode, err := cli.Exec(runCtx, docker.ExecOptions{ + Container: containerName, + Cmd: command, + WorkingDir: containerRoot, + AttachStdout: true, + AttachStderr: true, + Stdout: &stdout, + Stderr: &stderr, + }) + if err != nil { + return "", err + } + if exitCode != 0 { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail != "" { + return "", fmt.Errorf("drupal command failed with exit code %d: %s", exitCode, detail) + } + return "", fmt.Errorf("drupal command failed with exit code %d", exitCode) + } + return strings.TrimSpace(stdout.String()), nil +} + +func resolveContextDrupalConfigDir(ctx *config.Context, drupalRootfs string) (string, error) { + rootfs := strings.TrimSpace(drupalRootfs) + if rootfs == "" { + rootfs = ctx.EffectiveDrupalRootfs() + } + return ctx.ResolveProjectPath(filepath.Join(rootfs, "config", "sync")), nil +} + +func confirmDatabaseReplacement(targetContext, databaseName, inputPath string) (bool, error) { + prompt := []string{ + fmt.Sprintf("About to import %s database artifact %q into context %q.", databaseName, inputPath, targetContext), + "This will wipe out the target database.", + "Continue? [y/N]: ", + } + + input, err := config.GetInput(prompt...) + if err != nil { + return false, err + } + + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} + +func uploadDirectory(files *config.FileAccessor, sourceDir, destinationDir string) error { + return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + return files.UploadFile(path, filepath.Join(destinationDir, relPath)) + }) +} + +func extractTarGz(archivePath, destination string) error { + file, err := os.Open(archivePath) + if err != nil { + return err + } + defer file.Close() + + gzipReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + + targetPath := filepath.Join(destination, filepath.Clean(header.Name)) + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + out, err := os.Create(targetPath) + if err != nil { + return err + } + if _, err := io.Copy(out, tarReader); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + default: + return fmt.Errorf("unsupported tar entry type %q", string(header.Typeflag)) + } + } +}