From 93311d26bd180cd3c32a8d37ffc4ab40c3f5418f Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Fri, 9 Jan 2026 15:07:05 -0500 Subject: [PATCH] [minor] move optional features into new plugin architecture --- Makefile | 3 +- cmd/apikey.go | 214 --------------- cmd/compose.go | 130 --------- cmd/create.go | 299 --------------------- cmd/delete.go | 193 -------------- cmd/edit.go | 347 ------------------------- cmd/firewall.go | 250 ------------------ cmd/get.go | 136 ---------- cmd/list.go | 196 -------------- cmd/login.go | 245 ----------------- cmd/members.go | 236 ----------------- cmd/port-forward.go | 145 ----------- cmd/root.go | 66 ++++- cmd/secrets.go | 234 ----------------- docs/PLUGINS.md | 607 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 23 -- go.sum | 50 ---- main.go | 31 +-- pkg/docker/docker.go | 137 ++++++++++ pkg/plugin/sdk.go | 215 +++++++++++++++ 20 files changed, 1024 insertions(+), 2733 deletions(-) delete mode 100644 cmd/apikey.go delete mode 100644 cmd/compose.go delete mode 100644 cmd/create.go delete mode 100644 cmd/delete.go delete mode 100644 cmd/edit.go delete mode 100644 cmd/firewall.go delete mode 100644 cmd/get.go delete mode 100644 cmd/list.go delete mode 100644 cmd/login.go delete mode 100644 cmd/members.go delete mode 100644 cmd/port-forward.go delete mode 100644 cmd/secrets.go create mode 100644 docs/PLUGINS.md create mode 100644 pkg/plugin/sdk.go diff --git a/Makefile b/Makefile index 5b0107f..be5b0e7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps lint test docker integration-test docs +.PHONY: build deps lint test docker integration-test docs plugins install-plugins BINARY_NAME=sitectl @@ -22,3 +22,4 @@ lint: test: build go test -v -race ./... + diff --git a/cmd/apikey.go b/cmd/apikey.go deleted file mode 100644 index aeb02b2..0000000 --- a/cmd/apikey.go +++ /dev/null @@ -1,214 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - "strings" - "time" - - "connectrpc.com/connect" - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/format" - "github.com/spf13/cobra" -) - -var createAPIKeyCmd = &cobra.Command{ - Use: "apikey", - Short: "Create a new API key", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - description, err := cmd.Flags().GetString("description") - if err != nil { - return err - } - - scopes, err := cmd.Flags().GetStringSlice("scopes") - if err != nil { - return err - } - - resp, err := client.AccountService.CreateApiKey(cmd.Context(), connect.NewRequest(&libopsv1.CreateApiKeyRequest{ - Name: name, - Description: description, - Scopes: scopes, - })) - if err != nil { - slog.Error("Failed to create API key", "err", err) - return err - } - - fmt.Printf("✓ Created API key\n") - fmt.Printf(" UUID: %s\n", resp.Msg.ApiKeyId) - fmt.Printf(" Name: %s\n", name) - if description != "" { - fmt.Printf(" Description: %s\n", description) - } - if len(scopes) > 0 { - fmt.Printf(" Scopes: %s\n", strings.Join(scopes, ", ")) - } - fmt.Printf("\n") - fmt.Printf(" API Key: %s\n", resp.Msg.ApiKey) - fmt.Printf("\n") - fmt.Printf("⚠️ Save this API key now. It will not be shown again.\n") - - return nil - }, -} - -var listAPIKeysCmd = &cobra.Command{ - Use: "apikeys", - Short: "List API keys", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - resp, err := client.AccountService.ListApiKeys(cmd.Context(), connect.NewRequest(&libopsv1.ListApiKeysRequest{})) - if err != nil { - slog.Error("Failed to list API keys", "err", err) - return err - } - - // Filter out inactive keys - var activeKeys []*libopsv1.ApiKeyMetadata - for _, key := range resp.Msg.ApiKeys { - if key.Active { - activeKeys = append(activeKeys, key) - } - } - - if len(activeKeys) == 0 { - fmt.Println("No active API keys found") - return nil - } - - // Get format flag - formatStr, err := cmd.Flags().GetString("format") - if err != nil { - return err - } - - formatter, err := format.NewFormatter(formatStr) - if err != nil { - return fmt.Errorf("invalid format: %w", err) - } - - // Prepare data for formatting - headers := []string{"ID", "NAME", "SCOPES", "CREATED AT"} - var rows [][]string - - for _, key := range activeKeys { - scopes := "-" - if len(key.Scopes) > 0 { - scopes = strings.Join(key.Scopes, ", ") - } - - createdAt := "-" - if key.CreatedAt > 0 { - createdAt = time.Unix(key.CreatedAt, 0).Format("2006-01-02 15:04:05") - } - - rows = append(rows, []string{ - key.ApiKeyId, - key.Name, - scopes, - createdAt, - }) - } - - // Convert to interface{} slice for JSON/template formatting - var data []interface{} - for _, key := range activeKeys { - data = append(data, map[string]interface{}{ - "ApiKeyId": key.ApiKeyId, - "Name": key.Name, - "Scopes": key.Scopes, - "Active": key.Active, - "CreatedAt": key.CreatedAt, - }) - } - - return formatter.Print(data, headers, rows) - }, -} - -var deleteAPIKeyCmd = &cobra.Command{ - Use: "apikey ", - Short: "Delete (revoke) an API key", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - apiKeyID := args[0] - - confirmed, err := confirmDeletion(cmd, "API key", apiKeyID) - if err != nil { - return err - } - if !confirmed { - fmt.Println("Deletion cancelled.") - return nil - } - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - resp, err := client.AccountService.RevokeApiKey(cmd.Context(), connect.NewRequest(&libopsv1.RevokeApiKeyRequest{ - ApiKeyId: apiKeyID, - })) - if err != nil { - slog.Error("Failed to revoke API key", "id", apiKeyID, "err", err) - return err - } - - if resp.Msg.Success { - fmt.Printf("✓ Deleted API key: %s\n", apiKeyID) - } else { - fmt.Printf("⚠️ API key deletion returned success=false: %s\n", apiKeyID) - } - - return nil - }, -} - -func init() { - // Register with verb commands - createCmd.AddCommand(createAPIKeyCmd) - listCmd.AddCommand(listAPIKeysCmd) - deleteCmd.AddCommand(deleteAPIKeyCmd) - - // Create API key flags - createAPIKeyCmd.Flags().String("name", "", "API key name (required)") - createAPIKeyCmd.Flags().String("description", "", "API key description") - createAPIKeyCmd.Flags().StringSlice("scopes", []string{}, "API key scopes (e.g., organization:read, project:write)") - _ = createAPIKeyCmd.MarkFlagRequired("name") - - // Delete API key flags - deleteAPIKeyCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") -} diff --git a/cmd/compose.go b/cmd/compose.go deleted file mode 100644 index cdad932..0000000 --- a/cmd/compose.go +++ /dev/null @@ -1,130 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "slices" - - "github.com/libops/sitectl/internal/utils" - "github.com/libops/sitectl/pkg/config" - "github.com/spf13/cobra" -) - -var composeCmd = &cobra.Command{ - Use: "compose [command...]", - DisableFlagParsing: true, - Args: cobra.ArbitraryArgs, - Short: "Run docker compose commands", - Long: `Run docker compose commands - -This command wraps docker compose and automatically applies the correct profile and project directory -based on the current context. All docker compose commands and flags are supported. - -Automatic behaviors: - - 'compose up' automatically adds '-d --remove-orphans' if not already specified - - 'compose build' automatically adds '--pull' if not already specified - -Examples: - sitectl compose up # Start containers in detached mode - sitectl compose down # Stop and remove containers - sitectl compose logs -f nginx # Follow nginx container logs - sitectl compose ps # List running containers - sitectl compose exec -it nginx bash # Open shell in nginx container - sitectl compose --context prod up # Start containers on prod context`, - RunE: func(cmd *cobra.Command, args []string) error { - // since we're disabling flag parsing to make easy passing of flags to docker compose - // handle the context flag - filteredArgs, siteCtx, err := utils.GetContextFromArgs(cmd, args) - if err != nil { - return err - } - - validCommands := []string{ - "attach", - "build", - "commit", - "config", - "cp", - "create", - "down", - "events", - "exec", - "export", - "images", - "kill", - "logs", - "ls", - "pause", - "port", - "ps", - "pull", - "push", - "restart", - "rm", - "run", - "scale", - "start", - "stats", - "stop", - "top", - "unpause", - "up", - "version", - "wait", - "watch", - "-h", - "--help", - } - if len(filteredArgs) == 0 || !slices.Contains(validCommands, filteredArgs[0]) { - utils.ExitOnError(fmt.Errorf("unknown docker compose command: %s", filteredArgs[0])) - } - - context, err := config.GetContext(siteCtx) - if err != nil { - return err - } - - if context.DockerHostType == config.ContextLocal { - path := filepath.Join(context.ProjectDir, "docker-compose.yml") - _, err = os.Stat(path) - if err != nil { - utils.ExitOnError(fmt.Errorf("docker-compose.yml not found at %s: %v", path, err)) - } - } - - // consider adding a flag to not do this - // but this seems like a nice default for compose projects - if filteredArgs[0] == "up" && !slices.Contains(filteredArgs, "-d") && !slices.Contains(filteredArgs, "--detach") { - filteredArgs = append(filteredArgs, "-d", "--remove-orphans") - } - if filteredArgs[0] == "build" && !slices.Contains(filteredArgs, "--pull") { - filteredArgs = append(filteredArgs, "--pull") - } - - cmdArgs := []string{ - "compose", - "--profile", - context.Profile, - } - - for _, env := range context.EnvFile { - cmdArgs = append(cmdArgs, "--env-file", env) - } - - cmdArgs = append(cmdArgs, filteredArgs...) - c := exec.Command("docker", cmdArgs...) - c.Dir = context.ProjectDir - _, err = context.RunCommand(c) - if err != nil { - return err - } - - return nil - }, -} - -func init() { - RootCmd.AddCommand(composeCmd) -} diff --git a/cmd/create.go b/cmd/create.go deleted file mode 100644 index ba18f8e..0000000 --- a/cmd/create.go +++ /dev/null @@ -1,299 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/api/proto/libops/v1/common" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var createCmd = &cobra.Command{ - Use: "create", - Short: "Create resources", -} - -var createOrganizationCmd = &cobra.Command{ - Use: "organization", - Short: "Create a new organization", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - location, err := cmd.Flags().GetString("location") - if err != nil { - return err - } - - region, err := cmd.Flags().GetString("region") - if err != nil { - return err - } - - resp, err := client.OrganizationService.CreateOrganization(cmd.Context(), connect.NewRequest(&libopsv1.CreateOrganizationRequest{ - Folder: &common.FolderConfig{ - OrganizationName: name, - Location: common.Location(common.Location_value[location]), - Region: region, - }, - })) - if err != nil { - slog.Error("Failed to create organization", "err", err) - return err - } - - fmt.Printf("✓ Created organization\n") - fmt.Printf(" UUID: %s\n", resp.Msg.Folder.OrganizationId) - fmt.Printf(" Name: %s\n", resp.Msg.Folder.OrganizationName) - fmt.Printf(" Location: %s\n", resp.Msg.Folder.Location) - fmt.Printf(" Region: %s\n", resp.Msg.Folder.Region) - - // Invalidate organization cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var createProjectCmd = &cobra.Command{ - Use: "project", - Short: "Create a new project", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, err := cmd.Flags().GetString("organization-id") - if err != nil { - return err - } - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - region, err := cmd.Flags().GetString("region") - if err != nil { - return err - } - - zone, err := cmd.Flags().GetString("zone") - if err != nil { - return err - } - - machineType, err := cmd.Flags().GetString("machine-type") - if err != nil { - return err - } - - createBranchSites, err := cmd.Flags().GetBool("create-branch-sites") - if err != nil { - return err - } - - resp, err := client.ProjectService.CreateProject(cmd.Context(), connect.NewRequest(&libopsv1.CreateProjectRequest{ - OrganizationId: orgID, - Project: &common.ProjectConfig{ - ProjectName: name, - Region: region, - Zone: zone, - MachineType: machineType, - CreateBranchSites: createBranchSites, - }, - })) - if err != nil { - slog.Error("Failed to create project", "err", err) - return err - } - - fmt.Printf("✓ Created project\n") - fmt.Printf(" UUID: %s\n", resp.Msg.Project.ProjectId) - fmt.Printf(" Name: %s\n", resp.Msg.Project.ProjectName) - fmt.Printf(" Organization ID: %s\n", resp.Msg.Project.OrganizationId) - fmt.Printf(" Region: %s\n", resp.Msg.Project.Region) - fmt.Printf(" Zone: %s\n", resp.Msg.Project.Zone) - - // Invalidate project cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var createSiteCmd = &cobra.Command{ - Use: "site", - Short: "Create a new site", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - projID, err := cmd.Flags().GetString("project-id") - if err != nil { - return err - } - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - githubRepository, err := cmd.Flags().GetString("github-repository") - if err != nil { - return err - } - - githubRef, err := cmd.Flags().GetString("github-ref") - if err != nil { - return err - } - - composePath, err := cmd.Flags().GetString("compose-path") - if err != nil { - return err - } - - composeFile, err := cmd.Flags().GetString("compose-file") - if err != nil { - return err - } - - port, err := cmd.Flags().GetInt32("port") - if err != nil { - return err - } - - appType, err := cmd.Flags().GetString("application-type") - if err != nil { - return err - } - - upCmd, err := cmd.Flags().GetStringArray("up-cmd") - if err != nil { - return err - } - - initCmd, err := cmd.Flags().GetStringArray("init-cmd") - if err != nil { - return err - } - - rolloutCmd, err := cmd.Flags().GetStringArray("rollout-cmd") - if err != nil { - return err - } - - resp, err := client.SiteService.CreateSite(cmd.Context(), connect.NewRequest(&libopsv1.CreateSiteRequest{ - ProjectId: projID, - Site: &common.SiteConfig{ - SiteName: name, - GithubRepository: githubRepository, - GithubRef: githubRef, - ComposePath: composePath, - ComposeFile: composeFile, - Port: port, - ApplicationType: appType, - UpCmd: upCmd, - InitCmd: initCmd, - RolloutCmd: rolloutCmd, - }, - })) - if err != nil { - slog.Error("Failed to create site", "err", err) - return err - } - - fmt.Printf("✓ Created site\n") - fmt.Printf(" UUID: %s\n", resp.Msg.Site.SiteId) - fmt.Printf(" Name: %s\n", resp.Msg.Site.SiteName) - fmt.Printf(" Organization ID: %s\n", resp.Msg.Site.OrganizationId) - fmt.Printf(" Project ID: %s\n", resp.Msg.Site.ProjectId) - fmt.Printf(" GitHub Repo: %s\n", resp.Msg.Site.GithubRepository) - fmt.Printf(" GitHub Ref: %s\n", resp.Msg.Site.GithubRef) - fmt.Printf(" Compose Path: %s\n", resp.Msg.Site.ComposePath) - fmt.Printf(" Compose File: %s\n", resp.Msg.Site.ComposeFile) - fmt.Printf(" Port: %d\n", resp.Msg.Site.Port) - fmt.Printf(" Application Type: %s\n", resp.Msg.Site.ApplicationType) - - // Invalidate site cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -func init() { - RootCmd.AddCommand(createCmd) - createCmd.AddCommand(createOrganizationCmd) - createCmd.AddCommand(createProjectCmd) - createCmd.AddCommand(createSiteCmd) - - // Organization flags - createOrganizationCmd.Flags().String("name", "", "Organization name (required)") - createOrganizationCmd.Flags().String("location", "LOCATION_US", "Geographic location (LOCATION_ASIA, LOCATION_AU, LOCATION_CA, LOCATION_DE, LOCATION_EU, LOCATION_IN, LOCATION_IT, LOCATION_US)") - createOrganizationCmd.Flags().String("region", "us-central1", "Specific region (e.g., us-central1, europe-west1)") - _ = createOrganizationCmd.MarkFlagRequired("name") - - // Project flags - createProjectCmd.Flags().String("organization-id", "", "Organization ID (required)") - createProjectCmd.Flags().String("name", "", "Project name (required)") - createProjectCmd.Flags().String("region", "us-central1", "GCP region") - createProjectCmd.Flags().String("zone", "us-central1-f", "GCP zone") - createProjectCmd.Flags().String("machine-type", "e2-standard-2", "GCP machine type") - createProjectCmd.Flags().Bool("create-branch-sites", false, "Auto-create sites for new branches") - _ = createProjectCmd.MarkFlagRequired("organization-id") - _ = createProjectCmd.MarkFlagRequired("name") - - // Site flags - createSiteCmd.Flags().String("project-id", "", "Project ID (required)") - createSiteCmd.Flags().String("name", "", "Site name (required)") - createSiteCmd.Flags().String("github-repository", "", "GitHub repository URL (required)") - createSiteCmd.Flags().String("github-ref", "", "GitHub reference (e.g., heads/main, tags/v1.0)") - createSiteCmd.Flags().String("compose-path", "", "Path to docker-compose directory") - createSiteCmd.Flags().String("compose-file", "docker-compose.yml", "Docker compose file name") - createSiteCmd.Flags().Int32("port", 80, "Port the application listens on") - createSiteCmd.Flags().String("application-type", "generic", "Type of application") - createSiteCmd.Flags().StringArray("up-cmd", []string{}, "Commands to start containers") - createSiteCmd.Flags().StringArray("init-cmd", []string{}, "Commands to run on initial setup") - createSiteCmd.Flags().StringArray("rollout-cmd", []string{}, "Commands to run during rollout") - _ = createSiteCmd.MarkFlagRequired("project-id") - _ = createSiteCmd.MarkFlagRequired("name") - _ = createSiteCmd.MarkFlagRequired("github-repository") -} diff --git a/cmd/delete.go b/cmd/delete.go deleted file mode 100644 index 73bba88..0000000 --- a/cmd/delete.go +++ /dev/null @@ -1,193 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "log/slog" - "os" - "strings" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var deleteCmd = &cobra.Command{ - Use: "delete", - Short: "Delete resources", -} - -// confirmDeletion prompts the user for confirmation unless --yes flag is set -func confirmDeletion(cmd *cobra.Command, resourceType, resourceID string) (bool, error) { - yes, err := cmd.Flags().GetBool("yes") - if err != nil { - return false, err - } - - if yes { - return true, nil - } - - // Prompt user for confirmation - fmt.Printf("Are you sure you want to delete %s '%s'? This action cannot be undone.\n", resourceType, resourceID) - fmt.Print("Type 'yes' to confirm: ") - - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return false, err - } - - response = strings.TrimSpace(strings.ToLower(response)) - return response == "yes", nil -} - -var deleteOrganizationCmd = &cobra.Command{ - Use: "organization ", - Short: "Delete an organization", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - orgID := args[0] - - confirmed, err := confirmDeletion(cmd, "organization", orgID) - if err != nil { - return err - } - if !confirmed { - fmt.Println("Deletion cancelled.") - return nil - } - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - _, err = client.OrganizationService.DeleteOrganization(cmd.Context(), connect.NewRequest(&libopsv1.DeleteOrganizationRequest{ - OrganizationId: orgID, - })) - if err != nil { - slog.Error("Failed to delete organization", "id", orgID, "err", err) - return err - } - - fmt.Printf("✓ Deleted organization: %s\n", orgID) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var deleteProjectCmd = &cobra.Command{ - Use: "project ", - Short: "Delete a project", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - projectID := args[0] - - confirmed, err := confirmDeletion(cmd, "project", projectID) - if err != nil { - return err - } - if !confirmed { - fmt.Println("Deletion cancelled.") - return nil - } - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - _, err = client.ProjectService.DeleteProject(cmd.Context(), connect.NewRequest(&libopsv1.DeleteProjectRequest{ - ProjectId: projectID, - })) - if err != nil { - slog.Error("Failed to delete project", "id", projectID, "err", err) - return err - } - - fmt.Printf("✓ Deleted project: %s\n", projectID) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var deleteSiteCmd = &cobra.Command{ - Use: "site ", - Short: "Delete a site", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - siteID := args[0] - - confirmed, err := confirmDeletion(cmd, "site", siteID) - if err != nil { - return err - } - if !confirmed { - fmt.Println("Deletion cancelled.") - return nil - } - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - _, err = client.SiteService.DeleteSite(cmd.Context(), connect.NewRequest(&libopsv1.DeleteSiteRequest{ - SiteId: siteID, - })) - if err != nil { - slog.Error("Failed to delete site", "id", siteID, "err", err) - return err - } - - fmt.Printf("✓ Deleted site: %s\n", siteID) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -func init() { - RootCmd.AddCommand(deleteCmd) - deleteCmd.AddCommand(deleteOrganizationCmd) - deleteCmd.AddCommand(deleteProjectCmd) - deleteCmd.AddCommand(deleteSiteCmd) - - // Add --yes flag to all delete commands - deleteOrganizationCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") - deleteProjectCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") - deleteSiteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") -} diff --git a/cmd/edit.go b/cmd/edit.go deleted file mode 100644 index d32c223..0000000 --- a/cmd/edit.go +++ /dev/null @@ -1,347 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - - "connectrpc.com/connect" - "google.golang.org/protobuf/types/known/fieldmaskpb" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/api/proto/libops/v1/common" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" - "google.golang.org/protobuf/encoding/protojson" -) - -var editCmd = &cobra.Command{ - Use: "edit", - Short: "Edit resources", -} - -// buildFieldMask creates a field mask from changed flags -func buildFieldMask(cmd *cobra.Command, flagNames []string) *fieldmaskpb.FieldMask { - var paths []string - for _, flagName := range flagNames { - if cmd.Flags().Changed(flagName) { - // Convert flag name to field path (kebab-case to snake_case) - fieldPath := flagToFieldPath(flagName) - paths = append(paths, fieldPath) - } - } - if len(paths) == 0 { - return nil - } - return &fieldmaskpb.FieldMask{Paths: paths} -} - -// flagToFieldPath converts kebab-case flag names to snake_case field paths -func flagToFieldPath(flagName string) string { - // For nested fields in protobuf, we need to use dot notation - // Example: "organization-name" -> "organization_name" - result := "" - for i, c := range flagName { - if c == '-' { - result += "_" - } else { - result += string(c) - } - _ = i - } - return result -} - -var editOrganizationCmd = &cobra.Command{ - Use: "organization ", - Short: "Edit an organization", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - orgID := args[0] - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - // Build the folder config with only changed fields - folderConfig := &common.FolderConfig{ - OrganizationId: orgID, - } - - if cmd.Flags().Changed("name") { - name, _ := cmd.Flags().GetString("name") - folderConfig.OrganizationName = name - } - - if cmd.Flags().Changed("location") { - location, _ := cmd.Flags().GetString("location") - folderConfig.Location = common.Location(common.Location_value[location]) - } - - if cmd.Flags().Changed("region") { - region, _ := cmd.Flags().GetString("region") - folderConfig.Region = region - } - - // Build field mask - fieldMask := buildFieldMask(cmd, []string{"name", "location", "region"}) - if fieldMask == nil { - return fmt.Errorf("no fields to update - specify at least one flag to edit") - } - - resp, err := client.OrganizationService.UpdateOrganization(cmd.Context(), connect.NewRequest(&libopsv1.UpdateOrganizationRequest{ - Folder: folderConfig, - UpdateMask: fieldMask, - })) - if err != nil { - slog.Error("Failed to update organization", "id", orgID, "err", err) - return err - } - - fmt.Printf("✓ Updated organization: %s\n", resp.Msg.Folder.OrganizationId) - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Folder) - if err != nil { - return fmt.Errorf("failed to marshal organization to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var editProjectCmd = &cobra.Command{ - Use: "project ", - Short: "Edit a project", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - projectID := args[0] - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - // Build the project config with only changed fields - projectConfig := &common.ProjectConfig{} - - if cmd.Flags().Changed("name") { - name, _ := cmd.Flags().GetString("name") - projectConfig.ProjectName = name - } - - if cmd.Flags().Changed("machine-type") { - machineType, _ := cmd.Flags().GetString("machine-type") - projectConfig.MachineType = machineType - } - - if cmd.Flags().Changed("create-branch-sites") { - createBranchSites, _ := cmd.Flags().GetBool("create-branch-sites") - projectConfig.CreateBranchSites = createBranchSites - } - - // Build field mask - use "project." prefix for nested fields - var fieldMaskPaths []string - if cmd.Flags().Changed("name") { - fieldMaskPaths = append(fieldMaskPaths, "project.project_name") - } - if cmd.Flags().Changed("machine-type") { - fieldMaskPaths = append(fieldMaskPaths, "project.machine_type") - } - if cmd.Flags().Changed("create-branch-sites") { - fieldMaskPaths = append(fieldMaskPaths, "project.create_branch_sites") - } - - if len(fieldMaskPaths) == 0 { - return fmt.Errorf("no fields to update - specify at least one flag to edit") - } - - fieldMask := &fieldmaskpb.FieldMask{Paths: fieldMaskPaths} - - resp, err := client.ProjectService.UpdateProject(cmd.Context(), connect.NewRequest(&libopsv1.UpdateProjectRequest{ - ProjectId: projectID, - Project: projectConfig, - UpdateMask: fieldMask, - })) - if err != nil { - slog.Error("Failed to update project", "id", projectID, "err", err) - return err - } - - fmt.Printf("✓ Updated project: %s\n", resp.Msg.Project.ProjectId) - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Project) - if err != nil { - return fmt.Errorf("failed to marshal project to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -var editSiteCmd = &cobra.Command{ - Use: "site ", - Short: "Edit a site", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - siteID := args[0] - - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - // Build the site config with only changed fields - siteConfig := &common.SiteConfig{ - SiteId: siteID, - } - - if cmd.Flags().Changed("name") { - name, _ := cmd.Flags().GetString("name") - siteConfig.SiteName = name - } - - if cmd.Flags().Changed("github-repository") { - v, _ := cmd.Flags().GetString("github-repository") - siteConfig.GithubRepository = v - } - - if cmd.Flags().Changed("github-ref") { - githubRef, _ := cmd.Flags().GetString("github-ref") - siteConfig.GithubRef = githubRef - } - - if cmd.Flags().Changed("compose-path") { - v, _ := cmd.Flags().GetString("compose-path") - siteConfig.ComposePath = v - } - - if cmd.Flags().Changed("compose-file") { - v, _ := cmd.Flags().GetString("compose-file") - siteConfig.ComposeFile = v - } - - if cmd.Flags().Changed("port") { - v, _ := cmd.Flags().GetInt32("port") - siteConfig.Port = v - } - - if cmd.Flags().Changed("application-type") { - v, _ := cmd.Flags().GetString("application-type") - siteConfig.ApplicationType = v - } - - if cmd.Flags().Changed("up-cmd") { - v, _ := cmd.Flags().GetStringArray("up-cmd") - siteConfig.UpCmd = v - } - - if cmd.Flags().Changed("init-cmd") { - v, _ := cmd.Flags().GetStringArray("init-cmd") - siteConfig.InitCmd = v - } - - if cmd.Flags().Changed("rollout-cmd") { - v, _ := cmd.Flags().GetStringArray("rollout-cmd") - siteConfig.RolloutCmd = v - } - - // Build field mask - fieldMask := buildFieldMask(cmd, []string{ - "name", "github-repository", "github-ref", "compose-path", "compose-file", - "port", "application-type", "up-cmd", "init-cmd", "rollout-cmd", - }) - if fieldMask == nil { - return fmt.Errorf("no fields to update - specify at least one flag to edit") - } - - resp, err := client.SiteService.UpdateSite(cmd.Context(), connect.NewRequest(&libopsv1.UpdateSiteRequest{ - Site: siteConfig, - UpdateMask: fieldMask, - })) - if err != nil { - slog.Error("Failed to update site", "id", siteID, "err", err) - return err - } - - fmt.Printf("✓ Updated site: %s\n", resp.Msg.Site.SiteId) - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Site) - if err != nil { - return fmt.Errorf("failed to marshal site to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - // Invalidate cache - if err := resources.InvalidateAllResourceCaches(); err != nil { - slog.Warn("Failed to invalidate cache", "err", err) - } - - return nil - }, -} - -func init() { - RootCmd.AddCommand(editCmd) - editCmd.AddCommand(editOrganizationCmd) - editCmd.AddCommand(editProjectCmd) - editCmd.AddCommand(editSiteCmd) - - // Organization edit flags (same as create, but all optional) - editOrganizationCmd.Flags().String("name", "", "Organization name") - editOrganizationCmd.Flags().String("location", "", "Geographic location (LOCATION_ASIA, LOCATION_AU, LOCATION_CA, LOCATION_DE, LOCATION_EU, LOCATION_IN, LOCATION_IT, LOCATION_US)") - editOrganizationCmd.Flags().String("region", "", "Specific region (e.g., us-central1, europe-west1)") - - // Project edit flags (region and zone cannot be updated after creation) - editProjectCmd.Flags().String("name", "", "Project name") - editProjectCmd.Flags().String("machine-type", "", "GCP machine type") - editProjectCmd.Flags().Bool("create-branch-sites", false, "Auto-create sites for new branches") - - // Site edit flags (same as create, but all optional) - editSiteCmd.Flags().String("name", "", "Site name") - editSiteCmd.Flags().String("github-repository", "", "GitHub repository URL") - editSiteCmd.Flags().String("github-ref", "", "GitHub reference (e.g., heads/main, tags/v1.0)") - editSiteCmd.Flags().String("compose-path", "", "Path to docker-compose directory") - editSiteCmd.Flags().String("compose-file", "", "Docker compose file name") - editSiteCmd.Flags().Int32("port", 0, "Port the application listens on") - editSiteCmd.Flags().String("application-type", "", "Type of application") - editSiteCmd.Flags().StringArray("up-cmd", []string{}, "Commands to start containers") - editSiteCmd.Flags().StringArray("init-cmd", []string{}, "Commands to run on initial setup") - editSiteCmd.Flags().StringArray("rollout-cmd", []string{}, "Commands to run during rollout") -} diff --git a/cmd/firewall.go b/cmd/firewall.go deleted file mode 100644 index b905789..0000000 --- a/cmd/firewall.go +++ /dev/null @@ -1,250 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - "os" - "text/tabwriter" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var createFirewallCmd = &cobra.Command{ - Use: "firewall", - Short: "Create a firewall rule", - Long: "Create a firewall rule for an organization, project, or site. Specify one of --organization-id, --project-id, or --site-id.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - cidr, err := cmd.Flags().GetString("cidr") - if err != nil { - return err - } - - ruleType, err := cmd.Flags().GetString("type") - if err != nil { - return err - } - - ruleTypeEnum := libopsv1.FirewallRuleType(libopsv1.FirewallRuleType_value[ruleType]) - - // Determine which endpoint to call based on which ID is provided - if orgID != "" { - resp, err := client.FirewallService.CreateOrganizationFirewallRule(cmd.Context(), connect.NewRequest(&libopsv1.CreateOrganizationFirewallRuleRequest{ - OrganizationId: orgID, - Name: name, - Cidr: cidr, - RuleType: ruleTypeEnum, - })) - if err != nil { - return fmt.Errorf("failed to create organization firewall rule: %w", err) - } - fmt.Printf("✓ Created organization firewall rule: %s\n", resp.Msg.Rule.RuleId) - fmt.Printf(" Name: %s\n", resp.Msg.Rule.Name) - fmt.Printf(" CIDR: %s\n", resp.Msg.Rule.Cidr) - fmt.Printf(" Type: %s\n", resp.Msg.Rule.RuleType) - } else if projectID != "" { - resp, err := client.ProjectFirewallService.CreateProjectFirewallRule(cmd.Context(), connect.NewRequest(&libopsv1.CreateProjectFirewallRuleRequest{ - ProjectId: projectID, - Name: name, - Cidr: cidr, - RuleType: ruleTypeEnum, - })) - if err != nil { - return fmt.Errorf("failed to create project firewall rule: %w", err) - } - fmt.Printf("✓ Created project firewall rule: %s\n", resp.Msg.Rule.RuleId) - fmt.Printf(" Name: %s\n", resp.Msg.Rule.Name) - fmt.Printf(" CIDR: %s\n", resp.Msg.Rule.Cidr) - fmt.Printf(" Type: %s\n", resp.Msg.Rule.RuleType) - } else if siteID != "" { - resp, err := client.SiteFirewallService.CreateSiteFirewallRule(cmd.Context(), connect.NewRequest(&libopsv1.CreateSiteFirewallRuleRequest{ - SiteId: siteID, - Name: name, - Cidr: cidr, - RuleType: ruleTypeEnum, - })) - if err != nil { - return fmt.Errorf("failed to create site firewall rule: %w", err) - } - fmt.Printf("✓ Created site firewall rule: %s\n", resp.Msg.Rule.RuleId) - fmt.Printf(" Name: %s\n", resp.Msg.Rule.Name) - fmt.Printf(" CIDR: %s\n", resp.Msg.Rule.Cidr) - fmt.Printf(" Type: %s\n", resp.Msg.Rule.RuleType) - } else { - return fmt.Errorf("must specify one of --organization-id, --project-id, or --site-id") - } - - return nil - }, -} - -var listFirewallCmd = &cobra.Command{ - Use: "firewall", - Short: "List firewall rules", - Long: "List firewall rules. Optionally filter by --organization-id, --project-id, or --site-id. If no filter is specified, lists all firewall rules.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) - fmt.Fprintln(w, "RULE ID\tNAME\tCIDR\tTYPE\tSTATUS\tSCOPE") - fmt.Fprintln(w, "-------\t----\t----\t----\t------\t-----") - - // If specific ID is provided, query that endpoint - if orgID != "" { - resp, err := client.FirewallService.ListOrganizationFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationFirewallRulesRequest{ - OrganizationId: orgID, - })) - if err != nil { - return fmt.Errorf("failed to list organization firewall rules: %w", err) - } - for _, r := range resp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\torg:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, orgID) - } - } else if projectID != "" { - resp, err := client.ProjectFirewallService.ListProjectFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectFirewallRulesRequest{ - ProjectId: projectID, - })) - if err != nil { - return fmt.Errorf("failed to list project firewall rules: %w", err) - } - for _, r := range resp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tproject:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, projectID) - } - } else if siteID != "" { - resp, err := client.SiteFirewallService.ListSiteFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteFirewallRulesRequest{ - SiteId: siteID, - })) - if err != nil { - return fmt.Errorf("failed to list site firewall rules: %w", err) - } - for _, r := range resp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tsite:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, siteID) - } - } else { - // List all - use shared resource functions with caching - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - // List organization firewall rules - orgs, err := resources.ListOrganizations(cmd.Context(), apiBaseURL, useCache) - if err != nil { - slog.Warn("Failed to list organizations", "err", err) - } else { - for _, org := range orgs { - orgFirewallResp, err := client.FirewallService.ListOrganizationFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationFirewallRulesRequest{ - OrganizationId: org.OrganizationId, - })) - if err != nil { - slog.Warn("Failed to list firewall rules for organization", "org_id", org.OrganizationId, "err", err) - continue - } - for _, r := range orgFirewallResp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\torg:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, org.OrganizationId) - } - } - } - - // List project firewall rules - projects, err := resources.ListProjects(cmd.Context(), apiBaseURL, useCache, nil) - if err != nil { - slog.Warn("Failed to list projects", "err", err) - } else { - for _, proj := range projects { - projFirewallResp, err := client.ProjectFirewallService.ListProjectFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectFirewallRulesRequest{ - ProjectId: proj.ProjectId, - })) - if err != nil { - slog.Warn("Failed to list firewall rules for project", "project_id", proj.ProjectId, "err", err) - continue - } - for _, r := range projFirewallResp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tproject:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, proj.ProjectId) - } - } - } - - // List site firewall rules - sites, err := resources.ListSites(cmd.Context(), apiBaseURL, useCache, nil, nil) - if err != nil { - slog.Warn("Failed to list sites", "err", err) - } else { - for _, site := range sites { - siteFirewallResp, err := client.SiteFirewallService.ListSiteFirewallRules(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteFirewallRulesRequest{ - SiteId: site.SiteId, - })) - if err != nil { - slog.Warn("Failed to list firewall rules for site", "site_id", site.SiteId, "err", err) - continue - } - for _, r := range siteFirewallResp.Msg.Rules { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tsite:%s\n", r.RuleId, r.Name, r.Cidr, r.RuleType, r.Status, site.SiteId) - } - } - } - } - - w.Flush() - return nil - }, -} - -// Note: Firewall rules do not support update operations via the API -// Note: Firewall rules deletion requires parent resource ID (organization/project/site) -// These commands have been removed as they cannot be implemented with just the rule ID - -func init() { - // Add firewall subcommand to create command - createCmd.AddCommand(createFirewallCmd) - createFirewallCmd.Flags().String("organization-id", "", "Organization ID") - createFirewallCmd.Flags().String("project-id", "", "Project ID") - createFirewallCmd.Flags().String("site-id", "", "Site ID") - createFirewallCmd.Flags().String("name", "", "Firewall rule name (required)") - createFirewallCmd.Flags().String("cidr", "", "CIDR block (required)") - createFirewallCmd.Flags().String("type", "FIREWALL_RULE_TYPE_HTTPS_ALLOWED", "Rule type: FIREWALL_RULE_TYPE_HTTPS_ALLOWED (allow HTTPS), FIREWALL_RULE_TYPE_SSH_ALLOWED (allow SSH), FIREWALL_RULE_TYPE_BLOCKED (block traffic)") - _ = createFirewallCmd.MarkFlagRequired("name") - _ = createFirewallCmd.MarkFlagRequired("cidr") - createFirewallCmd.MarkFlagsOneRequired("organization-id", "project-id", "site-id") - createFirewallCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") - - // Add firewall subcommand to list command - listCmd.AddCommand(listFirewallCmd) - listFirewallCmd.Flags().String("organization-id", "", "Filter by organization ID") - listFirewallCmd.Flags().String("project-id", "", "Filter by project ID") - listFirewallCmd.Flags().String("site-id", "", "Filter by site ID") - listFirewallCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") -} diff --git a/cmd/get.go b/cmd/get.go deleted file mode 100644 index fb8033d..0000000 --- a/cmd/get.go +++ /dev/null @@ -1,136 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/spf13/cobra" - "google.golang.org/protobuf/encoding/protojson" -) - -var getCmd = &cobra.Command{ - Use: "get", - Short: "Get a resource by ID", -} - -var getOrganizationCmd = &cobra.Command{ - Use: "organization ", - Short: "Get an organization by ID", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID := args[0] - resp, err := client.OrganizationService.GetOrganization(cmd.Context(), connect.NewRequest(&libopsv1.GetOrganizationRequest{ - OrganizationId: orgID, - })) - if err != nil { - slog.Error("Failed to get organization", "id", orgID, "err", err) - return err - } - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Folder) - if err != nil { - return fmt.Errorf("failed to marshal organization to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - return nil - }, -} - -var getProjectCmd = &cobra.Command{ - Use: "project ", - Short: "Get a project by ID", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - projID := args[0] - resp, err := client.ProjectService.GetProject(cmd.Context(), connect.NewRequest(&libopsv1.GetProjectRequest{ - ProjectId: projID, - })) - if err != nil { - slog.Error("Failed to get project", "id", projID, "err", err) - return err - } - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Project) - if err != nil { - return fmt.Errorf("failed to marshal project to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - return nil - }, -} - -var getSiteCmd = &cobra.Command{ - Use: "site ", - Short: "Get a site by ID", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - siteID := args[0] - resp, err := client.SiteService.GetSite(cmd.Context(), connect.NewRequest(&libopsv1.GetSiteRequest{ - SiteId: siteID, - })) - if err != nil { - slog.Error("Failed to get site", "id", siteID, "err", err) - return err - } - - marshaler := protojson.MarshalOptions{ - Indent: " ", - } - jsonOutput, err := marshaler.Marshal(resp.Msg.Site) - if err != nil { - return fmt.Errorf("failed to marshal site to JSON: %w", err) - } - fmt.Println(string(jsonOutput)) - - return nil - }, -} - -func init() { - RootCmd.AddCommand(getCmd) - getCmd.AddCommand(getOrganizationCmd) - getCmd.AddCommand(getProjectCmd) - getCmd.AddCommand(getSiteCmd) -} diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index fc7665c..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,196 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - - "github.com/libops/sitectl/pkg/format" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var listCmd = &cobra.Command{ - Use: "list", - Short: "List resources", -} - -var listOrganizationsCmd = &cobra.Command{ - Use: "organizations", - Short: "List all organizations", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - orgs, err := resources.ListOrganizations(cmd.Context(), apiBaseURL, useCache) - if err != nil { - slog.Error("Failed to list organizations", "err", err) - return err - } - - formatStr, err := cmd.Flags().GetString("format") - if err != nil { - return err - } - - formatter, err := format.NewFormatter(formatStr) - if err != nil { - return fmt.Errorf("invalid format: %w", err) - } - - // Prepare data - headers := []string{"ID", "NAME"} - var rows [][]string - var data []interface{} - - for _, org := range orgs { - rows = append(rows, []string{org.OrganizationId, org.OrganizationName}) - data = append(data, map[string]interface{}{ - "OrganizationId": org.OrganizationId, - "OrganizationName": org.OrganizationName, - }) - } - - return formatter.Print(data, headers, rows) - }, -} - -var listProjectsCmd = &cobra.Command{ - Use: "projects", - Short: "List all projects", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - orgID, err := cmd.Flags().GetString("organization-id") - if err != nil { - return err - } - - var orgIDPtr *string - if orgID != "" { - orgIDPtr = &orgID - } - - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - projects, err := resources.ListProjects(cmd.Context(), apiBaseURL, useCache, orgIDPtr) - if err != nil { - slog.Error("Failed to list projects", "err", err) - return err - } - - formatStr, err := cmd.Flags().GetString("format") - if err != nil { - return err - } - - formatter, err := format.NewFormatter(formatStr) - if err != nil { - return fmt.Errorf("invalid format: %w", err) - } - - // Prepare data - headers := []string{"ID", "NAME", "ORG ID"} - var rows [][]string - var data []interface{} - - for _, proj := range projects { - rows = append(rows, []string{proj.ProjectId, proj.ProjectName, proj.OrganizationId}) - data = append(data, map[string]interface{}{ - "ProjectId": proj.ProjectId, - "ProjectName": proj.ProjectName, - "OrganizationId": proj.OrganizationId, - }) - } - - return formatter.Print(data, headers, rows) - }, -} - -var listSitesCmd = &cobra.Command{ - Use: "sites", - Short: "List all sites", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - orgID, err := cmd.Flags().GetString("organization-id") - if err != nil { - return err - } - projID, err := cmd.Flags().GetString("project-id") - if err != nil { - return err - } - - var orgIDPtr *string - if orgID != "" { - orgIDPtr = &orgID - } - var projIDPtr *string - if projID != "" { - projIDPtr = &projID - } - - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - sites, err := resources.ListSites(cmd.Context(), apiBaseURL, useCache, orgIDPtr, projIDPtr) - if err != nil { - slog.Error("Failed to list sites", "err", err) - return err - } - - formatStr, err := cmd.Flags().GetString("format") - if err != nil { - return err - } - - formatter, err := format.NewFormatter(formatStr) - if err != nil { - return fmt.Errorf("invalid format: %w", err) - } - - // Prepare data - headers := []string{"ID", "NAME", "PROJECT ID"} - var rows [][]string - var data []interface{} - - for _, site := range sites { - rows = append(rows, []string{site.SiteId, site.SiteName, site.ProjectId}) - data = append(data, map[string]interface{}{ - "SiteId": site.SiteId, - "SiteName": site.SiteName, - "ProjectId": site.ProjectId, - }) - } - - return formatter.Print(data, headers, rows) - }, -} - -func init() { - RootCmd.AddCommand(listCmd) - listCmd.AddCommand(listOrganizationsCmd) - listCmd.AddCommand(listProjectsCmd) - listCmd.AddCommand(listSitesCmd) - - // Project list filters - listProjectsCmd.Flags().String("organization-id", "", "Filter by organization ID") - - // Site list filters - listSitesCmd.Flags().String("organization-id", "", "Filter by organization ID") - listSitesCmd.Flags().String("project-id", "", "Filter by project ID") - - listCmd.PersistentFlags().Bool("no-cache", false, "Disable cache and fetch fresh data") -} diff --git a/cmd/login.go b/cmd/login.go deleted file mode 100644 index 3d66c8c..0000000 --- a/cmd/login.go +++ /dev/null @@ -1,245 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "connectrpc.com/connect" - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/auth" - "github.com/spf13/cobra" -) - -var loginCmd = &cobra.Command{ - Use: "login", - Short: "Authenticate with the libops API", - Long: `Authenticate with the libops API. - -This command opens a browser window where you can choose to authenticate with: -- Google OAuth -- Email and password - -After successful authentication, your credentials will be stored locally in ~/.sitectl/oauth.json. - -Examples: - sitectl login # Open browser to login page - sitectl login --api-url https://api.libops.io # Use a custom API URL`, - RunE: func(cmd *cobra.Command, args []string) error { - flags := cmd.Flags() - - apiURL, err := flags.GetString("api-url") - if err != nil { - return fmt.Errorf("failed to get api-url flag: %w", err) - } - - timeout, err := flags.GetDuration("timeout") - if err != nil { - return fmt.Errorf("failed to get timeout flag: %w", err) - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - fmt.Println("Opening browser for authentication...") - fmt.Println("You can choose to sign in with Google or email/password in the browser.") - - // Create unified OAuth client - authClient := auth.NewAuthClient(apiURL) - tokens, err := authClient.Login(ctx) - if err != nil { - return fmt.Errorf("authentication failed: %w", err) - } - - // Save tokens to disk - if err := auth.SaveTokens(tokens); err != nil { - return fmt.Errorf("failed to save authentication credentials: %w", err) - } - - tokenPath, _ := auth.TokenFilePath() - fmt.Printf("\n✓ Successfully authenticated!\n") - fmt.Printf("Credentials saved to: %s\n", tokenPath) - - // Display token expiry - expiryTime := time.Unix(tokens.ExpiryDate, 0) - fmt.Printf("Token expires: %s\n", expiryTime.Format(time.RFC1123)) - - // Check if API key already exists - keyPath := filepath.Join(os.Getenv("HOME"), ".sitectl", "key") - if _, err := os.Stat(keyPath); err == nil { - fmt.Printf("\nAPI key already exists at: %s\n", keyPath) - return nil - } - - // Prompt user to create API key - fmt.Println("\n" + strings.Repeat("-", 60)) - fmt.Println("Would you like to create an API key for future authentication?") - fmt.Println("This will allow you to skip the login flow in the future.") - fmt.Print("Create API key? [y/N]: ") - - reader := bufio.NewReader(os.Stdin) - response, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("failed to read input: %w", err) - } - - response = strings.TrimSpace(strings.ToLower(response)) - if response != "y" && response != "yes" { - fmt.Println("Skipping API key creation") - return nil - } - - // Create API key - fmt.Println("\nCreating API key...") - keyName := fmt.Sprintf("sitectl_%s", time.Now().Format("2006-01-02_15-04-05")) - - client, err := api.NewLibopsAPIClient(ctx, apiURL) - if err != nil { - return fmt.Errorf("failed to create API client: %w", err) - } - - resp, err := client.AccountService.CreateApiKey(ctx, connect.NewRequest(&libopsv1.CreateApiKeyRequest{ - Name: keyName, - Description: "Auto-generated by sitectl login", - Scopes: []string{}, // Full scope - })) - if err != nil { - return fmt.Errorf("failed to create API key: %w", err) - } - - // Save API key to ~/.sitectl/key - if err := saveAPIKey(resp.Msg.ApiKey); err != nil { - return fmt.Errorf("failed to save API key: %w", err) - } - - fmt.Printf("\n✓ API key created successfully!\n") - fmt.Printf(" Name: %s\n", keyName) - fmt.Printf(" ID: %s\n", resp.Msg.ApiKeyId) - fmt.Printf(" Stored at: %s\n", keyPath) - fmt.Println("\nYou can now use sitectl commands without logging in again.") - - return nil - }, -} - -// saveAPIKey saves the API key to ~/.sitectl/key with chmod 600 -func saveAPIKey(apiKey string) error { - homeDir := os.Getenv("HOME") - if homeDir == "" { - return fmt.Errorf("HOME environment variable not set") - } - - sitectlDir := filepath.Join(homeDir, ".sitectl") - if err := os.MkdirAll(sitectlDir, 0700); err != nil { - return fmt.Errorf("failed to create .sitectl directory: %w", err) - } - - keyPath := filepath.Join(sitectlDir, "key") - if err := os.WriteFile(keyPath, []byte(apiKey), 0600); err != nil { - return fmt.Errorf("failed to write key file: %w", err) - } - - return nil -} - -var logoutCmd = &cobra.Command{ - Use: "logout", - Short: "Remove local authentication credentials", - Long: `Remove local authentication credentials. - -This command removes the locally stored OAuth tokens from ~/.sitectl/oauth.json -and the API key from ~/.sitectl/key. -You will need to run 'sitectl login' again to authenticate.`, - RunE: func(cmd *cobra.Command, args []string) error { - tokenPath, err := auth.TokenFilePath() - if err != nil { - return fmt.Errorf("failed to get token path: %w", err) - } - - keyPath := filepath.Join(os.Getenv("HOME"), ".sitectl", "key") - - removedAny := false - - // Remove token file - if _, err := os.Stat(tokenPath); err == nil { - if err := auth.ClearTokens(); err != nil { - return fmt.Errorf("failed to remove credentials: %w", err) - } - fmt.Printf("Removed OAuth tokens from: %s\n", tokenPath) - removedAny = true - } - - // Remove API key file - if _, err := os.Stat(keyPath); err == nil { - if err := os.Remove(keyPath); err != nil { - return fmt.Errorf("failed to remove API key: %w", err) - } - fmt.Printf("Removed API key from: %s\n", keyPath) - removedAny = true - } - - if !removedAny { - fmt.Println("No authentication credentials found") - return nil - } - - fmt.Println("\n✓ Successfully logged out") - - return nil - }, -} - -var whoamiCmd = &cobra.Command{ - Use: "whoami", - Short: "Display current authentication status", - Long: `Display current authentication status and token information. - -This command shows information about your current authentication session, -including token expiry and whether you need to re-authenticate.`, - RunE: func(cmd *cobra.Command, args []string) error { - tokens, err := auth.LoadTokens() - if err != nil { - fmt.Println("Not authenticated") - fmt.Println("Run 'sitectl login' to authenticate") - return nil - } - - fmt.Println("Authentication Status: ✓ Authenticated") - fmt.Printf("Token Type: %s\n", tokens.TokenType) - - expiryTime := time.Unix(tokens.ExpiryDate, 0) - fmt.Printf("Token Expires: %s\n", expiryTime.Format(time.RFC1123)) - - if tokens.IsTokenExpired() { - fmt.Println("\n⚠ Token has expired") - fmt.Println("Run 'sitectl login' to re-authenticate") - } else { - timeUntilExpiry := time.Until(expiryTime) - fmt.Printf("Time Until Expiry: %s\n", timeUntilExpiry.Round(time.Minute)) - } - - if tokens.Scope != "" { - fmt.Printf("Scopes: %s\n", tokens.Scope) - } - - tokenPath, _ := auth.TokenFilePath() - fmt.Printf("\nCredentials stored at: %s\n", tokenPath) - - return nil - }, -} - -func init() { - // Login command flags - loginCmd.Flags().Duration("timeout", 5*time.Minute, "Timeout for authentication flow") - - RootCmd.AddCommand(loginCmd) - RootCmd.AddCommand(logoutCmd) - RootCmd.AddCommand(whoamiCmd) -} diff --git a/cmd/members.go b/cmd/members.go deleted file mode 100644 index 3f995f1..0000000 --- a/cmd/members.go +++ /dev/null @@ -1,236 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - "os" - "text/tabwriter" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var createMembersCmd = &cobra.Command{ - Use: "members", - Short: "Add a member", - Long: "Add a member to an organization, project, or site. Specify one of --organization-id, --project-id, or --site-id.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - accountID, err := cmd.Flags().GetString("account-id") - if err != nil { - return err - } - - role, err := cmd.Flags().GetString("role") - if err != nil { - return err - } - - // Determine which endpoint to call based on which ID is provided - if orgID != "" { - resp, err := client.MemberService.CreateOrganizationMember(cmd.Context(), connect.NewRequest(&libopsv1.CreateOrganizationMemberRequest{ - OrganizationId: orgID, - AccountId: accountID, - Role: role, - })) - if err != nil { - return fmt.Errorf("failed to create organization member: %w", err) - } - fmt.Printf("✓ Added member to organization\n") - fmt.Printf(" Account ID: %s\n", resp.Msg.Member.AccountId) - fmt.Printf(" Role: %s\n", resp.Msg.Member.Role) - } else if projectID != "" { - resp, err := client.ProjectMemberService.CreateProjectMember(cmd.Context(), connect.NewRequest(&libopsv1.CreateProjectMemberRequest{ - ProjectId: projectID, - AccountId: accountID, - Role: role, - })) - if err != nil { - return fmt.Errorf("failed to create project member: %w", err) - } - fmt.Printf("✓ Added member to project\n") - fmt.Printf(" Account ID: %s\n", resp.Msg.Member.AccountId) - fmt.Printf(" Role: %s\n", resp.Msg.Member.Role) - } else if siteID != "" { - resp, err := client.SiteMemberService.CreateSiteMember(cmd.Context(), connect.NewRequest(&libopsv1.CreateSiteMemberRequest{ - SiteId: siteID, - AccountId: accountID, - Role: role, - })) - if err != nil { - return fmt.Errorf("failed to create site member: %w", err) - } - fmt.Printf("✓ Added member to site\n") - fmt.Printf(" Account ID: %s\n", resp.Msg.Member.AccountId) - fmt.Printf(" Role: %s\n", resp.Msg.Member.Role) - } else { - return fmt.Errorf("must specify one of --organization-id, --project-id, or --site-id") - } - - return nil - }, -} - -var listMembersCmd = &cobra.Command{ - Use: "members", - Short: "List members", - Long: "List members. Optionally filter by --organization-id, --project-id, or --site-id. If no filter is specified, lists all members.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) - fmt.Fprintln(w, "ACCOUNT ID\tEMAIL\tNAME\tROLE\tSTATUS\tSCOPE") - fmt.Fprintln(w, "----------\t-----\t----\t----\t------\t-----") - - // If specific ID is provided, query that endpoint - if orgID != "" { - resp, err := client.MemberService.ListOrganizationMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationMembersRequest{ - OrganizationId: orgID, - })) - if err != nil { - return fmt.Errorf("failed to list organization members: %w", err) - } - for _, m := range resp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\torg:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, orgID) - } - } else if projectID != "" { - resp, err := client.ProjectMemberService.ListProjectMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectMembersRequest{ - ProjectId: projectID, - })) - if err != nil { - return fmt.Errorf("failed to list project members: %w", err) - } - for _, m := range resp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tproject:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, projectID) - } - } else if siteID != "" { - resp, err := client.SiteMemberService.ListSiteMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteMembersRequest{ - SiteId: siteID, - })) - if err != nil { - return fmt.Errorf("failed to list site members: %w", err) - } - for _, m := range resp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tsite:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, siteID) - } - } else { - // List all - use shared resource functions with caching - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - // List organization members - orgs, err := resources.ListOrganizations(cmd.Context(), apiBaseURL, useCache) - if err != nil { - slog.Warn("Failed to list organizations", "err", err) - } else { - for _, org := range orgs { - orgMembersResp, err := client.MemberService.ListOrganizationMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationMembersRequest{ - OrganizationId: org.OrganizationId, - })) - if err != nil { - slog.Warn("Failed to list members for organization", "org_id", org.OrganizationId, "err", err) - continue - } - for _, m := range orgMembersResp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\torg:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, org.OrganizationId) - } - } - } - - // List project members - projects, err := resources.ListProjects(cmd.Context(), apiBaseURL, useCache, nil) - if err != nil { - slog.Warn("Failed to list projects", "err", err) - } else { - for _, proj := range projects { - projMembersResp, err := client.ProjectMemberService.ListProjectMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectMembersRequest{ - ProjectId: proj.ProjectId, - })) - if err != nil { - slog.Warn("Failed to list members for project", "project_id", proj.ProjectId, "err", err) - continue - } - for _, m := range projMembersResp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tproject:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, proj.ProjectId) - } - } - } - - // List site members - sites, err := resources.ListSites(cmd.Context(), apiBaseURL, useCache, nil, nil) - if err != nil { - slog.Warn("Failed to list sites", "err", err) - } else { - for _, site := range sites { - siteMembersResp, err := client.SiteMemberService.ListSiteMembers(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteMembersRequest{ - SiteId: site.SiteId, - })) - if err != nil { - slog.Warn("Failed to list members for site", "site_id", site.SiteId, "err", err) - continue - } - for _, m := range siteMembersResp.Msg.Members { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\tsite:%s\n", m.AccountId, m.Email, m.Name, m.Role, m.Status, site.SiteId) - } - } - } - } - - w.Flush() - return nil - }, -} - -// Note: Member update/delete requires both the parent resource ID (organization/project/site) -// and the account ID. These commands have been removed as they cannot be implemented with -// just the member ID. Use the account-id shown in list output with the appropriate -// --organization-id, --project-id, or --site-id flag when creating members. - -func init() { - // Add members subcommand to create command - createCmd.AddCommand(createMembersCmd) - createMembersCmd.Flags().String("organization-id", "", "Organization ID") - createMembersCmd.Flags().String("project-id", "", "Project ID") - createMembersCmd.Flags().String("site-id", "", "Site ID") - createMembersCmd.Flags().String("account-id", "", "Account ID to add (required)") - createMembersCmd.Flags().String("role", "read", "Role (owner, developer, read)") - _ = createMembersCmd.MarkFlagRequired("account-id") - createMembersCmd.MarkFlagsOneRequired("organization-id", "project-id", "site-id") - createMembersCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") - - // Add members subcommand to list command - listCmd.AddCommand(listMembersCmd) - listMembersCmd.Flags().String("organization-id", "", "Filter by organization ID") - listMembersCmd.Flags().String("project-id", "", "Filter by project ID") - listMembersCmd.Flags().String("site-id", "", "Filter by site ID") - listMembersCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") -} diff --git a/cmd/port-forward.go b/cmd/port-forward.go deleted file mode 100644 index ef315dc..0000000 --- a/cmd/port-forward.go +++ /dev/null @@ -1,145 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "net" - "os" - "os/signal" - "runtime" - "strconv" - "strings" - "syscall" - - "github.com/libops/sitectl/pkg/config" - "github.com/libops/sitectl/pkg/docker" - "github.com/spf13/cobra" - "golang.org/x/crypto/ssh" -) - -var portForwardCmd = &cobra.Command{ - Use: "port-forward [LOCAL-PORT:SERVICE:REMOTE-PORT...]", - Args: cobra.ArbitraryArgs, - Short: "Forward one or more local ports to a service", - Long: ` -Access remote context docker service ports. - -For docker services running in remote contexts that do not have ports exposed on the host VM, accessing those services can be tricky. -The sitectl port-forward command can help in these situations. - -As an example, from a local machine, accessing your stage context's traefik dashboard and solr admin UI -could be done by running this command in the terminal: - -sitectl port-forward \ - 8983:solr:8983 \ - --context stage - -Then, while leaving the terminal open, in your web browser you can visit - -http://localhost:8983/solr to see the solr admin UI - -Be sure to run Ctrl+c in your terminal when you are done to close the connection. -`, - RunE: func(cmd *cobra.Command, args []string) error { - f := cmd.Flags() - c, err := config.CurrentContext(f) - if err != nil { - return err - } - if runtime.GOOS != "linux" && c.DockerHostType == config.ContextLocal { - return fmt.Errorf("port-forwarding on non-linux local contexts is not currently supported") - } - cli, err := docker.GetDockerCli(c) - if err != nil { - return err - } - defer cli.Close() - - listeners := make([]net.Listener, 0, len(args)) - done := make(chan os.Signal, 1) - signal.Notify(done, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - for _, arg := range args { - parts := strings.Split(arg, ":") - if len(parts) != 3 { - return fmt.Errorf("invalid port forwarding spec '%s': expected format LOCAL-PORT:SERVICE:REMOTE-PORT", arg) - } - localPortStr, service, remotePortStr := parts[0], parts[1], parts[2] - - localPort, err := strconv.Atoi(localPortStr) - if err != nil { - return fmt.Errorf("invalid local port '%s': must be an integer", localPortStr) - } - remotePort, err := strconv.Atoi(remotePortStr) - if err != nil { - return fmt.Errorf("invalid remote port '%s': must be an integer", remotePortStr) - } - - addr := fmt.Sprintf("localhost:%d", localPort) - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("local port %d appears to be in use: %v", localPort, err) - } - listeners = append(listeners, listener) - - containerName, err := cli.GetContainerName(c, service, false) - if err != nil { - return err - } - serviceIp, err := cli.GetServiceIp(ctx, c, containerName) - if err != nil { - return err - } - - remoteEndpoint := fmt.Sprintf("%s:%d", serviceIp, remotePort) - go func(listener net.Listener, lp, remoteAddr string) { - defer listener.Close() - fmt.Printf("Forwarding localhost:%s -> %s via SSH\n", lp, remoteAddr) - for { - localConn, err := listener.Accept() - if err != nil { - if strings.Contains(err.Error(), "use of closed network connection") { - return - } - fmt.Fprintf(os.Stderr, "error accepting connection on port %s: %v\n", lp, err) - return - } - go forward(cli.SshCli, localConn, remoteAddr) - } - }(listener, localPortStr, remoteEndpoint) - } - - <-done - fmt.Println("Shutting down port forwards...") - for _, listener := range listeners { - listener.Close() - } - return nil - }, -} - -func forward(client *ssh.Client, localConn net.Conn, remoteAddr string) { - defer localConn.Close() - remoteConn, err := client.Dial("tcp", remoteAddr) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to dial remote address %s: %v\n", remoteAddr, err) - return - } - defer remoteConn.Close() - - go func() { - if _, err := io.Copy(remoteConn, localConn); err != nil { - fmt.Fprintf(os.Stderr, "error while copying local to remote: %v\n", err) - } - }() - if _, err := io.Copy(localConn, remoteConn); err != nil { - fmt.Fprintf(os.Stderr, "error while copying remote to local: %v\n", err) - } -} - -func init() { - RootCmd.AddCommand(portForwardCmd) -} diff --git a/cmd/root.go b/cmd/root.go index a0780c0..184bacc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,7 +4,9 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" + "syscall" "github.com/libops/sitectl/pkg/config" "github.com/spf13/cobra" @@ -70,8 +72,64 @@ func init() { RootCmd.PersistentFlags().String("log-level", ll, "The logging level for the command") RootCmd.PersistentFlags().String("api-url", apiURL, "Base URL of the libops API") RootCmd.PersistentFlags().String("format", "table", `Format output using a custom template: - 'table': Print output in table format with column headers (default) - 'table TEMPLATE': Print output in table format using the given Go template - 'json': Print in JSON format - 'TEMPLATE': Print output using the given Go template`) +'table': Print output in table format with column headers (default) +'table TEMPLATE': Print output in table format using the given Go template +'json': Print in JSON format +'TEMPLATE': Print output using the given Go template`) + + discoverAndRegisterPlugins() +} + +func discoverAndRegisterPlugins() { + path := os.Getenv("PATH") + paths := strings.Split(path, string(os.PathListSeparator)) + + for _, p := range paths { + files, err := os.ReadDir(p) + if err != nil { + continue // Ignore directories that can't be read + } + + for _, f := range files { + if !f.IsDir() && strings.HasPrefix(f.Name(), "sitectl-") && f.Name() != "sitectl" { + pluginName := strings.TrimPrefix(f.Name(), "sitectl-") + pluginPath := filepath.Join(p, f.Name()) + + // Try to get plugin description from metadata + description := getPluginDescription(pluginPath, pluginName) + + pluginCmd := &cobra.Command{ + Use: pluginName, + Short: description, + RunE: func(cmd *cobra.Command, args []string) error { + err := syscall.Exec(pluginPath, append([]string{f.Name()}, args...), os.Environ()) + if err != nil { + return fmt.Errorf("failed to execute plugin %q: %w", pluginName, err) + } + return nil + }, + DisableFlagParsing: true, + } + RootCmd.AddCommand(pluginCmd) + } + } + } +} + +// getPluginDescription attempts to fetch plugin metadata +func getPluginDescription(pluginPath, pluginName string) string { + // Try to execute plugin with plugin-info command to get description + cmd := filepath.Base(pluginPath) + out, err := syscall.ForkExec(pluginPath, []string{cmd, "plugin-info"}, &syscall.ProcAttr{ + Files: []uintptr{0, 1, 2}, + Env: os.Environ(), + }) + + // If we can't get metadata, use a default description + if err != nil || out == 0 { + return fmt.Sprintf("the %s plugin", pluginName) + } + + // For now, return default - a more complete implementation would parse the output + return fmt.Sprintf("the %s plugin", pluginName) } diff --git a/cmd/secrets.go b/cmd/secrets.go deleted file mode 100644 index fda9128..0000000 --- a/cmd/secrets.go +++ /dev/null @@ -1,234 +0,0 @@ -package cmd - -import ( - "fmt" - "log/slog" - "os" - "text/tabwriter" - - "connectrpc.com/connect" - - libopsv1 "github.com/libops/api/proto/libops/v1" - "github.com/libops/sitectl/pkg/api" - "github.com/libops/sitectl/pkg/resources" - "github.com/spf13/cobra" -) - -var createSecretsCmd = &cobra.Command{ - Use: "secrets", - Short: "Create a secret", - Long: "Create a secret for an organization, project, or site. Specify one of --organization-id, --project-id, or --site-id.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } - - value, err := cmd.Flags().GetString("value") - if err != nil { - return err - } - - // Determine which endpoint to call based on which ID is provided - if orgID != "" { - resp, err := client.OrganizationSecretService.CreateOrganizationSecret(cmd.Context(), connect.NewRequest(&libopsv1.CreateOrganizationSecretRequest{ - OrganizationId: orgID, - Name: name, - Value: value, - })) - if err != nil { - return fmt.Errorf("failed to create organization secret: %w", err) - } - fmt.Printf("✓ Created organization secret: %s\n", resp.Msg.Secret.SecretId) - fmt.Printf(" Name: %s\n", resp.Msg.Secret.Name) - } else if projectID != "" { - resp, err := client.ProjectSecretService.CreateProjectSecret(cmd.Context(), connect.NewRequest(&libopsv1.CreateProjectSecretRequest{ - ProjectId: projectID, - Name: name, - Value: value, - })) - if err != nil { - return fmt.Errorf("failed to create project secret: %w", err) - } - fmt.Printf("✓ Created project secret: %s\n", resp.Msg.Secret.SecretId) - fmt.Printf(" Name: %s\n", resp.Msg.Secret.Name) - } else if siteID != "" { - resp, err := client.SiteSecretService.CreateSiteSecret(cmd.Context(), connect.NewRequest(&libopsv1.CreateSiteSecretRequest{ - SiteId: siteID, - Name: name, - Value: value, - })) - if err != nil { - return fmt.Errorf("failed to create site secret: %w", err) - } - fmt.Printf("✓ Created site secret: %s\n", resp.Msg.Secret.SecretId) - fmt.Printf(" Name: %s\n", resp.Msg.Secret.Name) - } else { - return fmt.Errorf("must specify one of --organization-id, --project-id, or --site-id") - } - - return nil - }, -} - -var listSecretsCmd = &cobra.Command{ - Use: "secrets", - Short: "List secrets", - Long: "List secrets. Optionally filter by --organization-id, --project-id, or --site-id. If no filter is specified, lists all secrets.", - RunE: func(cmd *cobra.Command, args []string) error { - apiBaseURL, err := cmd.Flags().GetString("api-url") - if err != nil { - return err - } - - client, err := api.NewLibopsAPIClient(cmd.Context(), apiBaseURL) - if err != nil { - return err - } - - orgID, _ := cmd.Flags().GetString("organization-id") - projectID, _ := cmd.Flags().GetString("project-id") - siteID, _ := cmd.Flags().GetString("site-id") - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', tabwriter.TabIndent) - fmt.Fprintln(w, "SECRET ID\tNAME\tSCOPE") - fmt.Fprintln(w, "---------\t----\t-----") - - // If specific ID is provided, query that endpoint - if orgID != "" { - resp, err := client.OrganizationSecretService.ListOrganizationSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationSecretsRequest{ - OrganizationId: orgID, - })) - if err != nil { - return fmt.Errorf("failed to list organization secrets: %w", err) - } - for _, s := range resp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\torg:%s\n", s.SecretId, s.Name, orgID) - } - } else if projectID != "" { - resp, err := client.ProjectSecretService.ListProjectSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectSecretsRequest{ - ProjectId: projectID, - })) - if err != nil { - return fmt.Errorf("failed to list project secrets: %w", err) - } - for _, s := range resp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\tproject:%s\n", s.SecretId, s.Name, projectID) - } - } else if siteID != "" { - resp, err := client.SiteSecretService.ListSiteSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteSecretsRequest{ - SiteId: siteID, - })) - if err != nil { - return fmt.Errorf("failed to list site secrets: %w", err) - } - for _, s := range resp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\tsite:%s\n", s.SecretId, s.Name, siteID) - } - } else { - // List all - use shared resource functions with caching - noCache, _ := cmd.Flags().GetBool("no-cache") - useCache := !noCache - - // List organization secrets - orgs, err := resources.ListOrganizations(cmd.Context(), apiBaseURL, useCache) - if err != nil { - slog.Warn("Failed to list organizations", "err", err) - } else { - for _, org := range orgs { - orgSecretsResp, err := client.OrganizationSecretService.ListOrganizationSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListOrganizationSecretsRequest{ - OrganizationId: org.OrganizationId, - })) - if err != nil { - slog.Warn("Failed to list secrets for organization", "org_id", org.OrganizationId, "err", err) - continue - } - for _, s := range orgSecretsResp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\torg:%s\n", s.SecretId, s.Name, org.OrganizationId) - } - } - } - - // List project secrets - projects, err := resources.ListProjects(cmd.Context(), apiBaseURL, useCache, nil) - if err != nil { - slog.Warn("Failed to list projects", "err", err) - } else { - for _, proj := range projects { - projSecretsResp, err := client.ProjectSecretService.ListProjectSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListProjectSecretsRequest{ - ProjectId: proj.ProjectId, - })) - if err != nil { - slog.Warn("Failed to list secrets for project", "project_id", proj.ProjectId, "err", err) - continue - } - for _, s := range projSecretsResp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\tproject:%s\n", s.SecretId, s.Name, proj.ProjectId) - } - } - } - - // List site secrets - sites, err := resources.ListSites(cmd.Context(), apiBaseURL, useCache, nil, nil) - if err != nil { - slog.Warn("Failed to list sites", "err", err) - } else { - for _, site := range sites { - siteSecretsResp, err := client.SiteSecretService.ListSiteSecrets(cmd.Context(), connect.NewRequest(&libopsv1.ListSiteSecretsRequest{ - SiteId: site.SiteId, - })) - if err != nil { - slog.Warn("Failed to list secrets for site", "site_id", site.SiteId, "err", err) - continue - } - for _, s := range siteSecretsResp.Msg.Secrets { - fmt.Fprintf(w, "%s\t%s\tsite:%s\n", s.SecretId, s.Name, site.SiteId) - } - } - } - } - - w.Flush() - return nil - }, -} - -// Note: Secret update/delete requires both the parent resource ID (organization/project/site) -// and the secret ID. These commands have been removed as they cannot be implemented with -// just the secret ID. Use the secret-id shown in list output with the appropriate -// --organization-id, --project-id, or --site-id flag when creating secrets. - -func init() { - // Add secrets subcommand to create command - createCmd.AddCommand(createSecretsCmd) - createSecretsCmd.Flags().String("organization-id", "", "Organization ID") - createSecretsCmd.Flags().String("project-id", "", "Project ID") - createSecretsCmd.Flags().String("site-id", "", "Site ID") - createSecretsCmd.Flags().String("name", "", "Secret name (required)") - createSecretsCmd.Flags().String("value", "", "Secret value (required)") - _ = createSecretsCmd.MarkFlagRequired("name") - _ = createSecretsCmd.MarkFlagRequired("value") - createSecretsCmd.MarkFlagsOneRequired("organization-id", "project-id", "site-id") - createSecretsCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") - - // Add secrets subcommand to list command - listCmd.AddCommand(listSecretsCmd) - listSecretsCmd.Flags().String("organization-id", "", "Filter by organization ID") - listSecretsCmd.Flags().String("project-id", "", "Filter by project ID") - listSecretsCmd.Flags().String("site-id", "", "Filter by site ID") - listSecretsCmd.MarkFlagsMutuallyExclusive("organization-id", "project-id", "site-id") -} diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..ff6375b --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,607 @@ +# Sitectl Plugin System + +Complete guide to developing, distributing, and using sitectl plugins. + +--- + +**Table of Contents** +- [Quick Start](#quick-start) +- [Plugin Development](#plugin-development) +- [Docker Integration](#docker-integration) +- [Architecture](#architecture) +- [Distribution](#distribution) + +--- + +## Quick Start + +### TL;DR + +```bash +# Create a new plugin +./scripts/create-plugin.sh awesome + +# Edit the plugin +vim plugins/awesome/main.go + +# Build it +make awesome + +# Install it +make install-plugin-awesome + +# Use it +sitectl awesome --help +``` + +### Minimal Plugin Example + +```go +package main + +import ( + "fmt" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +func main() { + sdk := plugin.NewSDK(plugin.Metadata{ + Name: "awesome", + Version: "1.0.0", + Description: "An awesome plugin", + Author: "You", + }) + + // Required for plugin discovery + sdk.AddCommand(sdk.GetMetadataCommand()) + + // Your command + cmd := &cobra.Command{ + Use: "do-thing", + Short: "Does a thing", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Thing done!") + }, + } + sdk.AddCommand(cmd) + + sdk.Execute() +} +``` + +Save as `plugins/awesome/main.go`, then: + +```bash +make awesome +./sitectl-awesome do-thing +``` + +--- + +## Plugin Development + +### Creating a Plugin + +**Option 1: Use the generator** +```bash +./scripts/create-plugin.sh myplugin +``` + +**Option 2: Copy an existing plugin** +```bash +cp -r plugins/isle plugins/myplugin +# Edit plugins/myplugin/main.go +``` + +### Project Structure + +``` +plugins/myplugin/ +├── main.go # Entry point (required) +├── cmd/ # Commands (optional) +│ └── *.go +├── pkg/ # Packages (optional) +│ └── *.go +├── Makefile # Build automation (for standalone repo) +├── go.mod # Go module +└── README.md # Documentation (for standalone repo) +``` + +### Using the Plugin SDK + +The SDK provides common functionality: + +```go +import "github.com/libops/sitectl/pkg/plugin" + +sdk := plugin.NewSDK(plugin.Metadata{ + Name: "myplugin", + Version: "1.0.0", + Description: "My plugin description", + Author: "Your Name", +}) + +// Standard flags (--log-level, --format) are automatic + +// Add custom commands +myCmd := &cobra.Command{ + Use: "subcommand", + Short: "Description", + RunE: func(cmd *cobra.Command, args []string) error { + // Your logic here + return nil + }, +} +sdk.AddCommand(myCmd) + +// Execute +sdk.Execute() +``` + +### Common Patterns + +**Command with flags:** +```go +cmd := &cobra.Command{ + Use: "list", + Short: "List things", + Run: func(cmd *cobra.Command, args []string) { + verbose, _ := cmd.Flags().GetBool("verbose") + if verbose { + fmt.Println("Verbose output...") + } + }, +} +cmd.Flags().BoolP("verbose", "v", false, "Verbose output") +``` + +**Command with arguments:** +```go +cmd := &cobra.Command{ + Use: "greet ", + Short: "Greet someone", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := args[0] + fmt.Printf("Hello, %s!\n", name) + }, +} +``` + +**Error handling:** +```go +cmd := &cobra.Command{ + Use: "risky", + Short: "Might fail", + RunE: func(cmd *cobra.Command, args []string) error { + if err := doSomething(); err != nil { + return fmt.Errorf("failed: %w", err) + } + return nil + }, +} +``` + +### Plugin Naming Convention + +- **Binary name**: `sitectl-` +- **Plugin name**: The part after `sitectl-` +- **Command usage**: `sitectl [subcommand]` + +Example: +- Binary: `sitectl-isle` +- Plugin name: `isle` +- Usage: `sitectl isle migrate-legacy` + +### Plugin Discovery + +Plugins are discovered by scanning all directories in your `PATH` for binaries matching `sitectl-*` (excluding `sitectl` itself). + +**Common installation locations:** +- `/usr/local/bin/` (system-wide) +- `~/.local/bin/` (user-specific) +- Any directory in your `PATH` + +--- + +## Docker Integration + +### Overview + +Plugins can easily interact with Docker containers (local or remote) using the SDK's built-in helpers. The SDK handles context management, SSH tunneling, and connection logic automatically. + +### Quick Example: Drupal Plugin + +```go +package main + +import ( + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +func main() { + sdk := plugin.NewSDK(plugin.Metadata{ + Name: "drupal", + Version: "1.0.0", + Description: "Drupal utilities", + }) + + currentContext, _ := config.Current() + sdk.AddLibopsFlags(currentContext) + sdk.AddCommand(sdk.GetMetadataCommand()) + + drushCmd := &cobra.Command{ + Use: "drush [args...]", + Short: "Run drush commands in the Drupal container", + RunE: func(cmd *cobra.Command, args []string) error { + // Works locally or remotely! + exitCode, err := sdk.ExecInContainer(cmd.Context(), "drupal", + append([]string{"drush"}, args...)) + if err != nil { + return err + } + return nil + }, + } + + sdk.AddCommand(drushCmd) + sdk.Execute() +} +``` + +**Usage:** +```bash +# Local development +$ sitectl drupal drush status + +# Remote production (automatically via SSH!) +$ sitectl --context production drupal drush status +``` + +### SDK Docker Helpers + +**Get a Docker client:** +```go +// Respects current context (local or remote) +// Automatically handles SSH tunneling for remote contexts +cli, err := sdk.GetDockerClient() +if err != nil { + return err +} +defer cli.Close() + +// Use the client - it wraps the standard Docker client +containers, err := cli.CLI.ContainerList(ctx, container.ListOptions{}) +``` + +**Execute commands in containers:** +```go +// Simple execution (stdout/stderr to terminal) +exitCode, err := sdk.ExecInContainer(ctx, "my-container", + []string{"ls", "-la", "/app"}) + +// Interactive with TTY (for shells) +exitCode, err := sdk.ExecInContainerInteractive(ctx, "my-container", + []string{"/bin/bash"}) +``` + +**Access context configuration:** +```go +context, err := sdk.GetContext() +if err != nil { + return err +} + +fmt.Printf("Context: %s\n", context.Name) +fmt.Printf("Type: %s\n", context.DockerHostType) + +if context.IsRemote() { + fmt.Printf("SSH: %s@%s\n", context.SSHUser, context.SSHHostname) +} +``` + +### Using Docker Package Directly + +For more control, use the docker package directly: + +```go +import ( + "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/config" +) + +// Get context +ctx, err := config.GetContext("production") +if err != nil { + return err +} + +// Create client - handles local and remote (SSH) automatically +cli, err := docker.GetDockerCli(ctx) +if err != nil { + return err +} +defer cli.Close() + +// Use standard Docker API via cli.CLI +containers, err := cli.CLI.ContainerList(context.Background(), container.ListOptions{}) + +// Execute commands with advanced options +exitCode, err := cli.Exec(context.Background(), docker.ExecOptions{ + Container: "my-container", + Cmd: []string{"php", "artisan", "migrate"}, + WorkingDir: "/var/www/html", + User: "www-data", + Env: []string{"APP_ENV=production"}, + AttachStdout: true, + AttachStderr: true, +}) + +// Or use convenience methods +exitCode, err := cli.ExecSimple(ctx, "my-container", []string{"ls", "-la"}) +exitCode, err := cli.ExecInteractive(ctx, "my-container", []string{"/bin/bash"}) +``` + +### Context Setup + +Users set up contexts for different environments: + +```bash +# Local development +$ sitectl config set-context local \ + --type local \ + --docker-socket /var/run/docker.sock + +# Remote production +$ sitectl config set-context production \ + --type remote \ + --ssh-hostname prod.example.com \ + --ssh-user deploy \ + --ssh-key ~/.ssh/id_rsa + +# Use a context +$ sitectl config use-context production +``` + +Plugins automatically use the current context when using SDK helpers. + +--- + +## Architecture + +### How It Works + +1. Main CLI starts and calls `discoverAndRegisterPlugins()` +2. Scans all directories in `$PATH` +3. Finds binaries prefixed with `sitectl-` (excluding `sitectl` itself) +4. Registers each as a cobra subcommand with `DisableFlagParsing: true` +5. When invoked, uses `syscall.Exec` to replace current process with plugin binary + +**Example:** +``` +Binary: /usr/local/bin/sitectl-isle +Command: sitectl isle [subcommands...] +``` + +### Plugin SDK Features + +**Metadata Support:** +```go +sdk := plugin.NewSDK(plugin.Metadata{ + Name: "myplugin", + Version: "1.0.0", + Description: "What this does", + Author: "Your Name", +}) +``` + +**Standard Flags:** +- `--log-level` (DEBUG, INFO, WARN, ERROR) +- `--format` (table, json, or custom template) +- `--version` (plugin version) + +**Libops Integration:** +```go +sdk.AddLibopsFlags(currentContext) +// Adds: --context, --api-url +``` + +**Docker Integration:** +- `sdk.GetDockerClient()` - Get Docker client (handles local/remote via SSH) +- `sdk.GetContext()` - Get context config +- `sdk.ExecInContainer()` - Execute command in container +- `sdk.ExecInContainerInteractive()` - Interactive exec with TTY + +### Design Benefits + +**For Plugin Developers:** +- Easy to get started +- No code duplication +- Consistent UX +- Simple build process +- Full Go power + +**For Users:** +- Easy installation +- Seamless integration +- No restarts needed +- Clear separation + +**For Maintainers:** +- Modular codebase +- Reduced main binary size +- Easier testing +- Backward compatibility + +--- + +## Distribution + +### Building + +```bash +# Build for current platform +make build + +# Build for all platforms +make build-all + +# Build for specific platform +make build-linux +make build-darwin +make build-windows +``` + +Binaries are placed in `bin/` directory. + +### Installation + +**Manual installation:** + +1. Download the binary for your platform +2. Rename to `sitectl-` (if needed) +3. Make it executable: `chmod +x sitectl-` +4. Move to PATH: `mv sitectl- /usr/local/bin/` + +**From source:** + +```bash +git clone https://github.com/libops/sitectl-.git +cd sitectl- +make install +``` + +### Publishing to GitHub + +Each plugin should be its own repository: + +**Repository structure:** +``` +sitectl-/ +├── cmd/ # Command implementations +├── main.go # Entry point +├── Makefile # Build automation +├── go.mod # Dependencies +├── README.md # Documentation +├── CONTRIBUTING.md # Contribution guide +├── LICENSE # License file +└── .gitignore # Git ignore +``` + +**Repository naming:** +- Format: `github.com/libops/sitectl-` +- Examples: + - `github.com/libops/sitectl-isle` + - `github.com/libops/sitectl-libops` + +**Release process:** + +1. Tag a release: `git tag v1.0.0` +2. Push tags: `git push --tags` +3. Build binaries: `make build-all` +4. Create GitHub release with binaries + +--- + +## Best Practices + +1. **Follow the naming convention**: Use `sitectl-` +2. **Include metadata command**: For plugin discovery +3. **Use the SDK**: Leverage shared functionality +4. **Handle errors gracefully**: Use `RunE` not `Run` +5. **Support standard flags**: `--log-level`, `--format`, `--context` +6. **Document your commands**: Good `Short` and `Long` descriptions +7. **Version semantically**: Follow semver (1.0.0, 1.1.0, etc.) +8. **Write tests**: Unit tests for your logic +9. **Respect contexts**: Use SDK helpers for Docker operations +10. **Keep it focused**: One plugin, one purpose + +--- + +## Examples + +### Available Plugins + +**[sitectl-isle](https://github.com/libops/sitectl-isle)** +- Islandora (ISLE) migration tools +- Migrate legacy compose files to unified format +- Example: Domain-specific utility plugin + +**[sitectl-libops](https://github.com/libops/sitectl-libops)** +- Libops API integration +- Manage organizations, projects, sites +- Example: Complex multi-command plugin + +### Plugin Ideas + +- `sitectl-drupal` - Drupal development utilities (drush, cache, shell) +- `sitectl-wordpress` - WordPress CLI integration +- `sitectl-db` - Database utilities (backup, restore, shell) +- `sitectl-deploy` - Deployment utilities +- `sitectl-monitor` - Monitoring and health checks + +--- + +## Troubleshooting + +### Plugin not discovered + +```bash +# Check if binary is in PATH +which sitectl-myplugin + +# Verify it's executable +ls -l $(which sitectl-myplugin) + +# Check PATH +echo $PATH +``` + +### Build errors + +```bash +# Clean and rebuild +go clean +go mod tidy +make build +``` + +### Import errors + +```bash +# From plugin directory +go mod init github.com/libops/sitectl-myplugin +go mod edit -replace github.com/libops/sitectl=../.. +go mod tidy +``` + +### Remote context not working + +```bash +# Test SSH connection +ssh -p @ + +# Check context config +sitectl config get-context +``` + +--- + +## Contributing + +Want to create a plugin? Great! Follow this guide and share it with the community. + +Have improvements to the plugin system itself? Submit a PR to the [main sitectl repository](https://github.com/libops/sitectl). + +--- + +## Resources + +- [Plugin SDK Source](../pkg/plugin/sdk.go) +- [Docker Package](../pkg/docker/) +- [Config Package](../pkg/config/) +- [Example Plugins](../plugins/) +- [Cobra Documentation](https://github.com/spf13/cobra) diff --git a/go.mod b/go.mod index 89e8310..05e255b 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25.3 require ( connectrpc.com/connect v1.19.1 - github.com/charmbracelet/fang v0.4.4 github.com/docker/docker v28.5.2+incompatible github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 @@ -19,19 +18,8 @@ require ( ) require ( - charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251215102126-8518113293e1 // indirect - github.com/charmbracelet/x/ansi v0.11.3 // indirect - github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/charmbracelet/x/termios v0.1.1 // indirect - github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.6.2 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -46,23 +34,14 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/fs v0.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/mango v0.2.0 // indirect - github.com/muesli/mango-cobra v1.3.0 // indirect - github.com/muesli/mango-pflag v0.2.0 // indirect - github.com/muesli/roff v0.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect @@ -70,9 +49,7 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect diff --git a/go.sum b/go.sum index f1b2707..7e328ca 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,13 @@ -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= -charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= -github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= -github.com/charmbracelet/ultraviolet v0.0.0-20251215102126-8518113293e1 h1:0uVEMbe5L80n1fbNAz+WSO9mEWIau6iEo6umDMstzis= -github.com/charmbracelet/ultraviolet v0.0.0-20251215102126-8518113293e1/go.mod h1:Ns3cOzzY9hEFFeGxB6VpfgRnqOJZJFhQAPfRxPqflQs= -github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= -github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383 h1:xGojlO6kHCDB1k6DolME79LG0u90TzVd8atGhmxFRIo= -github.com/charmbracelet/x/exp/charmtone v0.0.0-20251215102626-e0db08df7383/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= -github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= -github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= -github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= -github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -86,10 +58,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/libops/api/proto v0.0.1 h1:DCpWMOJK2vUsXk9r+VhXivy2SVdsj8XNL46MwAplW54= github.com/libops/api/proto v0.0.1/go.mod h1:0acmYutF3M5smZ3Za8LHSWbC+ccOxfWmTtzwslJ1fMk= -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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -100,16 +68,6 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= -github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= -github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec= -github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E= -github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k= -github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y= -github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= -github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -120,8 +78,6 @@ github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -134,8 +90,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -160,12 +114,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= diff --git a/main.go b/main.go index 0faf039..5f36542 100644 --- a/main.go +++ b/main.go @@ -1,38 +1,9 @@ package main import ( - "context" - "log/slog" - "os" - "strings" - - "github.com/charmbracelet/fang" "github.com/libops/sitectl/cmd" ) func main() { - level := getLogLevel() - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: level, - })) - slog.SetDefault(logger) - - if err := fang.Execute(context.Background(), cmd.RootCmd); err != nil { - os.Exit(1) - } -} - -func getLogLevel() slog.Level { - switch strings.ToLower(os.Getenv("LOG_LEVEL")) { - case "debug": - return slog.LevelDebug - case "info": - return slog.LevelInfo - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } + cmd.Execute() } diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index e312741..cd5c993 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -3,15 +3,18 @@ package docker import ( "context" "fmt" + "io" "log/slog" "net" "net/http" + "os" "path/filepath" "strings" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" "github.com/libops/sitectl/pkg/config" "golang.org/x/crypto/ssh" ) @@ -149,3 +152,137 @@ func (d *DockerClient) GetContainerName(c *config.Context, service string, never return "", nil } + +// ExecOptions holds options for executing a command in a container +type ExecOptions struct { + // Container is the container ID or name + Container string + + // Cmd is the command to execute + Cmd []string + + // Env is additional environment variables + Env []string + + // WorkingDir is the working directory + WorkingDir string + + // User to run as + User string + + // AttachStdin attaches stdin + AttachStdin bool + + // AttachStdout attaches stdout + AttachStdout bool + + // AttachStderr attaches stderr + AttachStderr bool + + // Tty allocates a pseudo-TTY + Tty bool + + // Stdin is the input stream + Stdin io.Reader + + // Stdout is the output stream + Stdout io.Writer + + // Stderr is the error stream + Stderr io.Writer +} + +// Exec executes a command in a container using the DockerClient +func (d *DockerClient) Exec(ctx context.Context, opts ExecOptions) (int, error) { + // Set defaults + if opts.Stdout == nil { + opts.Stdout = os.Stdout + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Stdin == nil { + opts.Stdin = os.Stdin + } + + // Get the underlying client (type assert to *client.Client) + cli, ok := d.CLI.(*client.Client) + if !ok { + return -1, fmt.Errorf("CLI is not a *client.Client") + } + + // Create exec instance + execConfig := dockercontainer.ExecOptions{ + AttachStdin: opts.AttachStdin, + AttachStdout: opts.AttachStdout, + AttachStderr: opts.AttachStderr, + Tty: opts.Tty, + Cmd: opts.Cmd, + Env: opts.Env, + WorkingDir: opts.WorkingDir, + User: opts.User, + } + + execID, err := cli.ContainerExecCreate(ctx, opts.Container, execConfig) + if err != nil { + return -1, fmt.Errorf("failed to create exec: %w", err) + } + + // Attach to exec + resp, err := cli.ContainerExecAttach(ctx, execID.ID, dockercontainer.ExecStartOptions{ + Tty: opts.Tty, + }) + if err != nil { + return -1, fmt.Errorf("failed to attach to exec: %w", err) + } + defer resp.Close() + + // Copy output + errCh := make(chan error, 1) + go func() { + if opts.Tty { + // For TTY, copy directly + _, err := io.Copy(opts.Stdout, resp.Reader) + errCh <- err + } else { + // For non-TTY, demux stdout/stderr + _, err := stdcopy.StdCopy(opts.Stdout, opts.Stderr, resp.Reader) + errCh <- err + } + }() + + // Wait for completion + if err := <-errCh; err != nil && err != io.EOF { + return -1, fmt.Errorf("failed to copy output: %w", err) + } + + // Get exit code + inspectResp, err := cli.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return -1, fmt.Errorf("failed to inspect exec: %w", err) + } + + return inspectResp.ExitCode, nil +} + +// ExecSimple executes a simple command and returns the exit code +func (d *DockerClient) ExecSimple(ctx context.Context, containerID string, cmd []string) (int, error) { + return d.Exec(ctx, ExecOptions{ + Container: containerID, + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) +} + +// ExecInteractive executes an interactive command with TTY +func (d *DockerClient) ExecInteractive(ctx context.Context, containerID string, cmd []string) (int, error) { + return d.Exec(ctx, ExecOptions{ + Container: containerID, + Cmd: cmd, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + }) +} diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go new file mode 100644 index 0000000..4cd7a99 --- /dev/null +++ b/pkg/plugin/sdk.go @@ -0,0 +1,215 @@ +package plugin + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/docker" + "github.com/spf13/cobra" +) + +// Metadata contains information about a plugin +type Metadata struct { + Name string + Version string + Description string + Author string +} + +// Config holds common plugin configuration +type Config struct { + LogLevel string + Context string + APIUrl string + Format string +} + +// SDK provides common functionality for plugins +type SDK struct { + Metadata Metadata + Config Config + RootCmd *cobra.Command +} + +// NewSDK creates a new plugin SDK instance +func NewSDK(metadata Metadata) *SDK { + sdk := &SDK{ + Metadata: metadata, + Config: Config{}, + } + + sdk.RootCmd = &cobra.Command{ + Use: fmt.Sprintf("sitectl-plugin-%s", metadata.Name), + Short: metadata.Description, + Version: metadata.Version, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return sdk.setupLogging(cmd) + }, + } + + sdk.addCommonFlags() + return sdk +} + +// setupLogging configures the logger based on flags +func (s *SDK) setupLogging(cmd *cobra.Command) error { + level := slog.LevelInfo + ll, err := cmd.Flags().GetString("log-level") + if err != nil { + return err + } + + switch strings.ToUpper(ll) { + case "DEBUG": + level = slog.LevelDebug + case "WARN": + level = slog.LevelWarn + case "ERROR": + level = slog.LevelError + } + + opts := &slog.HandlerOptions{ + Level: level, + } + handler := slog.New(slog.NewTextHandler(os.Stdout, opts)) + slog.SetDefault(handler) + + // Store config for plugin use + s.Config.LogLevel = ll + if s.RootCmd.PersistentFlags().Lookup("context") != nil { + s.Config.Context, _ = cmd.Flags().GetString("context") + } + if s.RootCmd.PersistentFlags().Lookup("api-url") != nil { + s.Config.APIUrl, _ = cmd.Flags().GetString("api-url") + } + if s.RootCmd.PersistentFlags().Lookup("format") != nil { + s.Config.Format, _ = cmd.Flags().GetString("format") + } + + return nil +} + +// addCommonFlags adds standard flags to the plugin +func (s *SDK) addCommonFlags() { + ll := os.Getenv("LOG_LEVEL") + if ll == "" { + ll = "INFO" + } + + s.RootCmd.PersistentFlags().String("log-level", ll, "The logging level for the command") + s.RootCmd.PersistentFlags().String("format", "table", `Format output using a custom template: +'table': Print output in table format with column headers (default) +'table TEMPLATE': Print output in table format using the given Go template +'json': Print in JSON format +'TEMPLATE': Print output using the given Go template`) +} + +// AddCommand adds a subcommand to the plugin +func (s *SDK) AddCommand(cmd *cobra.Command) { + s.RootCmd.AddCommand(cmd) +} + +// Execute runs the plugin +func (s *SDK) Execute() { + if err := s.RootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// AddLibopsFlags adds common libops-specific flags +func (s *SDK) AddLibopsFlags(currentContext string) { + apiURL := os.Getenv("LIBOPS_API_URL") + if apiURL == "" { + apiURL = "https://api.libops.io" + } + + s.RootCmd.PersistentFlags().String("context", currentContext, "The sitectl context to use. See sitectl config --help for more info") + s.RootCmd.PersistentFlags().String("api-url", apiURL, "Base URL of the libops API") +} + +// GetMetadataCommand returns a command that displays plugin metadata +func (s *SDK) GetMetadataCommand() *cobra.Command { + return &cobra.Command{ + Use: "plugin-info", + Short: "Display plugin metadata", + Hidden: true, // Hidden from normal help, used for plugin discovery + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Name: %s\n", s.Metadata.Name) + fmt.Printf("Version: %s\n", s.Metadata.Version) + fmt.Printf("Description: %s\n", s.Metadata.Description) + if s.Metadata.Author != "" { + fmt.Printf("Author: %s\n", s.Metadata.Author) + } + }, + } +} + +// GetDockerClient creates a Docker client respecting the sitectl context +// This is a helper for plugins that need to interact with Docker +// Returns the existing DockerClient which handles both local and remote contexts +func (s *SDK) GetDockerClient() (*docker.DockerClient, error) { + ctx, err := s.GetContext() + if err != nil { + return nil, fmt.Errorf("failed to get context: %w", err) + } + + return docker.GetDockerCli(ctx) +} + +// GetContext loads the sitectl context configuration +// This is useful for plugins that need to access context-specific settings +// If no context is specified, returns the current context from config +func (s *SDK) GetContext() (*config.Context, error) { + // Load the config + cfg, err := config.Load() + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + + // Use specified context or current context + contextName := s.Config.Context + if contextName == "" { + contextName = cfg.CurrentContext + } + + if contextName == "" { + return nil, fmt.Errorf("no context specified and no current context set") + } + + // Find the context + for _, ctx := range cfg.Contexts { + if ctx.Name == contextName { + return &ctx, nil + } + } + + return nil, fmt.Errorf("context %q not found", contextName) +} + +// ExecInContainer executes a command in a Docker container +// This is a convenience wrapper for plugins +func (s *SDK) ExecInContainer(ctx context.Context, containerID string, cmd []string) (int, error) { + cli, err := s.GetDockerClient() + if err != nil { + return -1, fmt.Errorf("failed to create Docker client: %w", err) + } + defer cli.Close() + + return cli.ExecSimple(ctx, containerID, cmd) +} + +// ExecInContainerInteractive executes an interactive command in a Docker container with TTY +// This is a convenience wrapper for plugins +func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string, cmd []string) (int, error) { + cli, err := s.GetDockerClient() + if err != nil { + return -1, fmt.Errorf("failed to create Docker client: %w", err) + } + defer cli.Close() + + return cli.ExecInteractive(ctx, containerID, cmd) +}