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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
@@ -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,
}
Comment thread
Copilot marked this conversation as resolved.

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.`,
)
}
5 changes: 3 additions & 2 deletions cmd/containersBuild.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
)
}

Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...")
Expand Down Expand Up @@ -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)
}
Expand Down
75 changes: 75 additions & 0 deletions cmd/internal/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import (
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"

"github.com/adrg/xdg"
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions cmd/internal/docker_output_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
60 changes: 60 additions & 0 deletions cmd/internal/seed.go
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions cmd/internal/seed_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
2 changes: 1 addition & 1 deletion cmd/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment thread
chrismaddalena marked this conversation as resolved.
if err != nil {
log.Fatalf("%v\n", err)
}
Comment thread
chrismaddalena marked this conversation as resolved.
Expand Down
Loading