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 docker integration-test plugins install-plugins publish-aptly-repo install
.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install docs-snippets

BINARY_NAME=sitectl
DOCS_PORT ?= 3000
Expand Down Expand Up @@ -32,3 +32,6 @@ test: build

publish-aptly-repo:
bash ./scripts/publish-aptly-repo.sh

docs-snippets:
go run ./scripts/gen-docs-snippets/
67 changes: 43 additions & 24 deletions cmd/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,22 @@ var (

var componentCmd = &cobra.Command{
Use: "component",
Short: "Describe and reconcile stack components for the active context",
Short: "Inspect and manage stack components for the active context",
Long: `Components are optional stack features — such as Fcrepo or Blazegraph — that can be toggled on or off.

sitectl dispatches component commands to the plugin associated with the active context. The plugin
provides the component registry; sitectl provides a consistent entry point regardless of which stack
you are working with.`,
}

var componentDescribeCmd = &cobra.Command{
Use: "describe",
Aliases: []string{"status"},
Short: "Describe the current component state",
Short: "Show the current state of each component",
Long: `Show the current state of each component registered by the active context's plugin.

Each component is reported as on, off, or drifted. A drifted component means the project files no
longer match the last recorded state — run reconcile to bring them back into alignment.`,
RunE: func(cmd *cobra.Command, args []string) error {
contextName, owner, name, err := resolveComponentOwner(cmd, componentDescribeName)
if err != nil {
Expand Down Expand Up @@ -86,7 +95,11 @@ var componentDescribeCmd = &cobra.Command{
var componentReconcileCmd = &cobra.Command{
Use: "reconcile",
Aliases: []string{"review", "align"},
Short: "Review and reconcile component state",
Short: "Detect and repair component configuration drift",
Long: `Inspect each component and apply any changes needed to bring the project back into alignment.

By default the command is interactive and asks before applying changes. Pass --report to preview
what would change without applying it.`,
RunE: func(cmd *cobra.Command, args []string) error {
contextName, owner, name, err := resolveComponentOwner(cmd, componentReconcileName)
if err != nil {
Expand Down Expand Up @@ -119,8 +132,14 @@ var componentReconcileCmd = &cobra.Command{

var componentSetCmd = &cobra.Command{
Use: "set <component> [disposition]",
Short: "Set a component disposition",
Args: cobra.RangeArgs(1, 2),
Short: "Enable, disable, or reconfigure a component",
Long: `Set the state or disposition of a named component in the active context's plugin.

Prefix the component name with the plugin namespace to target it directly:

sitectl component set isle/fcrepo off
sitectl component set isle/blazegraph off`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
contextName, owner, name, err := resolveComponentOwner(cmd, args[0])
if err != nil {
Expand Down Expand Up @@ -157,25 +176,25 @@ var componentSetCmd = &cobra.Command{
func init() {
pluginSDK = plugin.NewSDK(plugin.Metadata{Name: "sitectl"})

componentDescribeCmd.Flags().StringVarP(&componentDescribeName, "component", "c", "", "Namespaced component to describe, for example isle/fcrepo")
componentDescribeCmd.Flags().StringVar(&componentDescribePath, "path", "", "Project path override")
componentDescribeCmd.Flags().StringVar(&componentDescribeDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override")
componentDescribeCmd.Flags().BoolVar(&componentDescribeVerbose, "verbose", false, "Include verbose component details")
componentDescribeCmd.Flags().StringVar(&componentDescribeFormat, "format", "", "Output format override")

componentReconcileCmd.Flags().StringVarP(&componentReconcileName, "component", "c", "", "Namespaced component to reconcile, for example isle/fcrepo")
componentReconcileCmd.Flags().StringVar(&componentReconcilePath, "path", "", "Project path override")
componentReconcileCmd.Flags().StringVar(&componentReconcileDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override")
componentReconcileCmd.Flags().BoolVar(&componentReconcileReport, "report", false, "Render a report instead of applying changes")
componentReconcileCmd.Flags().BoolVar(&componentReconcileVerbose, "verbose", false, "Include verbose component details")
componentReconcileCmd.Flags().StringVar(&componentReconcileFormat, "format", "", "Output format override")

componentSetCmd.Flags().StringVar(&componentSetPath, "path", "", "Project path override")
componentSetCmd.Flags().StringVar(&componentSetDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override")
componentSetCmd.Flags().StringVar(&componentSetState, "state", "", "Explicit state override")
componentSetCmd.Flags().StringVar(&componentSetDisposition, "disposition", "", "Explicit disposition override")
componentSetCmd.Flags().StringVar(&componentSetTLSMode, "tls-mode", "", "TLS mode override")
componentSetCmd.Flags().BoolVar(&componentSetYolo, "yolo", false, "Apply without confirmation")
componentDescribeCmd.Flags().StringVarP(&componentDescribeName, "component", "c", "", "Component to describe, e.g. isle/fcrepo. Defaults to all components.")
componentDescribeCmd.Flags().StringVar(&componentDescribePath, "path", "", "Path to the project directory. Defaults to the active context project directory.")
componentDescribeCmd.Flags().StringVar(&componentDescribeDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.")
componentDescribeCmd.Flags().BoolVar(&componentDescribeVerbose, "verbose", false, "Show additional details for each component.")
componentDescribeCmd.Flags().StringVar(&componentDescribeFormat, "format", "", "Output format (default: table).")

componentReconcileCmd.Flags().StringVarP(&componentReconcileName, "component", "c", "", "Component to reconcile, e.g. isle/fcrepo. Defaults to all components.")
componentReconcileCmd.Flags().StringVar(&componentReconcilePath, "path", "", "Path to the project directory. Defaults to the active context project directory.")
componentReconcileCmd.Flags().StringVar(&componentReconcileDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.")
componentReconcileCmd.Flags().BoolVar(&componentReconcileReport, "report", false, "Preview changes without applying them.")
componentReconcileCmd.Flags().BoolVar(&componentReconcileVerbose, "verbose", false, "Show additional details for each component.")
componentReconcileCmd.Flags().StringVar(&componentReconcileFormat, "format", "", "Output format (default: table).")

componentSetCmd.Flags().StringVar(&componentSetPath, "path", "", "Path to the project directory. Defaults to the active context project directory.")
componentSetCmd.Flags().StringVar(&componentSetDrupalRoot, "drupal-rootfs", "", "Path to the Drupal web root, relative to --path.")
componentSetCmd.Flags().StringVar(&componentSetState, "state", "", "State to apply (on, off).")
componentSetCmd.Flags().StringVar(&componentSetDisposition, "disposition", "", "Disposition to apply (enabled, disabled, superceded, distributed).")
componentSetCmd.Flags().StringVar(&componentSetTLSMode, "tls-mode", "", "TLS mode (http, self-managed, mkcert, letsencrypt).")
componentSetCmd.Flags().BoolVar(&componentSetYolo, "yolo", false, "Skip the confirmation prompt.")

componentCmd.AddCommand(componentDescribeCmd)
componentCmd.AddCommand(componentReconcileCmd)
Expand Down
6 changes: 3 additions & 3 deletions cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Examples:
"--help",
}
if len(filteredArgs) == 0 || !slices.Contains(validCommands, filteredArgs[0]) {
helpers.ExitOnError(fmt.Errorf("unknown docker compose command: %s", filteredArgs[0]))
return fmt.Errorf("unknown docker compose command: %s", filteredArgs[0])
}

context, err := config.GetContext(sitectlContext)
Expand All @@ -90,10 +90,10 @@ Examples:
if context.DockerHostType == config.ContextLocal {
hasComposeProject, err := context.HasComposeProject()
if err != nil {
helpers.ExitOnError(fmt.Errorf("failed to inspect compose project in %s: %v", context.ProjectDir, err))
return fmt.Errorf("failed to inspect compose project in %s: %w", context.ProjectDir, err)
}
if !hasComposeProject {
helpers.ExitOnError(fmt.Errorf("no compose project file found in %s (expected one of docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml)", context.ProjectDir))
return fmt.Errorf("no compose project file found in %s (expected one of docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml)", context.ProjectDir)
}
if err := context.EnsureTrackedComposeOverrideSymlink(); err != nil {
return err
Expand Down
93 changes: 46 additions & 47 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"errors"
"fmt"
"io"
"log"
"log/slog"
"os"
"os/user"
"path/filepath"
Expand Down Expand Up @@ -43,74 +41,76 @@ You can have a default context which will be used when running sitectl commands,
var viewConfigCmd = &cobra.Command{
Use: "view",
Short: "Print your sitectl config",
Run: func(cmd *cobra.Command, args []string) {
path := config.ConfigFilePath()
RunE: func(cmd *cobra.Command, args []string) error {
path, err := config.ConfigFilePath()
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
fmt.Printf("File %q does not exist.\n", path)
return
fmt.Fprintf(cmd.OutOrStdout(), "File %q does not exist.\n", path)
return nil
}
log.Fatalf("Error checking file: %v", err)
return fmt.Errorf("error checking file: %w", err)
}

// Check if it's a regular file.
if !info.Mode().IsRegular() {
log.Fatalf("%q is not a regular file", path)
return fmt.Errorf("%q is not a regular file", path)
}

data, err := os.ReadFile(path)
if err != nil {
log.Fatalf("Error reading file: %v", err)
return fmt.Errorf("error reading file: %w", err)
}

fmt.Println(string(data))
fmt.Fprintln(cmd.OutOrStdout(), string(data))
return nil
},
}

var currentContextCmd = &cobra.Command{
Use: "current-context",
Short: "Display the current site context",
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.Current()
if err != nil {
log.Fatal(err)
return err
}
if c == "" {
fmt.Println("No current context is set")
fmt.Fprintln(cmd.OutOrStdout(), "No current context is set")
} else {
fmt.Println("Current context:", c)
fmt.Fprintln(cmd.OutOrStdout(), "Current context:", c)
}
return nil
},
}

var getContextsCmd = &cobra.Command{
Use: "get-contexts",
Short: "List all site contexts",
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
return err
}
if len(cfg.Contexts) == 0 {
fmt.Println("No contexts available")
return
fmt.Fprintln(cmd.OutOrStdout(), "No contexts available")
return nil
}
writeContextTable(cmd.OutOrStdout(), cfg)
return nil
},
}

var getSitesCmd = &cobra.Command{
Use: "get-sites",
Short: "List configured sites and their environments",
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
return err
}
if len(cfg.Contexts) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No sites available")
return
return nil
}

sites := map[string][]config.Context{}
Expand Down Expand Up @@ -152,21 +152,22 @@ var getSitesCmd = &cobra.Command{
)
}
_ = w.Flush()
return nil
},
}

var getEnvironmentsCmd = &cobra.Command{
Use: "get-environments [site]",
Short: "List environments grouped by site",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
return err
}
if len(cfg.Contexts) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No environments available")
return
return nil
}

siteFilter := ""
Expand All @@ -187,7 +188,7 @@ var getEnvironmentsCmd = &cobra.Command{
} else {
fmt.Fprintf(cmd.OutOrStdout(), "No environments found for site %q\n", siteFilter)
}
return
return nil
}

sort.Slice(contexts, func(i, j int) bool {
Expand Down Expand Up @@ -221,6 +222,7 @@ var getEnvironmentsCmd = &cobra.Command{
)
}
_ = w.Flush()
return nil
},
}

Expand Down Expand Up @@ -308,12 +310,11 @@ var setContextCmd = &cobra.Command{
cc.SSHKeyPath = ""
cc.DockerSocket = config.GetDefaultLocalDockerSocket(cc.DockerSocket)
default:
slog.Error("Unknown context type", "type", cc.DockerHostType)
os.Exit(1)
return fmt.Errorf("unknown context type %q", cc.DockerHostType)
}

if err = config.SaveContext(cc, defaultContext); err != nil {
helpers.ExitOnError(err)
return err
}

return nil
Expand All @@ -324,11 +325,11 @@ var useContextCmd = &cobra.Command{
Use: "use-context [context-name]",
Short: "Switch to the specified context",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
return err
}
found := false
for _, ctx := range cfg.Contexts {
Expand All @@ -338,32 +339,30 @@ var useContextCmd = &cobra.Command{
}
}
if !found {
log.Fatalf("Context %s not found", name)
return fmt.Errorf("context %q not found", name)
}
cfg.CurrentContext = name
if err = config.Save(cfg); err != nil {
log.Fatal(err)
return err
}
fmt.Println("Switched to context:", name)
fmt.Fprintln(cmd.OutOrStdout(), "Switched to context:", name)
return nil
},
}

var deleteContextCmd = &cobra.Command{
Use: "delete-context [context-name]",
Short: "Delete a site context",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
return err
}

if cfg.CurrentContext == name {
slog.Error("Cannot delete the current context. You can update it or create a new context with `sitectl config set-context`")
return
return fmt.Errorf("cannot delete the current context; switch to another context first")
}

found := false
var newContexts []config.Context
for _, ctx := range cfg.Contexts {
Expand All @@ -374,14 +373,14 @@ var deleteContextCmd = &cobra.Command{
newContexts = append(newContexts, ctx)
}
if !found {
log.Fatalf("Context %s not found", name)
return fmt.Errorf("context %q not found", name)
}
cfg.Contexts = newContexts

if err = config.Save(cfg); err != nil {
log.Fatal(err)
return err
}
fmt.Printf("Deleted context: %s\n", name)
fmt.Fprintf(cmd.OutOrStdout(), "Deleted context: %s\n", name)
return nil
},
}

Expand Down
Loading