diff --git a/gce/test/app.test.js b/gce/test/app.test.js index e0e84b9aa..496bbfe04 100644 --- a/gce/test/app.test.js +++ b/gce/test/app.test.js @@ -3,90 +3,211 @@ const path = require('path'); const fetch = require('node-fetch'); const {expect} = require('chai'); const {v4: uuidv4} = require('uuid'); -let testFlag = true; -let uniqueID; -let externalIP; -async function pingVMExponential(address, count) { - await new Promise((r) => setTimeout(r, Math.pow(2, count) * 1000)); +// Configuration Constants +const GCP_ZONE = 'us-central1-f'; +const IMAGE_FAMILY = 'debian-12'; +const IMAGE_PROJECT = 'debian-cloud'; +const MACHINE_TYPE = 'g1-small'; +const APP_PORT = '8080'; +const STARTUP_SCRIPT_PATH = 'gce/startup-script.sh'; // Relative to project root +const MAX_PING_ATTEMPTS = 10; +const INITIAL_PING_DELAY_SECONDS = 2; + +async function pingVMExponential(address, attempt = 1) { + if (attempt > MAX_PING_ATTEMPTS) { + throw new Error( + `Failed to connect to ${address} after ${MAX_PING_ATTEMPTS} attempts.` + ); + } + const delaySeconds = Math.pow(INITIAL_PING_DELAY_SECONDS, attempt - 1); + console.log( + `Ping attempt ${attempt}/${MAX_PING_ATTEMPTS}: Waiting ${delaySeconds}s before pinging ${address}...` + ); + await new Promise((r) => setTimeout(r, delaySeconds * 1000)); + try { - const res = await fetch(address); + const res = await fetch(address, {timeout: 15000}); // Add a timeout to fetch itself if (res.status !== 200) { - throw new Error(res.status); + console.warn( + `Ping attempt ${attempt} to ${address} failed with status: ${res.status}` + ); + throw new Error(`Status: ${res.status}`); } + console.log(`Successfully connected to ${address} on attempt ${attempt}.`); + return true; } catch (err) { process.stdout.write('.'); - await pingVMExponential(address, ++count); + if (attempt >= MAX_PING_ATTEMPTS) { + console.error( + `\nFinal ping attempt to ${address} failed: ${err.message}` + ); + throw err; // Re-throw the error if max attempts reached + } + // Log the error for the current attempt but continue to retry + // console.warn(`Ping attempt ${attempt} to ${address} caught error: ${err.message}. Retrying...`); + return pingVMExponential(address, attempt + 1); } } -async function getIP(uniqueID) { - externalIP = cp - .execSync( - `gcloud compute instances describe my-app-instance-${uniqueID} \ ---format='get(networkInterfaces[0].accessConfigs[0].natIP)' --zone=us-central1-f` - ) - .toString('utf8') - .trim(); - - await pingVMExponential(`http://${externalIP}:8080/`, 1); +async function getExternalIP(instanceName, zone) { + try { + // Retry a few times as IP address might take a moment to appear after instance is "RUNNING" + for (let i = 0; i < 5; i++) { + const ip = cp + .execSync( + `gcloud compute instances describe ${instanceName} --format='get(networkInterfaces[0].accessConfigs[0].natIP)' --zone=${zone}` + ) + .toString('utf8') + .trim(); + if (ip) return ip; + console.log( + `Attempt ${ + i + 1 + } to get IP for ${instanceName}: IP not found yet. Waiting 5s...` + ); + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + throw new Error( + `Could not retrieve external IP for ${instanceName} after multiple attempts.` + ); + } catch (error) { + console.error( + `Error getting external IP for ${instanceName}:`, + error.message + ); + throw error; // Re-throw to fail the calling function (e.g., before hook) + } } -describe('spin up gce instance', async function () { - console.time('beforeHook'); - console.time('test'); - console.time('afterHook'); - this.timeout(250000); - uniqueID = uuidv4().split('-')[0]; +describe('spin up gce instance', function () { + // Increase timeout for the whole describe block if necessary, + // but individual hooks/tests have their own timeouts. + this.timeout(300000); // e.g., 5 minutes for the whole suite + + let uniqueID; + let instanceName; + let firewallRuleName; + // 'this.externalIP' will be used to store the IP in the Mocha context + before(async function () { - this.timeout(200000); - cp.execSync( - `gcloud compute instances create my-app-instance-${uniqueID} \ - --image-family=debian-10 \ - --image-project=debian-cloud \ - --machine-type=g1-small \ - --scopes userinfo-email,cloud-platform \ - --metadata app-location=us-central1-f \ - --metadata-from-file startup-script=gce/startup-script.sh \ - --zone us-central1-f \ - --tags http-server`, - {cwd: path.join(__dirname, '../../')} - ); - cp.execSync(`gcloud compute firewall-rules create default-allow-http-8080-${uniqueID} \ - --allow tcp:8080 \ - --source-ranges 0.0.0.0/0 \ - --target-tags http-server \ - --description "Allow port 8080 access to http-server"`); + this.timeout(240000); // Timeout for the before hook (e.g., 4 minutes) + console.time('beforeHookDuration'); - try { - const timeOutPromise = new Promise((resolve, reject) => { - setTimeout(() => reject('Timed out!'), 90000); - }); - await Promise.race([timeOutPromise, getIP(uniqueID)]); - } catch (err) { - testFlag = false; - } - console.timeEnd('beforeHook'); - }); + uniqueID = uuidv4().split('-')[0]; + instanceName = `my-app-instance-${uniqueID}`; + firewallRuleName = `default-allow-http-${APP_PORT}-${uniqueID}`; - after(function () { + console.log(`Creating GCE instance: ${instanceName}`); try { cp.execSync( - `gcloud compute instances delete my-app-instance-${uniqueID} --zone=us-central1-f --delete-disks=all` + `gcloud compute instances create ${instanceName} \ + --image-family=${IMAGE_FAMILY} \ + --image-project=${IMAGE_PROJECT} \ + --machine-type=${MACHINE_TYPE} \ + --scopes userinfo-email,cloud-platform \ + --metadata app-location=${GCP_ZONE} \ + --metadata-from-file startup-script=${STARTUP_SCRIPT_PATH} \ + --zone ${GCP_ZONE} \ + --tags http-server`, // Keep a generic tag if startup script handles specific app setup + {cwd: path.join(__dirname, '../../'), stdio: 'inherit'} // Show gcloud output + ); + console.log(`Instance ${instanceName} created.`); + + console.log(`Creating firewall rule: ${firewallRuleName}`); + cp.execSync( + `gcloud compute firewall-rules create ${firewallRuleName} \ + --allow tcp:${APP_PORT} \ + --source-ranges 0.0.0.0/0 \ + --target-tags http-server \ + --description "Allow port ${APP_PORT} access for ${instanceName}"`, + {stdio: 'inherit'} ); + console.log(`Firewall rule ${firewallRuleName} created.`); + + console.log('Attempting to get external IP...'); + this.externalIP = await getExternalIP(instanceName, GCP_ZONE); + console.log(`Instance IP: ${this.externalIP}`); + + const appAddress = `http://${this.externalIP}:${APP_PORT}/`; + console.log(`Pinging application at ${appAddress}...`); + await pingVMExponential(appAddress); // pingVMExponential will throw on failure + + console.log('Setup complete.'); } catch (err) { - console.log("wasn't able to delete the instance"); + console.error('Error in "before" hook:', err.message); + throw err; // Re-throw to make Mocha mark 'before' as failed + } finally { + console.timeEnd('beforeHookDuration'); } - console.timeEnd('afterHook'); }); - it('should get the instance', async () => { - if (testFlag) { - console.log(`http://${externalIP}:8080/`); - const response = await fetch(`http://${externalIP}:8080/`); - const body = await response.text(); - expect(body).to.include('Hello, world!'); + after(async function () { + // 'after' hooks run even if 'before' or tests fail. + this.timeout(120000); // Timeout for cleanup (e.g., 2 minutes) + console.time('afterHookDuration'); + console.log('Starting cleanup...'); + + await cleanupResources( + instanceName, + firewallRuleName, + GCP_ZONE, + this.externalIP + ); + + console.timeEnd('afterHookDuration'); + }); + + async function cleanupResources(instName, fwRuleName, zone) { + if (instName) { + try { + console.log(`Deleting GCE instance: ${instName}`); + cp.execSync( + `gcloud compute instances delete ${instName} --zone=${zone} --delete-disks=all --quiet`, + {stdio: 'inherit'} + ); + console.log(`Instance ${instName} deleted.`); + } catch (err) { + console.warn( + `Warning: Wasn't able to delete instance ${instName}. Error: ${err.message}` + ); + console.warn('You may need to delete it manually.'); + } + } + + if (fwRuleName) { + try { + console.log(`Deleting firewall rule: ${fwRuleName}`); + cp.execSync( + `gcloud compute firewall-rules delete ${fwRuleName} --quiet`, + {stdio: 'inherit'} + ); + console.log(`Firewall rule ${fwRuleName} deleted.`); + } catch (err) { + console.warn( + `Warning: Wasn't able to delete firewall rule ${fwRuleName}. Error: ${err.message}` + ); + console.warn('You may need to delete it manually.'); + } } - console.timeEnd('test'); + // Optional: Release static IP if you were using one + // if (ip && IS_STATIC_IP) { /* gcloud compute addresses delete ... */ } + } + + it('should get the instance and verify content', async function () { + this.timeout(30000); // Timeout for this specific test + console.time('testExecutionTime'); + expect(this.externalIP, 'External IP should be available').to.exist; + + const appUrl = `http://${this.externalIP}:${APP_PORT}/`; + console.log(`Testing application at: ${appUrl}`); + + const response = await fetch(appUrl); + expect(response.status, 'Response status should be 200').to.equal(200); + + const body = await response.text(); + expect(body).to.include('Hello, world!'); + console.log('Test verification successful.'); + console.timeEnd('testExecutionTime'); }); }); diff --git a/package.json b/package.json index 304a2b332..e6109d4e9 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,9 @@ "name": "nodejs-getting-started", "private": true, "description": "End to end samples for running Node.js applications on Google Cloud Platform", - "dependencies": {}, "devDependencies": { "eslint": "^8.0.0", - "eslint-config-prettier": "^8.0.0", + "eslint-config-prettier": "^8.10.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.0.0", "prettier": "^2.0.0"