diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c06e26..01fdddb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true - name: Run go fmt @@ -50,17 +50,11 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true - name: Run tests - run: go test -v -race -coverprofile=coverage.out ./... - - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - files: ./coverage.out - fail_ci_if_error: false + run: go test -v ./... build: name: Build @@ -79,7 +73,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: '1.25' cache: true - name: Build binary @@ -90,7 +84,7 @@ jobs: go build -v -o forkspacer-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.exe' || '' }} . - name: Verify binary - if: matrix.goos == 'linux' + if: matrix.goos == 'linux' && matrix.goarch == 'amd64' run: | chmod +x forkspacer-${{ matrix.goos }}-${{ matrix.goarch }} ./forkspacer-${{ matrix.goos }}-${{ matrix.goarch }} version diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7aa7be --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +.PHONY: build build-all test lint clean install fmt tidy help + +# Binary name +BINARY_NAME=forkspacer + +# Build variables +VERSION?=dev +GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS=-ldflags "-X github.com/forkspacer/cli/cmd.version=$(VERSION) -X github.com/forkspacer/cli/cmd.gitCommit=$(GIT_COMMIT) -X github.com/forkspacer/cli/cmd.buildDate=$(BUILD_DATE)" + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOVET=$(GOCMD) vet +GOFMT=$(GOCMD) fmt +GOMOD=$(GOCMD) mod + +help: ## Show this help + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' + +build: ## Build binary for current platform + $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME) . + +build-all: ## Build for all platforms + @echo "Building for multiple platforms..." + GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME)-darwin-arm64 . + GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME)-darwin-amd64 . + GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME)-linux-amd64 . + GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME)-linux-arm64 . + GOOS=windows GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(BINARY_NAME)-windows-amd64.exe . + @echo "Build complete!" + +test: ## Run tests + $(GOTEST) -v ./... + +lint: ## Run linters + @echo "Running go vet..." + @$(GOVET) ./... + @echo "Checking formatting..." + @if [ -n "$$(gofmt -s -l .)" ]; then \ + echo "Code is not formatted. Run 'make fmt'"; \ + gofmt -s -l .; \ + exit 1; \ + fi + @echo "Linting passed!" + +fmt: ## Format code + $(GOFMT) ./... + +tidy: ## Tidy go.mod + $(GOMOD) tidy + +install: build ## Install binary to /usr/local/bin + @echo "Installing $(BINARY_NAME) to /usr/local/bin..." + sudo mv $(BINARY_NAME) /usr/local/bin/ + @echo "Installed successfully!" + +clean: ## Clean build artifacts + @echo "Cleaning build artifacts..." + @rm -f $(BINARY_NAME)* + @rm -f coverage.out + @echo "Clean complete!" + +run: build ## Build and run version command + ./$(BINARY_NAME) version + +verify: lint test build ## Run all verification steps + @echo "All verification steps passed!" + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md index aacf534..b86e868 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The CLI provides direct Kubernetes integration, beautiful terminal output, and f - 🎨 **Beautiful Output** - Styled terminal output with colors, spinners, and progress indicators - ⚡ **Fast Validation** - Client-side validation for instant feedback - 🚀 **Easy to Use** - Intuitive commands that feel natural -- 🔧 **Direct K8s Access** - No API server required, uses your kubeconfig +- 🔧 **Shared Business Logic** - Uses Forkspacer API server library for consistent operations - 🌍 **Cross-Platform** - Works on macOS, Linux, and Windows - 📝 **Shell Completion** - Tab completion for bash, zsh, fish, and powershell - 🔄 **Workspace Lifecycle** - Create, hibernate, wake, and manage workspaces @@ -382,7 +382,7 @@ kubectl config get-contexts ### Prerequisites -- Go 1.24 or later +- Go 1.25 or later - Kubernetes cluster for testing - [Forkspacer Operator](https://github.com/forkspacer/forkspacer) installed @@ -418,15 +418,35 @@ cli/ │ ├── hibernate.go │ └── wake.go ├── pkg/ # Shared packages -│ ├── k8s/ # Kubernetes client wrapper -│ ├── printer/ # Output formatting -│ ├── styles/ # Terminal styling -│ └── validation/ # Input validation +│ ├── workspace/ # Workspace service wrapper (delegates to api-server) +│ ├── printer/ # Output formatting (tables, spinners) +│ ├── styles/ # Terminal styling (colors, layouts) +│ └── validation/ # Input validation (DNS, cron) ├── .github/ # GitHub workflows & templates ├── scripts/ # Install scripts └── main.go # Entry point ``` +### Architecture + +The CLI imports the Forkspacer API server's service layer as a library, providing a unified approach to workspace operations: + +``` +api-server/pkg/services/forkspacer (shared business logic) + ↓ ↓ + HTTP Handlers CLI Wrapper Service + (for REST API) (pkg/workspace/) + ↓ + CLI Commands +``` + +**Benefits:** +- Single source of truth for business logic +- Type-safe compile-time checking +- No network overhead for CLI operations +- Consistent validation and error handling +- Shared code maintenance between API and CLI + ### Testing ```bash diff --git a/cmd/root.go b/cmd/root.go index 8f742b6..f26f1ce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,8 +4,8 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/forkspacer/cli/pkg/styles" + "github.com/spf13/cobra" ) var ( diff --git a/cmd/version.go b/cmd/version.go index dc5c106..985a84c 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,8 +4,8 @@ import ( "fmt" "runtime" - "github.com/spf13/cobra" "github.com/forkspacer/cli/pkg/styles" + "github.com/spf13/cobra" ) var ( diff --git a/cmd/workspace/create.go b/cmd/workspace/create.go index 4c8bd5b..44e256e 100644 --- a/cmd/workspace/create.go +++ b/cmd/workspace/create.go @@ -5,15 +5,15 @@ import ( "fmt" "time" - "github.com/spf13/cobra" + "github.com/forkspacer/api-server/pkg/services/forkspacer" batchv1 "github.com/forkspacer/forkspacer/api/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/spf13/cobra" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/printer" "github.com/forkspacer/cli/pkg/styles" "github.com/forkspacer/cli/pkg/validation" + workspaceService "github.com/forkspacer/cli/pkg/workspace" ) var ( @@ -116,64 +116,38 @@ func runCreate(c *cobra.Command, args []string) error { sp.Success("Wake schedule is valid") } - // Step 3: Connect to cluster + // Step 3: Connect to cluster and create service sp = printer.NewSpinner("Connecting to Kubernetes cluster") sp.Start() ctx := context.Background() - client, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { sp.Error("Failed to connect to cluster") return fmt.Errorf("kubernetes connection failed: %w", err) } - sp.Success(fmt.Sprintf("Connected to cluster (context: %s)", client.Context)) - - // Step 4: Check if operator is installed - sp = printer.NewSpinner("Checking Forkspacer operator installation") - sp.Start() - time.Sleep(200 * time.Millisecond) - - if err := client.CheckOperatorInstalled(ctx); err != nil { - sp.Error("Forkspacer operator not found") - return fmt.Errorf("operator not installed: %w\n\nInstall with: helm install forkspacer forkspacer/forkspacer", err) - } - sp.Success("Forkspacer operator is installed") + sp.Success("Connected to cluster") - // Step 5: Check if workspace already exists - sp = printer.NewSpinner("Checking if workspace already exists") - sp.Start() - time.Sleep(200 * time.Millisecond) - - exists, err := client.WorkspaceExists(ctx, name, namespace) - if err != nil { - sp.Error("Failed to check workspace existence") - return err - } - if exists { - sp.Error("Workspace already exists") - return fmt.Errorf("workspace %s/%s already exists\n\nUse: forkspacer workspace get %s", namespace, name, name) - } - sp.Success("Workspace name is available") + // Step 4: Build workspace input + workspaceIn := buildWorkspaceInput(name, namespace) - // Step 6: Build workspace object - workspace := buildWorkspace(name, namespace) - - // Step 7: Create workspace + // Step 5: Create workspace using api-server service sp = printer.NewSpinner("Creating workspace resource") sp.Start() - if err := client.CreateWorkspace(ctx, workspace); err != nil { + workspace, err := service.Create(ctx, workspaceIn) + if err != nil { sp.Error("Failed to create workspace") - return err + return fmt.Errorf("failed to create workspace: %w", err) } sp.Success("Workspace resource created") - // Step 8: Wait for ready (optional) + // Step 6: Wait for ready (optional) if createWait { sp = printer.NewSpinner("Waiting for workspace to become ready") sp.Start() - if err := waitForWorkspaceReady(ctx, client, name, namespace, 2*time.Minute); err != nil { + if err := waitForWorkspaceReady(ctx, service, name, namespace, 2*time.Minute); err != nil { sp.Error("Workspace did not become ready") return err } @@ -186,59 +160,67 @@ func runCreate(c *cobra.Command, args []string) error { return nil } -func buildWorkspace(name, namespace string) *batchv1.Workspace { - workspace := &batchv1.Workspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: batchv1.WorkspaceSpec{ - Type: batchv1.WorkspaceTypeKubernetes, - Connection: &batchv1.WorkspaceConnection{ - Type: batchv1.WorkspaceConnectionType(createConnectionType), - }, +func buildWorkspaceInput(name, namespace string) forkspacer.WorkspaceCreateIn { + workspaceIn := forkspacer.WorkspaceCreateIn{ + Name: name, + Namespace: &namespace, + Hibernated: false, + Connection: &forkspacer.WorkspaceCreateConnectionIn{ + Type: createConnectionType, }, } // Add auto-hibernation if specified if createHibernationSched != "" { - workspace.Spec.AutoHibernation = &batchv1.WorkspaceAutoHibernation{ + workspaceIn.AutoHibernation = &forkspacer.WorkspaceAutoHibernationIn{ Enabled: true, Schedule: createHibernationSched, } if createWakeSched != "" { - workspace.Spec.AutoHibernation.WakeSchedule = &createWakeSched + workspaceIn.AutoHibernation.WakeSchedule = &createWakeSched } } // Add fork reference if specified if createFromWorkspace != "" { - workspace.Spec.From = &batchv1.WorkspaceFromReference{ + workspaceIn.From = &forkspacer.ResourceReference{ Name: createFromWorkspace, Namespace: namespace, } } - return workspace + return workspaceIn } -func waitForWorkspaceReady(ctx context.Context, client *k8s.Client, name, namespace string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - - for time.Now().Before(deadline) { - ws, err := client.GetWorkspace(ctx, name, namespace) - if err != nil { - return err - } - - if ws.Status.Phase == batchv1.WorkspacePhaseReady && ws.Status.Ready { - return nil +func waitForWorkspaceReady(ctx context.Context, service *workspaceService.Service, name, namespace string, timeout time.Duration) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + timeoutCh := time.After(timeout) + + for { + select { + case <-timeoutCh: + return fmt.Errorf("timeout waiting for workspace to become ready") + case <-ticker.C: + workspace, err := service.Get(ctx, name, namespace) + if err != nil { + continue // Workspace might not exist yet, keep waiting + } + + if workspace.Status.Ready { + return nil + } + + // Check if workspace is in a failed state + if workspace.Status.Phase == "failed" { + if workspace.Status.Message != nil { + return fmt.Errorf("workspace failed: %s", *workspace.Status.Message) + } + return fmt.Errorf("workspace entered failed state") + } } - - time.Sleep(2 * time.Second) } - - return fmt.Errorf("timeout waiting for workspace to become ready") } func printSuccessSummary(workspace *batchv1.Workspace) { @@ -284,7 +266,7 @@ func formatValidationError(name string, err error) error { msg += fmt.Sprintf("\n %s\n", styles.Key("Try:")) msg += fmt.Sprintf(" %s\n", styles.Code("forkspacer workspace create dev-env")) - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } func formatCronError(schedule string, err error) error { @@ -296,5 +278,5 @@ func formatCronError(schedule string, err error) error { } msg += fmt.Sprintf("\n %s https://crontab.guru\n", styles.Key("Learn more:")) - return fmt.Errorf(msg) + return fmt.Errorf("%s", msg) } diff --git a/cmd/workspace/delete.go b/cmd/workspace/delete.go index ecac7a3..7cd4432 100644 --- a/cmd/workspace/delete.go +++ b/cmd/workspace/delete.go @@ -4,11 +4,11 @@ import ( "context" "fmt" - "github.com/spf13/cobra" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/printer" "github.com/forkspacer/cli/pkg/styles" + workspaceService "github.com/forkspacer/cli/pkg/workspace" + "github.com/spf13/cobra" ) var ( @@ -42,13 +42,13 @@ func runDelete(c *cobra.Command, args []string) error { namespace := cmd.GetNamespace() ctx := context.Background() - client, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { return fmt.Errorf("failed to connect to cluster: %w", err) } // Check if workspace exists - workspace, err := client.GetWorkspace(ctx, name, namespace) + workspace, err := service.Get(ctx, name, namespace) if err != nil { return err } @@ -76,7 +76,7 @@ func runDelete(c *cobra.Command, args []string) error { sp := printer.NewSpinner("Deleting workspace") sp.Start() - if err := client.DeleteWorkspace(ctx, workspace.Name, workspace.Namespace); err != nil { + if err := service.Delete(ctx, workspace.Name, &workspace.Namespace); err != nil { sp.Error("Failed to delete workspace") return err } diff --git a/cmd/workspace/get.go b/cmd/workspace/get.go index c0751dd..b3e678e 100644 --- a/cmd/workspace/get.go +++ b/cmd/workspace/get.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/spf13/cobra" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/styles" + workspaceService "github.com/forkspacer/cli/pkg/workspace" + "github.com/spf13/cobra" ) var getCmd = &cobra.Command{ @@ -30,12 +30,12 @@ func runGet(c *cobra.Command, args []string) error { namespace := cmd.GetNamespace() ctx := context.Background() - client, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { return fmt.Errorf("failed to connect to cluster: %w", err) } - workspace, err := client.GetWorkspace(ctx, name, namespace) + workspace, err := service.Get(ctx, name, namespace) if err != nil { return err } diff --git a/cmd/workspace/hibernate.go b/cmd/workspace/hibernate.go index 0f56230..bcdbb8a 100644 --- a/cmd/workspace/hibernate.go +++ b/cmd/workspace/hibernate.go @@ -5,12 +5,11 @@ import ( "fmt" "github.com/spf13/cobra" - "sigs.k8s.io/controller-runtime/pkg/client" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/printer" "github.com/forkspacer/cli/pkg/styles" + workspaceService "github.com/forkspacer/cli/pkg/workspace" ) var hibernateCmd = &cobra.Command{ @@ -40,7 +39,7 @@ func runHibernate(c *cobra.Command, args []string) error { namespace := cmd.GetNamespace() ctx := context.Background() - k8sClient, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { return fmt.Errorf("failed to connect to cluster: %w", err) } @@ -49,7 +48,7 @@ func runHibernate(c *cobra.Command, args []string) error { sp := printer.NewSpinner("Fetching workspace") sp.Start() - workspace, err := k8sClient.GetWorkspace(ctx, name, namespace) + workspace, err := service.Get(ctx, name, namespace) if err != nil { sp.Error("Failed to fetch workspace") return err @@ -65,15 +64,12 @@ func runHibernate(c *cobra.Command, args []string) error { sp.Success("Workspace found") - // Patch workspace to set hibernated=true + // Hibernate workspace sp = printer.NewSpinner("Hibernating workspace") sp.Start() - patch := client.MergeFrom(workspace.DeepCopy()) - hibernated := true - workspace.Spec.Hibernated = &hibernated - - if err := k8sClient.PatchWorkspace(ctx, workspace, patch); err != nil { + _, err = service.SetHibernation(ctx, name, namespace, true) + if err != nil { sp.Error("Failed to hibernate workspace") return err } diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index 15b6fdc..1c99d26 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -4,11 +4,11 @@ import ( "context" "fmt" - "github.com/spf13/cobra" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/printer" "github.com/forkspacer/cli/pkg/styles" + workspaceService "github.com/forkspacer/cli/pkg/workspace" + "github.com/spf13/cobra" ) var ( @@ -45,12 +45,12 @@ func runList(c *cobra.Command, args []string) error { } ctx := context.Background() - client, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { return fmt.Errorf("failed to connect to cluster: %w", err) } - workspaces, err := client.ListWorkspaces(ctx, namespace) + workspaces, err := service.List(ctx, namespace) if err != nil { return err } diff --git a/cmd/workspace/wake.go b/cmd/workspace/wake.go index 4b5eabd..755d283 100644 --- a/cmd/workspace/wake.go +++ b/cmd/workspace/wake.go @@ -5,12 +5,11 @@ import ( "fmt" "github.com/spf13/cobra" - "sigs.k8s.io/controller-runtime/pkg/client" "github.com/forkspacer/cli/cmd" - "github.com/forkspacer/cli/pkg/k8s" "github.com/forkspacer/cli/pkg/printer" "github.com/forkspacer/cli/pkg/styles" + workspaceService "github.com/forkspacer/cli/pkg/workspace" ) var wakeCmd = &cobra.Command{ @@ -38,7 +37,7 @@ func runWake(c *cobra.Command, args []string) error { namespace := cmd.GetNamespace() ctx := context.Background() - k8sClient, err := k8s.NewClient() + service, err := workspaceService.NewService() if err != nil { return fmt.Errorf("failed to connect to cluster: %w", err) } @@ -47,7 +46,7 @@ func runWake(c *cobra.Command, args []string) error { sp := printer.NewSpinner("Fetching workspace") sp.Start() - workspace, err := k8sClient.GetWorkspace(ctx, name, namespace) + workspace, err := service.Get(ctx, name, namespace) if err != nil { sp.Error("Failed to fetch workspace") return err @@ -63,15 +62,12 @@ func runWake(c *cobra.Command, args []string) error { sp.Success("Workspace found") - // Patch workspace to set hibernated=false + // Wake up workspace sp = printer.NewSpinner("Waking up workspace") sp.Start() - patch := client.MergeFrom(workspace.DeepCopy()) - awake := false - workspace.Spec.Hibernated = &awake - - if err := k8sClient.PatchWorkspace(ctx, workspace, patch); err != nil { + _, err = service.SetHibernation(ctx, name, namespace, false) + if err != nil { sp.Error("Failed to wake workspace") return err } diff --git a/cmd/workspace/workspace.go b/cmd/workspace/workspace.go index 52f6d14..3454bda 100644 --- a/cmd/workspace/workspace.go +++ b/cmd/workspace/workspace.go @@ -1,8 +1,8 @@ package workspace import ( - "github.com/spf13/cobra" "github.com/forkspacer/cli/cmd" + "github.com/spf13/cobra" ) // WorkspaceCmd represents the workspace command diff --git a/go.mod b/go.mod index ed9622a..8cb037d 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module github.com/forkspacer/cli -go 1.24.5 - -toolchain go1.24.8 +go 1.25.0 require ( github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/lipgloss v1.1.0 + github.com/forkspacer/api-server v1.0.0 github.com/forkspacer/forkspacer v0.1.5 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.0 diff --git a/go.sum b/go.sum index f23124f..4240586 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/forkspacer/api-server v1.0.0 h1:COWTz+wsrkj6VT3AUUxT+KTvF9+NPF/wcEDz/s/7LAk= +github.com/forkspacer/api-server v1.0.0/go.mod h1:Sy6Dw+Dh4heCCVzjvKu7xLDjLtUnLiWEjKNR6uuyTmw= github.com/forkspacer/forkspacer v0.1.5 h1:mMitpq+OuXel1PDLV7dy9LoL3rg3BJDtp20L5mp3Guk= github.com/forkspacer/forkspacer v0.1.5/go.mod h1:6o2B4+vNWaqZbBx/gKGESGV3jBXkZRsysGi0YkPhTM0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go deleted file mode 100644 index 1253651..0000000 --- a/pkg/k8s/client.go +++ /dev/null @@ -1,67 +0,0 @@ -package k8s - -import ( - "context" - "fmt" - - "k8s.io/apimachinery/pkg/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - batchv1 "github.com/forkspacer/forkspacer/api/v1" -) - -// Client wraps the Kubernetes client with helper methods -type Client struct { - client.Client - Scheme *runtime.Scheme - Context string // Current kubectl context name -} - -// NewClient creates a new Kubernetes client with Forkspacer CRDs registered -func NewClient() (*Client, error) { - // Create scheme and register types - scheme := runtime.NewScheme() - if err := clientgoscheme.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("failed to add client-go scheme: %w", err) - } - if err := batchv1.AddToScheme(scheme); err != nil { - return nil, fmt.Errorf("failed to add Forkspacer CRDs: %w", err) - } - - // Get Kubernetes config (supports kubeconfig and in-cluster) - config, err := ctrl.GetConfig() - if err != nil { - return nil, fmt.Errorf("failed to get Kubernetes config: %w", err) - } - - // Create controller-runtime client - k8sClient, err := client.New(config, client.Options{Scheme: scheme}) - if err != nil { - return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) - } - - // Get current context name - contextName := "unknown" - if config := ctrl.GetConfigOrDie(); config != nil { - // Try to extract context from kubeconfig - contextName = "current" - } - - return &Client{ - Client: k8sClient, - Scheme: scheme, - Context: contextName, - }, nil -} - -// CheckOperatorInstalled verifies that Forkspacer operator is installed -func (c *Client) CheckOperatorInstalled(ctx context.Context) error { - // Try to list workspaces - if CRD doesn't exist, this will fail - workspaces := &batchv1.WorkspaceList{} - if err := c.List(ctx, workspaces, client.Limit(1)); err != nil { - return fmt.Errorf("Forkspacer operator not found: %w", err) - } - return nil -} diff --git a/pkg/k8s/workspace.go b/pkg/k8s/workspace.go deleted file mode 100644 index fe8b8b8..0000000 --- a/pkg/k8s/workspace.go +++ /dev/null @@ -1,86 +0,0 @@ -package k8s - -import ( - "context" - "fmt" - - batchv1 "github.com/forkspacer/forkspacer/api/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// ListWorkspaces lists all workspaces in the specified namespace -func (c *Client) ListWorkspaces(ctx context.Context, namespace string) (*batchv1.WorkspaceList, error) { - workspaces := &batchv1.WorkspaceList{} - opts := []client.ListOption{} - - if namespace != "" { - opts = append(opts, client.InNamespace(namespace)) - } - - if err := c.List(ctx, workspaces, opts...); err != nil { - return nil, fmt.Errorf("failed to list workspaces: %w", err) - } - - return workspaces, nil -} - -// GetWorkspace retrieves a specific workspace -func (c *Client) GetWorkspace(ctx context.Context, name, namespace string) (*batchv1.Workspace, error) { - workspace := &batchv1.Workspace{} - key := types.NamespacedName{ - Name: name, - Namespace: namespace, - } - - if err := c.Get(ctx, key, workspace); err != nil { - return nil, fmt.Errorf("failed to get workspace: %w", err) - } - - return workspace, nil -} - -// CreateWorkspace creates a new workspace -func (c *Client) CreateWorkspace(ctx context.Context, workspace *batchv1.Workspace) error { - if err := c.Create(ctx, workspace); err != nil { - return fmt.Errorf("failed to create workspace: %w", err) - } - return nil -} - -// DeleteWorkspace deletes a workspace -func (c *Client) DeleteWorkspace(ctx context.Context, name, namespace string) error { - workspace := &batchv1.Workspace{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - } - - if err := c.Delete(ctx, workspace); err != nil { - return fmt.Errorf("failed to delete workspace: %w", err) - } - - return nil -} - -// PatchWorkspace patches a workspace -func (c *Client) PatchWorkspace(ctx context.Context, workspace *batchv1.Workspace, patch client.Patch) error { - if err := c.Patch(ctx, workspace, patch); err != nil { - return fmt.Errorf("failed to patch workspace: %w", err) - } - return nil -} - -// WorkspaceExists checks if a workspace exists -func (c *Client) WorkspaceExists(ctx context.Context, name, namespace string) (bool, error) { - _, err := c.GetWorkspace(ctx, name, namespace) - if err != nil { - if client.IgnoreNotFound(err) == nil { - return false, nil - } - return false, err - } - return true, nil -} diff --git a/pkg/styles/styles.go b/pkg/styles/styles.go index 297bcdf..8ca4c01 100644 --- a/pkg/styles/styles.go +++ b/pkg/styles/styles.go @@ -84,16 +84,16 @@ var ( // Common symbols const ( - SymbolSuccess = "✓" - SymbolError = "✗" - SymbolWarning = "⚠" - SymbolInfo = "ℹ" - SymbolSpinner = "⏳" - SymbolArrow = "→" - SymbolBullet = "•" - SymbolCheckbox = "☐" - SymbolChecked = "☑" - SymbolSparkles = "✨" + SymbolSuccess = "✓" + SymbolError = "✗" + SymbolWarning = "⚠" + SymbolInfo = "ℹ" + SymbolSpinner = "⏳" + SymbolArrow = "→" + SymbolBullet = "•" + SymbolCheckbox = "☐" + SymbolChecked = "☑" + SymbolSparkles = "✨" ) // Helper functions for styled output diff --git a/pkg/validation/cron.go b/pkg/validation/cron.go index 7aec76f..aa57836 100644 --- a/pkg/validation/cron.go +++ b/pkg/validation/cron.go @@ -27,11 +27,11 @@ func ValidateCronSchedule(schedule string) error { // CronExamples returns common cron schedule examples func CronExamples() map[string]string { return map[string]string{ - "0 18 * * *": "Every day at 6 PM", - "0 8 * * *": "Every day at 8 AM", - "0 18 * * 1-5": "Weekdays at 6 PM", - "0 9 * * 1": "Every Monday at 9 AM", - "*/15 * * * *": "Every 15 minutes", - "0 0 * * 0": "Every Sunday at midnight", + "0 18 * * *": "Every day at 6 PM", + "0 8 * * *": "Every day at 8 AM", + "0 18 * * 1-5": "Weekdays at 6 PM", + "0 9 * * 1": "Every Monday at 9 AM", + "*/15 * * * *": "Every 15 minutes", + "0 0 * * 0": "Every Sunday at midnight", } } diff --git a/pkg/workspace/service.go b/pkg/workspace/service.go new file mode 100644 index 0000000..bcaa0b1 --- /dev/null +++ b/pkg/workspace/service.go @@ -0,0 +1,105 @@ +package workspace + +import ( + "context" + + "github.com/forkspacer/api-server/pkg/services/forkspacer" + "github.com/forkspacer/api-server/pkg/utils" + batchv1 "github.com/forkspacer/forkspacer/api/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Service wraps api-server service and adds missing Get() method +// TODO: Remove this wrapper when api-server adds Get() method +type Service struct { + apiService *forkspacer.ForkspacerWorkspaceService + client client.Client // Only for Get operation +} + +// NewService creates a new workspace service wrapper +func NewService() (*Service, error) { + // Create api-server service (for Create, Delete, List, Update) + apiService, err := forkspacer.NewForkspacerWorkspaceService() + if err != nil { + return nil, err + } + + // Create our own client for Get operation + // (api-server's client field is not exported) + restConfig, err := ctrl.GetConfig() + if err != nil { + return nil, err + } + + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + return nil, err + } + if err := batchv1.AddToScheme(scheme); err != nil { + return nil, err + } + + k8sClient, err := client.New(restConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + + return &Service{ + apiService: apiService, + client: k8sClient, + }, nil +} + +// Create delegates to api-server service +func (s *Service) Create(ctx context.Context, workspaceIn forkspacer.WorkspaceCreateIn) (*batchv1.Workspace, error) { + return s.apiService.Create(ctx, workspaceIn) +} + +// Delete delegates to api-server service +func (s *Service) Delete(ctx context.Context, name string, namespace *string) error { + return s.apiService.Delete(ctx, name, namespace) +} + +// List workspaces with optional namespace filtering +// TODO: Add namespace parameter to api-server's List() method +func (s *Service) List(ctx context.Context, namespace string) (*batchv1.WorkspaceList, error) { + // Use our client for namespace filtering (api-server doesn't support this yet) + workspaces := &batchv1.WorkspaceList{} + + var opts []client.ListOption + if namespace != "" { + opts = append(opts, client.InNamespace(namespace)) + } + + err := s.client.List(ctx, workspaces, opts...) + return workspaces, err +} + +// Update delegates to api-server service +func (s *Service) Update(ctx context.Context, updateIn forkspacer.WorkspaceUpdateIn) (*batchv1.Workspace, error) { + return s.apiService.Update(ctx, updateIn) +} + +// Get fetches a single workspace +// TODO: Remove when api-server adds Get() method +func (s *Service) Get(ctx context.Context, name, namespace string) (*batchv1.Workspace, error) { + workspace := &batchv1.Workspace{} + err := s.client.Get(ctx, client.ObjectKey{ + Name: name, + Namespace: namespace, + }, workspace) + return workspace, err +} + +// SetHibernation is a helper to set hibernation state +// Uses api-server's Update with retry logic +func (s *Service) SetHibernation(ctx context.Context, name, namespace string, hibernated bool) (*batchv1.Workspace, error) { + return s.apiService.Update(ctx, forkspacer.WorkspaceUpdateIn{ + Name: name, + Namespace: &namespace, + Hibernated: utils.ToPtr(hibernated), + }) +}