Repository: openops-cloud/openops
Pull request #318: Add validation for new Google Cloud connections
Author: login: MarceloRGonc
Created: 2025-03-27T11:57:55Z | Merged: 2025-03-27T15:05:12Z

Part of OPS-1427




Diff:
diff --git a/packages/blocks/google-cloud/src/lib/google-cloud-cli.ts b/packages/blocks/google-cloud/src/lib/google-cloud-cli.ts
index 3fc90cf04c..c3b4850881 100644
--- a/packages/blocks/google-cloud/src/lib/google-cloud-cli.ts
+++ b/packages/blocks/google-cloud/src/lib/google-cloud-cli.ts
@@ -1,8 +1,8 @@
-import { runCliCommand, useTempFile } from '@openops/common';
-import fs from 'fs/promises';
-import os from 'os';
-import path from 'path';
-
+import {
+  getDefaultCloudSDKConfig,
+  loginGCPWithKeyObject,
+  runCliCommand,
+} from '@openops/common';
 export async function runCommand(
   command: string,
   auth: any,
@@ -20,11 +20,8 @@ export async function runCommand(
   }
 
   if (!shouldUseHostCredentials) {
-    const gcpConfigDir = await fs.mkdtemp(
-      path.join(os.tmpdir(), 'gcloud-config'),
-    );
+    const gcpConfigDir = await getDefaultCloudSDKConfig();
     envVars['CLOUDSDK_CONFIG'] = gcpConfigDir;
-
     await loginGCPWithKeyObject(auth.keyFileContent, envVars);
   }
 
@@ -38,12 +35,3 @@ export async function runCommand(
 
   return await runCliCommand(command, 'gcloud', envVars);
 }
-
-async function loginGCPWithKeyObject(keyObject: string, envVars: any) {
-  const result = await useTempFile(keyObject, async (filePath) => {
-    const loginCommand = `gcloud auth activate-service-account --key-file=${filePath}`;
-    return await runCliCommand(loginCommand, 'gcloud', envVars);
-  });
-
-  return result;
-}
diff --git a/packages/blocks/google-cloud/test/google-cloud-cli.test.ts b/packages/blocks/google-cloud/test/google-cloud-cli.test.ts
index fb335c3a3a..ea6bbc679f 100644
--- a/packages/blocks/google-cloud/test/google-cloud-cli.test.ts
+++ b/packages/blocks/google-cloud/test/google-cloud-cli.test.ts
@@ -1,17 +1,15 @@
+const getDefaultCloudSDKConfigMock = jest
+  .fn()
+  .mockResolvedValue('/tmp/gcloud-config-abc');
+const loginGCPWithKeyObjectMock = jest.fn();
 const runCliCommandMock = jest.fn();
-const useTempFileMock = jest.fn();
 
 jest.mock('@openops/common', () => ({
+  getDefaultCloudSDKConfig: getDefaultCloudSDKConfigMock,
+  loginGCPWithKeyObject: loginGCPWithKeyObjectMock,
   runCliCommand: runCliCommandMock,
-  useTempFile: useTempFileMock,
 }));
 
-const fsPromisesMock = {
-  ...jest.requireActual('fs/promises'),
-  mkdtemp: jest.fn(),
-};
-jest.mock('fs/promises', () => fsPromisesMock);
-
 import { runCommand } from '../src/lib/google-cloud-cli';
 
 describe('Google cloud runCommand', () => {
@@ -22,13 +20,9 @@ describe('Google cloud runCommand', () => {
   });
 
   test('calls login, sets project, and runs command', async () => {
-    fsPromisesMock.mkdtemp.mockResolvedValue('/tmp/gcloud-config-abc');
-    useTempFileMock.mockImplementation(async (_contents, callback) => {
-      return callback('/tmp/mock-key-file.json');
-    });
+    loginGCPWithKeyObjectMock.mockResolvedValue('login success');
 
     runCliCommandMock
-      .mockResolvedValueOnce('login success')
       .mockResolvedValueOnce('project set success')
       .mockResolvedValueOnce('command output');
 
@@ -41,27 +35,12 @@ describe('Google cloud runCommand', () => {
 
     expect(result).toBe('command output');
 
-    expect(fsPromisesMock.mkdtemp).toHaveBeenCalledWith(
-      expect.stringMatching(/^\/tmp\/gcloud-config/),
-    );
-
-    expect(useTempFileMock).toHaveBeenCalledWith(
-      keyFileContent,
-      expect.any(Function),
-    );
+    expect(runCliCommandMock).toHaveBeenCalledTimes(2);
+    expect(loginGCPWithKeyObjectMock).toHaveBeenCalledTimes(1);
+    expect(getDefaultCloudSDKConfigMock).toHaveBeenCalledTimes(1);
 
     expect(runCliCommandMock).toHaveBeenNthCalledWith(
       1,
-      'gcloud auth activate-service-account --key-file=/tmp/mock-key-file.json',
-      'gcloud',
-      expect.objectContaining({
-        CLOUDSDK_CONFIG: '/tmp/gcloud-config-abc',
-        PATH: expect.any(String),
-      }),
-    );
-
-    expect(runCliCommandMock).toHaveBeenNthCalledWith(
-      2,
       'gcloud config set project my-project',
       'gcloud',
       expect.objectContaining({
@@ -71,7 +50,7 @@ describe('Google cloud runCommand', () => {
     );
 
     expect(runCliCommandMock).toHaveBeenNthCalledWith(
-      3,
+      2,
       'gcloud compute instances list',
       'gcloud',
       expect.objectContaining({
@@ -92,8 +71,7 @@ describe('Google cloud runCommand', () => {
     );
 
     expect(result).toBe('command output');
-    expect(fsPromisesMock.mkdtemp).not.toHaveBeenCalled();
-    expect(useTempFileMock).not.toHaveBeenCalled();
+    expect(loginGCPWithKeyObjectMock).not.toHaveBeenCalled();
 
     expect(runCliCommandMock).toHaveBeenCalledWith(
       'gcloud info',
diff --git a/packages/openops/src/lib/google-cloud/auth.ts b/packages/openops/src/lib/google-cloud/auth.ts
index 1cc697904a..b09c267e08 100644
--- a/packages/openops/src/lib/google-cloud/auth.ts
+++ b/packages/openops/src/lib/google-cloud/auth.ts
@@ -1,5 +1,10 @@
 import { BlockAuth } from '@openops/blocks-framework';
 import { SharedSystemProp, system } from '@openops/server-shared';
+import fs from 'fs/promises';
+import os from 'os';
+import path from 'path';
+import { runCliCommand } from '../cli-command-wrapper';
+import { useTempFile } from '../use-temp-file';
 
 const enableHostSession =
   system.getBoolean(SharedSystemProp.ENABLE_HOST_SESSION) ?? false;
@@ -13,4 +18,42 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
+      return {
+        valid: true,
+      };
+    } catch (e) {
+      return {
+        valid: false,
+        error: (e as Error).message,
+      };
+    }
+  },
 });
+
+async function runAuthCommand(keyObject: string): Promise<string> {
+  const gcpConfigDir = await getDefaultCloudSDKConfig();
+
+  const envVars: Record<string, string> = {
+    PATH: process.env['PATH'] || '',
+    CLOUDSDK_CORE_DISABLE_PROMPTS: '1',
+    CLOUDSDK_CONFIG: gcpConfigDir,
+  };
+
+  return await loginGCPWithKeyObject(keyObject, envVars);
+}
+
+export async function getDefaultCloudSDKConfig(): Promise<string> {
+  return await fs.mkdtemp(path.join(os.tmpdir(), 'gcloud-config'));
+}
+
+export async function loginGCPWithKeyObject(keyObject: string, envVars: any) {
+  const result = await useTempFile(keyObject, async (filePath) => {
+    const loginCommand = `gcloud auth activate-service-account --key-file=${filePath}`;
+    return await runCliCommand(loginCommand, 'gcloud', envVars);
+  });
+
+  return result;
+}
diff --git a/packages/openops/test/google-cloud/auth.test.ts b/packages/openops/test/google-cloud/auth.test.ts
new file mode 100644
index 0000000000..6d59b8a779
--- /dev/null
+++ b/packages/openops/test/google-cloud/auth.test.ts
@@ -0,0 +1,54 @@
+const runCliCommandMock = jest.fn();
+const useTempFileMock = jest.fn();
+
+jest.mock('../../src/lib/cli-command-wrapper', () => ({
+  runCliCommand: runCliCommandMock,
+}));
+
+jest.mock('../../src/lib/use-temp-file', () => ({
+  useTempFile: useTempFileMock,
+}));
+
+const fsPromisesMock = {
+  ...jest.requireActual('fs/promises'),
+  mkdtemp: jest.fn(),
+};
+jest.mock('fs/promises', () => fsPromisesMock);
+
+import { loginGCPWithKeyObject } from '../../src/lib/google-cloud/auth';
+
+describe('Google cloud login with key', () => {
+  const keyFileContent = 'key file content';
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+  });
+
+  test('should call the login command with a temporary file', async () => {
+    fsPromisesMock.mkdtemp.mockResolvedValue('/tmp/gcloud-config-abc');
+    useTempFileMock.mockImplementation(async (_contents, callback) => {
+      return callback('/tmp/mock-key-file.json');
+    });
+
+    runCliCommandMock.mockResolvedValueOnce('login success');
+    const envVar = {
+      PATH: 'path/to/env',
+    };
+    const result = await loginGCPWithKeyObject(keyFileContent, envVar);
+
+    expect(result).toBe('login success');
+
+    expect(useTempFileMock).toHaveBeenCalledWith(
+      keyFileContent,
+      expect.any(Function),
+    );
+    expect(runCliCommandMock).toHaveBeenCalledTimes(1);
+    expect(runCliCommandMock).toHaveBeenCalledWith(
+      'gcloud auth activate-service-account --key-file=/tmp/mock-key-file.json',
+      'gcloud',
+      expect.objectContaining({
+        ...envVar,
+      }),
+    );
+  });
+});


Reviews:
user: login: bigfluffycookie
body: looks good, just needs the tests fixed 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:08:12Z
user: login: rita-gorokhod
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:28:17Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:59:32Z
user: login: rita-gorokhod
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:28:47Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:48:07Z
user: login: MarceloRGonc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T14:21:41Z
user: login: rita-gorokhod
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T14:28:07Z

Review comments:
diff_hunk: @@ -13,4 +18,40 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
path: packages/openops/src/lib/google-cloud/auth.ts
user: login: rita-gorokhod
body: so if this doesn't work, it's just going to get stuck forever until it times out?
created_at: 2025-03-27T12:28:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,4 +18,40 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
path: packages/openops/src/lib/google-cloud/auth.ts
user: login: MarceloRGonc
body: This is just the login command. If the command takes a long time, yes. But it's the same for properties.
created_at: 2025-03-27T12:59:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,4 +18,40 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
path: packages/openops/src/lib/google-cloud/auth.ts
user: login: rita-gorokhod
body: The question is will the user eventually see an error, or is it going to get stuck for a minute+ and not show anything?
Will the user see a descriptive message that there are missing permissions?
created_at: 2025-03-27T13:28:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,4 +18,40 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
path: packages/openops/src/lib/google-cloud/auth.ts
user: login: MarceloRGonc
body: The user will see an error on the popup
created_at: 2025-03-27T13:48:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -13,4 +18,40 @@ export const googleCloudAuth = BlockAuth.CustomAuth({
     }),
   },
   required: !enableHostSession,
+  validate: async ({ auth }) => {
+    try {
+      await runAuthCommand(auth.keyFileContent);
path: packages/openops/src/lib/google-cloud/auth.ts
user: login: MarceloRGonc
body: <img width="884" alt="image" src="https://github.com/user-attachments/assets/fe4d8e53-242a-4cc9-ab07-7bfca342e432" />
created_at: 2025-03-27T14:21:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-27T11:58:55Z
body: <p><a href="https://linear.app/openops/issue/OPS-1427/add-gcp-cli-block">OPS-1427 Add GCP CLI block</a></p>
author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-27T14:24:06Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=318) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=318&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=openops-cloud_openops&pullRequest=318&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=openops-cloud_openops&pullRequest=318&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=318&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=openops-cloud_openops&pullRequest=318&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=openops-cloud_openops&pullRequest=318)


author_association: NONE