Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build deps lint test work install
.PHONY: build deps lint test work install docs-snippets

BINARY_NAME=sitectl-drupal
INSTALL_DIR ?= $(or $(dir $(shell which $(BINARY_NAME) 2>/dev/null)),/usr/local/bin/)
Expand Down Expand Up @@ -28,3 +28,6 @@ test: build

work:
./scripts/use-go-work.sh

docs-snippets: work
go run ./scripts/gen-docs-snippets/
25 changes: 10 additions & 15 deletions cmd/drush.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,19 @@ var drushCmd = &cobra.Command{
Use: "drush [COMMAND]",
DisableFlagParsing: true,
Args: cobra.ArbitraryArgs,
Short: "Run drush commands on ISLE contexts",
Long: `Run drush commands on ISLE contexts.
Short: "Run drush commands inside the Drupal container",
Long: `Run drush commands inside the Drupal container of the active context.

This is a shorthand for "sitectl compose exec drupal drush" with automatic --uri handling.
The DRUPAL_DRUSH_URI environment variable is automatically passed unless you specify --uri or -l.

Special subcommands:
uli - Generate and auto-open a one-time login link in your browser
This wraps "docker compose exec drupal drush" and automatically injects DRUPAL_DRUSH_URI
so --uri does not need to be specified manually.

Examples:
sitectl drush status # Check Drupal status
sitectl drush cr # Clear all caches
sitectl drush cex # Export configuration
sitectl drush cim # Import configuration
sitectl drush uli # Generate login link and open in browser
sitectl drush uli --uid=2 # Login link for user ID 2
sitectl drush sqlq "SHOW TABLES" # Run SQL query
sitectl drush --context prod status # Check status on prod context`,
sitectl isle drush status # Check Drupal status
sitectl isle drush cr # Clear all caches
sitectl isle drush cex # Export configuration
sitectl isle drush cim # Import configuration
sitectl isle drush sqlq "SHOW TABLES" # Run a SQL query
sitectl isle drush --context prod status # Check status on the prod context`,
RunE: func(cmd *cobra.Command, args []string) error {
filteredArgs, ctx, cli, containerName, err := getDrupalContainer(cmd, args)
if err != nil {
Expand Down
173 changes: 19 additions & 154 deletions cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ import (
"strconv"
"strings"

"charm.land/lipgloss/v2"
"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/docker"
"github.com/libops/sitectl/pkg/plugin"
"github.com/libops/sitectl/pkg/plugin/debugui"
"github.com/spf13/cobra"
"golang.org/x/term"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -111,27 +110,6 @@ var drupalCoreModules = map[string]struct{}{
"workspaces": {},
}

var (
debugPanelStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#112235")).
Padding(1, 2)
debugTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#98C1D9"))
debugSectionDividerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#29425E"))
debugStatusOKStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7BD389"))
debugStatusWarningStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#F4C95D"))
debugMutedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#9FB3C8"))
debugRowStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#112235"))
)

var componentExtensionCmd = &cobra.Command{
Use: "__component",
Short: "Internal component extension command",
Expand Down Expand Up @@ -221,11 +199,11 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
slog.Debug("resolved drupal root", "plugin", "drupal", "drupal_root", drupalRoot)
configDir := filepath.Join(drupalRoot, "config", "sync")
body := []string{
debugDivider(),
debugui.Divider(),
"",
debugTitleStyle.Render("General"),
debugui.Title("General"),
"",
formatDebugRows([]debugRow{
debugui.FormatRows([]debugui.Row{
{Label: "Context", Value: ctx.Name},
{Label: "Project dir", Value: ctx.ProjectDir},
{Label: "Drupal root", Value: drupalRoot},
Expand All @@ -236,7 +214,7 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
if strings.TrimSpace(drupalRoot) == "" {
slog.Debug("drupal root unavailable; skipping extension scan", "plugin", "drupal")
body = append(body, "", "Installed modules: unavailable")
return renderDebugPanel("drupal", strings.Join(body, "\n")), nil
return debugui.RenderPanel("drupal", strings.Join(body, "\n")), nil
}

slog.Debug("reading core.extension.yml", "plugin", "drupal", "path", filepath.Join(configDir, "core.extension.yml"))
Expand All @@ -256,27 +234,27 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
slog.Debug("rendering cache_page summary", "plugin", "drupal")
cachePageSummary, err := renderCachePageSummary(runCtx)
if err != nil {
body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", formatDebugRows([]debugRow{
{Label: "Status", Value: renderStatus("warning")},
body = append(body, "", debugui.Divider(), "", debugui.Title("Cache Page"), "", debugui.FormatRows([]debugui.Row{
{Label: "Status", Value: debugui.Status("warning")},
{Label: "cache_page", Value: fmt.Sprintf("unavailable (%v)", err)},
}))
} else if strings.TrimSpace(cachePageSummary) != "" {
body = append(body, "", debugDivider(), "", debugTitleStyle.Render("Cache Page"), "", cachePageSummary)
body = append(body, "", debugui.Divider(), "", debugui.Title("Cache Page"), "", cachePageSummary)
}

moduleLines, err := renderModuleList(runCtx, files, drupalRoot, modules, moduleVersionInfo)
if err != nil {
return "", err
}

configLines := []string{debugDivider(), "", debugTitleStyle.Render("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))}
configLines := []string{debugui.Divider(), "", debugui.Title("Installed Extensions"), "", fmt.Sprintf("Installed modules (%d):", len(modules))}
configLines = append(configLines, moduleLines...)
configLines = append(configLines, "")
configLines = append(configLines, fmt.Sprintf("Installed themes (%d):", len(themes)))
configLines = append(configLines, formatListLines(themes, 3)...)
body = append(body, "", strings.Join(configLines, "\n"))

patchLines := []string{debugDivider(), "", debugTitleStyle.Render("Composer Patches"), ""}
patchLines := []string{debugui.Divider(), "", debugui.Title("Composer Patches"), ""}
if strings.TrimSpace(composerPatches) == "" {
patchLines = append(patchLines, " none")
} else {
Expand All @@ -285,7 +263,7 @@ func renderDrupalDebug(runCtx context.Context) (string, error) {
body = append(body, "", strings.Join(patchLines, "\n"))

slog.Debug("finished plugin debug", "plugin", "drupal")
return renderDebugPanel("drupal", strings.Join(body, "\n")), nil
return debugui.RenderPanel("drupal", strings.Join(body, "\n")), nil
}

func renderCachePageSummary(runCtx context.Context) (string, error) {
Expand All @@ -304,23 +282,25 @@ func renderCachePageSummary(runCtx context.Context) (string, error) {
return "", err
}

rows := []debugRow{
{Label: "Status", Value: renderStatus("ok")},
rows := []debugui.Row{
{Label: "Status", Value: debugui.Status("ok")},
{Label: "cache_page", Value: humanBytes(cachePageSize)},
{Label: "cache_render", Value: humanBytes(cacheRenderSize)},
}
if cachePageSize >= cachePageWarningThreshold || cacheRenderSize >= cachePageWarningThreshold {
rows[0].Value = renderStatus("warning")
rows[0].Value = debugui.Status("warning")
}
if cachePageSize >= cachePageWarningThreshold {
rows = append(rows, debugRow{Label: "Recommendation", Value: pageCacheExclusionURL})
rows = append(rows, debugui.Row{Label: "Recommendation", Value: pageCacheExclusionURL})
}
return formatDebugRows(rows), nil
return debugui.FormatRows(rows), nil
}

func readDrupalCacheTableSize(runCtx context.Context, cli *docker.DockerClient, containerName, containerRoot, tableName string) (int64, error) {
query := fmt.Sprintf("SELECT COALESCE(data_length + index_length, 0) FROM information_schema.TABLES WHERE table_schema = DATABASE() AND table_name = '%s';", strings.TrimSpace(tableName))
output, err := execDrupalCommandCapture(runCtx, cli, containerName, containerRoot, []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"})
cmd := []string{"drush", "sql:query", query, "--extra=--batch", "--extra=--skip-column-names"}
slog.Debug(strings.Join(cmd, " "), "plugin", "drupal", "container", containerName)
output, err := docker.ExecCapture(runCtx, cli, containerName, containerRoot, cmd)
if err != nil {
return 0, err
}
Expand Down Expand Up @@ -351,37 +331,6 @@ func getDrupalContainerForSDK(runCtx context.Context) (ctx *config.Context, cli
return ctx, cli, containerName, nil
}

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

exitCode, err := cli.Exec(runCtx, docker.ExecOptions{
Container: containerName,
Cmd: cmd,
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 parseFirstInt(output string) (int64, error) {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
Expand Down Expand Up @@ -650,87 +599,3 @@ func formatListLines(values []string, perLine int) []string {
}
return lines
}

type debugRow struct {
Label string
Value string
}

func renderDebugPanel(title, body string) string {
header := debugTitleStyle.Render(strings.TrimSpace(title))
content := header
if strings.TrimSpace(body) != "" {
content += "\n\n" + body
}
return debugPanelStyle.Width(debugPanelWidth()).Render(content)
}

func formatDebugRows(rows []debugRow) string {
labelWidth := 0
for _, row := range rows {
if width := len(strings.TrimSpace(row.Label)); width > labelWidth {
labelWidth = width
}
}
lines := make([]string, 0, len(rows))
rowWidth := debugContentWidth()
for _, row := range rows {
label := strings.TrimSpace(row.Label)
value := strings.TrimSpace(row.Value)
if label == "" {
lines = append(lines, renderDebugRow(rowWidth, "", value))
continue
}
lines = append(lines, renderDebugRow(rowWidth, fmt.Sprintf("%-*s", labelWidth, label), value))
}
return strings.Join(lines, "\n")
}

func renderStatus(state string) string {
switch strings.ToLower(strings.TrimSpace(state)) {
case "ok":
return debugStatusOKStyle.Render("OK")
case "warning":
return debugStatusWarningStyle.Render("WARNING")
default:
return debugMutedStyle.Render(strings.ToUpper(strings.TrimSpace(state)))
}
}

func renderDebugRow(width int, label, value string) string {
valueWidth := max(0, width-lipgloss.Width(label)-2)
row := label
if strings.TrimSpace(label) != "" {
row += " "
}
row += lipgloss.NewStyle().
Width(valueWidth).
Background(lipgloss.Color("#112235")).
Render(value)
return debugRowStyle.Width(width).Render(row)
}

func max(a, b int) int {
if a > b {
return a
}
return b
}

func debugPanelWidth() int {
if columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && columns > 0 {
return max(40, columns)
}
if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 {
return max(40, width)
}
return 100
}

func debugContentWidth() int {
return max(20, debugPanelWidth()-4)
}

func debugDivider() string {
return debugSectionDividerStyle.Width(debugContentWidth()).Render(strings.Repeat("─", debugContentWidth()))
}
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var (
)

func init() {
loginCmd.Flags().Uint("uid", 1, "Drupal user ID to provide a direct login link for")
loginCmd.Flags().Uint("uid", 1, "Drupal user ID to generate the login link for.")
}

// RegisterCommands registers all drupal commands with the plugin SDK
Expand Down
30 changes: 22 additions & 8 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ var (
var syncCmd = &cobra.Command{
Use: "sync",
Short: "Sync Drupal artifacts between contexts",
Long: `Copy Drupal artifacts from one sitectl context to another.

Available artifacts: database, config (Drupal config/sync directory)`,
}

var syncDatabaseCmd = &cobra.Command{
Use: "database",
Aliases: []string{"db"},
Short: "Sync the Drupal database from one context to another",
Long: `Copy the Drupal database from a source context to a target context.

This backs up the database on the source, transfers the artifact, and imports it into the
target. The command asks for confirmation before importing. Pass --yolo to skip the prompt
in automation.

Use --fresh to always take a new backup rather than reusing one from today.`,
RunE: func(cmd *cobra.Command, args []string) error {
progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Database", "Resolving contexts")
defer progress.Close()
Expand Down Expand Up @@ -82,6 +92,10 @@ var syncDatabaseCmd = &cobra.Command{
var syncConfigCmd = &cobra.Command{
Use: "config",
Short: "Sync the Drupal config/sync directory from one context to another",
Long: `Copy the Drupal configuration from a source context to a target context.

This exports config as a tarball from the source, transfers it, and imports it on the target
using drush cim.`,
RunE: func(cmd *cobra.Command, args []string) error {
progress := plugin.NewProgressLine(cmd.ErrOrStderr(), "Syncing Drupal Config", "Resolving contexts")
defer progress.Close()
Expand Down Expand Up @@ -132,17 +146,17 @@ var syncConfigCmd = &cobra.Command{
}

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")
syncDatabaseCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context.")
syncDatabaseCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context.")
syncDatabaseCmd.Flags().BoolVar(&syncFresh, "fresh", false, "Always take a fresh backup instead of reusing one from today.")
syncDatabaseCmd.Flags().StringVar(&syncBackupDir, "backup-dir", "/tmp/sitectl-drupal-jobs/db-backup", "Directory on the source host used to cache backup artifacts.")
syncDatabaseCmd.Flags().BoolVar(&syncYolo, "yolo", false, "Skip the confirmation prompt before importing.")
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")
syncConfigCmd.Flags().StringVar(&syncSourceContext, "source", "", "Source sitectl context.")
syncConfigCmd.Flags().StringVar(&syncTargetContext, "target", "", "Target sitectl context.")
syncConfigCmd.Flags().StringVar(&syncDrupalRootfs, "drupal-rootfs", "", "Path to the Drupal web root on the target, relative to the project directory.")
must(syncConfigCmd.MarkFlagRequired("source"))
must(syncConfigCmd.MarkFlagRequired("target"))

Expand Down
Loading