diff --git a/CHANGELOG.md b/CHANGELOG.md index 69302a2..ed30f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-rc1] - 2026-02-24 +## [1.0.1] - 2026-07-01 + +### Added + +* Added a `build` command that is a shortcut for `containers build` + +### Changed + +* Adjusted the `build` and `update` commands to use Ghostwriter v7.2.1's `--required-only` flag for database fixtures + * Ghostwriter CLI will now skip re-seeding database entries marked as `required: false` + * For now, the starter templates are flagged as non-required so they will not return with updates if an admin deletes them + +## [1.0.0] - 2026-04-09 ### Added diff --git a/README.md b/README.md index eb49f41..7ae14bd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Usage: Available Commands: backup Creates a backup of the PostgreSQL database + build Shortcut for `containers build` completion Generate the autocompletion script for the specified shell config Display or adjust the configuration containers Manage Ghostwriter containers with subcommands diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..16a18e5 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// buildCmd represents the build command +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Shortcut for `containers build`", + Run: buildContainers, +} + +func init() { + rootCmd.AddCommand(buildCmd) + buildCmd.Flags().BoolVar( + &skipseed, + "skip-seed", + false, + `Skip (re-)seeding the database. This is useful when upgrading an existing installation and you know there are no new or adjusted values.`, + ) +} diff --git a/cmd/containersBuild.go b/cmd/containersBuild.go index 40ac6a3..1ecd324 100644 --- a/cmd/containersBuild.go +++ b/cmd/containersBuild.go @@ -30,7 +30,7 @@ func init() { &skipseed, "skip-seed", false, - `Skip (re-)seeding the database. This is useful when upgrading an existing and you know there are no new or adjusted values.`, + `Skip (re-)seeding the database. This is useful when upgrading an existing installation and you know there are no new or adjusted values.`, ) } @@ -61,7 +61,8 @@ func buildContainers(cmd *cobra.Command, args []string) { for { if dockerInterface.WaitForDjango() { fmt.Println("[+] Re-seeding database in case initial values were added or adjusted...") - seedErr := dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data") + // SeedDatabase retries without optional args when older Ghostwriter builds reject them. + seedErr := internal.SeedDatabase(*dockerInterface, "--required-only") if seedErr != nil { log.Fatalf("Error trying to seed the database: %v\n", seedErr) } diff --git a/cmd/install.go b/cmd/install.go index 7cdbbb4..e804663 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -107,7 +107,7 @@ func fetchAndWriteComposeFile(mode internal.DockerMode, version string) error { } // Performs common setup -func updateContainers(dockerInterface internal.DockerInterface) error { +func updateContainers(dockerInterface internal.DockerInterface, seedArgs ...string) error { var err error if dockerInterface.ManageComposeFile { fmt.Println("[+] Pulling containers...") @@ -139,7 +139,8 @@ func updateContainers(dockerInterface internal.DockerInterface) error { } fmt.Println("[+] Seeding database with initial data...") - err = dockerInterface.RunComposeCmd("run", "--rm", "django", "/seed_data") + // SeedDatabase retries without optional args when older Ghostwriter builds reject them. + err = internal.SeedDatabase(dockerInterface, seedArgs...) if err != nil { return fmt.Errorf("Could not seed database: %w", err) } diff --git a/cmd/internal/docker.go b/cmd/internal/docker.go index 9e6e8f9..021628f 100644 --- a/cmd/internal/docker.go +++ b/cmd/internal/docker.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "errors" "fmt" + "io" "log" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "regexp" "slices" "strings" + "sync" "time" "github.com/adrg/xdg" @@ -234,12 +236,85 @@ func (this *DockerInterface) RunCmdWithOutput(args ...string) (string, error) { return output, err } +const commandOutputTailLimit = 64 * 1024 + +type commandOutputTail struct { + mu sync.Mutex + data []byte + limit int +} + +func newCommandOutputTail(limit int) *commandOutputTail { + return &commandOutputTail{limit: limit} +} + +func (tail *commandOutputTail) Write(p []byte) (int, error) { + tail.mu.Lock() + defer tail.mu.Unlock() + + if len(p) >= tail.limit { + tail.data = append(tail.data[:0], p[len(p)-tail.limit:]...) + return len(p), nil + } + + tail.data = append(tail.data, p...) + if len(tail.data) > tail.limit { + tail.data = tail.data[len(tail.data)-tail.limit:] + } + return len(p), nil +} + +func (tail *commandOutputTail) String() string { + tail.mu.Lock() + defer tail.mu.Unlock() + return string(tail.data) +} + +type commandOutputTee struct { + terminal io.Writer + tail *commandOutputTail +} + +func (tee commandOutputTee) Write(p []byte) (int, error) { + if _, err := tee.tail.Write(p); err != nil { + return 0, err + } + if _, err := tee.terminal.Write(p); err != nil { + return 0, err + } + return len(p), nil +} + +// RunCmdWithCombinedOutput streams docker/podman stdout and stderr to the +// terminal while returning a bounded combined tail for error inspection. +func (this *DockerInterface) RunCmdWithCombinedOutput(args ...string) (string, error) { + path, err := exec.LookPath(this.command) + if err != nil { + log.Fatalf("`%s` is not installed or not available in the current PATH variable", this.command) + } + outputTail := newCommandOutputTail(commandOutputTailLimit) + command := exec.Command(path, args...) + command.Dir = this.Dir + command.Stdin = os.Stdin + command.Stdout = commandOutputTee{terminal: os.Stdout, tail: outputTail} + command.Stderr = commandOutputTee{terminal: os.Stderr, tail: outputTail} + err = command.Run() + return outputTail.String(), err +} + // Runs a `docker compose` subcommand, pointing to the configured compose file, with additional arguments. func (this *DockerInterface) RunComposeCmd(args ...string) error { args = append([]string{"compose", "-f", this.ComposeFile}, args...) return this.RunCmd(args...) } +// RunComposeCmdWithCombinedOutput streams a docker compose subcommand and +// returns a bounded combined output tail so callers can inspect failures. +func (this *DockerInterface) RunComposeCmdWithCombinedOutput(args ...string) (string, error) { + args = append([]string{"compose", "-f", this.ComposeFile}, args...) + return this.RunCmdWithCombinedOutput(args...) +} + // Bring all containers up func (this *DockerInterface) Up() error { fmt.Printf("[+] Running `%s` to bring up the containers with %s...\n", this.command, this.ComposeFile) diff --git a/cmd/internal/docker_output_test.go b/cmd/internal/docker_output_test.go new file mode 100644 index 0000000..883f855 --- /dev/null +++ b/cmd/internal/docker_output_test.go @@ -0,0 +1,18 @@ +package internal + +import "testing" + +func TestCommandOutputTailKeepsBoundedTail(t *testing.T) { + tail := newCommandOutputTail(10) + + if _, err := tail.Write([]byte("first-")); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + if _, err := tail.Write([]byte("second")); err != nil { + t.Fatalf("unexpected write error: %v", err) + } + + if got := tail.String(); got != "rst-second" { + t.Fatalf("expected bounded tail %q, got %q", "rst-second", got) + } +} diff --git a/cmd/internal/seed.go b/cmd/internal/seed.go new file mode 100644 index 0000000..300e81c --- /dev/null +++ b/cmd/internal/seed.go @@ -0,0 +1,60 @@ +package internal + +import ( + "fmt" + "strings" +) + +// SeedDatabase runs Ghostwriter's /seed_data helper with optional arguments. +// +// Some older Ghostwriter images and local source checkouts do not support newer +// /seed_data flags, and the CLI cannot reliably infer support from VERSION or +// the network. When an optional argument is rejected by the command-line parser, +// retry once without optional arguments so updates can proceed against older +// builds. Other seed failures still return as hard errors. +func SeedDatabase(dockerInterface DockerInterface, seedArgs ...string) error { + output, err := runSeedData(dockerInterface, seedArgs...) + fmt.Print(output) + if err == nil { + return nil + } + + if len(seedArgs) > 0 && seedArgsUnsupported(output, seedArgs...) { + fmt.Println("[!] This Ghostwriter build does not support the requested seed option(s); retrying without them...") + output, retryErr := runSeedData(dockerInterface) + fmt.Print(output) + if retryErr == nil { + return nil + } + return fmt.Errorf("optional seed arguments are unsupported, and retrying without them failed: %w", retryErr) + } + + return err +} + +// runSeedData builds and runs the docker compose command for /seed_data. +func runSeedData(dockerInterface DockerInterface, seedArgs ...string) (string, error) { + seedCmd := append([]string{"run", "--rm", "django", "/seed_data"}, seedArgs...) + return dockerInterface.RunComposeCmdWithCombinedOutput(seedCmd...) +} + +// seedArgsUnsupported reports whether Django's loaddata command rejected one +// of the optional seed arguments. This matches the observed invalid-argument +// output from Ghostwriter's local loaddata command: +// +// manage.py loaddata: error: unrecognized arguments: --unknown-option +func seedArgsUnsupported(output string, seedArgs ...string) bool { + lowerOutput := strings.ToLower(output) + loaddataInvalidArgs := "manage.py loaddata: error: unrecognized arguments:" + if !strings.Contains(lowerOutput, loaddataInvalidArgs) { + return false + } + + for _, seedArg := range seedArgs { + if strings.Contains(lowerOutput, strings.ToLower(seedArg)) { + return true + } + } + + return false +} diff --git a/cmd/internal/seed_test.go b/cmd/internal/seed_test.go new file mode 100644 index 0000000..19e5118 --- /dev/null +++ b/cmd/internal/seed_test.go @@ -0,0 +1,31 @@ +package internal + +import "testing" + +func TestSeedArgsUnsupportedDetectsLoaddataInvalidArgument(t *testing.T) { + output := `usage: manage.py loaddata [-h] [--force] + fixture [fixture ...] +manage.py loaddata: error: unrecognized arguments: --required-only` + + if !seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected loaddata invalid-argument output to be detected") + } +} + +func TestSeedArgsUnsupportedIgnoresOtherParserErrors(t *testing.T) { + output := `usage: manage.py loaddata [-h] [--force] + fixture [fixture ...] +manage.py loaddata: error: the following arguments are required: fixture` + + if seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected non-invalid-argument parser error to be ignored") + } +} + +func TestSeedArgsUnsupportedIgnoresOtherSeedFailures(t *testing.T) { + output := "django.db.utils.IntegrityError: duplicate key value violates unique constraint" + + if seedArgsUnsupported(output, "--required-only") { + t.Fatalf("expected unrelated seed failure to be ignored") + } +} diff --git a/cmd/update.go b/cmd/update.go index 8b1a89d..abc046e 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -65,7 +65,7 @@ func updateGhostwriter(cmd *cobra.Command, args []string) { log.Fatalf("Could not tear down containers: %v", err) } - err = updateContainers(*dockerInterface) + err = updateContainers(*dockerInterface, "--required-only") if err != nil { log.Fatalf("%v\n", err) }