From a5704fd69d2e2d8769d07d93309c1423482e8280 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:11:15 +0100 Subject: [PATCH 01/16] feat: add integration test for kubernetes first attempt --- cli/cmd/generate_docker.go | 21 +- cli/cmd/generate_docker_test.go | 6 +- int/export_kubernetes_test.go | 633 ++++++++++++++++++++++++++++++++ int/util/workspace.go | 39 ++ 4 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 int/export_kubernetes_test.go diff --git a/cli/cmd/generate_docker.go b/cli/cmd/generate_docker.go index ae164cd3..0d71684b 100644 --- a/cli/cmd/generate_docker.go +++ b/cli/cmd/generate_docker.go @@ -30,15 +30,15 @@ type GenerateDockerOpts struct { func (c *GenerateDockerCmd) RunE(cc *cobra.Command, args []string) error { log.Println(c.Opts.Force) fs := cs.NewOSFileSystem(".") - git := git.NewGitService(fs) + gitSvc := git.NewGitService(fs) - client, err := NewClient(c.Opts.GlobalOptions) - if err != nil { - return fmt.Errorf("failed to create Codesphere client: %w", err) + exporter := exporter.NewExporterService(fs, c.Opts.Output, c.Opts.BaseImage, c.Opts.Envs, c.Opts.RepoRoot, c.Opts.Force) + + clientFactory := func() (Client, error) { + return NewClient(c.Opts.GlobalOptions) } - exporter := exporter.NewExporterService(fs, c.Opts.Output, c.Opts.BaseImage, c.Opts.Envs, c.Opts.RepoRoot, c.Opts.Force) - if err := c.GenerateDocker(fs, exporter, git, client); err != nil { + if err := c.GenerateDocker(fs, exporter, gitSvc, clientFactory); err != nil { return fmt.Errorf("failed to generate docker: %w", err) } @@ -95,7 +95,7 @@ func AddGenerateDockerCmd(generate *cobra.Command, opts *GenerateOpts) { docker.cmd.RunE = docker.RunE } -func (c *GenerateDockerCmd) GenerateDocker(fs *cs.FileSystem, exp exporter.Exporter, git git.Git, csClient Client) error { +func (c *GenerateDockerCmd) GenerateDocker(fs *cs.FileSystem, exp exporter.Exporter, git git.Git, clientFactory func() (Client, error)) error { if c.Opts.BaseImage == "" { return errors.New("baseimage is required") } @@ -104,7 +104,12 @@ func (c *GenerateDockerCmd) GenerateDocker(fs *cs.FileSystem, exp exporter.Expor if !fs.FileExists(ciInput) { log.Printf("Input file %s not found attempting to clone workspace repository...\n", c.Opts.Input) - if err := c.CloneRepository(csClient, fs, git, c.Opts.RepoRoot); err != nil { + client, err := clientFactory() + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + if err := c.CloneRepository(client, fs, git, c.Opts.RepoRoot); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } if !fs.FileExists(ciInput) { diff --git a/cli/cmd/generate_docker_test.go b/cli/cmd/generate_docker_test.go index e4401bc1..8d2ad43e 100644 --- a/cli/cmd/generate_docker_test.go +++ b/cli/cmd/generate_docker_test.go @@ -55,7 +55,8 @@ var _ = Describe("GenerateDocker", func() { Context("the baseimage is not provided", func() { It("should return an error", func() { - err := c.GenerateDocker(memoryFs, mockExporter, mockGit, mockClient) + clientFactory := func() (cmd.Client, error) { return mockClient, nil } + err := c.GenerateDocker(memoryFs, mockExporter, mockGit, clientFactory) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("baseimage is required")) }) @@ -78,7 +79,8 @@ var _ = Describe("GenerateDocker", func() { It("should not return an error", func() { mockExporter.EXPECT().ReadYmlFile(ciYmlPath).Return(&ci.CiYml{}, nil) mockExporter.EXPECT().ExportDockerArtifacts().Return(nil) - err := c.GenerateDocker(memoryFs, mockExporter, mockGit, mockClient) + clientFactory := func() (cmd.Client, error) { return mockClient, nil } + err := c.GenerateDocker(memoryFs, mockExporter, mockGit, clientFactory) Expect(err).To(Not(HaveOccurred())) }) }) diff --git a/int/export_kubernetes_test.go b/int/export_kubernetes_test.go new file mode 100644 index 00000000..622f0520 --- /dev/null +++ b/int/export_kubernetes_test.go @@ -0,0 +1,633 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Sample ci.yml for testing - simulates the flask-demo structure from the blog post +const flaskDemoCiYml = `schemaVersion: v0.2 +prepare: + steps: + - name: install dependencies + command: pip install -r requirements.txt +test: + steps: [] +run: + frontend-service: + steps: + - command: python app.py + plan: 21 + replicas: 1 + isPublic: true + network: + paths: + - port: 3000 + path: / + stripPath: false + ports: + - port: 3000 + isPublic: true + backend-service: + steps: + - command: python backend.py + plan: 21 + replicas: 1 + isPublic: true + network: + paths: + - port: 3000 + path: /api + stripPath: true + ports: + - port: 3000 + isPublic: true +` + +// Simple ci.yml with a single service +const simpleCiYml = `schemaVersion: v0.2 +prepare: + steps: + - name: install + command: npm install +test: + steps: [] +run: + web: + steps: + - command: npm start + plan: 21 + replicas: 1 + isPublic: true + network: + paths: + - port: 8080 + path: / + stripPath: false + ports: + - port: 8080 + isPublic: true +` + +// Legacy ci.yml format with path directly in network +const legacyCiYml = `schemaVersion: v0.2 +prepare: + steps: [] +test: + steps: [] +run: + app: + steps: + - command: ./start.sh + plan: 21 + replicas: 1 + isPublic: true + network: + path: / + stripPath: true +` + +var _ = Describe("Kubernetes Export Integration Tests", func() { + var ( + tempDir string + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "cs-export-test-") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + if tempDir != "" { + Expect(os.RemoveAll(tempDir)).NotTo(HaveOccurred()) + } + }) + + Context("Generate Docker Command", func() { + It("should generate Dockerfiles and docker-compose from flask-demo ci.yml", func() { + By("Creating ci.yml in temp directory") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(flaskDemoCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Running generate docker command") + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate docker output: %s\n", output) + + Expect(output).To(ContainSubstring("docker artifacts created")) + Expect(output).To(ContainSubstring("docker compose up")) + + By("Verifying frontend-service Dockerfile was created") + frontendDockerfile := filepath.Join(tempDir, "export", "frontend-service", "Dockerfile") + Expect(frontendDockerfile).To(BeAnExistingFile()) + content, err := os.ReadFile(frontendDockerfile) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("FROM ubuntu:latest")) + Expect(string(content)).To(ContainSubstring("pip install")) + + By("Verifying frontend-service entrypoint was created") + frontendEntrypoint := filepath.Join(tempDir, "export", "frontend-service", "entrypoint.sh") + Expect(frontendEntrypoint).To(BeAnExistingFile()) + content, err = os.ReadFile(frontendEntrypoint) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("python app.py")) + + By("Verifying backend-service Dockerfile was created") + backendDockerfile := filepath.Join(tempDir, "export", "backend-service", "Dockerfile") + Expect(backendDockerfile).To(BeAnExistingFile()) + + By("Verifying backend-service entrypoint was created") + backendEntrypoint := filepath.Join(tempDir, "export", "backend-service", "entrypoint.sh") + Expect(backendEntrypoint).To(BeAnExistingFile()) + content, err = os.ReadFile(backendEntrypoint) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("python backend.py")) + + By("Verifying docker-compose.yml was created") + dockerComposePath := filepath.Join(tempDir, "export", "docker-compose.yml") + Expect(dockerComposePath).To(BeAnExistingFile()) + content, err = os.ReadFile(dockerComposePath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("frontend-service")) + Expect(string(content)).To(ContainSubstring("backend-service")) + + By("Verifying nginx config was created") + nginxConfigPath := filepath.Join(tempDir, "export", "nginx.conf") + Expect(nginxConfigPath).To(BeAnExistingFile()) + }) + + It("should generate Docker artifacts with different base image", func() { + By("Creating ci.yml in temp directory") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(simpleCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Running generate docker with alpine base image") + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "alpine:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate docker output: %s\n", output) + + Expect(output).To(ContainSubstring("docker artifacts created")) + + By("Verifying Dockerfile uses alpine base image") + dockerfile := filepath.Join(tempDir, "export", "web", "Dockerfile") + Expect(dockerfile).To(BeAnExistingFile()) + content, err := os.ReadFile(dockerfile) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("FROM alpine:latest")) + }) + + It("should fail when baseimage is not provided", func() { + By("Creating ci.yml in temp directory") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(simpleCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Running generate docker without baseimage") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "docker", + "--reporoot", tempDir, + "-i", "ci.yml", + ) + fmt.Printf("Generate docker without baseimage output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(ContainSubstring("baseimage is required")) + }) + + It("should fail when ci.yml does not exist", func() { + By("Running generate docker without ci.yml") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "nonexistent.yml", + ) + fmt.Printf("Generate docker with nonexistent file output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + }) + }) + + Context("Generate Kubernetes Command", func() { + BeforeEach(func() { + By("Creating ci.yml and generating docker artifacts first") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(flaskDemoCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + Expect(output).To(ContainSubstring("docker artifacts created")) + }) + + It("should generate Kubernetes artifacts with registry and namespace", func() { + By("Running generate kubernetes command") + output := intutil.RunCommand( + "generate", "kubernetes", + "--reporoot", tempDir, + "-r", "ghcr.io/codesphere-cloud/flask-demo", + "-p", "cs-demo", + "-i", "ci.yml", + "-o", "export", + "-n", "flask-demo", + "--hostname", "flask-demo.local", + ) + fmt.Printf("Generate kubernetes output: %s\n", output) + + Expect(output).To(ContainSubstring("Kubernetes artifacts export successful")) + Expect(output).To(ContainSubstring("kubectl apply")) + + By("Verifying kubernetes directory was created") + kubernetesDir := filepath.Join(tempDir, "export", "kubernetes") + info, err := os.Stat(kubernetesDir) + Expect(err).NotTo(HaveOccurred()) + Expect(info.IsDir()).To(BeTrue()) + + By("Verifying frontend-service deployment was created") + frontendService := filepath.Join(kubernetesDir, "service-frontend-service.yml") + Expect(frontendService).To(BeAnExistingFile()) + content, err := os.ReadFile(frontendService) + Expect(err).NotTo(HaveOccurred()) + contentStr := string(content) + Expect(contentStr).To(ContainSubstring("kind: Deployment")) + Expect(contentStr).To(ContainSubstring("kind: Service")) + Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) + Expect(contentStr).To(ContainSubstring("ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) + + By("Verifying backend-service deployment was created") + backendService := filepath.Join(kubernetesDir, "service-backend-service.yml") + Expect(backendService).To(BeAnExistingFile()) + content, err = os.ReadFile(backendService) + Expect(err).NotTo(HaveOccurred()) + contentStr = string(content) + Expect(contentStr).To(ContainSubstring("kind: Deployment")) + Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) + Expect(contentStr).To(ContainSubstring("cs-demo-backend-service:latest")) + + By("Verifying ingress was created") + ingressPath := filepath.Join(kubernetesDir, "ingress.yml") + Expect(ingressPath).To(BeAnExistingFile()) + content, err = os.ReadFile(ingressPath) + Expect(err).NotTo(HaveOccurred()) + contentStr = string(content) + Expect(contentStr).To(ContainSubstring("kind: Ingress")) + Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) + Expect(contentStr).To(ContainSubstring("host: flask-demo.local")) + Expect(contentStr).To(ContainSubstring("ingressClassName: nginx")) + }) + + It("should generate Kubernetes artifacts with custom ingress class", func() { + By("Running generate kubernetes with custom ingress class") + output := intutil.RunCommand( + "generate", "kubernetes", + "--reporoot", tempDir, + "-r", "docker.io/myorg", + "-i", "ci.yml", + "-o", "export", + "-n", "production", + "--hostname", "myapp.example.com", + "--ingressClass", "traefik", + ) + fmt.Printf("Generate kubernetes with traefik output: %s\n", output) + + Expect(output).To(ContainSubstring("Kubernetes artifacts export successful")) + + By("Verifying ingress uses traefik class") + ingressPath := filepath.Join(tempDir, "export", "kubernetes", "ingress.yml") + content, err := os.ReadFile(ingressPath) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("ingressClassName: traefik")) + }) + + It("should generate Kubernetes artifacts with pull secret", func() { + By("Running generate kubernetes with pull secret") + output := intutil.RunCommand( + "generate", "kubernetes", + "--reporoot", tempDir, + "-r", "private-registry.io/myorg", + "-i", "ci.yml", + "-o", "export", + "-n", "staging", + "--hostname", "staging.myapp.com", + "--pullsecret", "my-registry-secret", + ) + fmt.Printf("Generate kubernetes with pull secret output: %s\n", output) + + Expect(output).To(ContainSubstring("Kubernetes artifacts export successful")) + + By("Verifying deployment includes pull secret") + frontendService := filepath.Join(tempDir, "export", "kubernetes", "service-frontend-service.yml") + content, err := os.ReadFile(frontendService) + Expect(err).NotTo(HaveOccurred()) + Expect(string(content)).To(ContainSubstring("my-registry-secret")) + }) + + It("should fail when registry is not provided", func() { + By("Running generate kubernetes without registry") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "kubernetes", + "--reporoot", tempDir, + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate kubernetes without registry output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(ContainSubstring("registry is required")) + }) + }) + + Context("Full Export Workflow", func() { + It("should complete the full export workflow from ci.yml to Kubernetes artifacts", func() { + By("Step 1: Creating ci.yml with multi-service application") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(flaskDemoCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Step 2: Generate Docker artifacts") + dockerOutput := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Docker generation output: %s\n", dockerOutput) + Expect(dockerOutput).To(ContainSubstring("docker artifacts created")) + + // Verify Docker artifacts + Expect(filepath.Join(tempDir, "export", "frontend-service", "Dockerfile")).To(BeAnExistingFile()) + Expect(filepath.Join(tempDir, "export", "backend-service", "Dockerfile")).To(BeAnExistingFile()) + Expect(filepath.Join(tempDir, "export", "docker-compose.yml")).To(BeAnExistingFile()) + + By("Step 3: Generate Kubernetes artifacts") + k8sOutput := intutil.RunCommand( + "generate", "kubernetes", + "--reporoot", tempDir, + "-r", "ghcr.io/codesphere-cloud/flask-demo", + "-p", "cs-demo", + "-i", "ci.yml", + "-o", "export", + "-n", "flask-demo-ns", + "--hostname", "colima-cluster", + ) + fmt.Printf("Kubernetes generation output: %s\n", k8sOutput) + Expect(k8sOutput).To(ContainSubstring("Kubernetes artifacts export successful")) + + By("Step 4: Verify all expected files exist") + expectedFiles := []string{ + "export/frontend-service/Dockerfile", + "export/frontend-service/entrypoint.sh", + "export/backend-service/Dockerfile", + "export/backend-service/entrypoint.sh", + "export/docker-compose.yml", + "export/nginx.conf", + "export/Dockerfile.nginx", + "export/kubernetes/service-frontend-service.yml", + "export/kubernetes/service-backend-service.yml", + "export/kubernetes/ingress.yml", + } + + for _, file := range expectedFiles { + fullPath := filepath.Join(tempDir, file) + Expect(fullPath).To(BeAnExistingFile(), fmt.Sprintf("Expected file %s to exist", file)) + } + + By("Step 5: Verify Kubernetes manifests are valid YAML with correct content") + kubernetesDir := filepath.Join(tempDir, "export", "kubernetes") + + // Check ingress contains all services + ingressContent, err := os.ReadFile(filepath.Join(kubernetesDir, "ingress.yml")) + Expect(err).NotTo(HaveOccurred()) + ingressStr := string(ingressContent) + Expect(ingressStr).To(ContainSubstring("host: colima-cluster")) + Expect(ingressStr).To(ContainSubstring("frontend-service")) + Expect(ingressStr).To(ContainSubstring("backend-service")) + Expect(ingressStr).To(ContainSubstring("path: /")) + Expect(ingressStr).To(ContainSubstring("path: /api")) + + // Check frontend service has correct image + frontendContent, err := os.ReadFile(filepath.Join(kubernetesDir, "service-frontend-service.yml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(frontendContent)).To(ContainSubstring("image: ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) + + // Check backend service has correct image + backendContent, err := os.ReadFile(filepath.Join(kubernetesDir, "service-backend-service.yml")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(backendContent)).To(ContainSubstring("image: ghcr.io/codesphere-cloud/flask-demo/cs-demo-backend-service:latest")) + }) + + It("should handle different ci.yml profiles", func() { + By("Creating multiple ci.yml profiles") + // Dev profile + devCiYml := strings.Replace(simpleCiYml, "npm start", "npm run dev", 1) + err := os.WriteFile(filepath.Join(tempDir, "ci.dev.yml"), []byte(devCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + // Prod profile + prodCiYml := strings.Replace(simpleCiYml, "npm start", "npm run prod", 1) + err = os.WriteFile(filepath.Join(tempDir, "ci.prod.yml"), []byte(prodCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Generating Docker artifacts for dev profile") + devDockerOutput := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "node:18", + "-i", "ci.dev.yml", + "-o", "export-dev", + ) + Expect(devDockerOutput).To(ContainSubstring("docker artifacts created")) + + By("Generating Docker artifacts for prod profile") + prodDockerOutput := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "node:18-alpine", + "-i", "ci.prod.yml", + "-o", "export-prod", + ) + Expect(prodDockerOutput).To(ContainSubstring("docker artifacts created")) + + By("Verifying dev and prod have different configurations") + devEntrypoint, err := os.ReadFile(filepath.Join(tempDir, "export-dev", "web", "entrypoint.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(devEntrypoint)).To(ContainSubstring("npm run dev")) + + prodEntrypoint, err := os.ReadFile(filepath.Join(tempDir, "export-prod", "web", "entrypoint.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(prodEntrypoint)).To(ContainSubstring("npm run prod")) + + devDockerfile, err := os.ReadFile(filepath.Join(tempDir, "export-dev", "web", "Dockerfile")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(devDockerfile)).To(ContainSubstring("FROM node:18")) + + prodDockerfile, err := os.ReadFile(filepath.Join(tempDir, "export-prod", "web", "Dockerfile")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(prodDockerfile)).To(ContainSubstring("FROM node:18-alpine")) + }) + }) + + Context("Legacy ci.yml Format Support", func() { + It("should handle legacy ci.yml with path directly in network", func() { + By("Creating legacy format ci.yml") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(legacyCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Generating Docker artifacts") + dockerOutput := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Legacy Docker generation output: %s\n", dockerOutput) + Expect(dockerOutput).To(ContainSubstring("docker artifacts created")) + + By("Generating Kubernetes artifacts") + k8sOutput := intutil.RunCommand( + "generate", "kubernetes", + "--reporoot", tempDir, + "-r", "docker.io/myorg", + "-i", "ci.yml", + "-o", "export", + "-n", "legacy-app", + "--hostname", "legacy.local", + ) + fmt.Printf("Legacy Kubernetes generation output: %s\n", k8sOutput) + Expect(k8sOutput).To(ContainSubstring("Kubernetes artifacts export successful")) + + By("Verifying artifacts were created correctly") + Expect(filepath.Join(tempDir, "export", "app", "Dockerfile")).To(BeAnExistingFile()) + Expect(filepath.Join(tempDir, "export", "kubernetes", "service-app.yml")).To(BeAnExistingFile()) + Expect(filepath.Join(tempDir, "export", "kubernetes", "ingress.yml")).To(BeAnExistingFile()) + }) + }) + + Context("Environment Variables in Docker Artifacts", func() { + It("should include environment variables in generated artifacts", func() { + By("Creating ci.yml") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(simpleCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Generating Docker artifacts with environment variables") + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "node:18", + "-i", "ci.yml", + "-o", "export", + "-e", "NODE_ENV=production", + "-e", "API_URL=https://api.example.com", + ) + fmt.Printf("Docker generation with envs output: %s\n", output) + Expect(output).To(ContainSubstring("docker artifacts created")) + + By("Verifying docker-compose contains environment variables") + dockerCompose, err := os.ReadFile(filepath.Join(tempDir, "export", "docker-compose.yml")) + Expect(err).NotTo(HaveOccurred()) + content := string(dockerCompose) + Expect(content).To(ContainSubstring("NODE_ENV")) + Expect(content).To(ContainSubstring("API_URL")) + }) + }) + + Context("Force Overwrite Behavior", func() { + It("should overwrite existing files when --force is specified", func() { + By("Creating ci.yml") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(simpleCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("First generation") + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + Expect(output).To(ContainSubstring("docker artifacts created")) + + By("Second generation with --force") + output = intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "alpine:latest", + "-i", "ci.yml", + "-o", "export", + "--force", + ) + Expect(output).To(ContainSubstring("docker artifacts created")) + + By("Verifying files were overwritten with new base image") + dockerfile, err := os.ReadFile(filepath.Join(tempDir, "export", "web", "Dockerfile")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(dockerfile)).To(ContainSubstring("FROM alpine:latest")) + }) + }) + + Context("Generate Command Help", func() { + It("should display help for generate docker command", func() { + output := intutil.RunCommand("generate", "docker", "--help") + fmt.Printf("Generate docker help: %s\n", output) + + Expect(output).To(ContainSubstring("generated artifacts")) + Expect(output).To(ContainSubstring("-b, --baseimage")) + Expect(output).To(ContainSubstring("-i, --input")) + Expect(output).To(ContainSubstring("-o, --output")) + }) + + It("should display help for generate kubernetes command", func() { + output := intutil.RunCommand("generate", "kubernetes", "--help") + fmt.Printf("Generate kubernetes help: %s\n", output) + + Expect(output).To(ContainSubstring("generated artifacts")) + Expect(output).To(ContainSubstring("-r, --registry")) + Expect(output).To(ContainSubstring("-p, --imagePrefix")) + Expect(output).To(ContainSubstring("-n, --namespace")) + Expect(output).To(ContainSubstring("--hostname")) + Expect(output).To(ContainSubstring("--pullsecret")) + Expect(output).To(ContainSubstring("--ingressClass")) + }) + + It("should display help for generate images command", func() { + output := intutil.RunCommand("generate", "images", "--help") + fmt.Printf("Generate images help: %s\n", output) + + Expect(output).To(ContainSubstring("generated images will be pushed")) + Expect(output).To(ContainSubstring("-r, --registry")) + Expect(output).To(ContainSubstring("-p, --imagePrefix")) + }) + }) +}) diff --git a/int/util/workspace.go b/int/util/workspace.go index c6269cc7..ce43214c 100644 --- a/int/util/workspace.go +++ b/int/util/workspace.go @@ -5,9 +5,11 @@ package util import ( "bytes" + "fmt" "log" "os" "os/exec" + "path/filepath" "regexp" "strings" "time" @@ -66,6 +68,43 @@ func RunCommandWithExitCode(args ...string) (string, int) { return outputBuffer.String(), exitCode } +// RunCommandInDir runs a command from a specific working directory. +// The cs binary path is resolved relative to the int/ directory. +func RunCommandInDir(dir string, args ...string) string { + output, _ := RunCommandInDirWithExitCode(dir, args...) + return output +} + +// RunCommandInDirWithExitCode runs a command from a specific working directory and returns exit code. +func RunCommandInDirWithExitCode(dir string, args ...string) (string, int) { + // Get absolute path to cs binary (relative to int/ directory) + csBinary, err := filepath.Abs("../cs") + if err != nil { + return fmt.Sprintf("failed to get cs binary path: %v", err), -1 + } + + command := exec.Command(csBinary, args...) + command.Dir = dir + command.Env = os.Environ() + + var outputBuffer bytes.Buffer + command.Stdout = &outputBuffer + command.Stderr = &outputBuffer + + err = command.Run() + + exitCode := 0 + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + exitCode = exitError.ExitCode() + } else { + exitCode = -1 + } + } + + return outputBuffer.String(), exitCode +} + func ExtractWorkspaceId(output string) string { re := regexp.MustCompile(`ID:\s*(\d+)`) matches := re.FindStringSubmatch(output) From 955167b86634d5f6f227785ddc164f820e9af6cf Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:11:56 +0100 Subject: [PATCH 02/16] fix: merge error --- cli/cmd/generate_docker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/cmd/generate_docker.go b/cli/cmd/generate_docker.go index 0d71684b..7857ad5d 100644 --- a/cli/cmd/generate_docker.go +++ b/cli/cmd/generate_docker.go @@ -35,7 +35,7 @@ func (c *GenerateDockerCmd) RunE(cc *cobra.Command, args []string) error { exporter := exporter.NewExporterService(fs, c.Opts.Output, c.Opts.BaseImage, c.Opts.Envs, c.Opts.RepoRoot, c.Opts.Force) clientFactory := func() (Client, error) { - return NewClient(c.Opts.GlobalOptions) + return NewClient(*c.Opts.GlobalOptions) } if err := c.GenerateDocker(fs, exporter, gitSvc, clientFactory); err != nil { From 3f57bdc8f1aa9203f3311883454f70293d3e169b Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:13:39 +0000 Subject: [PATCH 03/16] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 12 ++++++------ pkg/tmpl/NOTICE | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/NOTICE b/NOTICE index c861e91c..16e5f1ed 100644 --- a/NOTICE +++ b/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: code.gitea.io/sdk/gitea -Version: v0.22.1 +Version: v0.23.2 License: MIT -License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.22.1/gitea/LICENSE +License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.23.2/gitea/LICENSE ---------- Module: dario.cat/mergo @@ -365,9 +365,9 @@ License URL: https://github.com/xanzy/ssh-agent/blob/v0.3.3/LICENSE ---------- Module: gitlab.com/gitlab-org/api/client-go -Version: v1.11.0 +Version: v1.39.0 License: Apache-2.0 -License URL: https://gitlab.com/gitlab-org/api/blob/client-go/v1.11.0/client-go/LICENSE +License URL: https://gitlab.com/gitlab-org/api/blob/client-go/v1.39.0/client-go/LICENSE ---------- Module: go.yaml.in/yaml/v2 @@ -401,9 +401,9 @@ License URL: https://cs.opensource.google/go/x/net/+/v0.50.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/errgroup diff --git a/pkg/tmpl/NOTICE b/pkg/tmpl/NOTICE index c861e91c..16e5f1ed 100644 --- a/pkg/tmpl/NOTICE +++ b/pkg/tmpl/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: code.gitea.io/sdk/gitea -Version: v0.22.1 +Version: v0.23.2 License: MIT -License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.22.1/gitea/LICENSE +License URL: https://gitea.com/gitea/go-sdk/src/tag/gitea/v0.23.2/gitea/LICENSE ---------- Module: dario.cat/mergo @@ -365,9 +365,9 @@ License URL: https://github.com/xanzy/ssh-agent/blob/v0.3.3/LICENSE ---------- Module: gitlab.com/gitlab-org/api/client-go -Version: v1.11.0 +Version: v1.39.0 License: Apache-2.0 -License URL: https://gitlab.com/gitlab-org/api/blob/client-go/v1.11.0/client-go/LICENSE +License URL: https://gitlab.com/gitlab-org/api/blob/client-go/v1.39.0/client-go/LICENSE ---------- Module: go.yaml.in/yaml/v2 @@ -401,9 +401,9 @@ License URL: https://cs.opensource.google/go/x/net/+/v0.50.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/errgroup From 09c2f2337dbd14c409b40c3e453089f716edfdbe Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:37:44 +0100 Subject: [PATCH 04/16] ref: streamline file path handling in Docker and Kubernetes commands --- cli/cmd/generate_docker.go | 6 ++---- cli/cmd/generate_images.go | 5 ++--- cli/cmd/generate_images_test.go | 4 +--- cli/cmd/generate_kubernetes.go | 4 ++-- cli/cmd/generate_kubernetes_test.go | 6 +----- int/export_kubernetes_test.go | 32 +++++++++++++++++++++++++++++ pkg/exporter/exporter.go | 10 ++------- pkg/exporter/exporter_test.go | 16 +++++++-------- 8 files changed, 50 insertions(+), 33 deletions(-) diff --git a/cli/cmd/generate_docker.go b/cli/cmd/generate_docker.go index 7857ad5d..9ce62bb2 100644 --- a/cli/cmd/generate_docker.go +++ b/cli/cmd/generate_docker.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "log" - "path" "github.com/codesphere-cloud/cs-go/pkg/cs" "github.com/codesphere-cloud/cs-go/pkg/exporter" @@ -28,8 +27,7 @@ type GenerateDockerOpts struct { } func (c *GenerateDockerCmd) RunE(cc *cobra.Command, args []string) error { - log.Println(c.Opts.Force) - fs := cs.NewOSFileSystem(".") + fs := cs.NewOSFileSystem(c.Opts.RepoRoot) gitSvc := git.NewGitService(fs) exporter := exporter.NewExporterService(fs, c.Opts.Output, c.Opts.BaseImage, c.Opts.Envs, c.Opts.RepoRoot, c.Opts.Force) @@ -100,7 +98,7 @@ func (c *GenerateDockerCmd) GenerateDocker(fs *cs.FileSystem, exp exporter.Expor return errors.New("baseimage is required") } - ciInput := path.Join(c.Opts.RepoRoot, c.Opts.Input) + ciInput := c.Opts.Input if !fs.FileExists(ciInput) { log.Printf("Input file %s not found attempting to clone workspace repository...\n", c.Opts.Input) diff --git a/cli/cmd/generate_images.go b/cli/cmd/generate_images.go index 2df7b8b2..7c8c6e52 100644 --- a/cli/cmd/generate_images.go +++ b/cli/cmd/generate_images.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "log" - "path" "github.com/codesphere-cloud/cs-go/pkg/cs" "github.com/codesphere-cloud/cs-go/pkg/exporter" @@ -28,7 +27,7 @@ type GenerateImagesOpts struct { } func (c *GenerateImagesCmd) RunE(_ *cobra.Command, args []string) error { - fs := cs.NewOSFileSystem(".") + fs := cs.NewOSFileSystem(c.Opts.RepoRoot) exporter := exporter.NewExporterService(fs, c.Opts.Output, "", []string{}, c.Opts.RepoRoot, c.Opts.Force) if err := c.GenerateImages(fs, exporter); err != nil { @@ -69,7 +68,7 @@ func AddGenerateImagesCmd(generate *cobra.Command, opts *GenerateOpts) { } func (c *GenerateImagesCmd) GenerateImages(fs *cs.FileSystem, exp exporter.Exporter) error { - ciInput := path.Join(c.Opts.RepoRoot, c.Opts.Input) + ciInput := c.Opts.Input if c.Opts.Registry == "" { return errors.New("registry is required") } diff --git a/cli/cmd/generate_images_test.go b/cli/cmd/generate_images_test.go index 577c2efe..01977fd2 100644 --- a/cli/cmd/generate_images_test.go +++ b/cli/cmd/generate_images_test.go @@ -5,7 +5,6 @@ package cmd_test import ( "context" - "path" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -75,8 +74,7 @@ var _ = Describe("GenerateImages", func() { Expect(err).To(Not(HaveOccurred())) }) It("should not return an error", func() { - ciYmlPath := path.Join(c.Opts.RepoRoot, "ci.dev.yml") - mockExporter.EXPECT().ReadYmlFile(ciYmlPath).Return(&ci.CiYml{}, nil) + mockExporter.EXPECT().ReadYmlFile("ci.dev.yml").Return(&ci.CiYml{}, nil) mockExporter.EXPECT().ExportImages(context.Background(), "my-registry.com", "").Return(nil) err := c.GenerateImages(memoryFs, mockExporter) Expect(err).To(Not(HaveOccurred())) diff --git a/cli/cmd/generate_kubernetes.go b/cli/cmd/generate_kubernetes.go index c8d68a5a..87f66c7c 100644 --- a/cli/cmd/generate_kubernetes.go +++ b/cli/cmd/generate_kubernetes.go @@ -31,7 +31,7 @@ type GenerateKubernetesOpts struct { } func (c *GenerateKubernetesCmd) RunE(_ *cobra.Command, args []string) error { - fs := cs.NewOSFileSystem(".") + fs := cs.NewOSFileSystem(c.Opts.RepoRoot) exporter := exporter.NewExporterService(fs, c.Opts.Output, "", []string{}, c.Opts.RepoRoot, c.Opts.Force) if err := c.GenerateKubernetes(fs, exporter); err != nil { @@ -88,7 +88,7 @@ func AddGenerateKubernetesCmd(generate *cobra.Command, opts *GenerateOpts) { } func (c *GenerateKubernetesCmd) GenerateKubernetes(fs *cs.FileSystem, exp exporter.Exporter) error { - ciInput := path.Join(c.Opts.RepoRoot, c.Opts.Input) + ciInput := c.Opts.Input if c.Opts.Registry == "" { return errors.New("registry is required") } diff --git a/cli/cmd/generate_kubernetes_test.go b/cli/cmd/generate_kubernetes_test.go index 986d955c..77bd955a 100644 --- a/cli/cmd/generate_kubernetes_test.go +++ b/cli/cmd/generate_kubernetes_test.go @@ -4,8 +4,6 @@ package cmd_test import ( - "path" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -61,7 +59,6 @@ var _ = Describe("GenerateKubernetes", func() { }) Context("A new input file and registry is provided", func() { - var ciYmlPath string BeforeEach(func() { c.Opts.Registry = "my-registry.com" }) @@ -71,8 +68,7 @@ var _ = Describe("GenerateKubernetes", func() { Expect(err).To(Not(HaveOccurred())) }) It("should not return an error", func() { - ciYmlPath = path.Join(c.Opts.RepoRoot, "ci.dev.yml") - mockExporter.EXPECT().ReadYmlFile(ciYmlPath).Return(&ci.CiYml{}, nil) + mockExporter.EXPECT().ReadYmlFile("ci.dev.yml").Return(&ci.CiYml{}, nil) mockExporter.EXPECT().ExportKubernetesArtifacts("my-registry.com", "", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) err := c.GenerateKubernetes(memoryFs, mockExporter) Expect(err).To(Not(HaveOccurred())) diff --git a/int/export_kubernetes_test.go b/int/export_kubernetes_test.go index 622f0520..58306623 100644 --- a/int/export_kubernetes_test.go +++ b/int/export_kubernetes_test.go @@ -364,6 +364,38 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { }) }) + Context("Generate Images Command", func() { + BeforeEach(func() { + By("Creating ci.yml and generating docker artifacts first") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(simpleCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + output := intutil.RunCommand( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + Expect(output).To(ContainSubstring("docker artifacts created")) + }) + + It("should fail when registry is not provided for generate images", func() { + By("Running generate images without registry") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "images", + "--reporoot", tempDir, + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate images without registry output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(ContainSubstring("registry is required")) + }) + }) + Context("Full Export Workflow", func() { It("should complete the full export workflow from ci.yml to Kubernetes artifacts", func() { By("Step 1: Creating ci.yml with multi-service application") diff --git a/pkg/exporter/exporter.go b/pkg/exporter/exporter.go index c7cb21d1..821d41b3 100644 --- a/pkg/exporter/exporter.go +++ b/pkg/exporter/exporter.go @@ -58,13 +58,11 @@ func (e *ExporterService) ReadYmlFile(path string) (*ci.CiYml, error) { } func (e *ExporterService) GetExportDir() string { - return filepath.Join(e.repoRoot, e.outputPath) - + return e.outputPath } func (e *ExporterService) GetKubernetesDir() string { - return filepath.Join(e.repoRoot, e.outputPath, "kubernetes") - + return filepath.Join(e.outputPath, "kubernetes") } // ExportDockerArtifacts exports Docker artifacts based on the provided input path, output path, base image, and environment variables. @@ -90,9 +88,6 @@ func (e *ExporterService) ExportDockerArtifacts() error { if err != nil { return fmt.Errorf("error creating dockerfile for service %s: %w", serviceName, err) } - log.Println(e.outputPath) - log.Println(e.GetExportDir()) - log.Println(filepath.Join(e.GetExportDir(), serviceName)) err = e.fs.WriteFile(filepath.Join(e.GetExportDir(), serviceName), "Dockerfile", dockerfile, e.force) if err != nil { return fmt.Errorf("error writing dockerfile for service %s: %w", serviceName, err) @@ -239,7 +234,6 @@ func (e *ExporterService) ExportImages(ctx context.Context, registry string, ima // CreateImageTag creates a Docker image tag from the registry, image prefix and service name. // It returns the full image tag in the format: /-:latest. func (e *ExporterService) CreateImageTag(registry string, imagePrefix string, serviceName string) (string, error) { - log.Println(imagePrefix) if imagePrefix == "" { tag, err := url.JoinPath(registry, fmt.Sprintf("%s:latest", serviceName)) if err != nil { diff --git a/pkg/exporter/exporter_test.go b/pkg/exporter/exporter_test.go index d2613c3d..e0b5bd82 100644 --- a/pkg/exporter/exporter_test.go +++ b/pkg/exporter/exporter_test.go @@ -71,20 +71,20 @@ var _ = Describe("GenerateDockerfile", func() { err = e.ExportDockerArtifacts() Expect(err).To(Not(HaveOccurred())) - Expect(memoryFs.DirExists("workspace-repo/export")).To(BeTrue()) - Expect(memoryFs.FileExists("workspace-repo/export/docker-compose.yml")).To(BeTrue()) + Expect(memoryFs.DirExists("./export")).To(BeTrue()) + Expect(memoryFs.FileExists("./export/docker-compose.yml")).To(BeTrue()) - Expect(memoryFs.DirExists("workspace-repo/export/frontend")).To(BeTrue()) - Expect(memoryFs.FileExists("workspace-repo/export/frontend/Dockerfile")).To(BeTrue()) - Expect(memoryFs.FileExists("workspace-repo/export/frontend/entrypoint.sh")).To(BeTrue()) + Expect(memoryFs.DirExists("./export/frontend")).To(BeTrue()) + Expect(memoryFs.FileExists("./export/frontend/Dockerfile")).To(BeTrue()) + Expect(memoryFs.FileExists("./export/frontend/entrypoint.sh")).To(BeTrue()) err = e.ExportKubernetesArtifacts("registry", "image", mock.Anything, mock.Anything, mock.Anything, mock.Anything) Expect(err).To(Not(HaveOccurred())) - Expect(memoryFs.DirExists("workspace-repo/export/kubernetes")).To(BeTrue()) - Expect(memoryFs.FileExists("workspace-repo/export/kubernetes/ingress.yml")).To(BeTrue()) + Expect(memoryFs.DirExists("./export/kubernetes")).To(BeTrue()) + Expect(memoryFs.FileExists("./export/kubernetes/ingress.yml")).To(BeTrue()) - Expect(memoryFs.FileExists("workspace-repo/export/kubernetes/service-frontend.yml")).To(BeTrue()) + Expect(memoryFs.FileExists("./export/kubernetes/service-frontend.yml")).To(BeTrue()) }) }) }) From 483d1c454f256653cff97e4e10e7eeedb37f80db Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:12:15 +0100 Subject: [PATCH 05/16] test: add integration tests for invalid and empty ci.yml scenarios --- int/export_kubernetes_test.go | 52 +++++++++++++++++++++++++++++++++++ int/util/workspace.go | 39 -------------------------- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/int/export_kubernetes_test.go b/int/export_kubernetes_test.go index 58306623..c56d493e 100644 --- a/int/export_kubernetes_test.go +++ b/int/export_kubernetes_test.go @@ -96,6 +96,19 @@ run: stripPath: true ` +const invalidYaml = `this is not valid yaml: + - missing proper structure + broken: [indentation +` + +const emptyCiYml = `schemaVersion: v0.2 +prepare: + steps: [] +test: + steps: [] +run: {} +` + var _ = Describe("Kubernetes Export Integration Tests", func() { var ( tempDir string @@ -228,6 +241,45 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(exitCode).NotTo(Equal(0)) }) + + It("should fail with invalid YAML content", func() { + By("Creating invalid ci.yml") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(invalidYaml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Running generate docker with invalid YAML") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate docker with invalid YAML output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + }) + + It("should fail with ci.yml with no services", func() { + By("Creating ci.yml with empty run section") + ciYmlPath := filepath.Join(tempDir, "ci.yml") + err := os.WriteFile(ciYmlPath, []byte(emptyCiYml), 0644) + Expect(err).NotTo(HaveOccurred()) + + By("Running generate docker with empty services") + output, exitCode := intutil.RunCommandWithExitCode( + "generate", "docker", + "--reporoot", tempDir, + "-b", "ubuntu:latest", + "-i", "ci.yml", + "-o", "export", + ) + fmt.Printf("Generate docker with empty services output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(ContainSubstring("at least one service is required")) + }) }) Context("Generate Kubernetes Command", func() { diff --git a/int/util/workspace.go b/int/util/workspace.go index ce43214c..c6269cc7 100644 --- a/int/util/workspace.go +++ b/int/util/workspace.go @@ -5,11 +5,9 @@ package util import ( "bytes" - "fmt" "log" "os" "os/exec" - "path/filepath" "regexp" "strings" "time" @@ -68,43 +66,6 @@ func RunCommandWithExitCode(args ...string) (string, int) { return outputBuffer.String(), exitCode } -// RunCommandInDir runs a command from a specific working directory. -// The cs binary path is resolved relative to the int/ directory. -func RunCommandInDir(dir string, args ...string) string { - output, _ := RunCommandInDirWithExitCode(dir, args...) - return output -} - -// RunCommandInDirWithExitCode runs a command from a specific working directory and returns exit code. -func RunCommandInDirWithExitCode(dir string, args ...string) (string, int) { - // Get absolute path to cs binary (relative to int/ directory) - csBinary, err := filepath.Abs("../cs") - if err != nil { - return fmt.Sprintf("failed to get cs binary path: %v", err), -1 - } - - command := exec.Command(csBinary, args...) - command.Dir = dir - command.Env = os.Environ() - - var outputBuffer bytes.Buffer - command.Stdout = &outputBuffer - command.Stderr = &outputBuffer - - err = command.Run() - - exitCode := 0 - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } else { - exitCode = -1 - } - } - - return outputBuffer.String(), exitCode -} - func ExtractWorkspaceId(output string) string { re := regexp.MustCompile(`ID:\s*(\d+)`) matches := re.FindStringSubmatch(output) From f54ac3f7584b7f77137d87eea3de4229d07042f2 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:57:17 +0100 Subject: [PATCH 06/16] fix: integration tests --- api/openapi_client/client.go | 172 ++++++++++++++++++----------------- int/integration_test.go | 20 ++-- int/util/test_helpers.go | 2 +- 3 files changed, 102 insertions(+), 92 deletions(-) diff --git a/api/openapi_client/client.go b/api/openapi_client/client.go index 85a2f0e4..7f3f1405 100644 --- a/api/openapi_client/client.go +++ b/api/openapi_client/client.go @@ -9,7 +9,6 @@ API version: 0.1.0 // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. - package openapi_client import ( @@ -33,14 +32,13 @@ import ( "strings" "time" "unicode/utf8" - ) var ( JsonCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:[^;]+\+)?json)`) XmlCheck = regexp.MustCompile(`(?i:(?:application|text)/(?:[^;]+\+)?xml)`) queryParamSplit = regexp.MustCompile(`(^|&)([^&]+)`) - queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" ) + queryDescape = strings.NewReplacer("%5B", "[", "%5D", "]") ) // APIClient manages communication with the Codesphere Public API API v0.1.0 @@ -139,23 +137,35 @@ func typeCheckParameter(obj interface{}, expected string, name string) error { return nil } -func parameterValueToString( obj interface{}, key string ) string { +func parameterValueToString(obj interface{}, key string) string { if reflect.TypeOf(obj).Kind() != reflect.Ptr { if actualObj, ok := obj.(interface{ GetActualInstanceValue() interface{} }); ok { - return fmt.Sprintf("%v", actualObj.GetActualInstanceValue()) + return formatValue(actualObj.GetActualInstanceValue()) } - return fmt.Sprintf("%v", obj) + return formatValue(obj) } - var param,ok = obj.(MappedNullable) + var param, ok = obj.(MappedNullable) if !ok { return "" } - dataMap,err := param.ToMap() + dataMap, err := param.ToMap() if err != nil { return "" } - return fmt.Sprintf("%v", dataMap[key]) + return formatValue(dataMap[key]) +} + +// formatValue converts a value to string, avoiding scientific notation for floats +func formatValue(obj interface{}) string { + switch v := obj.(type) { + case float32: + return fmt.Sprintf("%.0f", v) + case float64: + return fmt.Sprintf("%.0f", v) + default: + return fmt.Sprintf("%v", obj) + } } // parameterAddToHeaderOrQuery adds the provided object to the request header or url query @@ -167,85 +177,85 @@ func parameterAddToHeaderOrQuery(headerOrQueryParams interface{}, keyPrefix stri value = "null" } else { switch v.Kind() { - case reflect.Invalid: - value = "invalid" + case reflect.Invalid: + value = "invalid" - case reflect.Struct: - if t,ok := obj.(MappedNullable); ok { - dataMap,err := t.ToMap() - if err != nil { - return - } - parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefix, dataMap, style, collectionType) - return - } - if t, ok := obj.(time.Time); ok { - parameterAddToHeaderOrQuery(headerOrQueryParams, keyPrefix, t.Format(time.RFC3339Nano), style, collectionType) - return - } - value = v.Type().String() + " value" - case reflect.Slice: - var indValue = reflect.ValueOf(obj) - if indValue == reflect.ValueOf(nil) { + case reflect.Struct: + if t, ok := obj.(MappedNullable); ok { + dataMap, err := t.ToMap() + if err != nil { return } - var lenIndValue = indValue.Len() - for i:=0;i Date: Thu, 5 Mar 2026 09:34:04 +0000 Subject: [PATCH 07/16] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 32 ++++++++++++++++---------------- pkg/tmpl/NOTICE | 32 ++++++++++++++++---------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/NOTICE b/NOTICE index 16e5f1ed..ef30fb14 100644 --- a/NOTICE +++ b/NOTICE @@ -59,9 +59,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE ---------- Module: github.com/cloudflare/circl -Version: v1.6.2 +Version: v1.6.3 License: BSD-3-Clause -License URL: https://github.com/cloudflare/circl/blob/v1.6.2/LICENSE +License URL: https://github.com/cloudflare/circl/blob/v1.6.3/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/tmpl @@ -131,9 +131,9 @@ License URL: https://github.com/go-git/go-billy/blob/v5.8.0/LICENSE ---------- Module: github.com/go-git/go-git/v5 -Version: v5.16.5 +Version: v5.17.0 License: Apache-2.0 -License URL: https://github.com/go-git/go-git/blob/v5.16.5/LICENSE +License URL: https://github.com/go-git/go-git/blob/v5.17.0/LICENSE ---------- Module: github.com/go-logr/logr @@ -467,39 +467,39 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: k8s.io/api -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/api/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/api/blob/v0.35.2/LICENSE ---------- Module: k8s.io/apimachinery/pkg -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/LICENSE ---------- Module: k8s.io/apimachinery/third_party/forked/golang/reflect -Version: v0.35.1 +Version: v0.35.2 License: BSD-3-Clause -License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.1/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/third_party/forked/golang/LICENSE ---------- Module: k8s.io/cli-runtime/pkg/printers -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.2/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/golang/template -Version: v0.35.1 +Version: v0.35.2 License: BSD-3-Clause -License URL: https://github.com/kubernetes/client-go/blob/v0.35.1/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/third_party/forked/golang/LICENSE ---------- Module: k8s.io/client-go/util/jsonpath -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/client-go/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/LICENSE ---------- Module: k8s.io/klog/v2 diff --git a/pkg/tmpl/NOTICE b/pkg/tmpl/NOTICE index 16e5f1ed..ef30fb14 100644 --- a/pkg/tmpl/NOTICE +++ b/pkg/tmpl/NOTICE @@ -59,9 +59,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.3.0/LICENSE ---------- Module: github.com/cloudflare/circl -Version: v1.6.2 +Version: v1.6.3 License: BSD-3-Clause -License URL: https://github.com/cloudflare/circl/blob/v1.6.2/LICENSE +License URL: https://github.com/cloudflare/circl/blob/v1.6.3/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go/pkg/tmpl @@ -131,9 +131,9 @@ License URL: https://github.com/go-git/go-billy/blob/v5.8.0/LICENSE ---------- Module: github.com/go-git/go-git/v5 -Version: v5.16.5 +Version: v5.17.0 License: Apache-2.0 -License URL: https://github.com/go-git/go-git/blob/v5.16.5/LICENSE +License URL: https://github.com/go-git/go-git/blob/v5.17.0/LICENSE ---------- Module: github.com/go-logr/logr @@ -467,39 +467,39 @@ License URL: https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE ---------- Module: k8s.io/api -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/api/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/api/blob/v0.35.2/LICENSE ---------- Module: k8s.io/apimachinery/pkg -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/LICENSE ---------- Module: k8s.io/apimachinery/third_party/forked/golang/reflect -Version: v0.35.1 +Version: v0.35.2 License: BSD-3-Clause -License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.1/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/third_party/forked/golang/LICENSE ---------- Module: k8s.io/cli-runtime/pkg/printers -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.2/LICENSE ---------- Module: k8s.io/client-go/third_party/forked/golang/template -Version: v0.35.1 +Version: v0.35.2 License: BSD-3-Clause -License URL: https://github.com/kubernetes/client-go/blob/v0.35.1/third_party/forked/golang/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/third_party/forked/golang/LICENSE ---------- Module: k8s.io/client-go/util/jsonpath -Version: v0.35.1 +Version: v0.35.2 License: Apache-2.0 -License URL: https://github.com/kubernetes/client-go/blob/v0.35.1/LICENSE +License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/LICENSE ---------- Module: k8s.io/klog/v2 From f287fd736f4ad205c0104c69d21d9034109137a4 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:09:19 +0100 Subject: [PATCH 08/16] feat: enhance Kubernetes integration tests with validation functions for YAML and Dockerfile structures --- go.mod | 2 +- int/export_kubernetes_test.go | 266 ++++++++++++++++++++++++++++------ 2 files changed, 219 insertions(+), 49 deletions(-) diff --git a/go.mod b/go.mod index cde8d5f5..f2042e61 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 gopkg.in/validator.v2 v2.0.1 k8s.io/apimachinery v0.35.2 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -499,7 +500,6 @@ require ( sigs.k8s.io/kind v0.31.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect ) diff --git a/int/export_kubernetes_test.go b/int/export_kubernetes_test.go index c56d493e..0f608e71 100644 --- a/int/export_kubernetes_test.go +++ b/int/export_kubernetes_test.go @@ -4,6 +4,7 @@ package int_test import ( + "bytes" "fmt" "os" "path/filepath" @@ -12,9 +13,14 @@ import ( intutil "github.com/codesphere-cloud/cs-go/int/util" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + apps "k8s.io/api/apps/v1" + core "k8s.io/api/core/v1" + networking "k8s.io/api/networking/v1" + sigsyaml "sigs.k8s.io/yaml" ) -// Sample ci.yml for testing - simulates the flask-demo structure from the blog post +// Sample ci.yml for testing - simulates the flask-demo structure. +// Reference: https://github.com/codesphere-cloud/flask-demo const flaskDemoCiYml = `schemaVersion: v0.2 prepare: steps: @@ -109,6 +115,118 @@ test: run: {} ` +// splitYAMLDocuments splits a multi-document YAML byte slice on "---" separators. +func splitYAMLDocuments(content []byte) [][]byte { + docs := bytes.Split(content, []byte("\n---\n")) + var result [][]byte + for _, doc := range docs { + trimmed := bytes.TrimSpace(doc) + if len(trimmed) > 0 { + result = append(result, trimmed) + } + } + return result +} + +// unmarshalDeployment unmarshals YAML into a Kubernetes Deployment and validates its structure. +func unmarshalDeployment(yamlContent []byte) *apps.Deployment { + GinkgoHelper() + deployment := &apps.Deployment{} + err := sigsyaml.Unmarshal(yamlContent, deployment) + Expect(err).NotTo(HaveOccurred(), "Failed to unmarshal Deployment YAML") + Expect(deployment.Kind).To(Equal("Deployment"), "Expected kind Deployment") + Expect(deployment.APIVersion).To(Equal("apps/v1"), "Expected apiVersion apps/v1") + Expect(deployment.Name).NotTo(BeEmpty(), "Deployment name should not be empty") + return deployment +} + +// unmarshalService unmarshals YAML into a Kubernetes Service and validates its structure. +func unmarshalService(yamlContent []byte) *core.Service { + GinkgoHelper() + service := &core.Service{} + err := sigsyaml.Unmarshal(yamlContent, service) + Expect(err).NotTo(HaveOccurred(), "Failed to unmarshal Service YAML") + Expect(service.Kind).To(Equal("Service"), "Expected kind Service") + Expect(service.APIVersion).To(Equal("v1"), "Expected apiVersion v1") + Expect(service.Name).NotTo(BeEmpty(), "Service name should not be empty") + return service +} + +// unmarshalIngress unmarshals YAML into a Kubernetes Ingress and validates its structure. +func unmarshalIngress(yamlContent []byte) *networking.Ingress { + GinkgoHelper() + ingress := &networking.Ingress{} + err := sigsyaml.Unmarshal(yamlContent, ingress) + Expect(err).NotTo(HaveOccurred(), "Failed to unmarshal Ingress YAML") + Expect(ingress.Kind).To(Equal("Ingress"), "Expected kind Ingress") + Expect(ingress.APIVersion).To(Equal("networking.k8s.io/v1"), "Expected apiVersion networking.k8s.io/v1") + Expect(ingress.Name).NotTo(BeEmpty(), "Ingress name should not be empty") + return ingress +} + +// validateServiceFile validates a K8s service YAML file containing a Deployment and Service. +func validateServiceFile(content []byte) (*apps.Deployment, *core.Service) { + GinkgoHelper() + docs := splitYAMLDocuments(content) + Expect(docs).To(HaveLen(2), "Service file should contain a Deployment and a Service document") + return unmarshalDeployment(docs[0]), unmarshalService(docs[1]) +} + +// validateDockerfile performs basic structural validation of a Dockerfile. +func validateDockerfile(content string) { + GinkgoHelper() + lines := strings.Split(strings.TrimSpace(content), "\n") + Expect(len(lines)).To(BeNumerically(">", 0), "Dockerfile should not be empty") + + validInstructions := map[string]bool{ + "FROM": true, "RUN": true, "CMD": true, "LABEL": true, + "EXPOSE": true, "ENV": true, "ADD": true, "COPY": true, + "ENTRYPOINT": true, "VOLUME": true, "USER": true, + "WORKDIR": true, "ARG": true, "ONBUILD": true, + "STOPSIGNAL": true, "HEALTHCHECK": true, "SHELL": true, + } + + hasFrom := false + inContinuation := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + inContinuation = false + continue + } + if inContinuation { + inContinuation = strings.HasSuffix(trimmed, "\\") + continue + } + + parts := strings.Fields(trimmed) + instruction := strings.ToUpper(parts[0]) + Expect(validInstructions).To(HaveKey(instruction), + fmt.Sprintf("Invalid Dockerfile instruction: '%s' in line: '%s'", parts[0], trimmed)) + if instruction == "FROM" { + hasFrom = true + } + inContinuation = strings.HasSuffix(trimmed, "\\") + } + Expect(hasFrom).To(BeTrue(), "Dockerfile must contain a FROM instruction") +} + +// validateShellScript validates that a shell script has a proper shebang and is non-empty. +func validateShellScript(content string) { + GinkgoHelper() + Expect(content).NotTo(BeEmpty(), "Shell script should not be empty") + Expect(content).To(HavePrefix("#!/bin/bash"), "Shell script should start with #!/bin/bash shebang") +} + +// validateDockerCompose validates docker-compose.yml content is valid YAML with required structure. +func validateDockerCompose(content []byte) { + GinkgoHelper() + var compose map[string]interface{} + err := sigsyaml.Unmarshal(content, &compose) + Expect(err).NotTo(HaveOccurred(), "docker-compose.yml should be valid YAML") + Expect(compose).To(HaveKey("services"), "docker-compose.yml should have a 'services' key") +} + var _ = Describe("Kubernetes Export Integration Tests", func() { var ( tempDir string @@ -153,6 +271,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("FROM ubuntu:latest")) Expect(string(content)).To(ContainSubstring("pip install")) + validateDockerfile(string(content)) By("Verifying frontend-service entrypoint was created") frontendEntrypoint := filepath.Join(tempDir, "export", "frontend-service", "entrypoint.sh") @@ -160,10 +279,14 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { content, err = os.ReadFile(frontendEntrypoint) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("python app.py")) + validateShellScript(string(content)) By("Verifying backend-service Dockerfile was created") backendDockerfile := filepath.Join(tempDir, "export", "backend-service", "Dockerfile") Expect(backendDockerfile).To(BeAnExistingFile()) + content, err = os.ReadFile(backendDockerfile) + Expect(err).NotTo(HaveOccurred()) + validateDockerfile(string(content)) By("Verifying backend-service entrypoint was created") backendEntrypoint := filepath.Join(tempDir, "export", "backend-service", "entrypoint.sh") @@ -171,6 +294,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { content, err = os.ReadFile(backendEntrypoint) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("python backend.py")) + validateShellScript(string(content)) By("Verifying docker-compose.yml was created") dockerComposePath := filepath.Join(tempDir, "export", "docker-compose.yml") @@ -179,6 +303,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("frontend-service")) Expect(string(content)).To(ContainSubstring("backend-service")) + validateDockerCompose(content) By("Verifying nginx config was created") nginxConfigPath := filepath.Join(tempDir, "export", "nginx.conf") @@ -209,6 +334,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { content, err := os.ReadFile(dockerfile) Expect(err).NotTo(HaveOccurred()) Expect(string(content)).To(ContainSubstring("FROM alpine:latest")) + validateDockerfile(string(content)) }) It("should fail when baseimage is not provided", func() { @@ -322,37 +448,38 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(err).NotTo(HaveOccurred()) Expect(info.IsDir()).To(BeTrue()) - By("Verifying frontend-service deployment was created") - frontendService := filepath.Join(kubernetesDir, "service-frontend-service.yml") - Expect(frontendService).To(BeAnExistingFile()) - content, err := os.ReadFile(frontendService) - Expect(err).NotTo(HaveOccurred()) - contentStr := string(content) - Expect(contentStr).To(ContainSubstring("kind: Deployment")) - Expect(contentStr).To(ContainSubstring("kind: Service")) - Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) - Expect(contentStr).To(ContainSubstring("ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) - - By("Verifying backend-service deployment was created") - backendService := filepath.Join(kubernetesDir, "service-backend-service.yml") - Expect(backendService).To(BeAnExistingFile()) - content, err = os.ReadFile(backendService) - Expect(err).NotTo(HaveOccurred()) - contentStr = string(content) - Expect(contentStr).To(ContainSubstring("kind: Deployment")) - Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) - Expect(contentStr).To(ContainSubstring("cs-demo-backend-service:latest")) - - By("Verifying ingress was created") + By("Verifying frontend-service deployment was created and is valid") + frontendServicePath := filepath.Join(kubernetesDir, "service-frontend-service.yml") + Expect(frontendServicePath).To(BeAnExistingFile()) + content, err := os.ReadFile(frontendServicePath) + Expect(err).NotTo(HaveOccurred()) + frontDep, frontSvc := validateServiceFile(content) + Expect(frontDep.Namespace).To(Equal("flask-demo")) + Expect(frontDep.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(frontDep.Spec.Template.Spec.Containers[0].Image).To(Equal("ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) + Expect(frontSvc.Namespace).To(Equal("flask-demo")) + + By("Verifying backend-service deployment was created and is valid") + backendServicePath := filepath.Join(kubernetesDir, "service-backend-service.yml") + Expect(backendServicePath).To(BeAnExistingFile()) + content, err = os.ReadFile(backendServicePath) + Expect(err).NotTo(HaveOccurred()) + backDep, backSvc := validateServiceFile(content) + Expect(backDep.Namespace).To(Equal("flask-demo")) + Expect(backDep.Spec.Template.Spec.Containers).To(HaveLen(1)) + Expect(backDep.Spec.Template.Spec.Containers[0].Image).To(ContainSubstring("cs-demo-backend-service:latest")) + Expect(backSvc.Namespace).To(Equal("flask-demo")) + + By("Verifying ingress was created and is valid") ingressPath := filepath.Join(kubernetesDir, "ingress.yml") Expect(ingressPath).To(BeAnExistingFile()) content, err = os.ReadFile(ingressPath) Expect(err).NotTo(HaveOccurred()) - contentStr = string(content) - Expect(contentStr).To(ContainSubstring("kind: Ingress")) - Expect(contentStr).To(ContainSubstring("namespace: flask-demo")) - Expect(contentStr).To(ContainSubstring("host: flask-demo.local")) - Expect(contentStr).To(ContainSubstring("ingressClassName: nginx")) + ingress := unmarshalIngress(content) + Expect(ingress.Namespace).To(Equal("flask-demo")) + Expect(ingress.Spec.Rules).To(HaveLen(1)) + Expect(ingress.Spec.Rules[0].Host).To(Equal("flask-demo.local")) + Expect(*ingress.Spec.IngressClassName).To(Equal("nginx")) }) It("should generate Kubernetes artifacts with custom ingress class", func() { @@ -375,7 +502,8 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { ingressPath := filepath.Join(tempDir, "export", "kubernetes", "ingress.yml") content, err := os.ReadFile(ingressPath) Expect(err).NotTo(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("ingressClassName: traefik")) + ingress := unmarshalIngress(content) + Expect(*ingress.Spec.IngressClassName).To(Equal("traefik")) }) It("should generate Kubernetes artifacts with pull secret", func() { @@ -395,10 +523,12 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(output).To(ContainSubstring("Kubernetes artifacts export successful")) By("Verifying deployment includes pull secret") - frontendService := filepath.Join(tempDir, "export", "kubernetes", "service-frontend-service.yml") - content, err := os.ReadFile(frontendService) + frontendServicePath := filepath.Join(tempDir, "export", "kubernetes", "service-frontend-service.yml") + content, err := os.ReadFile(frontendServicePath) Expect(err).NotTo(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("my-registry-secret")) + dep, _ := validateServiceFile(content) + Expect(dep.Spec.Template.Spec.ImagePullSecrets).To(HaveLen(1)) + Expect(dep.Spec.Template.Spec.ImagePullSecrets[0].Name).To(Equal("my-registry-secret")) }) It("should fail when registry is not provided", func() { @@ -504,28 +634,48 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { Expect(fullPath).To(BeAnExistingFile(), fmt.Sprintf("Expected file %s to exist", file)) } - By("Step 5: Verify Kubernetes manifests are valid YAML with correct content") + By("Step 5: Validate generated Dockerfiles and shell scripts") + for _, svc := range []string{"frontend-service", "backend-service"} { + df, err := os.ReadFile(filepath.Join(tempDir, "export", svc, "Dockerfile")) + Expect(err).NotTo(HaveOccurred()) + validateDockerfile(string(df)) + + ep, err := os.ReadFile(filepath.Join(tempDir, "export", svc, "entrypoint.sh")) + Expect(err).NotTo(HaveOccurred()) + validateShellScript(string(ep)) + } + dc, err := os.ReadFile(filepath.Join(tempDir, "export", "docker-compose.yml")) + Expect(err).NotTo(HaveOccurred()) + validateDockerCompose(dc) + + By("Step 6: Verify Kubernetes manifests by unmarshalling into typed structs") kubernetesDir := filepath.Join(tempDir, "export", "kubernetes") - // Check ingress contains all services + // Validate ingress ingressContent, err := os.ReadFile(filepath.Join(kubernetesDir, "ingress.yml")) Expect(err).NotTo(HaveOccurred()) - ingressStr := string(ingressContent) - Expect(ingressStr).To(ContainSubstring("host: colima-cluster")) - Expect(ingressStr).To(ContainSubstring("frontend-service")) - Expect(ingressStr).To(ContainSubstring("backend-service")) - Expect(ingressStr).To(ContainSubstring("path: /")) - Expect(ingressStr).To(ContainSubstring("path: /api")) + ingress := unmarshalIngress(ingressContent) + Expect(ingress.Spec.Rules).To(HaveLen(1)) + Expect(ingress.Spec.Rules[0].Host).To(Equal("colima-cluster")) + // Verify all paths are present in the ingress + ingressPaths := ingress.Spec.Rules[0].HTTP.Paths + pathStrings := make([]string, len(ingressPaths)) + for i, p := range ingressPaths { + pathStrings[i] = p.Path + } + Expect(pathStrings).To(ContainElements("/", "/api")) - // Check frontend service has correct image + // Validate frontend service frontendContent, err := os.ReadFile(filepath.Join(kubernetesDir, "service-frontend-service.yml")) Expect(err).NotTo(HaveOccurred()) - Expect(string(frontendContent)).To(ContainSubstring("image: ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) + frontDep, _ := validateServiceFile(frontendContent) + Expect(frontDep.Spec.Template.Spec.Containers[0].Image).To(Equal("ghcr.io/codesphere-cloud/flask-demo/cs-demo-frontend-service:latest")) - // Check backend service has correct image + // Validate backend service backendContent, err := os.ReadFile(filepath.Join(kubernetesDir, "service-backend-service.yml")) Expect(err).NotTo(HaveOccurred()) - Expect(string(backendContent)).To(ContainSubstring("image: ghcr.io/codesphere-cloud/flask-demo/cs-demo-backend-service:latest")) + backDep, _ := validateServiceFile(backendContent) + Expect(backDep.Spec.Template.Spec.Containers[0].Image).To(Equal("ghcr.io/codesphere-cloud/flask-demo/cs-demo-backend-service:latest")) }) It("should handle different ci.yml profiles", func() { @@ -564,18 +714,22 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { devEntrypoint, err := os.ReadFile(filepath.Join(tempDir, "export-dev", "web", "entrypoint.sh")) Expect(err).NotTo(HaveOccurred()) Expect(string(devEntrypoint)).To(ContainSubstring("npm run dev")) + validateShellScript(string(devEntrypoint)) prodEntrypoint, err := os.ReadFile(filepath.Join(tempDir, "export-prod", "web", "entrypoint.sh")) Expect(err).NotTo(HaveOccurred()) Expect(string(prodEntrypoint)).To(ContainSubstring("npm run prod")) + validateShellScript(string(prodEntrypoint)) devDockerfile, err := os.ReadFile(filepath.Join(tempDir, "export-dev", "web", "Dockerfile")) Expect(err).NotTo(HaveOccurred()) Expect(string(devDockerfile)).To(ContainSubstring("FROM node:18")) + validateDockerfile(string(devDockerfile)) prodDockerfile, err := os.ReadFile(filepath.Join(tempDir, "export-prod", "web", "Dockerfile")) Expect(err).NotTo(HaveOccurred()) Expect(string(prodDockerfile)).To(ContainSubstring("FROM node:18-alpine")) + validateDockerfile(string(prodDockerfile)) }) }) @@ -610,10 +764,24 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { fmt.Printf("Legacy Kubernetes generation output: %s\n", k8sOutput) Expect(k8sOutput).To(ContainSubstring("Kubernetes artifacts export successful")) - By("Verifying artifacts were created correctly") - Expect(filepath.Join(tempDir, "export", "app", "Dockerfile")).To(BeAnExistingFile()) - Expect(filepath.Join(tempDir, "export", "kubernetes", "service-app.yml")).To(BeAnExistingFile()) - Expect(filepath.Join(tempDir, "export", "kubernetes", "ingress.yml")).To(BeAnExistingFile()) + By("Verifying artifacts were created and are valid") + appDockerfile := filepath.Join(tempDir, "export", "app", "Dockerfile") + Expect(appDockerfile).To(BeAnExistingFile()) + dfContent, err := os.ReadFile(appDockerfile) + Expect(err).NotTo(HaveOccurred()) + validateDockerfile(string(dfContent)) + + serviceFilePath := filepath.Join(tempDir, "export", "kubernetes", "service-app.yml") + Expect(serviceFilePath).To(BeAnExistingFile()) + serviceContent, err := os.ReadFile(serviceFilePath) + Expect(err).NotTo(HaveOccurred()) + validateServiceFile(serviceContent) + + ingressFilePath := filepath.Join(tempDir, "export", "kubernetes", "ingress.yml") + Expect(ingressFilePath).To(BeAnExistingFile()) + ingressContent, err := os.ReadFile(ingressFilePath) + Expect(err).NotTo(HaveOccurred()) + unmarshalIngress(ingressContent) }) }) @@ -643,6 +811,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { content := string(dockerCompose) Expect(content).To(ContainSubstring("NODE_ENV")) Expect(content).To(ContainSubstring("API_URL")) + validateDockerCompose(dockerCompose) }) }) @@ -678,6 +847,7 @@ var _ = Describe("Kubernetes Export Integration Tests", func() { dockerfile, err := os.ReadFile(filepath.Join(tempDir, "export", "web", "Dockerfile")) Expect(err).NotTo(HaveOccurred()) Expect(string(dockerfile)).To(ContainSubstring("FROM alpine:latest")) + validateDockerfile(string(dockerfile)) }) }) From 2c9d059fb23446dd052a16bc7b25d200d69b6c33 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:21:19 +0100 Subject: [PATCH 09/16] feat: add local integration test target and update test filtering for Kubernetes export tests --- .github/workflows/build.yml | 3 +++ Makefile | 5 ++++- int/export_kubernetes_test.go | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 75ebab13..bcf521c5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,9 @@ jobs: - name: Test run: make test + - name: Local Integration Tests + run: make test-int-local + integration-tests: uses: ./.github/workflows/integration-test.yml secrets: inherit diff --git a/Makefile b/Makefile index 5948a150..622e926f 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,10 @@ test: go test ./api/... ./cli/... ./pkg/... -count=1 test-int: build - go test ./int/... -count=1 -v + go test ./int/... -count=1 -v -ginkgo.label-filter='!local' + +test-int-local: build + go test ./int/... -count=1 -v -ginkgo.label-filter='local' generate: install-build-deps go generate ./... diff --git a/int/export_kubernetes_test.go b/int/export_kubernetes_test.go index 0f608e71..d983075b 100644 --- a/int/export_kubernetes_test.go +++ b/int/export_kubernetes_test.go @@ -227,7 +227,7 @@ func validateDockerCompose(content []byte) { Expect(compose).To(HaveKey("services"), "docker-compose.yml should have a 'services' key") } -var _ = Describe("Kubernetes Export Integration Tests", func() { +var _ = Describe("Kubernetes Export Integration Tests", Label("local"), func() { var ( tempDir string ) From 32559e1e2677b58e6285ea1979285a897408ffda Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:28:22 +0100 Subject: [PATCH 10/16] ref: move Local Integration Tests to integration-test.yml and remove from build.yml --- .github/workflows/build.yml | 3 --- .github/workflows/integration-test.yml | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bcf521c5..75ebab13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,9 +30,6 @@ jobs: - name: Test run: make test - - name: Local Integration Tests - run: make test-int-local - integration-tests: uses: ./.github/workflows/integration-test.yml secrets: inherit diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 846ed78e..fad8abab 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -23,6 +23,9 @@ jobs: CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} run: make test-int + - name: Local Integration Tests + run: make test-int-local + - name: Cleanup Orphaned Test Resources if: always() # Run even if tests fail env: From 51fafa789248ab089d12ca1047397b3699df71f3 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:58:03 +0100 Subject: [PATCH 11/16] fix: ensure Local Integration Tests run even if previous tests fail --- .github/workflows/integration-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index fad8abab..47e41b92 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -24,6 +24,7 @@ jobs: run: make test-int - name: Local Integration Tests + if: always() # Run even if make test-int fails run: make test-int-local - name: Cleanup Orphaned Test Resources From 2b4aa6b0c078e99f320de01d1456a8784ac73587 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:28:30 +0000 Subject: [PATCH 12/16] chore(docs): Auto-update docs and licenses Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> --- NOTICE | 4 ++-- pkg/tmpl/NOTICE | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NOTICE b/NOTICE index 94c6e9ab..6dfa6242 100644 --- a/NOTICE +++ b/NOTICE @@ -401,9 +401,9 @@ License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/errgroup -Version: v0.19.0 +Version: v0.20.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sync/+/v0.19.0:LICENSE +License URL: https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE ---------- Module: golang.org/x/sys diff --git a/pkg/tmpl/NOTICE b/pkg/tmpl/NOTICE index 94c6e9ab..6dfa6242 100644 --- a/pkg/tmpl/NOTICE +++ b/pkg/tmpl/NOTICE @@ -401,9 +401,9 @@ License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/errgroup -Version: v0.19.0 +Version: v0.20.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sync/+/v0.19.0:LICENSE +License URL: https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE ---------- Module: golang.org/x/sys From 6c1da248b53c94a91ccf228c7bdb42c5ced7d096 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:20:47 +0100 Subject: [PATCH 13/16] feat: split integration tests by label --- .github/workflows/integration-test.yml | 74 +- Makefile | 31 +- int/curl_test.go | 177 ++++ int/error_handling_test.go | 59 ++ int/git_test.go | 61 ++ int/integration_test.go | 1343 ------------------------ int/list_test.go | 124 +++ int/log_test.go | 62 ++ int/monitor_test.go | 350 ++++++ int/pipeline_test.go | 61 ++ int/version_help_test.go | 115 ++ int/wakeup_test.go | 188 ++++ int/workspace_test.go | 271 +++++ 13 files changed, 1568 insertions(+), 1348 deletions(-) create mode 100644 int/curl_test.go create mode 100644 int/error_handling_test.go create mode 100644 int/git_test.go delete mode 100644 int/integration_test.go create mode 100644 int/list_test.go create mode 100644 int/log_test.go create mode 100644 int/monitor_test.go create mode 100644 int/pipeline_test.go create mode 100644 int/version_help_test.go create mode 100644 int/wakeup_test.go create mode 100644 int/workspace_test.go diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 47e41b92..b8fd54f7 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -16,17 +16,83 @@ jobs: with: go-version-file: 'go.mod' - - name: Run Integration Tests + - name: Integration Tests - workspace env: CS_TOKEN: ${{ secrets.CS_TOKEN }} CS_API: ${{ secrets.CS_API }} CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int + run: make test-int-workspace - - name: Local Integration Tests - if: always() # Run even if make test-int fails + - name: Integration Tests - list + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-list + + - name: Integration Tests - error-handling + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-error-handling + + - name: Integration Tests - log + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-log + + - name: Integration Tests - pipeline + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-pipeline + + - name: Integration Tests - git + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-git + + - name: Integration Tests - wakeup + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-wakeup + + - name: Integration Tests - curl + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: make test-int-curl + + - name: Integration Tests - local + if: always() run: make test-int-local + - name: Integration Tests - unlabeled (label missing!) + if: always() + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: | + echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." + make test-int-unlabeled + - name: Cleanup Orphaned Test Resources if: always() # Run even if tests fail env: diff --git a/Makefile b/Makefile index 622e926f..8a6cfbba 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,40 @@ test: go test ./api/... ./cli/... ./pkg/... -count=1 test-int: build - go test ./int/... -count=1 -v -ginkgo.label-filter='!local' + go test ./int/... -count=1 -v + +# new integration tests should be added here with a label, e.g. 'workspace' or 'list', +# the label must be added to the unlabeled test filter in the 'test-int-unlabeled' target below +test-int-workspace: build + go test ./int/... -count=1 -v -ginkgo.label-filter='workspace' + +test-int-list: build + go test ./int/... -count=1 -v -ginkgo.label-filter='list' + +test-int-error-handling: build + go test ./int/... -count=1 -v -ginkgo.label-filter='error-handling' + +test-int-log: build + go test ./int/... -count=1 -v -ginkgo.label-filter='log' + +test-int-pipeline: build + go test ./int/... -count=1 -v -ginkgo.label-filter='pipeline' + +test-int-git: build + go test ./int/... -count=1 -v -ginkgo.label-filter='git' + +test-int-wakeup: build + go test ./int/... -count=1 -v -ginkgo.label-filter='wakeup' + +test-int-curl: build + go test ./int/... -count=1 -v -ginkgo.label-filter='curl' test-int-local: build go test ./int/... -count=1 -v -ginkgo.label-filter='local' +test-int-unlabeled: build + go test ./int/... -count=1 -v -ginkgo.label-filter='!local && !workspace && !list && !error-handling && !log && !pipeline && !git && !wakeup && !curl' + generate: install-build-deps go generate ./... diff --git a/int/curl_test.go b/int/curl_test.go new file mode 100644 index 00000000..141a807c --- /dev/null +++ b/int/curl_test.go @@ -0,0 +1,177 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "os" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Curl Workspace Integration Tests", Label("curl"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-curl-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Curl Command", func() { + BeforeEach(func() { + By("Creating a workspace for curl testing") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Waiting for workspace to be fully provisioned") + time.Sleep(5 * time.Second) + }) + + It("should send authenticated request to workspace", func() { + By("Sending curl request to workspace root") + output := intutil.RunCommand( + "curl", "/", + "-w", workspaceId, + "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + + It("should support custom paths", func() { + By("Sending curl request to custom path") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", "/api/health", + "-w", workspaceId, + "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl with custom path output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + }) + + It("should pass through curl arguments", func() { + By("Sending HEAD request using curl -I flag") + output := intutil.RunCommand( + "curl", "/", + "-w", workspaceId, + "--", "-k", "-I", + ) + fmt.Printf("Curl with -I flag output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + }) + + It("should work with workspace ID from environment variable", func() { + By("Setting CS_WORKSPACE_ID environment variable") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + By("Sending curl request using environment variable") + output := intutil.RunCommand( + "curl", "/", + "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", + ) + fmt.Printf("Curl with env var output: %s\n", output) + + Expect(output).To(ContainSubstring("Sending request to workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + }) + + Context("Curl Error Handling", func() { + It("should fail when workspace ID is missing", func() { + By("Attempting to curl without workspace ID") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + originalWsIdFallback := os.Getenv("WORKSPACE_ID") + _ = os.Unsetenv("CS_WORKSPACE_ID") + _ = os.Unsetenv("WORKSPACE_ID") + defer func() { + _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) + _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) + }() + + output, exitCode := intutil.RunCommandWithExitCode("curl", "/") + fmt.Printf("Curl without workspace ID output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("workspace"), + ContainSubstring("required"), + ContainSubstring("not set"), + )) + }) + + It("should fail gracefully with non-existent workspace", func() { + By("Attempting to curl non-existent workspace") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", "/", + "-w", "99999999", + ) + fmt.Printf("Curl non-existent workspace output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("failed to get workspace"), + ContainSubstring("not found"), + ContainSubstring("404"), + )) + }) + + It("should require path argument", func() { + By("Attempting to curl without path") + output, exitCode := intutil.RunCommandWithExitCode( + "curl", + "-w", "1234", + ) + fmt.Printf("Curl without path output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("path"), + ContainSubstring("required"), + ContainSubstring("argument"), + )) + }) + }) + + Context("Curl Command Help", func() { + It("should display help information", func() { + By("Running curl --help") + output := intutil.RunCommand("curl", "--help") + fmt.Printf("Curl help output: %s\n", output) + + Expect(output).To(ContainSubstring("Send authenticated HTTP requests")) + Expect(output).To(ContainSubstring("--timeout")) + Expect(output).To(ContainSubstring("-w, --workspace")) + }) + }) +}) diff --git a/int/error_handling_test.go b/int/error_handling_test.go new file mode 100644 index 00000000..220b1843 --- /dev/null +++ b/int/error_handling_test.go @@ -0,0 +1,59 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Command Error Handling Tests", Label("error-handling"), func() { + It("should fail gracefully with non-existent workspace for all commands", func() { + testCases := []struct { + commandName string + args []string + }{ + {"open workspace", []string{"open", "workspace", "-w", "99999999"}}, + {"log", []string{"log", "-w", "99999999"}}, + {"start pipeline", []string{"start", "pipeline", "-w", "99999999"}}, + {"git pull", []string{"git", "pull", "-w", "99999999"}}, + {"set-env", []string{"set-env", "-w", "99999999", "TEST_VAR=test"}}, + {"wake-up", []string{"wake-up", "-w", "99999999"}}, + {"curl", []string{"curl", "/", "-w", "99999999"}}, + } + + for _, tc := range testCases { + By(fmt.Sprintf("Testing %s with non-existent workspace", tc.commandName)) + output, exitCode := intutil.RunCommandWithExitCode(tc.args...) + log.Printf("%s non-existent workspace output: %s (exit code: %d)\n", tc.commandName, output, exitCode) + Expect(exitCode).NotTo(Equal(0)) + } + }) +}) + +var _ = Describe("Command Error Handling Tests - Additional", Label("error-handling"), func() { + It("should fail gracefully with non-existent workspace for all commands", func() { + testCases := []struct { + commandName string + args []string + }{ + {"open workspace", []string{"open", "workspace", "-w", "99999999"}}, + {"log", []string{"log", "-w", "99999999"}}, + {"start pipeline", []string{"start", "pipeline", "-w", "99999999"}}, + {"git pull", []string{"git", "pull", "-w", "99999999"}}, + {"set-env", []string{"set-env", "-w", "99999999", "TEST_VAR=test"}}, + } + + for _, tc := range testCases { + By(fmt.Sprintf("Testing %s with non-existent workspace", tc.commandName)) + output, exitCode := intutil.RunCommandWithExitCode(tc.args...) + log.Printf("%s non-existent workspace output: %s (exit code: %d)\n", tc.commandName, output, exitCode) + Expect(exitCode).NotTo(Equal(0)) + } + }) +}) diff --git a/int/git_test.go b/int/git_test.go new file mode 100644 index 00000000..9bbca276 --- /dev/null +++ b/int/git_test.go @@ -0,0 +1,61 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Git Pull Integration Tests", Label("git"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-git-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Git Pull Command", func() { + BeforeEach(func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + }) + + It("should execute git pull command", func() { + By("Running git pull") + output, exitCode := intutil.RunCommandWithExitCode( + "git", "pull", + "-w", workspaceId, + ) + log.Printf("Git pull output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).NotTo(BeEmpty()) + }) + }) +}) diff --git a/int/integration_test.go b/int/integration_test.go deleted file mode 100644 index 72262248..00000000 --- a/int/integration_test.go +++ /dev/null @@ -1,1343 +0,0 @@ -// Copyright (c) Codesphere Inc. -// SPDX-License-Identifier: Apache-2.0 - -package int_test - -import ( - "bytes" - "context" - "fmt" - "io" - "log" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - intutil "github.com/codesphere-cloud/cs-go/int/util" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("cs monitor", func() { - var ( - certsDir string - tempDir string - caCertPath string - serverCertPath string - serverKeyPath string - monitorListenPort int - targetServerPort int - targetServer *http.Server - monitorCmdProcess *exec.Cmd - testHttpClient *http.Client - monitorOutputBuf *bytes.Buffer - targetServerOutputBuf *bytes.Buffer - ) - - BeforeEach(func() { - var err error - tempDir, err = os.MkdirTemp("", "e2e-tls-monitor-test-") - Expect(err).NotTo(HaveOccurred()) - certsDir = filepath.Join(tempDir, "certs") - - monitorListenPort, err = intutil.GetEphemeralPort() - Expect(err).NotTo(HaveOccurred()) - targetServerPort, err = intutil.GetEphemeralPort() - Expect(err).NotTo(HaveOccurred()) - - testHttpClient = &http.Client{ - Timeout: 10 * time.Second, - } - - monitorOutputBuf = new(bytes.Buffer) - targetServerOutputBuf = new(bytes.Buffer) - }) - - AfterEach(func() { - if monitorCmdProcess != nil && monitorCmdProcess.Process != nil { - log.Printf("Terminating monitor process (PID: %d). Output:\n%s\n", monitorCmdProcess.Process.Pid, monitorOutputBuf.String()) - _ = monitorCmdProcess.Process.Kill() - _, _ = monitorCmdProcess.Process.Wait() - } - - Expect(os.RemoveAll(tempDir)).NotTo(HaveOccurred()) - }) - - Context("Healthcheck forwarding", func() { - AfterEach(func() { - if targetServer != nil { - log.Printf("Terminating HTTP(S) server. Output:\n%s\n", targetServerOutputBuf.String()) - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 1*time.Second) - defer shutdownCancel() - _ = targetServer.Shutdown(shutdownCtx) - } - }) - It("should start a Go HTTP server, and proxy successfully", func() { - var err error - - By("Starting Go HTTPS server with generated certs") - targetServer, err = intutil.StartTestHttpServer(targetServerPort) - Expect(err).NotTo(HaveOccurred()) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) - log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) - - By("Running 'cs monitor' command with --forward and --insecure-skip-verify") - intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--forward", fmt.Sprintf("http://127.0.0.1:%d/", targetServerPort), - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--", "sleep", "60s", - ) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - - By("Making request to monitor proxy to verify successful forwarding") - resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusOK)) - bodyBytes, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(Equal("OK (HTTP)")) - - log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) - }) - - It("should start a Go HTTPS server with generated certs, run monitor with --insecure-skip-verify, and proxy successfully", func() { - By("Generating TLS certificates") - var err error - caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( - certsDir, - "localhost", - []string{"localhost", "127.0.0.1"}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(caCertPath).To(BeAnExistingFile()) - Expect(serverCertPath).To(BeAnExistingFile()) - Expect(serverKeyPath).To(BeAnExistingFile()) - - By("Starting Go HTTPS server with generated certs") - targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) - Expect(err).NotTo(HaveOccurred()) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) - log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) - - By("Running 'cs monitor' command with --forward and --insecure-skip-verify") - intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--insecure-skip-verify", - "--", "sleep", "60s", - ) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - - By("Making request to monitor proxy to verify successful forwarding") - resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusOK)) - bodyBytes, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(Equal("OK (HTTPS)")) - - log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) - }) - - It("should get an error for an invalid HTTPS certificate without --insecure-skip-verify or --ca-cert-file", func() { - By("Generating TLS certificates in Go") - var err error - caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( - certsDir, - "localhost", - []string{"localhost", "127.0.0.1"}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(caCertPath).To(BeAnExistingFile()) - Expect(serverCertPath).To(BeAnExistingFile()) - Expect(serverKeyPath).To(BeAnExistingFile()) - - By("Starting Go HTTPS server with generated certs") - targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) - Expect(err).NotTo(HaveOccurred()) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) - log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) - - By("Running 'cs monitor' command without TLS bypass/trust") - intutil.RunCommandInBackground( - monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), - "--", "sleep", "60s", - ) - - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) - - By("Making request to monitor proxy and expecting a Bad Gateway error") - resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusBadGateway)) - bodyBytes, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(ContainSubstring("Error forwarding request")) - Expect(string(bodyBytes)).To(ContainSubstring("tls: failed to verify certificate")) - - log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) - }) - - It("should forward to an HTTPS target with --ca-cert-file and return 200 OK", func() { - By("Generating TLS certificates in Go") - var err error - caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( - certsDir, - "localhost", - []string{"localhost", "127.0.0.1"}, - ) - Expect(err).NotTo(HaveOccurred()) - Expect(caCertPath).To(BeAnExistingFile()) - Expect(serverCertPath).To(BeAnExistingFile()) - Expect(serverKeyPath).To(BeAnExistingFile()) - - By("Starting Go HTTPS server with generated certs") - targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) - Expect(err).NotTo(HaveOccurred()) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) - log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) - - By("Running 'cs monitor' command with --ca-cert-file") - intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), - "--ca-cert-file", caCertPath, - "--", - "sleep", "60s", - ) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) - - By("Making request to monitor proxy to verify successful forwarding") - resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusOK)) - bodyBytes, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(Equal("OK (HTTPS)")) - - log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) - }) - }) - - Context("Prometheus Metrics Endpoint", func() { - It("should expose Prometheus metrics when no forward is specified", func() { - By("Running 'cs monitor' command without forwarding (metrics only)") - monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--", "sleep", "60s", - ) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) - - By("Making a request to the monitor's metrics endpoint") - resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusOK)) - bodyBytes, err := io.ReadAll(resp.Body) - Expect(err).NotTo(HaveOccurred()) - Expect(string(bodyBytes)).To(ContainSubstring("cs_monitor_restarts_total")) - log.Printf("Monitor output after metrics request:\n%s\n", monitorOutputBuf.String()) - }) - - It("should redirect root to /metrics", func() { - By("Running 'cs monitor' command without forwarding (metrics only)") - monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--", "sleep", "60s", - ) - intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) - log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) - - By("Making a request to the monitor's root endpoint and expecting a redirect") - client := &http.Client{ - CheckRedirect: func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - }, - Timeout: 5 * time.Second, - } - resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) - Expect(err).NotTo(HaveOccurred()) - defer func() { _ = resp.Body.Close() }() - - Expect(resp.StatusCode).To(Equal(http.StatusMovedPermanently)) - Expect(resp.Header.Get("Location")).To(Equal("/metrics")) - log.Printf("Monitor output after redirect request:\n%s\n", monitorOutputBuf.String()) - }) - }) - - Context("Command Execution and Restart Logic", func() { - It("should execute the command once if it succeeds", func() { - monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--max-restarts", "0", - "--", "true", - ) - - Eventually(monitorCmdProcess.Wait, "5s").Should(Succeed(), "Monitor process should exit successfully") - - output := monitorOutputBuf.String() - Expect(output).To(ContainSubstring("command exited")) - Expect(output).To(ContainSubstring("returnCode=0")) - Expect(output).To(ContainSubstring("maximum number of restarts reached, exiting")) - Expect(strings.Count(output, "command exited")).To(Equal(1), "Command should have executed only once") - }) - - It("should restart the command if it exits with non-zero code quickly", func() { - - monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--max-restarts", "1", - "--", "bash", "-c", "echo FAKE_OUTPUT;exit 1", - ) - - Eventually(monitorCmdProcess.Wait, "15s").Should(Succeed(), "Monitor process should exit after restarts") - - output := monitorOutputBuf.String() - Expect(output).To(ContainSubstring("command exited")) - Expect(output).To(ContainSubstring("returnCode=1")) - Expect(output).To(ContainSubstring("command exited with non-zero code in less than 1 second. Waiting 5 seconds before next restart")) - Expect(output).To(ContainSubstring("cs monitor: restarting")) - Expect(output).To(ContainSubstring("maximum number of restarts reached, exiting")) - Expect(strings.Count(output, "FAKE_OUTPUT")).To(Equal(3), "Command should have executed twice") - }) - - It("should stop command runner on context cancellation", func() { - By("Running 'cs monitor' command with infinite restarts") - monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, - "monitor", - "--address", fmt.Sprintf(":%d", monitorListenPort), - "--max-restarts", "-1", - "--", "sleep", "10s", - ) - Eventually(func() string { return monitorOutputBuf.String() }, "5s").Should(ContainSubstring("starting monitor")) - - By("Stopping command execution") - err := monitorCmdProcess.Process.Signal(os.Interrupt) - Expect(err).NotTo(HaveOccurred()) - _, _ = monitorCmdProcess.Process.Wait() - - output := monitorOutputBuf.String() - Expect(output).To(ContainSubstring("initiating graceful shutdown...")) - Expect(output).To(ContainSubstring("stopping command runner.")) - Expect(output).NotTo(ContainSubstring("error executing command")) - }) - }) -}) - -var _ = Describe("Open Workspace Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-open-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Open Workspace Command", func() { - BeforeEach(func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - log.Printf("Create workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - }) - - It("should open workspace successfully", func() { - By("Opening the workspace") - output := intutil.RunCommand( - "open", "workspace", - "-w", workspaceId, - ) - log.Printf("Open workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Opening workspace")) - Expect(output).To(ContainSubstring(workspaceId)) - }) - }) - - Context("Open Workspace Error Handling", func() { - It("should fail when workspace ID is missing", func() { - By("Attempting to open workspace without ID") - originalWsId := os.Getenv("CS_WORKSPACE_ID") - originalWsIdFallback := os.Getenv("WORKSPACE_ID") - _ = os.Unsetenv("CS_WORKSPACE_ID") - _ = os.Unsetenv("WORKSPACE_ID") - defer func() { - _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) - _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) - }() - - output, exitCode := intutil.RunCommandWithExitCode( - "open", "workspace", - ) - log.Printf("Open without workspace ID output: %s (exit code: %d)\n", output, exitCode) - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("workspace"), - ContainSubstring("required"), - )) - }) - }) -}) - -var _ = Describe("Workspace Edge Cases and Advanced Operations", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-edge-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Workspace Creation Edge Cases", func() { - It("should create a workspace with a very long name", func() { - longName := fmt.Sprintf("cli-very-long-workspace-name-test-%d", time.Now().Unix()) - By("Creating a workspace with a long name") - output := intutil.RunCommand( - "create", "workspace", longName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - log.Printf("Create workspace with long name output: %s\n", output) - - if output != "" && !strings.Contains(output, "error") { - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - } - }) - - It("should handle creation timeout gracefully", func() { - By("Creating a workspace with very short timeout") - output, exitCode := intutil.RunCommandWithExitCode( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "1s", - ) - log.Printf("Create with short timeout output: %s (exit code: %d)\n", output, exitCode) - - if exitCode != 0 { - Expect(output).To(Or( - ContainSubstring("timeout"), - ContainSubstring("timed out"), - )) - } else if strings.Contains(output, "Workspace created") { - workspaceId = intutil.ExtractWorkspaceId(output) - } - }) - }) - - Context("Exec Command Edge Cases", func() { - BeforeEach(func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - }) - - It("should execute commands with multiple arguments", func() { - By("Executing a command with multiple arguments") - output := intutil.RunCommand( - "exec", - "-w", workspaceId, - "--", - "sh", "-c", "echo test1 && echo test2", - ) - log.Printf("Exec with multiple args output: %s\n", output) - Expect(output).To(ContainSubstring("test1")) - Expect(output).To(ContainSubstring("test2")) - }) - - It("should handle commands that output to stderr", func() { - By("Executing a command that writes to stderr") - output := intutil.RunCommand( - "exec", - "-w", workspaceId, - "--", - "sh", "-c", "echo error message >&2", - ) - log.Printf("Exec with stderr output: %s\n", output) - Expect(output).To(ContainSubstring("error message")) - }) - - It("should handle commands with exit codes", func() { - By("Executing a command that exits with non-zero code") - output, exitCode := intutil.RunCommandWithExitCode( - "exec", - "-w", workspaceId, - "--", - "sh", "-c", "exit 42", - ) - log.Printf("Exec with exit code output: %s (exit code: %d)\n", output, exitCode) - }) - - It("should execute long-running commands", func() { - By("Executing a command that takes a few seconds") - output := intutil.RunCommand( - "exec", - "-w", workspaceId, - "--", - "sh", "-c", "sleep 2 && echo completed", - ) - log.Printf("Exec long-running command output: %s\n", output) - Expect(output).To(ContainSubstring("completed")) - }) - }) - - Context("Workspace Deletion Edge Cases", func() { - It("should prevent deletion without confirmation when not forced", func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - - By("Attempting to delete without --yes flag") - output = intutil.RunCommand( - "delete", "workspace", - "-w", workspaceId, - "--yes", - ) - log.Printf("Delete with confirmation output: %s\n", output) - Expect(output).To(ContainSubstring("deleted")) - workspaceId = "" - }) - - It("should fail gracefully when deleting already deleted workspace", func() { - By("Creating and deleting a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - tempWsId := intutil.ExtractWorkspaceId(output) - - output = intutil.RunCommand( - "delete", "workspace", - "-w", tempWsId, - "--yes", - ) - Expect(output).To(ContainSubstring("deleted")) - - By("Attempting to delete the same workspace again") - output, exitCode := intutil.RunCommandWithExitCode( - "delete", "workspace", - "-w", tempWsId, - "--yes", - ) - log.Printf("Delete already deleted workspace output: %s (exit code: %d)\n", output, exitCode) - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("error"), - ContainSubstring("failed"), - ContainSubstring("not found"), - )) - }) - }) -}) - -var _ = Describe("Version and Help Tests", func() { - Context("Version Command", func() { - It("should display version information", func() { - By("Running version command") - output := intutil.RunCommand("version") - log.Printf("Version output: %s\n", output) - - Expect(output).To(Or( - ContainSubstring("version"), - ContainSubstring("Version"), - MatchRegexp(`\d+\.\d+\.\d+`), - )) - }) - }) - - Context("Help Commands", func() { - It("should display main help", func() { - By("Running help command") - output := intutil.RunCommand("--help") - log.Printf("Help output length: %d\n", len(output)) - - Expect(output).To(ContainSubstring("Usage:")) - Expect(output).To(ContainSubstring("Available Commands:")) - }) - - It("should display help for all subcommands", func() { - testCases := []struct { - command []string - shouldMatch string - }{ - {[]string{"create", "--help"}, "workspace"}, - {[]string{"exec", "--help"}, "exec"}, - {[]string{"log", "--help"}, "log"}, - {[]string{"start", "pipeline", "--help"}, "pipeline"}, - {[]string{"git", "pull", "--help"}, "pull"}, - {[]string{"set-env", "--help"}, "set-env"}, - } - - for _, tc := range testCases { - By(fmt.Sprintf("Testing %v", tc.command)) - output := intutil.RunCommand(tc.command...) - Expect(output).To(ContainSubstring("Usage:")) - Expect(output).To(ContainSubstring(tc.shouldMatch)) - } - }) - }) - - Context("Invalid Commands", func() { - It("should handle unknown commands gracefully", func() { - By("Running unknown command") - output, exitCode := intutil.RunCommandWithExitCode("unknowncommand") - log.Printf("Unknown command output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("unknown command"), - ContainSubstring("Error:"), - )) - }) - - It("should suggest similar commands for typos", func() { - By("Running misspelled command") - output, exitCode := intutil.RunCommandWithExitCode("listt") - log.Printf("Typo command output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - lowerOutput := strings.ToLower(output) - Expect(lowerOutput).To(Or( - ContainSubstring("unknown"), - ContainSubstring("error"), - ContainSubstring("did you mean"), - )) - }) - }) - - Context("Global Flags", func() { - It("should accept all global flags", func() { - By("Testing --api flag") - output := intutil.RunCommand( - "--api", "https://example.com/api", - "list", "teams", - ) - Expect(output).NotTo(ContainSubstring("unknown flag")) - - By("Testing --verbose flag") - output = intutil.RunCommand( - "--verbose", - "list", "plans", - ) - Expect(output).NotTo(ContainSubstring("unknown flag")) - - By("Testing -v shorthand") - output = intutil.RunCommand( - "-v", - "list", "baseimages", - ) - Expect(output).NotTo(ContainSubstring("unknown flag")) - }) - }) -}) - -var _ = Describe("List Command Tests", func() { - var teamId string - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - }) - - Context("List Workspaces", func() { - It("should list all workspaces in team with proper formatting", func() { - By("Listing workspaces") - output := intutil.RunCommand("list", "workspaces", "-t", teamId) - log.Printf("List workspaces output length: %d\n", len(output)) - - Expect(output).To(ContainSubstring("TEAM ID")) - Expect(output).To(ContainSubstring("ID")) - Expect(output).To(ContainSubstring("NAME")) - }) - }) - - Context("List Plans", func() { - It("should list all available plans", func() { - By("Listing plans") - output := intutil.RunCommand("list", "plans") - log.Printf("List plans output: %s\n", output) - - Expect(output).To(ContainSubstring("ID")) - Expect(output).To(ContainSubstring("NAME")) - Expect(output).To(Or( - ContainSubstring("Micro"), - ContainSubstring("Free"), - )) - }) - - It("should show plan details like CPU and RAM", func() { - By("Listing plans with details") - output := intutil.RunCommand("list", "plans") - log.Printf("Plan details output length: %d\n", len(output)) - - Expect(output).To(ContainSubstring("CPU")) - Expect(output).To(ContainSubstring("RAM")) - }) - }) - - Context("List Base Images", func() { - It("should list available base images", func() { - By("Listing base images") - output := intutil.RunCommand("list", "baseimages") - log.Printf("List baseimages output: %s\n", output) - - Expect(output).To(ContainSubstring("ID")) - Expect(output).To(ContainSubstring("NAME")) - }) - - It("should show Ubuntu images", func() { - By("Checking for Ubuntu in base images") - output := intutil.RunCommand("list", "baseimages") - - Expect(output).To(ContainSubstring("Ubuntu")) - }) - }) - - Context("List Teams", func() { - It("should list teams user has access to", func() { - By("Listing teams") - output := intutil.RunCommand("list", "teams") - log.Printf("List teams output: %s\n", output) - - Expect(output).To(ContainSubstring("ID")) - Expect(output).To(ContainSubstring("NAME")) - Expect(output).To(ContainSubstring(teamId)) - }) - - It("should show team role", func() { - By("Checking team roles") - output := intutil.RunCommand("list", "teams") - - Expect(output).To(Or( - ContainSubstring("Admin"), - ContainSubstring("Member"), - ContainSubstring("ROLE"), - )) - }) - }) - - Context("List Error Handling", func() { - It("should handle missing or invalid list subcommand", func() { - By("Running list without subcommand") - output, exitCode := intutil.RunCommandWithExitCode("list") - log.Printf("List without subcommand output: %s (exit code: %d)\n", output, exitCode) - Expect(output).To(Or( - ContainSubstring("Available Commands:"), - ContainSubstring("Usage:"), - )) - - By("Running list with invalid subcommand") - output, _ = intutil.RunCommandWithExitCode("list", "invalid") - log.Printf("List invalid output (first 200 chars): %s\n", output[:min(200, len(output))]) - Expect(output).To(Or( - ContainSubstring("Available Commands:"), - ContainSubstring("Usage:"), - )) - }) - - It("should require team ID for workspace listing when not set globally", func() { - By("Listing workspaces without team ID in specific contexts") - output := intutil.RunCommand("list", "workspaces", "-t", teamId) - - Expect(output).NotTo(BeEmpty()) - }) - }) -}) - -var _ = Describe("Command Error Handling Tests", func() { - It("should fail gracefully with non-existent workspace for all commands", func() { - testCases := []struct { - commandName string - args []string - }{ - {"open workspace", []string{"open", "workspace", "-w", "99999999"}}, - {"log", []string{"log", "-w", "99999999"}}, - {"start pipeline", []string{"start", "pipeline", "-w", "99999999"}}, - {"git pull", []string{"git", "pull", "-w", "99999999"}}, - {"set-env", []string{"set-env", "-w", "99999999", "TEST_VAR=test"}}, - {"wake-up", []string{"wake-up", "-w", "99999999"}}, - {"curl", []string{"curl", "/", "-w", "99999999"}}, - } - - for _, tc := range testCases { - By(fmt.Sprintf("Testing %s with non-existent workspace", tc.commandName)) - output, exitCode := intutil.RunCommandWithExitCode(tc.args...) - log.Printf("%s non-existent workspace output: %s (exit code: %d)\n", tc.commandName, output, exitCode) - Expect(exitCode).NotTo(Equal(0)) - } - }) -}) - -var _ = Describe("Log Command Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-log-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Log Command", func() { - BeforeEach(func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - }) - - It("should retrieve logs from workspace", func() { - By("Getting logs from workspace") - output, exitCode := intutil.RunCommandWithExitCode( - "log", - "-w", workspaceId, - ) - log.Printf("Log command output (first 500 chars): %s... (exit code: %d)\n", - output[:min(500, len(output))], exitCode) - - Expect(exitCode).To(Or(Equal(0), Equal(1))) - }) - }) -}) - -var _ = Describe("Start Pipeline Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-pipeline-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Start Pipeline Command", func() { - BeforeEach(func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - }) - - It("should start pipeline successfully", func() { - By("Starting pipeline") - output, exitCode := intutil.RunCommandWithExitCode( - "start", "pipeline", - "-w", workspaceId, - ) - log.Printf("Start pipeline output: %s (exit code: %d)\n", output, exitCode) - - Expect(output).NotTo(BeEmpty()) - }) - }) -}) - -var _ = Describe("Git Pull Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-git-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Git Pull Command", func() { - BeforeEach(func() { - By("Creating a workspace") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - }) - - It("should execute git pull command", func() { - By("Running git pull") - output, exitCode := intutil.RunCommandWithExitCode( - "git", "pull", - "-w", workspaceId, - ) - log.Printf("Git pull output: %s (exit code: %d)\n", output, exitCode) - - Expect(output).NotTo(BeEmpty()) - }) - }) -}) - -var _ = Describe("Wake Up Workspace Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-wakeup-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Wake Up Command", func() { - BeforeEach(func() { - By("Creating a workspace for wake-up testing") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - fmt.Printf("Create workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - - By("Waiting for workspace to be fully provisioned") - time.Sleep(5 * time.Second) - }) - - It("should wake up workspace successfully", func() { - By("Waking up the workspace") - output := intutil.RunCommand( - "wake-up", - "-w", workspaceId, - ) - fmt.Printf("Wake up workspace output: %s\n", output) - - Expect(output).To(Or( - ContainSubstring("Waking up workspace"), - // The workspace might already be running - ContainSubstring("is already running"), - )) - Expect(output).To(ContainSubstring(workspaceId)) - }) - - It("should respect custom timeout", func() { - By("Waking up workspace with custom timeout") - output, exitCode := intutil.RunCommandWithExitCode( - "wake-up", - "-w", workspaceId, - "--timeout", "5s", - ) - fmt.Printf("Wake up with timeout output: %s (exit code: %d)\n", output, exitCode) - - Expect(output).To(Or( - ContainSubstring("Waking up workspace"), - // The workspace might already be running - ContainSubstring("is already running"), - )) - Expect(exitCode).To(Equal(0)) - }) - - It("should work with workspace ID from environment variable", func() { - By("Setting CS_WORKSPACE_ID environment variable") - originalWsId := os.Getenv("CS_WORKSPACE_ID") - _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) - defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() - - By("Waking up workspace using environment variable") - output := intutil.RunCommand("wake-up") - fmt.Printf("Wake up with env var output: %s\n", output) - - Expect(output).To(Or( - ContainSubstring("Waking up workspace"), - // The workspace might already be running - ContainSubstring("is already running"), - )) - Expect(output).To(ContainSubstring(workspaceId)) - }) - }) - - Context("Wake Up Error Handling", func() { - It("should fail when workspace ID is missing", func() { - By("Attempting to wake up workspace without ID") - originalWsId := os.Getenv("CS_WORKSPACE_ID") - originalWsIdFallback := os.Getenv("WORKSPACE_ID") - _ = os.Unsetenv("CS_WORKSPACE_ID") - _ = os.Unsetenv("WORKSPACE_ID") - defer func() { - _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) - _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) - }() - - output, exitCode := intutil.RunCommandWithExitCode("wake-up") - fmt.Printf("Wake up without workspace ID output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("workspace"), - ContainSubstring("required"), - ContainSubstring("not set"), - )) - }) - - It("should fail gracefully with non-existent workspace", func() { - By("Attempting to wake up non-existent workspace") - output, exitCode := intutil.RunCommandWithExitCode( - "wake-up", - "-w", "99999999", - ) - fmt.Printf("Wake up non-existent workspace output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("failed to get workspace"), - ContainSubstring("not found"), - ContainSubstring("404"), - )) - }) - - It("should handle workspace without dev domain gracefully", func() { - By("Creating a workspace (which might not have dev domain configured)") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - fmt.Printf("Create workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - - By("Attempting to wake up the workspace") - wakeupOutput, wakeupExitCode := intutil.RunCommandWithExitCode( - "wake-up", - "-w", workspaceId, - ) - fmt.Printf("Wake up workspace output: %s (exit code: %d)\n", wakeupOutput, wakeupExitCode) - - if wakeupExitCode != 0 { - Expect(wakeupOutput).To(Or( - ContainSubstring("development domain"), - ContainSubstring("dev domain"), - ContainSubstring("failed to wake up"), - )) - } - }) - }) - - Context("Wake Up Command Help", func() { - It("should display help information", func() { - By("Running wake-up --help") - output := intutil.RunCommand("wake-up", "--help") - fmt.Printf("Wake up help output: %s\n", output) - - Expect(output).To(ContainSubstring("Wake up an on-demand workspace")) - Expect(output).To(ContainSubstring("--timeout")) - Expect(output).To(ContainSubstring("-w, --workspace")) - }) - }) -}) - -var _ = Describe("Curl Workspace Integration Tests", func() { - var ( - teamId string - workspaceName string - workspaceId string - ) - - BeforeEach(func() { - teamId, _ = intutil.FailIfMissingEnvVars() - workspaceName = fmt.Sprintf("cli-curl-test-%d", time.Now().Unix()) - }) - - AfterEach(func() { - if workspaceId != "" { - By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) - intutil.CleanupWorkspace(workspaceId) - workspaceId = "" - } - }) - - Context("Curl Command", func() { - BeforeEach(func() { - By("Creating a workspace for curl testing") - output := intutil.RunCommand( - "create", "workspace", workspaceName, - "-t", teamId, - "-p", "8", - "--timeout", "15m", - ) - fmt.Printf("Create workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Workspace created")) - workspaceId = intutil.ExtractWorkspaceId(output) - Expect(workspaceId).NotTo(BeEmpty()) - - By("Waiting for workspace to be fully provisioned") - time.Sleep(5 * time.Second) - }) - - It("should send authenticated request to workspace", func() { - By("Sending curl request to workspace root") - output := intutil.RunCommand( - "curl", "/", - "-w", workspaceId, - "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", - ) - fmt.Printf("Curl workspace output: %s\n", output) - - Expect(output).To(ContainSubstring("Sending request to workspace")) - Expect(output).To(ContainSubstring(workspaceId)) - }) - - It("should support custom paths", func() { - By("Sending curl request to custom path") - output, exitCode := intutil.RunCommandWithExitCode( - "curl", "/api/health", - "-w", workspaceId, - "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", - ) - fmt.Printf("Curl with custom path output: %s (exit code: %d)\n", output, exitCode) - - Expect(output).To(ContainSubstring("Sending request to workspace")) - }) - - It("should pass through curl arguments", func() { - By("Sending HEAD request using curl -I flag") - output := intutil.RunCommand( - "curl", "/", - "-w", workspaceId, - "--", "-k", "-I", - ) - fmt.Printf("Curl with -I flag output: %s\n", output) - - Expect(output).To(ContainSubstring("Sending request to workspace")) - }) - - It("should work with workspace ID from environment variable", func() { - By("Setting CS_WORKSPACE_ID environment variable") - originalWsId := os.Getenv("CS_WORKSPACE_ID") - _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) - defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() - - By("Sending curl request using environment variable") - output := intutil.RunCommand( - "curl", "/", - "--", "-k", "-s", "-o", "/dev/null", "-w", "%{http_code}", - ) - fmt.Printf("Curl with env var output: %s\n", output) - - Expect(output).To(ContainSubstring("Sending request to workspace")) - Expect(output).To(ContainSubstring(workspaceId)) - }) - }) - - Context("Curl Error Handling", func() { - It("should fail when workspace ID is missing", func() { - By("Attempting to curl without workspace ID") - originalWsId := os.Getenv("CS_WORKSPACE_ID") - originalWsIdFallback := os.Getenv("WORKSPACE_ID") - _ = os.Unsetenv("CS_WORKSPACE_ID") - _ = os.Unsetenv("WORKSPACE_ID") - defer func() { - _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) - _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) - }() - - output, exitCode := intutil.RunCommandWithExitCode("curl", "/") - fmt.Printf("Curl without workspace ID output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("workspace"), - ContainSubstring("required"), - ContainSubstring("not set"), - )) - }) - - It("should fail gracefully with non-existent workspace", func() { - By("Attempting to curl non-existent workspace") - output, exitCode := intutil.RunCommandWithExitCode( - "curl", "/", - "-w", "99999999", - ) - fmt.Printf("Curl non-existent workspace output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("failed to get workspace"), - ContainSubstring("not found"), - ContainSubstring("404"), - )) - }) - - It("should require path argument", func() { - By("Attempting to curl without path") - output, exitCode := intutil.RunCommandWithExitCode( - "curl", - "-w", "1234", - ) - fmt.Printf("Curl without path output: %s (exit code: %d)\n", output, exitCode) - - Expect(exitCode).NotTo(Equal(0)) - Expect(output).To(Or( - ContainSubstring("path"), - ContainSubstring("required"), - ContainSubstring("argument"), - )) - }) - }) - - Context("Curl Command Help", func() { - It("should display help information", func() { - By("Running curl --help") - output := intutil.RunCommand("curl", "--help") - fmt.Printf("Curl help output: %s\n", output) - - Expect(output).To(ContainSubstring("Send authenticated HTTP requests")) - Expect(output).To(ContainSubstring("--timeout")) - Expect(output).To(ContainSubstring("-w, --workspace")) - }) - }) -}) - -var _ = Describe("Command Error Handling Tests", func() { - It("should fail gracefully with non-existent workspace for all commands", func() { - testCases := []struct { - commandName string - args []string - }{ - {"open workspace", []string{"open", "workspace", "-w", "99999999"}}, - {"log", []string{"log", "-w", "99999999"}}, - {"start pipeline", []string{"start", "pipeline", "-w", "99999999"}}, - {"git pull", []string{"git", "pull", "-w", "99999999"}}, - {"set-env", []string{"set-env", "-w", "99999999", "TEST_VAR=test"}}, - } - - for _, tc := range testCases { - By(fmt.Sprintf("Testing %s with non-existent workspace", tc.commandName)) - output, exitCode := intutil.RunCommandWithExitCode(tc.args...) - log.Printf("%s non-existent workspace output: %s (exit code: %d)\n", tc.commandName, output, exitCode) - Expect(exitCode).NotTo(Equal(0)) - } - }) -}) diff --git a/int/list_test.go b/int/list_test.go new file mode 100644 index 00000000..f0392adc --- /dev/null +++ b/int/list_test.go @@ -0,0 +1,124 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "log" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("List Command Tests", Label("list"), func() { + var teamId string + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + }) + + Context("List Workspaces", func() { + It("should list all workspaces in team with proper formatting", func() { + By("Listing workspaces") + output := intutil.RunCommand("list", "workspaces", "-t", teamId) + log.Printf("List workspaces output length: %d\n", len(output)) + + Expect(output).To(ContainSubstring("TEAM ID")) + Expect(output).To(ContainSubstring("ID")) + Expect(output).To(ContainSubstring("NAME")) + }) + }) + + Context("List Plans", func() { + It("should list all available plans", func() { + By("Listing plans") + output := intutil.RunCommand("list", "plans") + log.Printf("List plans output: %s\n", output) + + Expect(output).To(ContainSubstring("ID")) + Expect(output).To(ContainSubstring("NAME")) + Expect(output).To(Or( + ContainSubstring("Micro"), + ContainSubstring("Free"), + )) + }) + + It("should show plan details like CPU and RAM", func() { + By("Listing plans with details") + output := intutil.RunCommand("list", "plans") + log.Printf("Plan details output length: %d\n", len(output)) + + Expect(output).To(ContainSubstring("CPU")) + Expect(output).To(ContainSubstring("RAM")) + }) + }) + + Context("List Base Images", func() { + It("should list available base images", func() { + By("Listing base images") + output := intutil.RunCommand("list", "baseimages") + log.Printf("List baseimages output: %s\n", output) + + Expect(output).To(ContainSubstring("ID")) + Expect(output).To(ContainSubstring("NAME")) + }) + + It("should show Ubuntu images", func() { + By("Checking for Ubuntu in base images") + output := intutil.RunCommand("list", "baseimages") + + Expect(output).To(ContainSubstring("Ubuntu")) + }) + }) + + Context("List Teams", func() { + It("should list teams user has access to", func() { + By("Listing teams") + output := intutil.RunCommand("list", "teams") + log.Printf("List teams output: %s\n", output) + + Expect(output).To(ContainSubstring("ID")) + Expect(output).To(ContainSubstring("NAME")) + Expect(output).To(ContainSubstring(teamId)) + }) + + It("should show team role", func() { + By("Checking team roles") + output := intutil.RunCommand("list", "teams") + + Expect(output).To(Or( + ContainSubstring("Admin"), + ContainSubstring("Member"), + ContainSubstring("ROLE"), + )) + }) + }) + + Context("List Error Handling", func() { + It("should handle missing or invalid list subcommand", func() { + By("Running list without subcommand") + output, exitCode := intutil.RunCommandWithExitCode("list") + log.Printf("List without subcommand output: %s (exit code: %d)\n", output, exitCode) + Expect(output).To(Or( + ContainSubstring("Available Commands:"), + ContainSubstring("Usage:"), + )) + + By("Running list with invalid subcommand") + output, _ = intutil.RunCommandWithExitCode("list", "invalid") + log.Printf("List invalid output (first 200 chars): %s\n", output[:min(200, len(output))]) + Expect(output).To(Or( + ContainSubstring("Available Commands:"), + ContainSubstring("Usage:"), + )) + }) + + It("should require team ID for workspace listing when not set globally", func() { + By("Listing workspaces without team ID in specific contexts") + output := intutil.RunCommand("list", "workspaces", "-t", teamId) + + Expect(output).NotTo(BeEmpty()) + }) + }) +}) diff --git a/int/log_test.go b/int/log_test.go new file mode 100644 index 00000000..9b846d82 --- /dev/null +++ b/int/log_test.go @@ -0,0 +1,62 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Log Command Integration Tests", Label("log"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-log-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Log Command", func() { + BeforeEach(func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + }) + + It("should retrieve logs from workspace", func() { + By("Getting logs from workspace") + output, exitCode := intutil.RunCommandWithExitCode( + "log", + "-w", workspaceId, + ) + log.Printf("Log command output (first 500 chars): %s... (exit code: %d)\n", + output[:min(500, len(output))], exitCode) + + Expect(exitCode).To(Or(Equal(0), Equal(1))) + }) + }) +}) diff --git a/int/monitor_test.go b/int/monitor_test.go new file mode 100644 index 00000000..8468e446 --- /dev/null +++ b/int/monitor_test.go @@ -0,0 +1,350 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("cs monitor", Label("local"), func() { + var ( + certsDir string + tempDir string + caCertPath string + serverCertPath string + serverKeyPath string + monitorListenPort int + targetServerPort int + targetServer *http.Server + monitorCmdProcess *exec.Cmd + testHttpClient *http.Client + monitorOutputBuf *bytes.Buffer + targetServerOutputBuf *bytes.Buffer + ) + + BeforeEach(func() { + var err error + tempDir, err = os.MkdirTemp("", "e2e-tls-monitor-test-") + Expect(err).NotTo(HaveOccurred()) + certsDir = filepath.Join(tempDir, "certs") + + monitorListenPort, err = intutil.GetEphemeralPort() + Expect(err).NotTo(HaveOccurred()) + targetServerPort, err = intutil.GetEphemeralPort() + Expect(err).NotTo(HaveOccurred()) + + testHttpClient = &http.Client{ + Timeout: 10 * time.Second, + } + + monitorOutputBuf = new(bytes.Buffer) + targetServerOutputBuf = new(bytes.Buffer) + }) + + AfterEach(func() { + if monitorCmdProcess != nil && monitorCmdProcess.Process != nil { + log.Printf("Terminating monitor process (PID: %d). Output:\n%s\n", monitorCmdProcess.Process.Pid, monitorOutputBuf.String()) + _ = monitorCmdProcess.Process.Kill() + _, _ = monitorCmdProcess.Process.Wait() + } + + Expect(os.RemoveAll(tempDir)).NotTo(HaveOccurred()) + }) + + Context("Healthcheck forwarding", func() { + AfterEach(func() { + if targetServer != nil { + log.Printf("Terminating HTTP(S) server. Output:\n%s\n", targetServerOutputBuf.String()) + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 1*time.Second) + defer shutdownCancel() + _ = targetServer.Shutdown(shutdownCtx) + } + }) + + It("should start a Go HTTP server, and proxy successfully", func() { + var err error + + By("Starting Go HTTPS server with generated certs") + targetServer, err = intutil.StartTestHttpServer(targetServerPort) + Expect(err).NotTo(HaveOccurred()) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) + log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) + + By("Running 'cs monitor' command with --forward and --insecure-skip-verify") + intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--forward", fmt.Sprintf("http://127.0.0.1:%d/", targetServerPort), + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--", "sleep", "60s", + ) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + + By("Making request to monitor proxy to verify successful forwarding") + resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + bodyBytes, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(bodyBytes)).To(Equal("OK (HTTP)")) + + log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) + }) + + It("should start a Go HTTPS server with generated certs, run monitor with --insecure-skip-verify, and proxy successfully", func() { + By("Generating TLS certificates") + var err error + caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( + certsDir, + "localhost", + []string{"localhost", "127.0.0.1"}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(caCertPath).To(BeAnExistingFile()) + Expect(serverCertPath).To(BeAnExistingFile()) + Expect(serverKeyPath).To(BeAnExistingFile()) + + By("Starting Go HTTPS server with generated certs") + targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) + Expect(err).NotTo(HaveOccurred()) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) + log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) + + By("Running 'cs monitor' command with --forward and --insecure-skip-verify") + intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--insecure-skip-verify", + "--", "sleep", "60s", + ) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + + By("Making request to monitor proxy to verify successful forwarding") + resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + bodyBytes, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(bodyBytes)).To(Equal("OK (HTTPS)")) + + log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) + }) + + It("should get an error for an invalid HTTPS certificate without --insecure-skip-verify or --ca-cert-file", func() { + By("Generating TLS certificates in Go") + var err error + caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( + certsDir, + "localhost", + []string{"localhost", "127.0.0.1"}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(caCertPath).To(BeAnExistingFile()) + Expect(serverCertPath).To(BeAnExistingFile()) + Expect(serverKeyPath).To(BeAnExistingFile()) + + By("Starting Go HTTPS server with generated certs") + targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) + Expect(err).NotTo(HaveOccurred()) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) + log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) + + By("Running 'cs monitor' command without TLS bypass/trust") + intutil.RunCommandInBackground( + monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), + "--", "sleep", "60s", + ) + + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) + + By("Making request to monitor proxy and expecting a Bad Gateway error") + resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusBadGateway)) + bodyBytes, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(bodyBytes)).To(ContainSubstring("Error forwarding request")) + Expect(string(bodyBytes)).To(ContainSubstring("tls: failed to verify certificate")) + + log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) + }) + + It("should forward to an HTTPS target with --ca-cert-file and return 200 OK", func() { + By("Generating TLS certificates in Go") + var err error + caCertPath, serverCertPath, serverKeyPath, err = intutil.GenerateTLSCerts( + certsDir, + "localhost", + []string{"localhost", "127.0.0.1"}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(caCertPath).To(BeAnExistingFile()) + Expect(serverCertPath).To(BeAnExistingFile()) + Expect(serverKeyPath).To(BeAnExistingFile()) + + By("Starting Go HTTPS server with generated certs") + targetServer, err = intutil.StartTestHttpsServer(targetServerPort, serverCertPath, serverKeyPath) + Expect(err).NotTo(HaveOccurred()) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", targetServerPort), 10*time.Second) + log.Printf("Go HTTPS server started on port %d.\n", targetServerPort) + + By("Running 'cs monitor' command with --ca-cert-file") + intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--forward", fmt.Sprintf("https://127.0.0.1:%d/", targetServerPort), + "--ca-cert-file", caCertPath, + "--", + "sleep", "60s", + ) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) + + By("Making request to monitor proxy to verify successful forwarding") + resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + bodyBytes, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(bodyBytes)).To(Equal("OK (HTTPS)")) + + log.Printf("Monitor output after request:\n%s\n", monitorOutputBuf.String()) + }) + }) + + Context("Prometheus Metrics Endpoint", func() { + It("should expose Prometheus metrics when no forward is specified", func() { + By("Running 'cs monitor' command without forwarding (metrics only)") + monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--", "sleep", "60s", + ) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) + + By("Making a request to the monitor's metrics endpoint") + resp, err := testHttpClient.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + bodyBytes, err := io.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(bodyBytes)).To(ContainSubstring("cs_monitor_restarts_total")) + log.Printf("Monitor output after metrics request:\n%s\n", monitorOutputBuf.String()) + }) + + It("should redirect root to /metrics", func() { + By("Running 'cs monitor' command without forwarding (metrics only)") + monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--", "sleep", "60s", + ) + intutil.WaitForPort(fmt.Sprintf("127.0.0.1:%d", monitorListenPort), 10*time.Second) + log.Printf("Monitor command started on port %d. Initial output:\n%s\n", monitorListenPort, monitorOutputBuf.String()) + + By("Making a request to the monitor's root endpoint and expecting a redirect") + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + Timeout: 5 * time.Second, + } + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", monitorListenPort)) + Expect(err).NotTo(HaveOccurred()) + defer func() { _ = resp.Body.Close() }() + + Expect(resp.StatusCode).To(Equal(http.StatusMovedPermanently)) + Expect(resp.Header.Get("Location")).To(Equal("/metrics")) + log.Printf("Monitor output after redirect request:\n%s\n", monitorOutputBuf.String()) + }) + }) + + Context("Command Execution and Restart Logic", func() { + It("should execute the command once if it succeeds", func() { + monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--max-restarts", "0", + "--", "true", + ) + + Eventually(monitorCmdProcess.Wait, "5s").Should(Succeed(), "Monitor process should exit successfully") + + output := monitorOutputBuf.String() + Expect(output).To(ContainSubstring("command exited")) + Expect(output).To(ContainSubstring("returnCode=0")) + Expect(output).To(ContainSubstring("maximum number of restarts reached, exiting")) + Expect(strings.Count(output, "command exited")).To(Equal(1), "Command should have executed only once") + }) + + It("should restart the command if it exits with non-zero code quickly", func() { + monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--max-restarts", "1", + "--", "bash", "-c", "echo FAKE_OUTPUT;exit 1", + ) + + Eventually(monitorCmdProcess.Wait, "15s").Should(Succeed(), "Monitor process should exit after restarts") + + output := monitorOutputBuf.String() + Expect(output).To(ContainSubstring("command exited")) + Expect(output).To(ContainSubstring("returnCode=1")) + Expect(output).To(ContainSubstring("command exited with non-zero code in less than 1 second. Waiting 5 seconds before next restart")) + Expect(output).To(ContainSubstring("cs monitor: restarting")) + Expect(output).To(ContainSubstring("maximum number of restarts reached, exiting")) + Expect(strings.Count(output, "FAKE_OUTPUT")).To(Equal(3), "Command should have executed twice") + }) + + It("should stop command runner on context cancellation", func() { + By("Running 'cs monitor' command with infinite restarts") + monitorCmdProcess = intutil.RunCommandInBackground(monitorOutputBuf, + "monitor", + "--address", fmt.Sprintf(":%d", monitorListenPort), + "--max-restarts", "-1", + "--", "sleep", "10s", + ) + Eventually(func() string { return monitorOutputBuf.String() }, "5s").Should(ContainSubstring("starting monitor")) + + By("Stopping command execution") + err := monitorCmdProcess.Process.Signal(os.Interrupt) + Expect(err).NotTo(HaveOccurred()) + _, _ = monitorCmdProcess.Process.Wait() + + output := monitorOutputBuf.String() + Expect(output).To(ContainSubstring("initiating graceful shutdown...")) + Expect(output).To(ContainSubstring("stopping command runner.")) + Expect(output).NotTo(ContainSubstring("error executing command")) + }) + }) +}) diff --git a/int/pipeline_test.go b/int/pipeline_test.go new file mode 100644 index 00000000..ea7ff6f3 --- /dev/null +++ b/int/pipeline_test.go @@ -0,0 +1,61 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Start Pipeline Integration Tests", Label("pipeline"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-pipeline-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Start Pipeline Command", func() { + BeforeEach(func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + }) + + It("should start pipeline successfully", func() { + By("Starting pipeline") + output, exitCode := intutil.RunCommandWithExitCode( + "start", "pipeline", + "-w", workspaceId, + ) + log.Printf("Start pipeline output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).NotTo(BeEmpty()) + }) + }) +}) diff --git a/int/version_help_test.go b/int/version_help_test.go new file mode 100644 index 00000000..5bda6461 --- /dev/null +++ b/int/version_help_test.go @@ -0,0 +1,115 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + "strings" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Version and Help Tests", Label("local"), func() { + Context("Version Command", func() { + It("should display version information", func() { + By("Running version command") + output := intutil.RunCommand("version") + log.Printf("Version output: %s\n", output) + + Expect(output).To(Or( + ContainSubstring("version"), + ContainSubstring("Version"), + MatchRegexp(`\d+\.\d+\.\d+`), + )) + }) + }) + + Context("Help Commands", func() { + It("should display main help", func() { + By("Running help command") + output := intutil.RunCommand("--help") + log.Printf("Help output length: %d\n", len(output)) + + Expect(output).To(ContainSubstring("Usage:")) + Expect(output).To(ContainSubstring("Available Commands:")) + }) + + It("should display help for all subcommands", func() { + testCases := []struct { + command []string + shouldMatch string + }{ + {[]string{"create", "--help"}, "workspace"}, + {[]string{"exec", "--help"}, "exec"}, + {[]string{"log", "--help"}, "log"}, + {[]string{"start", "pipeline", "--help"}, "pipeline"}, + {[]string{"git", "pull", "--help"}, "pull"}, + {[]string{"set-env", "--help"}, "set-env"}, + } + + for _, tc := range testCases { + By(fmt.Sprintf("Testing %v", tc.command)) + output := intutil.RunCommand(tc.command...) + Expect(output).To(ContainSubstring("Usage:")) + Expect(output).To(ContainSubstring(tc.shouldMatch)) + } + }) + }) + + Context("Invalid Commands", func() { + It("should handle unknown commands gracefully", func() { + By("Running unknown command") + output, exitCode := intutil.RunCommandWithExitCode("unknowncommand") + log.Printf("Unknown command output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("unknown command"), + ContainSubstring("Error:"), + )) + }) + + It("should suggest similar commands for typos", func() { + By("Running misspelled command") + output, exitCode := intutil.RunCommandWithExitCode("listt") + log.Printf("Typo command output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + lowerOutput := strings.ToLower(output) + Expect(lowerOutput).To(Or( + ContainSubstring("unknown"), + ContainSubstring("error"), + ContainSubstring("did you mean"), + )) + }) + }) + + Context("Global Flags", func() { + It("should accept all global flags", func() { + By("Testing --api flag") + output := intutil.RunCommand( + "--api", "https://example.com/api", + "list", "teams", + ) + Expect(output).NotTo(ContainSubstring("unknown flag")) + + By("Testing --verbose flag") + output = intutil.RunCommand( + "--verbose", + "list", "plans", + ) + Expect(output).NotTo(ContainSubstring("unknown flag")) + + By("Testing -v shorthand") + output = intutil.RunCommand( + "-v", + "list", "baseimages", + ) + Expect(output).NotTo(ContainSubstring("unknown flag")) + }) + }) +}) diff --git a/int/wakeup_test.go b/int/wakeup_test.go new file mode 100644 index 00000000..4be418ce --- /dev/null +++ b/int/wakeup_test.go @@ -0,0 +1,188 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "os" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Wake Up Workspace Integration Tests", Label("wakeup"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-wakeup-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Wake Up Command", func() { + BeforeEach(func() { + By("Creating a workspace for wake-up testing") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Waiting for workspace to be fully provisioned") + time.Sleep(5 * time.Second) + }) + + It("should wake up workspace successfully", func() { + By("Waking up the workspace") + output := intutil.RunCommand( + "wake-up", + "-w", workspaceId, + ) + fmt.Printf("Wake up workspace output: %s\n", output) + + Expect(output).To(Or( + ContainSubstring("Waking up workspace"), + // The workspace might already be running + ContainSubstring("is already running"), + )) + Expect(output).To(ContainSubstring(workspaceId)) + }) + + It("should respect custom timeout", func() { + By("Waking up workspace with custom timeout") + output, exitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", workspaceId, + "--timeout", "5s", + ) + fmt.Printf("Wake up with timeout output: %s (exit code: %d)\n", output, exitCode) + + Expect(output).To(Or( + ContainSubstring("Waking up workspace"), + // The workspace might already be running + ContainSubstring("is already running"), + )) + Expect(exitCode).To(Equal(0)) + }) + + It("should work with workspace ID from environment variable", func() { + By("Setting CS_WORKSPACE_ID environment variable") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + _ = os.Setenv("CS_WORKSPACE_ID", workspaceId) + defer func() { _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) }() + + By("Waking up workspace using environment variable") + output := intutil.RunCommand("wake-up") + fmt.Printf("Wake up with env var output: %s\n", output) + + Expect(output).To(Or( + ContainSubstring("Waking up workspace"), + // The workspace might already be running + ContainSubstring("is already running"), + )) + Expect(output).To(ContainSubstring(workspaceId)) + }) + }) + + Context("Wake Up Error Handling", func() { + It("should fail when workspace ID is missing", func() { + By("Attempting to wake up workspace without ID") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + originalWsIdFallback := os.Getenv("WORKSPACE_ID") + _ = os.Unsetenv("CS_WORKSPACE_ID") + _ = os.Unsetenv("WORKSPACE_ID") + defer func() { + _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) + _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) + }() + + output, exitCode := intutil.RunCommandWithExitCode("wake-up") + fmt.Printf("Wake up without workspace ID output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("workspace"), + ContainSubstring("required"), + ContainSubstring("not set"), + )) + }) + + It("should fail gracefully with non-existent workspace", func() { + By("Attempting to wake up non-existent workspace") + output, exitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", "99999999", + ) + fmt.Printf("Wake up non-existent workspace output: %s (exit code: %d)\n", output, exitCode) + + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("failed to get workspace"), + ContainSubstring("not found"), + ContainSubstring("404"), + )) + }) + + It("should handle workspace without dev domain gracefully", func() { + By("Creating a workspace (which might not have dev domain configured)") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + fmt.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Attempting to wake up the workspace") + wakeupOutput, wakeupExitCode := intutil.RunCommandWithExitCode( + "wake-up", + "-w", workspaceId, + ) + fmt.Printf("Wake up workspace output: %s (exit code: %d)\n", wakeupOutput, wakeupExitCode) + + if wakeupExitCode != 0 { + Expect(wakeupOutput).To(Or( + ContainSubstring("development domain"), + ContainSubstring("dev domain"), + ContainSubstring("failed to wake up"), + )) + } + }) + }) + + Context("Wake Up Command Help", func() { + It("should display help information", func() { + By("Running wake-up --help") + output := intutil.RunCommand("wake-up", "--help") + fmt.Printf("Wake up help output: %s\n", output) + + Expect(output).To(ContainSubstring("Wake up an on-demand workspace")) + Expect(output).To(ContainSubstring("--timeout")) + Expect(output).To(ContainSubstring("-w, --workspace")) + }) + }) +}) diff --git a/int/workspace_test.go b/int/workspace_test.go new file mode 100644 index 00000000..58492318 --- /dev/null +++ b/int/workspace_test.go @@ -0,0 +1,271 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package int_test + +import ( + "fmt" + "log" + "os" + "strings" + "time" + + intutil "github.com/codesphere-cloud/cs-go/int/util" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Open Workspace Integration Tests", Label("workspace"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-open-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Open Workspace Command", func() { + BeforeEach(func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + log.Printf("Create workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + }) + + It("should open workspace successfully", func() { + By("Opening the workspace") + output := intutil.RunCommand( + "open", "workspace", + "-w", workspaceId, + ) + log.Printf("Open workspace output: %s\n", output) + + Expect(output).To(ContainSubstring("Opening workspace")) + Expect(output).To(ContainSubstring(workspaceId)) + }) + }) + + Context("Open Workspace Error Handling", func() { + It("should fail when workspace ID is missing", func() { + By("Attempting to open workspace without ID") + originalWsId := os.Getenv("CS_WORKSPACE_ID") + originalWsIdFallback := os.Getenv("WORKSPACE_ID") + _ = os.Unsetenv("CS_WORKSPACE_ID") + _ = os.Unsetenv("WORKSPACE_ID") + defer func() { + _ = os.Setenv("CS_WORKSPACE_ID", originalWsId) + _ = os.Setenv("WORKSPACE_ID", originalWsIdFallback) + }() + + output, exitCode := intutil.RunCommandWithExitCode( + "open", "workspace", + ) + log.Printf("Open without workspace ID output: %s (exit code: %d)\n", output, exitCode) + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("workspace"), + ContainSubstring("required"), + )) + }) + }) +}) + +var _ = Describe("Workspace Edge Cases and Advanced Operations", Label("workspace"), func() { + var ( + teamId string + workspaceName string + workspaceId string + ) + + BeforeEach(func() { + teamId, _ = intutil.FailIfMissingEnvVars() + workspaceName = fmt.Sprintf("cli-edge-test-%d", time.Now().Unix()) + }) + + AfterEach(func() { + if workspaceId != "" { + By(fmt.Sprintf("Cleaning up: deleting workspace %s (ID: %s)", workspaceName, workspaceId)) + intutil.CleanupWorkspace(workspaceId) + workspaceId = "" + } + }) + + Context("Workspace Creation Edge Cases", func() { + It("should create a workspace with a very long name", func() { + longName := fmt.Sprintf("cli-very-long-workspace-name-test-%d", time.Now().Unix()) + By("Creating a workspace with a long name") + output := intutil.RunCommand( + "create", "workspace", longName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + log.Printf("Create workspace with long name output: %s\n", output) + + if output != "" && !strings.Contains(output, "error") { + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + } + }) + + It("should handle creation timeout gracefully", func() { + By("Creating a workspace with very short timeout") + output, exitCode := intutil.RunCommandWithExitCode( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "1s", + ) + log.Printf("Create with short timeout output: %s (exit code: %d)\n", output, exitCode) + + if exitCode != 0 { + Expect(output).To(Or( + ContainSubstring("timeout"), + ContainSubstring("timed out"), + )) + } else if strings.Contains(output, "Workspace created") { + workspaceId = intutil.ExtractWorkspaceId(output) + } + }) + }) + + Context("Exec Command Edge Cases", func() { + BeforeEach(func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + }) + + It("should execute commands with multiple arguments", func() { + By("Executing a command with multiple arguments") + output := intutil.RunCommand( + "exec", + "-w", workspaceId, + "--", + "sh", "-c", "echo test1 && echo test2", + ) + log.Printf("Exec with multiple args output: %s\n", output) + Expect(output).To(ContainSubstring("test1")) + Expect(output).To(ContainSubstring("test2")) + }) + + It("should handle commands that output to stderr", func() { + By("Executing a command that writes to stderr") + output := intutil.RunCommand( + "exec", + "-w", workspaceId, + "--", + "sh", "-c", "echo error message >&2", + ) + log.Printf("Exec with stderr output: %s\n", output) + Expect(output).To(ContainSubstring("error message")) + }) + + It("should handle commands with exit codes", func() { + By("Executing a command that exits with non-zero code") + output, exitCode := intutil.RunCommandWithExitCode( + "exec", + "-w", workspaceId, + "--", + "sh", "-c", "exit 42", + ) + log.Printf("Exec with exit code output: %s (exit code: %d)\n", output, exitCode) + }) + + It("should execute long-running commands", func() { + By("Executing a command that takes a few seconds") + output := intutil.RunCommand( + "exec", + "-w", workspaceId, + "--", + "sh", "-c", "sleep 2 && echo completed", + ) + log.Printf("Exec long-running command output: %s\n", output) + Expect(output).To(ContainSubstring("completed")) + }) + }) + + Context("Workspace Deletion Edge Cases", func() { + It("should prevent deletion without confirmation when not forced", func() { + By("Creating a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + workspaceId = intutil.ExtractWorkspaceId(output) + Expect(workspaceId).NotTo(BeEmpty()) + + By("Attempting to delete without --yes flag") + output = intutil.RunCommand( + "delete", "workspace", + "-w", workspaceId, + "--yes", + ) + log.Printf("Delete with confirmation output: %s\n", output) + Expect(output).To(ContainSubstring("deleted")) + workspaceId = "" + }) + + It("should fail gracefully when deleting already deleted workspace", func() { + By("Creating and deleting a workspace") + output := intutil.RunCommand( + "create", "workspace", workspaceName, + "-t", teamId, + "-p", "8", + "--timeout", "15m", + ) + Expect(output).To(ContainSubstring("Workspace created")) + tempWsId := intutil.ExtractWorkspaceId(output) + + output = intutil.RunCommand( + "delete", "workspace", + "-w", tempWsId, + "--yes", + ) + Expect(output).To(ContainSubstring("deleted")) + + By("Attempting to delete the same workspace again") + output, exitCode := intutil.RunCommandWithExitCode( + "delete", "workspace", + "-w", tempWsId, + "--yes", + ) + log.Printf("Delete already deleted workspace output: %s (exit code: %d)\n", output, exitCode) + Expect(exitCode).NotTo(Equal(0)) + Expect(output).To(Or( + ContainSubstring("error"), + ContainSubstring("failed"), + ContainSubstring("not found"), + )) + }) + }) +}) From 5ec6ec9ead7b05fe364ae5ae17088d86bb518895 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:17:13 +0100 Subject: [PATCH 14/16] feat: refactor integration tests to use matrix strategy for improved scalability --- .github/workflows/integration-test.yml | 98 +++++++------------------- 1 file changed, 25 insertions(+), 73 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index b8fd54f7..fc787b46 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -8,6 +8,11 @@ on: jobs: integration-tests: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + label: [workspace, list, error-handling, log, pipeline, git, wakeup, curl, local, unlabeled] + name: Integration Tests (${{ matrix.label }}) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -16,92 +21,39 @@ jobs: with: go-version-file: 'go.mod' - - name: Integration Tests - workspace - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-workspace - - - name: Integration Tests - list - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-list - - - name: Integration Tests - error-handling - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-error-handling - - - name: Integration Tests - log - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-log - - - name: Integration Tests - pipeline - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-pipeline + - name: Warn about unlabeled tests + if: matrix.label == 'unlabeled' + run: echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." - - name: Integration Tests - git - if: always() + - name: Run integration tests env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-git - - - name: Integration Tests - wakeup - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-wakeup + CS_TOKEN: ${{ matrix.label != 'local' && secrets.CS_TOKEN || '' }} + CS_API: ${{ matrix.label != 'local' && secrets.CS_API || '' }} + CS_TEAM_ID: ${{ matrix.label != 'local' && secrets.CS_TEAM_ID || '' }} + run: make test-int-${{ matrix.label }} - - name: Integration Tests - curl - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: make test-int-curl + integration-tests-cleanup: + runs-on: ubuntu-latest + needs: integration-tests + if: always() + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Integration Tests - local - if: always() - run: make test-int-local + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version-file: 'go.mod' - - name: Integration Tests - unlabeled (label missing!) - if: always() - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} - run: | - echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." - make test-int-unlabeled + - name: Build CLI + run: make build - name: Cleanup Orphaned Test Resources - if: always() # Run even if tests fail env: CS_TOKEN: ${{ secrets.CS_TOKEN }} CS_API: ${{ secrets.CS_API }} CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} run: | echo "Cleaning up any orphaned test workspaces..." - # List all workspaces and delete any with test name prefixes ./cs list workspaces -t $CS_TEAM_ID | grep -E "cli-(test|git-test|pipeline-test|log-test|sync-test|open-test|setenv-test|edge-test|wakeup-test|curl-test)-" | awk '{print $2}' | while read ws_id; do if [ ! -z "$ws_id" ]; then echo "Deleting orphaned workspace: $ws_id" From 59094811f98ae9cfc480dbb926c2147ec21eb7b9 Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:24:53 +0100 Subject: [PATCH 15/16] ref: streamline integration tests execution and cleanup process --- .github/workflows/integration-test.yml | 48 +++++++++++--------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index fc787b46..79e95175 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -8,11 +8,6 @@ on: jobs: integration-tests: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - label: [workspace, list, error-handling, log, pipeline, git, wakeup, curl, local, unlabeled] - name: Integration Tests (${{ matrix.label }}) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -21,33 +16,30 @@ jobs: with: go-version-file: 'go.mod' - - name: Warn about unlabeled tests - if: matrix.label == 'unlabeled' - run: echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." - - name: Run integration tests env: - CS_TOKEN: ${{ matrix.label != 'local' && secrets.CS_TOKEN || '' }} - CS_API: ${{ matrix.label != 'local' && secrets.CS_API || '' }} - CS_TEAM_ID: ${{ matrix.label != 'local' && secrets.CS_TEAM_ID || '' }} - run: make test-int-${{ matrix.label }} - - integration-tests-cleanup: - runs-on: ubuntu-latest - needs: integration-tests - if: always() - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 - with: - go-version-file: 'go.mod' - - - name: Build CLI - run: make build + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + run: | + failed="" + for label in workspace list error-handling log pipeline git wakeup curl local unlabeled; do + echo "::group::Integration Tests - $label" + if [ "$label" = "unlabeled" ]; then + echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." + fi + if ! make test-int-$label; then + failed="$failed $label" + fi + echo "::endgroup::" + done + if [ -n "$failed" ]; then + echo "::error::Failed labels:$failed" + exit 1 + fi - name: Cleanup Orphaned Test Resources + if: always() env: CS_TOKEN: ${{ secrets.CS_TOKEN }} CS_API: ${{ secrets.CS_API }} From 872d998823bef3cb9db2d5c2397ca4186c43edbd Mon Sep 17 00:00:00 2001 From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:28:49 +0100 Subject: [PATCH 16/16] ref: streamline integration tests setup and execution --- .github/workflows/integration-test.yml | 63 ++++++++++++++++++-------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 79e95175..8a0b11d7 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -8,6 +8,10 @@ on: jobs: integration-tests: runs-on: ubuntu-latest + env: + CS_TOKEN: ${{ secrets.CS_TOKEN }} + CS_API: ${{ secrets.CS_API }} + CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -16,27 +20,46 @@ jobs: with: go-version-file: 'go.mod' - - name: Run integration tests - env: - CS_TOKEN: ${{ secrets.CS_TOKEN }} - CS_API: ${{ secrets.CS_API }} - CS_TEAM_ID: ${{ secrets.CS_TEAM_ID }} + - name: Run integration tests - workspace + run: make test-int-workspace + + - name: Run integration tests - list + if: always() + run: make test-int-list + + - name: Run integration tests - error-handling + if: always() + run: make test-int-error-handling + + - name: Run integration tests - log + if: always() + run: make test-int-log + + - name: Run integration tests - pipeline + if: always() + run: make test-int-pipeline + + - name: Run integration tests - git + if: always() + run: make test-int-git + + - name: Run integration tests - wakeup + if: always() + run: make test-int-wakeup + + - name: Run integration tests - curl + if: always() + run: make test-int-curl + + - name: Run integration tests - local + if: always() + run: make test-int-local + + - name: Run integration tests - unlabeled + if: always() run: | - failed="" - for label in workspace list error-handling log pipeline git wakeup curl local unlabeled; do - echo "::group::Integration Tests - $label" - if [ "$label" = "unlabeled" ]; then - echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." - fi - if ! make test-int-$label; then - failed="$failed $label" - fi - echo "::endgroup::" - done - if [ -n "$failed" ]; then - echo "::error::Failed labels:$failed" - exit 1 - fi + echo "::warning::Running tests without a known label. If tests are found here, please add a label to the Describe block." + make test-int-unlabeled - name: Cleanup Orphaned Test Resources if: always()