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
4 changes: 1 addition & 3 deletions 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 docs-snippets
.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install

BINARY_NAME=sitectl
DOCS_PORT ?= 3000
Expand Down Expand Up @@ -33,5 +33,3 @@ test: build
publish-aptly-repo:
bash ./scripts/publish-aptly-repo.sh

docs-snippets:
go run ./scripts/gen-docs-snippets/
1 change: 1 addition & 0 deletions cmd/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func init() {
componentCmd.AddCommand(componentDescribeCmd)
componentCmd.AddCommand(componentReconcileCmd)
componentCmd.AddCommand(componentSetCmd)
componentCmd.GroupID = "advanced"
RootCmd.AddCommand(componentCmd)
}

Expand Down
1 change: 1 addition & 0 deletions cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,6 @@ Examples:
}

func init() {
composeCmd.GroupID = "ops"
RootCmd.AddCommand(composeCmd)
}
1 change: 1 addition & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,5 +489,6 @@ func init() {
configCmd.AddCommand(setContextCmd)
configCmd.AddCommand(useContextCmd)
configCmd.AddCommand(deleteContextCmd)
configCmd.GroupID = "setup"
RootCmd.AddCommand(configCmd)
}
66 changes: 66 additions & 0 deletions cmd/converge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cmd

import (
"fmt"
"strings"

"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/helpers"
"github.com/libops/sitectl/pkg/plugin"
"github.com/spf13/cobra"
)

var convergeCmd = &cobra.Command{
Use: "converge [flags]",
Short: "Detect and repair component configuration drift",
Long: `Inspect each component registered by the active context's plugin 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.

This command dispatches to the plugin associated with the active context.
All flags and arguments are forwarded to the plugin's converge handler.

Examples:
sitectl converge
sitectl converge --report
sitectl converge --component fcrepo`,
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
filteredArgs, contextName, err := helpers.GetContextFromArgs(cmd, args)
if err != nil {
return err
}

ctx, err := config.GetContext(contextName)
if err != nil {
return err
}

pluginName := strings.TrimSpace(ctx.Plugin)
if pluginName == "" || pluginName == "core" {
return fmt.Errorf("context %q does not define a plugin that supports converge", ctx.Name)
}
if !pluginHasConverge(pluginName) {
return fmt.Errorf("plugin %q does not support converge", pluginName)
}

invocation := append([]string{"--context", contextName, "__converge"}, filteredArgs...)
_, err = pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{
Context: RootCmd.Context(),
Stdin: RootCmd.InOrStdin(),
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
})
if err != nil {
return cleanPluginCommandError(err)
}
return nil
},
}

func init() {
convergeCmd.GroupID = "workflow"
RootCmd.AddCommand(convergeCmd)
}
1 change: 1 addition & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var createCmd = &cobra.Command{
}

func init() {
createCmd.GroupID = "setup"
RootCmd.AddCommand(createCmd)
}

Expand Down
1 change: 1 addition & 0 deletions cmd/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,5 +478,6 @@ func init() {
cronCmd.AddCommand(cronRunCmd)
cronCmd.AddCommand(cronInstalledCmd)
cronCmd.AddCommand(cronRenderSystemdCmd)
cronCmd.GroupID = "ops"
RootCmd.AddCommand(cronCmd)
}
1 change: 1 addition & 0 deletions cmd/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ func collectDebugReport(runCtx context.Context, contextName string, ctx config.C
func init() {
debugCmd.Flags().StringVarP(&debugOutputPath, "output", "o", "", "Write the bundle to a file instead of stdout.")
debugCmd.Flags().BoolVarP(&debugVerbose, "verbose", "v", false, "Include additional diagnostic details.")
debugCmd.GroupID = "troubleshoot"
RootCmd.AddCommand(debugCmd)
}

Expand Down
202 changes: 202 additions & 0 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package cmd

import (
"fmt"
"log/slog"
"os/exec"
"slices"
"strings"

"github.com/libops/sitectl/pkg/config"
"github.com/libops/sitectl/pkg/plugin"
"github.com/spf13/cobra"
)

var (
deployBranch string
deployNoPull bool
deploySkipGit bool
)

var deployCmd = &cobra.Command{
Use: "deploy",
Short: "Deploy the active context: pull updates and restart services",
Long: `Deploy the active context by orchestrating a full update cycle.

The deploy sequence runs:
1. Plugin pre-down hooks (if the context plugin registers a deploy runner)
2. docker compose down
3. git fetch and git checkout <branch> (unless --skip-git is set)
4. docker compose pull (unless --no-pull is set)
5. docker compose up -d --remove-orphans
6. Plugin post-up hooks (if the context plugin registers a deploy runner)

The --branch flag overrides which branch is checked out during the git step.
If omitted, the repository's current branch is updated via fetch without switching.

Examples:
sitectl deploy # Deploy current branch on active context
sitectl deploy --branch main # Switch to main and deploy
sitectl deploy --skip-git # Restart services without pulling git changes
sitectl deploy --context prod # Deploy on a specific context`,
RunE: func(cmd *cobra.Command, args []string) error {
contextName, err := config.ResolveCurrentContextName(cmd.Flags())
if err != nil {
return err
}
ctx, err := config.GetContext(contextName)
if err != nil {
return err
}

pluginName := strings.TrimSpace(ctx.Plugin)
hasDeployHooks := pluginHasDeployHooks(cmd, contextName, pluginName)

// 1. Pre-down hooks
if hasDeployHooks {
slog.Debug("running pre-down hooks", "context", contextName, "plugin", pluginName)
if err := invokeDeployHook(cmd, contextName, pluginName, "pre-down"); err != nil {
return fmt.Errorf("pre-down hook failed: %w", err)
}
}

// 2. Compose down
slog.Debug("running compose down", "context", contextName)
if err := runContextCompose(cmd, ctx, []string{"down"}); err != nil {
return fmt.Errorf("compose down failed: %w", err)
}

// 3. Git update
if !deploySkipGit {
slog.Debug("running git update", "context", contextName, "branch", deployBranch)
if err := runGitUpdate(cmd, ctx, deployBranch); err != nil {
return fmt.Errorf("git update failed: %w", err)
}
}

// 4. Compose pull
if !deployNoPull {
slog.Debug("running compose pull", "context", contextName)
if err := runContextCompose(cmd, ctx, []string{"pull"}); err != nil {
return fmt.Errorf("compose pull failed: %w", err)
}
}

// 5. Compose up
slog.Debug("running compose up", "context", contextName)
if err := runContextCompose(cmd, ctx, []string{"up", "-d", "--remove-orphans"}); err != nil {
return fmt.Errorf("compose up failed: %w", err)
}

// 6. Post-up hooks
if hasDeployHooks {
slog.Debug("running post-up hooks", "context", contextName, "plugin", pluginName)
if err := invokeDeployHook(cmd, contextName, pluginName, "post-up"); err != nil {
return fmt.Errorf("post-up hook failed: %w", err)
}
}

return nil
},
}

func init() {
deployCmd.Flags().StringVar(&deployBranch, "branch", "", "Git branch to check out during the deploy (default: update current branch)")
deployCmd.Flags().BoolVar(&deployNoPull, "no-pull", false, "Skip docker compose pull before bringing services up")
deployCmd.Flags().BoolVar(&deploySkipGit, "skip-git", false, "Skip the git fetch/checkout step")
deployCmd.GroupID = "workflow"
RootCmd.AddCommand(deployCmd)
}

// pluginHasDeployHooks checks whether the context plugin has registered deploy hooks
// using the lightweight plugin discovery metadata.
func pluginHasDeployHooks(_ *cobra.Command, _ string, pluginName string) bool {
if pluginName == "" || pluginName == "core" {
return false
}
installed, ok := plugin.FindInstalled(pluginName)
if !ok {
return false
}
return installed.CanDeploy
}

// invokeDeployHook calls __deploy <hook> on the context plugin.
func invokeDeployHook(cmd *cobra.Command, contextName, pluginName, hook string) error {
invocation := []string{"--context", contextName, "__deploy", hook}
_, err := pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{
Context: cmd.Context(),
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
})
return err
}

// runContextCompose runs a docker compose subcommand via the context's RunCommandContext,
// mirroring the compose.go injection of -f and --env-file flags.
func runContextCompose(cmd *cobra.Command, ctx config.Context, args []string) error {
if ctx.DockerHostType == config.ContextLocal {
hasProject, err := ctx.HasComposeProject()
if err != nil {
return fmt.Errorf("inspect compose project in %s: %w", ctx.ProjectDir, err)
}
if !hasProject {
return fmt.Errorf("no compose project file found in %s", ctx.ProjectDir)
}
if err := ctx.EnsureTrackedComposeOverrideSymlink(); err != nil {
return err
}
}

cmdArgs := []string{"compose"}
for _, f := range ctx.ComposeFile {
cmdArgs = append(cmdArgs, "-f", f)
}
for _, e := range ctx.EnvFile {
cmdArgs = append(cmdArgs, "--env-file", e)
}
// Auto-add -d --remove-orphans for up if not already present.
if len(args) > 0 && args[0] == "up" {
if !slices.Contains(args, "-d") && !slices.Contains(args, "--detach") {
args = append(args, "-d", "--remove-orphans")
}
}
cmdArgs = append(cmdArgs, args...)

c := exec.Command("docker", cmdArgs...)
c.Dir = ctx.ProjectDir
_, err := ctx.RunCommandContext(cmd.Context(), c)
return err
}

// runGitUpdate runs git fetch and optionally git checkout <branch> in the project dir.
func runGitUpdate(cmd *cobra.Command, ctx config.Context, branch string) error {
fetchCmd := exec.Command("git", "fetch")
fetchCmd.Dir = ctx.ProjectDir
if _, err := ctx.RunCommandContext(cmd.Context(), fetchCmd); err != nil {
return fmt.Errorf("git fetch: %w", err)
}

if strings.TrimSpace(branch) == "" {
// No branch specified: pull the current branch.
pullCmd := exec.Command("git", "pull")
pullCmd.Dir = ctx.ProjectDir
if _, err := ctx.RunCommandContext(cmd.Context(), pullCmd); err != nil {
return fmt.Errorf("git pull: %w", err)
}
return nil
}

checkoutCmd := exec.Command("git", "checkout", strings.TrimSpace(branch))
checkoutCmd.Dir = ctx.ProjectDir
if _, err := ctx.RunCommandContext(cmd.Context(), checkoutCmd); err != nil {
return fmt.Errorf("git checkout %s: %w", branch, err)
}

pullCmd := exec.Command("git", "pull")
pullCmd.Dir = ctx.ProjectDir
if _, err := ctx.RunCommandContext(cmd.Context(), pullCmd); err != nil {
return fmt.Errorf("git pull: %w", err)
}
return nil
}
1 change: 1 addition & 0 deletions cmd/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ func resolveJobOwner(ctx config.Context, raw string) (string, string, error) {
func init() {
jobCmd.AddCommand(jobListCmd)
jobCmd.AddCommand(jobExecCmd)
jobCmd.GroupID = "ops"
RootCmd.AddCommand(jobCmd)
}

Expand Down
30 changes: 30 additions & 0 deletions cmd/plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package cmd

import "github.com/libops/sitectl/pkg/plugin"

// pluginHasConverge checks whether the named plugin has registered a converge runner.
func pluginHasConverge(pluginName string) bool {
installed, ok := plugin.FindInstalled(pluginName)
if !ok {
return false
}
return installed.CanConverge
}

// pluginHasSet checks whether the named plugin has registered a set runner.
func pluginHasSet(pluginName string) bool {
installed, ok := plugin.FindInstalled(pluginName)
if !ok {
return false
}
return installed.CanSet
}

// pluginHasValidate checks whether the named plugin has registered a validate runner.
func pluginHasValidate(pluginName string) bool {
installed, ok := plugin.FindInstalled(pluginName)
if !ok {
return false
}
return installed.CanValidate
}
1 change: 1 addition & 0 deletions cmd/port-forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,5 +151,6 @@ func forward(client *ssh.Client, localConn net.Conn, remoteAddr string, errw io.
}

func init() {
portForwardCmd.GroupID = "troubleshoot"
RootCmd.AddCommand(portForwardCmd)
}
11 changes: 11 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ func init() {

RootCmd.PersistentFlags().String("context", c, "The sitectl context to use. See sitectl config --help for more info")
RootCmd.PersistentFlags().String("log-level", ll, "The logging level for the command")

RootCmd.AddGroup(
&cobra.Group{ID: "setup", Title: "Setup:"},
&cobra.Group{ID: "workflow", Title: "Workflow:"},
&cobra.Group{ID: "ops", Title: "Operations:"},
&cobra.Group{ID: "troubleshoot", Title: "Troubleshooting:"},
&cobra.Group{ID: "advanced", Title: "Advanced:"},
&cobra.Group{ID: "plugins", Title: "Plugin Commands:"},
)

discoverAndRegisterPlugins()
}

Expand All @@ -107,6 +117,7 @@ func discoverAndRegisterPlugins() {
return nil
},
DisableFlagParsing: true,
GroupID: "plugins",
}
RootCmd.AddCommand(pluginCmd)
}
Expand Down
Loading