diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..f5a88df --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,231 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + corecomponent "github.com/libops/sitectl/pkg/component" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +const ( + drupalCreateRepo = "https://github.com/libops/drupal" + drupalCreateBranch = "main" + drupalCreateDrupalRoot = "." + drupalContainerRoot = "/var/www/drupal" +) + +var ( + drupalCreateInput = config.GetInput + drupalCreateCloneTemplateRepo = func(opts plugin.GitTemplateOptions) error { + return sdk.CloneTemplateRepo(opts) + } + drupalCreateRunShellCommand = runCreateShellCommand +) + +type createRunner struct{} + +type createRequest struct { + plugin.ComposeCreateRequest +} + +func (createRunner) BindFlags(cmd *cobra.Command) { + if err := sdk.BindComposeCreateFlags(cmd, createDefinition(), nil, ""); err != nil { + panic(err) + } +} + +func (createRunner) Run(cmd *cobra.Command) error { + if sdk == nil { + return fmt.Errorf("plugin sdk is not initialized") + } + req, err := resolveCreateRequest(cmd) + if err != nil { + return err + } + ctx, err := ensureCreateContext(req) + if err != nil { + return err + } + cloned, err := ensureDrupalCheckout(cmd.OutOrStdout(), req, ctx) + if err != nil { + return err + } + if cloned { + if err := runCommandList(cmd, ctx, createDefinition().DockerComposeInit); err != nil { + return err + } + } + if !req.SetupOnly { + if err := runCommandList(cmd, ctx, createDefinition().DockerComposeUp); err != nil { + return err + } + } + printCreateSummary(cmd.OutOrStdout(), ctx, req) + return nil +} + +func createDefinition() plugin.CreateSpec { + return plugin.CreateSpec{ + Name: "default", + Description: "Create a Docker Compose Drupal stack", + Default: true, + MinCPUCores: 2, + MinMemory: "4 GiB", + MinDiskSpace: "20 GiB", + DockerComposeRepo: drupalCreateRepo, + DockerComposeBranch: drupalCreateBranch, + DockerComposeUp: []string{"docker compose up --remove-orphans"}, + DockerComposeDown: []string{"docker compose down"}, + } +} + +func resolveCreateRequest(cmd *cobra.Command) (createRequest, error) { + resolved, err := sdk.ResolveComposeCreateRequest(cmd, drupalCreateInput, "", "", drupalCreateRepo, drupalCreateBranch) + if err != nil { + return createRequest{}, err + } + return createRequest{ComposeCreateRequest: resolved}, nil +} + +func ensureCreateContext(req createRequest) (*config.Context, error) { + defaultDir := helpers.FirstNonEmpty(req.Path, "./drupal") + defaultName := filepath.Base(defaultDir) + "-local" + return sdk.EnsureComposeCreateContext(req.ComposeCreateRequest, plugin.ComposeCreateContextOptions{ + DefaultName: defaultName, + DefaultSite: filepath.Base(defaultDir), + DefaultPlugin: "drupal", + DefaultProjectDir: defaultDir, + DefaultProjectName: filepath.Base(defaultDir), + DefaultEnvironment: "local", + DefaultDrupalRootfs: drupalCreateDrupalRoot, + DrupalContainerRoot: drupalContainerRoot, + Input: drupalCreateInput, + }) +} + +func ensureDrupalCheckout(out io.Writer, req createRequest, ctx *config.Context) (bool, error) { + if req.CheckoutSource == plugin.CheckoutSourceExisting { + return false, nil + } + if req.TemplateRepo == "" { + return false, fmt.Errorf("template repo cannot be empty") + } + if ctx == nil || ctx.ProjectDir == "" { + return false, fmt.Errorf("project directory cannot be empty") + } + if ctx.DockerHostType == config.ContextRemote { + return ensureRemoteDrupalCheckout(out, req, ctx) + } + return ensureLocalDrupalCheckout(out, req, ctx.ProjectDir) +} + +func ensureLocalDrupalCheckout(out io.Writer, req createRequest, projectDir string) (bool, error) { + entries, err := os.ReadDir(projectDir) + if err == nil && len(entries) > 0 { + return false, nil + } + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("read project directory %q: %w", projectDir, err) + } + if err := os.MkdirAll(filepath.Dir(projectDir), 0o755); err != nil { + return false, fmt.Errorf("create parent directory for %q: %w", projectDir, err) + } + fmt.Fprintf(out, "Cloning %s (%s) into %s\n", req.TemplateRepo, helpers.FirstNonEmpty(req.TemplateBranch, "default branch"), projectDir) + if err := drupalCreateCloneTemplateRepo(plugin.GitTemplateOptions{ + TemplateRepo: req.TemplateRepo, + TemplateBranch: req.TemplateBranch, + ProjectDir: projectDir, + Quiet: true, + }); err != nil { + return false, err + } + return true, nil +} + +func ensureRemoteDrupalCheckout(out io.Writer, req createRequest, ctx *config.Context) (bool, error) { + checkCmd := exec.Command("bash", "-lc", fmt.Sprintf("if [ -d %s ] && [ -n \"$(ls -A %s 2>/dev/null)\" ]; then echo present; fi", shellQuote(ctx.ProjectDir), shellQuote(ctx.ProjectDir))) + output, err := ctx.RunCommand(checkCmd) + if err == nil && strings.TrimSpace(output) == "present" { + return false, nil + } + mkdirCmd := exec.Command("bash", "-lc", fmt.Sprintf("mkdir -p %s", shellQuote(filepath.Dir(ctx.ProjectDir)))) + if _, err := ctx.RunCommand(mkdirCmd); err != nil { + return false, fmt.Errorf("prepare remote parent directory: %w", err) + } + cloneCmd := fmt.Sprintf("git clone --branch %s %s %s && rm -rf %s/.git && git -C %s init -b %s", shellQuote(req.TemplateBranch), shellQuote(req.TemplateRepo), shellQuote(ctx.ProjectDir), shellQuote(ctx.ProjectDir), shellQuote(ctx.ProjectDir), shellQuote(req.TemplateBranch)) + fmt.Fprintf(out, "Cloning %s (%s) into %s on %s\n", req.TemplateRepo, helpers.FirstNonEmpty(req.TemplateBranch, "default branch"), ctx.ProjectDir, ctx.SSHHostname) + if err := drupalCreateRunShellCommand(ctx, "", io.Discard, io.Discard, cloneCmd); err != nil { + return false, err + } + return true, nil +} + +func runCommandList(cmd *cobra.Command, ctx *config.Context, commands []string) error { + for _, command := range commands { + command = strings.TrimSpace(command) + if command == "" { + continue + } + fmt.Fprintf(cmd.OutOrStdout(), "Running %s\n", command) + if err := drupalCreateRunShellCommand(ctx, ctx.ProjectDir, cmd.OutOrStdout(), cmd.ErrOrStderr(), command); err != nil { + return err + } + } + return nil +} + +func runCreateShellCommand(ctx *config.Context, projectDir string, stdout, stderr io.Writer, command string) error { + if ctx == nil { + return fmt.Errorf("context is nil") + } + if ctx.DockerHostType == config.ContextRemote { + remoteCommand := command + if strings.TrimSpace(projectDir) != "" { + remoteCommand = fmt.Sprintf("cd %s && %s", shellQuote(projectDir), command) + } + output, err := ctx.RunCommand(exec.Command("bash", "-lc", remoteCommand)) + if strings.TrimSpace(output) != "" && stdout != nil { + _, _ = io.WriteString(stdout, output) + if !strings.HasSuffix(output, "\n") { + _, _ = io.WriteString(stdout, "\n") + } + } + if err != nil { + return err + } + return nil + } + localCmd := exec.Command("bash", "-lc", command) + localCmd.Dir = projectDir + localCmd.Stdout = stdout + localCmd.Stderr = stderr + localCmd.Env = os.Environ() + return localCmd.Run() +} + +func printCreateSummary(out io.Writer, ctx *config.Context, req createRequest) { + fmt.Fprintln(out) + fmt.Fprintln(out, corecomponent.RenderSection("Create complete", "Drupal is ready for use through sitectl.")) + fmt.Fprintln(out) + fmt.Fprintf(out, "Checkout: %s\n", ctx.ProjectDir) + fmt.Fprintf(out, "Context: %s\n", ctx.Name) + fmt.Fprintf(out, "Target: %s\n", ctx.DockerHostType) + if req.SetupOnly { + fmt.Fprintln(out, "The stack was prepared but left stopped because --setup-only was used.") + } else { + fmt.Fprintln(out, "The stack was prepared and started.") + } + fmt.Fprintln(out) +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..195aeeb --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +func TestCreateDefinition(t *testing.T) { + spec := createDefinition() + if spec.Name != "default" { + t.Fatalf("expected default definition name, got %q", spec.Name) + } + if spec.DockerComposeRepo != drupalCreateRepo { + t.Fatalf("expected repo %q, got %q", drupalCreateRepo, spec.DockerComposeRepo) + } + if spec.DockerComposeBranch != drupalCreateBranch { + t.Fatalf("expected branch %q, got %q", drupalCreateBranch, spec.DockerComposeBranch) + } + if !spec.Default { + t.Fatal("expected Drupal create definition to be the default") + } +} + +func TestResolveCreateRequestHonorsExplicitFlags(t *testing.T) { + oldSDK := sdk + t.Cleanup(func() { sdk = oldSDK }) + sdk = plugin.NewSDK(plugin.Metadata{Name: "drupal"}) + + cmd := &cobra.Command{Use: "default"} + cmd.Flags().String("context", "", "") + if err := sdk.BindComposeCreateFlags(cmd, createDefinition(), nil, ""); err != nil { + t.Fatalf("BindComposeCreateFlags() error = %v", err) + } + _ = cmd.Flags().Set("context", "drupal-local") + _ = cmd.Flags().Set("type", "local") + _ = cmd.Flags().Set("checkout-source", "existing") + _ = cmd.Flags().Set("project-dir", "/tmp/drupal") + _ = cmd.Flags().Set("setup-only", "true") + + req, err := resolveCreateRequest(cmd) + if err != nil { + t.Fatalf("resolveCreateRequest() error = %v", err) + } + if req.ContextName != "drupal-local" { + t.Fatalf("expected context name drupal-local, got %q", req.ContextName) + } + if req.Path != "/tmp/drupal" { + t.Fatalf("expected path /tmp/drupal, got %q", req.Path) + } + if req.TargetType != config.ContextLocal { + t.Fatalf("expected local target, got %q", req.TargetType) + } + if req.CheckoutSource != plugin.CheckoutSourceExisting { + t.Fatalf("expected existing checkout source, got %q", req.CheckoutSource) + } + if !req.SetupOnly { + t.Fatal("expected setup-only request") + } +} + +func TestEnsureLocalDrupalCheckoutClonesEmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + projectDir := filepath.Join(tmpDir, "site") + + oldClone := drupalCreateCloneTemplateRepo + t.Cleanup(func() { drupalCreateCloneTemplateRepo = oldClone }) + + var cloneInvoked bool + drupalCreateCloneTemplateRepo = func(opts plugin.GitTemplateOptions) error { + cloneInvoked = true + if opts.TemplateRepo != drupalCreateRepo { + t.Fatalf("expected repo %q, got %q", drupalCreateRepo, opts.TemplateRepo) + } + if opts.TemplateBranch != drupalCreateBranch { + t.Fatalf("expected branch %q, got %q", drupalCreateBranch, opts.TemplateBranch) + } + if opts.ProjectDir != projectDir { + t.Fatalf("expected project dir %q, got %q", projectDir, opts.ProjectDir) + } + return os.MkdirAll(opts.ProjectDir, 0o755) + } + + cloned, err := ensureLocalDrupalCheckout(ioDiscard{}, createRequest{ComposeCreateRequest: plugin.ComposeCreateRequest{ + TemplateRepo: drupalCreateRepo, + TemplateBranch: drupalCreateBranch, + }}, projectDir) + if err != nil { + t.Fatalf("ensureLocalDrupalCheckout() error = %v", err) + } + if !cloned { + t.Fatal("expected checkout to be cloned") + } + if !cloneInvoked { + t.Fatal("expected clone to run") + } +} + +func TestPrintCreateSummarySetupOnly(t *testing.T) { + var out bytes.Buffer + printCreateSummary(&out, &config.Context{Name: "drupal-local", ProjectDir: "/tmp/drupal", DockerHostType: config.ContextLocal}, createRequest{ + ComposeCreateRequest: plugin.ComposeCreateRequest{SetupOnly: true}, + }) + if !strings.Contains(out.String(), "left stopped because --setup-only was used") { + t.Fatalf("expected setup-only summary, got:\n%s", out.String()) + } +} + +type ioDiscard struct{} + +func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } diff --git a/cmd/root.go b/cmd/root.go index 0e89ea8..72c63a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,8 +18,9 @@ func init() { func RegisterCommands(s *plugin.SDK) { sdk = s pluginjobs.Register(s) - sdk.AddCommand(sdk.GetMetadataCommand()) + sdk.AddCommand(sdk.GetDiscoveryMetadataCommand()) sdk.AddCommand(componentExtensionCmd) + sdk.RegisterCreateRunner(createDefinition(), createRunner{}) sdk.AddCommand(debugExtensionCmd) sdk.AddCommand(drushCmd) sdk.AddCommand(loginCmd) diff --git a/go.mod b/go.mod index 05b2f9e..a0ea322 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/libops/sitectl-drupal go 1.25.8 require ( - github.com/libops/sitectl v0.13.4 + github.com/libops/sitectl v0.15.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index bce27b7..093bee8 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.13.4 h1:x8NmFcfBzq7zVG45qiN+uoOrQaRdpo8p4JmXYljMhg0= -github.com/libops/sitectl v0.13.4/go.mod h1:Q4mIOPKbV1CJAYJ/x0e+ZxKQ2M/zOrqiWE7YmL5kaH4= +github.com/libops/sitectl v0.15.0 h1:YZrBcpvY3fAZlLeCFs9OndDHiam7l9nABy2hDpUllSI= +github.com/libops/sitectl v0.15.0/go.mod h1:Q4mIOPKbV1CJAYJ/x0e+ZxKQ2M/zOrqiWE7YmL5kaH4= 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=