From ff43e2dd3c51b09ad77cf526fe72d1e078e42921 Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Tue, 26 Aug 2025 14:55:38 +0300 Subject: [PATCH 1/7] Add GCE label support --- .../playbooks/tasks/create_linux_instance.yml | 1 + .../playbooks/tasks/create_linux_windows.yml | 83 ++++++ test/gce/scenarios/label-verify/INSTALL.md | 19 ++ test/gce/scenarios/label-verify/create.yml | 59 +++++ test/gce/scenarios/label-verify/destroy.yml | 38 +++ .../label-verify/files/windows_auth.py | 243 ++++++++++++++++++ .../scenarios/label-verify/handlers/main.yml | 49 ++++ .../molecule/default/converge.yml | 6 + .../molecule/default/molecule.yml | 25 ++ .../label-verify/molecule/default/prepare.yml | 7 + .../label-verify/molecule/default/verify.yml | 15 ++ .../molecule/default/verify_main_logic.yml | 38 +++ .../scenarios/label-verify/requirements.yml | 2 + .../tasks/create_linux_instance.yml | 61 +++++ .../tasks/create_windows_instance.yml | 70 +++++ 15 files changed, 716 insertions(+) create mode 100644 src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml create mode 100644 test/gce/scenarios/label-verify/INSTALL.md create mode 100644 test/gce/scenarios/label-verify/create.yml create mode 100644 test/gce/scenarios/label-verify/destroy.yml create mode 100755 test/gce/scenarios/label-verify/files/windows_auth.py create mode 100644 test/gce/scenarios/label-verify/handlers/main.yml create mode 100644 test/gce/scenarios/label-verify/molecule/default/converge.yml create mode 100644 test/gce/scenarios/label-verify/molecule/default/molecule.yml create mode 100644 test/gce/scenarios/label-verify/molecule/default/prepare.yml create mode 100644 test/gce/scenarios/label-verify/molecule/default/verify.yml create mode 100644 test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml create mode 100644 test/gce/scenarios/label-verify/requirements.yml create mode 100644 test/gce/scenarios/label-verify/tasks/create_linux_instance.yml create mode 100644 test/gce/scenarios/label-verify/tasks/create_windows_instance.yml diff --git a/src/molecule_plugins/gce/playbooks/tasks/create_linux_instance.yml b/src/molecule_plugins/gce/playbooks/tasks/create_linux_instance.yml index a5efeb33..416502b2 100644 --- a/src/molecule_plugins/gce/playbooks/tasks/create_linux_instance.yml +++ b/src/molecule_plugins/gce/playbooks/tasks/create_linux_instance.yml @@ -29,6 +29,7 @@ selfLink: "{{ gcp_snet }}" access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" tags: "{{ item.tags | default(omit) }}" + labels: "{{ item.labels | default(omit) }}" zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" project: "{{ gcp_project_id }}" scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" diff --git a/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml b/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml new file mode 100644 index 00000000..b134d759 --- /dev/null +++ b/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml @@ -0,0 +1,83 @@ +--- +- name: Create molecule Windows instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + scheduling: + preemptible: "{{ item.preemptible | default(false) }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: + - network: + selfLink: "{{ gcp_net }}" + subnetwork: + selfLink: "{{ gcp_snet }}" + access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" + tags: "{{ item.tags | default(omit) }}" + labels: "{{ item.labels | default(omit) }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Windows" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for WinRM + ansible.builtin.wait_for: + port: 5986 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + delay: 10 + loop: "{{ server.results }}" + register: waitfor + until: waitfor.failed == false + retries: 6 + delay: 10 + +- name: Prepare Windows User + ansible.builtin.script: > + ./files/windows_auth.py + --instance {{ item.name }} + --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} + --project {{ gcp_project_id }} + --username molecule_usr + args: + executable: python3 + environment: + GOOGLE_APPLICATION_CREDENTIALS: "{{ molecule_yml.driver.service_account_file | default(lookup('env', 'GCP_SERVICE_ACCOUNT_FILE'), true) }}" + loop: "{{ molecule_yml.platforms }}" + changed_when: + - password.rc == 0 + - password.stdout + register: password + retries: 10 + delay: 10 + +- name: Add password for instances in server list + ansible.builtin.set_fact: + win_instances: "{{ win_instances | default([]) + [dict(item[0], password=item[1].stdout_lines | last)] }}" + loop: "{{ server.results | zip(password.results) | list }}" + no_log: true diff --git a/test/gce/scenarios/label-verify/INSTALL.md b/test/gce/scenarios/label-verify/INSTALL.md new file mode 100644 index 00000000..86c4ad56 --- /dev/null +++ b/test/gce/scenarios/label-verify/INSTALL.md @@ -0,0 +1,19 @@ +# Google Cloud Engine driver installation guide + +## Requirements + +- A GCE credentials rc file + +## Install + +Please refer to the [Virtual environment][] documentation for +installation best practices. If not using a virtual environment, please +consider passing the widely recommended ['--user' flag][] when invoking +`pip`. + +```bash +$ pip install 'molecule_gce' +``` + +[Virtual environment]: https://virtualenv.pypa.io/en/latest/ +['--user' flag]: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site diff --git a/test/gce/scenarios/label-verify/create.yml b/test/gce/scenarios/label-verify/create.yml new file mode 100644 index 00000000..ba72e2c3 --- /dev/null +++ b/test/gce/scenarios/label-verify/create.yml @@ -0,0 +1,59 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_identity_file: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + gcp_project_id: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + + tasks: + - name: Make sure if linux or windows either specified + ansible.builtin.assert: + that: + - molecule_yml.driver.instance_os_type | lower == "linux" or molecule_yml.driver.instance_os_type | lower == "windows" + fail_msg: instance_os_type is possible only to specify linux or windows either + + - name: Get network info + google.cloud.gcp_compute_network_info: + filters: + - name = "{{ molecule_yml.driver.network_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_network + + - name: Get subnetwork info + google.cloud.gcp_compute_subnetwork_info: + filters: + - name = "{{ molecule_yml.driver.subnetwork_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + region: "{{ molecule_yml.driver.region }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_subnetwork + + - name: Set external access config + ansible.builtin.set_fact: + external_access_config: + - access_configs: + - name: External NAT + type: ONE_TO_NAT + when: molecule_yml.driver.external_access + + - name: Include create_linux_instance tasks + ansible.builtin.include_tasks: tasks/create_linux_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "linux" + + - name: Include create_windows_instance tasks + ansible.builtin.include_tasks: tasks/create_windows_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "windows" + + handlers: + - name: Import main handler tasks + ansible.builtin.import_tasks: handlers/main.yml diff --git a/test/gce/scenarios/label-verify/destroy.yml b/test/gce/scenarios/label-verify/destroy.yml new file mode 100644 index 00000000..d9a8fb91 --- /dev/null +++ b/test/gce/scenarios/label-verify/destroy.yml @@ -0,0 +1,38 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + + tasks: + - name: Destroy molecule instance(s) + google.cloud.gcp_compute_instance: + name: "{{ item.name }}" + state: absent + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + notify: + - Wipe out instance config + - Dump instance config + + - name: Wait for instance(s) deletion to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: server + until: server.finished + retries: 300 + delay: 10 + loop: "{{ async_results.results }}" + + handlers: + - name: Import main handler tasks + ansible.builtin.import_tasks: handlers/main.yml diff --git a/test/gce/scenarios/label-verify/files/windows_auth.py b/test/gce/scenarios/label-verify/files/windows_auth.py new file mode 100755 index 00000000..5dd89558 --- /dev/null +++ b/test/gce/scenarios/label-verify/files/windows_auth.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import base64 +import copy +import datetime +import json +import time + +# PyCrypto library: https://pypi.python.org/pypi/pycrypto +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA +from Crypto.Util.number import long_to_bytes +from googleapiclient.discovery import build + +# Google API Client Library for Python: +# https://developers.google.com/api-client-library/python/start/get_started +from oauth2client.client import GoogleCredentials + + +def GetCompute(): + """Get a compute object for communicating with the Compute Engine API.""" + credentials = GoogleCredentials.get_application_default() + compute = build("compute", "v1", credentials=credentials) + return compute + + +def GetInstance(compute, instance, zone, project): + """Get the data for a Google Compute Engine instance.""" + cmd = compute.instances().get(instance=instance, project=project, zone=zone) + return cmd.execute() + + +def GetKey(): + """Get an RSA key for encryption.""" + # This uses the PyCrypto library + key = RSA.generate(2048) + return key + + +def GetModulusExponentInBase64(key): + """Return the public modulus and exponent for the key in bas64 encoding.""" + mod = long_to_bytes(key.n) + exp = long_to_bytes(key.e) + + modulus = base64.b64encode(mod) + exponent = base64.b64encode(exp) + + return modulus, exponent + + +def GetExpirationTimeString(): + """Return an RFC3339 UTC timestamp for 5 minutes from now.""" + utc_now = datetime.datetime.utcnow() + # These metadata entries are one-time-use, so the expiration time does + # not need to be very far in the future. In fact, one minute would + # generally be sufficient. Five minutes allows for minor variations + # between the time on the client and the time on the server. + expire_time = utc_now + datetime.timedelta(minutes=5) + return expire_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def GetJsonString(user, modulus, exponent, email): + """Return the JSON string object that represents the windows-keys entry.""" + converted_modulus = modulus.decode("utf-8") + converted_exponent = exponent.decode("utf-8") + + expire = GetExpirationTimeString() + data = { + "userName": user, + "modulus": converted_modulus, + "exponent": converted_exponent, + "email": email, + "expireOn": expire, + } + + return json.dumps(data) + + +def UpdateWindowsKeys(old_metadata, metadata_entry): + """Return updated metadata contents with the new windows-keys entry.""" + # Simply overwrites the "windows-keys" metadata entry. Production code may + # want to append new lines to the metadata value and remove any expired + # entries. + new_metadata = copy.deepcopy(old_metadata) + new_metadata["items"] = [{"key": "windows-keys", "value": metadata_entry}] + return new_metadata + + +def UpdateInstanceMetadata(compute, instance, zone, project, new_metadata): + """Update the instance metadata.""" + cmd = compute.instances().setMetadata( + instance=instance, + project=project, + zone=zone, + body=new_metadata, + ) + return cmd.execute() + + +def GetSerialPortFourOutput(compute, instance, zone, project): + """Get the output from serial port 4 from the instance.""" + # Encrypted passwords are printed to COM4 on the windows server: + port = 4 + cmd = compute.instances().getSerialPortOutput( + instance=instance, + project=project, + zone=zone, + port=port, + ) + output = cmd.execute() + return output["contents"] + + +def GetEncryptedPasswordFromSerialPort(serial_port_output, modulus): + """Find and return the correct encrypted password, based on the modulus.""" + # In production code, this may need to be run multiple times if the output + # does not yet contain the correct entry. + + converted_modulus = modulus.decode("utf-8") + + output = serial_port_output.split("\n") + for line in reversed(output): + try: + entry = json.loads(line) + if converted_modulus == entry["modulus"]: + return entry["encryptedPassword"] + except ValueError: + pass + return None + + +def DecryptPassword(encrypted_password, key): + """Decrypt a base64 encoded encrypted password using the provided key.""" + decoded_password = base64.b64decode(encrypted_password) + cipher = PKCS1_OAEP.new(key) + password = cipher.decrypt(decoded_password) + return password + + +def Arguments(): + # Create the parser + args = argparse.ArgumentParser(description="List the content of a folder") + + # Add the arguments + args.add_argument( + "--instance", + metavar="instance", + type=str, + help="compute instance name", + ) + + args.add_argument("--zone", metavar="zone", type=str, help="compute zone") + + args.add_argument("--project", metavar="project", type=str, help="gcp project") + + args.add_argument("--username", metavar="username", type=str, help="username") + + args.add_argument("--email", metavar="email", type=str, help="email") + + return args.parse_args() + + +def main(): + config_args = Arguments() + + # Setup + compute = GetCompute() + key = GetKey() + modulus, exponent = GetModulusExponentInBase64(key) + + # Get existing metadata + instance_ref = GetInstance( + compute, + config_args.instance, + config_args.zone, + config_args.project, + ) + old_metadata = instance_ref["metadata"] + # Create and set new metadata + metadata_entry = GetJsonString( + config_args.username, + modulus, + exponent, + config_args.email, + ) + new_metadata = UpdateWindowsKeys(old_metadata, metadata_entry) + + # Get Serial output BEFORE the modification + serial_port_output = GetSerialPortFourOutput( + compute, + config_args.instance, + config_args.zone, + config_args.project, + ) + + UpdateInstanceMetadata( + compute, + config_args.instance, + config_args.zone, + config_args.project, + new_metadata, + ) + + # Get and decrypt password from serial port output + # Monitor changes from output to get the encrypted password as soon as it's generated, will wait for 30 seconds + i = 0 + new_serial_port_output = serial_port_output + while i <= 30 and serial_port_output == new_serial_port_output: + new_serial_port_output = GetSerialPortFourOutput( + compute, + config_args.instance, + config_args.zone, + config_args.project, + ) + i += 1 + time.sleep(1) + + enc_password = GetEncryptedPasswordFromSerialPort(new_serial_port_output, modulus) + + password = DecryptPassword(enc_password, key) + converted_password = password.decode("utf-8") + + # Display only the password + print(format(converted_password)) # noqa: T201 + + +if __name__ == "__main__": + main() diff --git a/test/gce/scenarios/label-verify/handlers/main.yml b/test/gce/scenarios/label-verify/handlers/main.yml new file mode 100644 index 00000000..935a17a5 --- /dev/null +++ b/test/gce/scenarios/label-verify/handlers/main.yml @@ -0,0 +1,49 @@ +--- +- name: Populate instance config dict Linux + ansible.builtin.set_fact: + instance_conf_dict: + instance: "{{ instance_info.name }}" + address: + "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP + }}" + user: "{{ lookup('env', 'USER') }}" + port: "22" + identity_file: "{{ ssh_identity_file }}" + instance_os_type: "{{ molecule_yml.driver.instance_os_type }}" + loop: "{{ server.results }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + +- name: Populate instance config dict Windows + ansible.builtin.set_fact: + instance_conf_dict: + instance: "{{ instance_info.name }}" + address: + "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP + }}" + user: molecule_usr + password: "{{ instance_info.password }}" + port: "{{ instance_info.winrm_port | default(5986) }}" + winrm_transport: "{{ molecule_yml.driver.winrm_transport | default('ntlm') }}" + winrm_server_cert_validation: "{{ molecule_yml.driver.winrm_server_cert_validation | default('ignore') }}" + instance_os_type: "{{ molecule_yml.driver.instance_os_type }}" + loop: "{{ win_instances }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + +- name: Wipe out instance config + ansible.builtin.set_fact: + instance_conf: {} +- name: Convert instance config dict to a list + ansible.builtin.set_fact: + instance_conf: "{{ instance_conf_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + +- name: Dump instance config + ansible.builtin.copy: + content: "{{ instance_conf }}" + dest: "{{ molecule_instance_config }}" + mode: "0600" diff --git a/test/gce/scenarios/label-verify/molecule/default/converge.yml b/test/gce/scenarios/label-verify/molecule/default/converge.yml new file mode 100644 index 00000000..42c5902a --- /dev/null +++ b/test/gce/scenarios/label-verify/molecule/default/converge.yml @@ -0,0 +1,6 @@ +--- +- name: Converge + hosts: all + tasks: + - ansible.builtin.debug: + msg: "Skipping converge; labels are tested in verify." diff --git a/test/gce/scenarios/label-verify/molecule/default/molecule.yml b/test/gce/scenarios/label-verify/molecule/default/molecule.yml new file mode 100644 index 00000000..66b0b629 --- /dev/null +++ b/test/gce/scenarios/label-verify/molecule/default/molecule.yml @@ -0,0 +1,25 @@ +--- +dependency: + name: galaxy +driver: + name: gce + project_id: change-me # if not set, will default to env GCE_PROJECT_ID + auth_kind: # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND + #service_account_email: # set to an email associated with the project - if set to null, will default to GCP_SERVICE_ACCOUNT_EMAIL. Should not be set if using auth_kind serviceaccount. + #service_account_file: # set to the path to the JSON credentials file - if set to null, will default to env GCP_SERVICE_ACCOUNT_FILE + region: us-central1 # REQUIRED. example: us-central1 + external_access: false # chose whether to create a public IP for the VM or not - default is private IP only + instance_os_type: linux # will be considered linux by default, but can be explicitely set to windows +platforms: + - name: linuxgce-createdbymolecule # is an instance name + machine_type: n1-standard-1 # define your machine type + preemptible: true # Define if it should be a preemptive or not + #zone: # example: us-west1-b, will default to zone b of driver.region + image: projects/debian-cloud/global/images/family/debian-12 + labels: + env: ci + role: under-test +provisioner: + name: ansible +verifier: + name: ansible diff --git a/test/gce/scenarios/label-verify/molecule/default/prepare.yml b/test/gce/scenarios/label-verify/molecule/default/prepare.yml new file mode 100644 index 00000000..076f259d --- /dev/null +++ b/test/gce/scenarios/label-verify/molecule/default/prepare.yml @@ -0,0 +1,7 @@ +--- +- name: Prepare + hosts: all + gather_facts: false + tasks: + - name: Wait 600 seconds for target connection to become reachable/usable + ansible.builtin.wait_for_connection: diff --git a/test/gce/scenarios/label-verify/molecule/default/verify.yml b/test/gce/scenarios/label-verify/molecule/default/verify.yml new file mode 100644 index 00000000..c0a0968c --- /dev/null +++ b/test/gce/scenarios/label-verify/molecule/default/verify.yml @@ -0,0 +1,15 @@ +--- +- name: Verify + hosts: localhost + gather_facts: false + vars: + gcp_project_id: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + gcp_zone_default: "{{ molecule_yml.driver.region ~ '-b' }}" + + tasks: + - name: Verify labels for each platform + ansible.builtin.include_tasks: verify_main_logic.yml + loop: "{{ molecule_yml.platforms }}" + loop_control: + loop_var: plat + diff --git a/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml b/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml new file mode 100644 index 00000000..21c89641 --- /dev/null +++ b/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml @@ -0,0 +1,38 @@ +--- +# Runs once per platform (variable: plat) + +- name: Fetch instance info (retry for eventual consistency) + google.cloud.gcp_compute_instance_info: + project: "{{ gcp_project_id }}" + zone: "{{ plat.zone | default(gcp_zone_default) }}" + filters: + - "name = {{ plat.name }}" + register: info + retries: 6 + delay: 5 + until: > + (info.resources is defined and (info.resources | length) > 0) + or + (info.items is defined and (info.items | length) > 0) + +- name: Pick the instance object + set_fact: + inst: >- + {{ + (info.resources | default([]) | first) + if (info.resources is defined and (info.resources | length) > 0) + else (info.items | default([]) | first) + }} + +- name: Assert each label key/value matches + when: plat.labels is defined and (plat.labels | length) > 0 + loop: "{{ plat.labels | dict2items }}" + loop_control: + loop_var: label + ansible.builtin.assert: + that: + - inst.labels is defined + - inst.labels.get(label.key) == label.value + success_msg: "Label {{ label.key }}={{ label.value }} present on {{ plat.name }}" + fail_msg: "Missing/incorrect label {{ label.key }} on {{ plat.name }} (got={{ inst.labels | default({}) }})" + diff --git a/test/gce/scenarios/label-verify/requirements.yml b/test/gce/scenarios/label-verify/requirements.yml new file mode 100644 index 00000000..ac6a3656 --- /dev/null +++ b/test/gce/scenarios/label-verify/requirements.yml @@ -0,0 +1,2 @@ +collections: + - name: google.cloud diff --git a/test/gce/scenarios/label-verify/tasks/create_linux_instance.yml b/test/gce/scenarios/label-verify/tasks/create_linux_instance.yml new file mode 100644 index 00000000..9ebdc80f --- /dev/null +++ b/test/gce/scenarios/label-verify/tasks/create_linux_instance.yml @@ -0,0 +1,61 @@ +--- +- name: Create ssh keypair + community.crypto.openssh_keypair: + comment: "{{ lookup('env', 'USER') }} user for Molecule" + path: "{{ ssh_identity_file }}" + register: keypair + +- name: Create molecule Linux instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + metadata: + ssh-keys: "{{ lookup('env', 'USER') }}:{{ keypair.public_key }}" + labels: "{{ item.labels | default(omit) }}" + scheduling: + preemptible: "{{ item.preemptible | default(false) }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/debian-cloud/global/images/family/debian-10') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: + "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config + | default([]) ) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + loop_control: + pause: 3 + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - Populate instance config dict Linux + - Convert instance config dict to a list + - Dump instance config + +- name: Wait for SSH + ansible.builtin.wait_for: + port: 22 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + search_regex: SSH + delay: 10 + loop: "{{ server.results }}" diff --git a/test/gce/scenarios/label-verify/tasks/create_windows_instance.yml b/test/gce/scenarios/label-verify/tasks/create_windows_instance.yml new file mode 100644 index 00000000..41765c4f --- /dev/null +++ b/test/gce/scenarios/label-verify/tasks/create_windows_instance.yml @@ -0,0 +1,70 @@ +--- +- name: Create molecule Windows instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + labels: "{{ item.labels | default(omit) }}" + scheduling: + preemptible: "{{ item.preemptible | default(false) }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: + "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config + | default([])) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - Populate instance config dict Windows + - Convert instance config dict to a list + - Dump instance config + +- name: Wait for WinRM + ansible.builtin.wait_for: + port: 5986 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + delay: 10 + loop: "{{ server.results }}" + +- name: Prepare Windows User + ansible.builtin.script: + ./files/windows_auth.py --instance {{ item.name }} --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} --project {{ gcp_project_id + }} --username molecule_usr + args: + executable: python3 + loop: "{{ molecule_yml.platforms }}" + changed_when: + - password.rc == 0 + - password.stdout + register: password + retries: 10 + delay: 10 + +- name: Add password for instances in server list + ansible.builtin.set_fact: + win_instances: "{{ win_instances | default([]) + [dict(item[0], password=item[1].stdout_lines | last)] }}" + loop: "{{ server.results | zip(password.results) | list }}" + no_log: true From be1db24ef6756db341e0e816a154e10c4f6e3c20 Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Tue, 26 Aug 2025 14:57:47 +0300 Subject: [PATCH 2/7] Fix the name of a task --- .../playbooks/tasks/create_linux_windows.yml | 83 ------------------- .../tasks/create_windows_instance.yml | 1 + 2 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml diff --git a/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml b/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml deleted file mode 100644 index b134d759..00000000 --- a/src/molecule_plugins/gce/playbooks/tasks/create_linux_windows.yml +++ /dev/null @@ -1,83 +0,0 @@ ---- -- name: Create molecule Windows instance(s) - google.cloud.gcp_compute_instance: - state: present - name: "{{ item.name }}" - machine_type: "{{ item.machine_type | default('n1-standard-1') }}" - scheduling: - preemptible: "{{ item.preemptible | default(false) }}" - disks: - - auto_delete: true - boot: true - initialize_params: - disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" - source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" - source_image_encryption_key: - raw_key: "{{ item.image_encryption_key | default(omit) }}" - network_interfaces: - - network: - selfLink: "{{ gcp_net }}" - subnetwork: - selfLink: "{{ gcp_snet }}" - access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" - tags: "{{ item.tags | default(omit) }}" - labels: "{{ item.labels | default(omit) }}" - zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" - project: "{{ gcp_project_id }}" - scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" - service_account_email: "{{ molecule_yml.driver.service_account_email | default(omit, true) }}" - service_account_file: "{{ molecule_yml.driver.service_account_file | default(omit, true) }}" - auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" - register: async_results - loop: "{{ molecule_yml.platforms }}" - async: 7200 - poll: 0 - -- name: Wait for instance(s) creation to complete - ansible.builtin.async_status: - jid: "{{ item.ansible_job_id }}" - loop: "{{ async_results.results }}" - register: server - until: server.finished - retries: 300 - delay: 10 - notify: - - "Populate instance config dict Windows" - - "Convert instance config dict to a list" - - "Dump instance config" - -- name: Wait for WinRM - ansible.builtin.wait_for: - port: 5986 - host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" - delay: 10 - loop: "{{ server.results }}" - register: waitfor - until: waitfor.failed == false - retries: 6 - delay: 10 - -- name: Prepare Windows User - ansible.builtin.script: > - ./files/windows_auth.py - --instance {{ item.name }} - --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} - --project {{ gcp_project_id }} - --username molecule_usr - args: - executable: python3 - environment: - GOOGLE_APPLICATION_CREDENTIALS: "{{ molecule_yml.driver.service_account_file | default(lookup('env', 'GCP_SERVICE_ACCOUNT_FILE'), true) }}" - loop: "{{ molecule_yml.platforms }}" - changed_when: - - password.rc == 0 - - password.stdout - register: password - retries: 10 - delay: 10 - -- name: Add password for instances in server list - ansible.builtin.set_fact: - win_instances: "{{ win_instances | default([]) + [dict(item[0], password=item[1].stdout_lines | last)] }}" - loop: "{{ server.results | zip(password.results) | list }}" - no_log: true diff --git a/src/molecule_plugins/gce/playbooks/tasks/create_windows_instance.yml b/src/molecule_plugins/gce/playbooks/tasks/create_windows_instance.yml index 86e2a20b..b134d759 100644 --- a/src/molecule_plugins/gce/playbooks/tasks/create_windows_instance.yml +++ b/src/molecule_plugins/gce/playbooks/tasks/create_windows_instance.yml @@ -21,6 +21,7 @@ selfLink: "{{ gcp_snet }}" access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" tags: "{{ item.tags | default(omit) }}" + labels: "{{ item.labels | default(omit) }}" zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" project: "{{ gcp_project_id }}" scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" From 7f3ec5aa8155776a98baa0c97622ae063b5d475f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:01:02 +0000 Subject: [PATCH 3/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/gce/scenarios/label-verify/molecule/default/verify.yml | 1 - .../label-verify/molecule/default/verify_main_logic.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/test/gce/scenarios/label-verify/molecule/default/verify.yml b/test/gce/scenarios/label-verify/molecule/default/verify.yml index c0a0968c..90e3e545 100644 --- a/test/gce/scenarios/label-verify/molecule/default/verify.yml +++ b/test/gce/scenarios/label-verify/molecule/default/verify.yml @@ -12,4 +12,3 @@ loop: "{{ molecule_yml.platforms }}" loop_control: loop_var: plat - diff --git a/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml b/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml index 21c89641..b7c695f5 100644 --- a/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml +++ b/test/gce/scenarios/label-verify/molecule/default/verify_main_logic.yml @@ -35,4 +35,3 @@ - inst.labels.get(label.key) == label.value success_msg: "Label {{ label.key }}={{ label.value }} present on {{ plat.name }}" fail_msg: "Missing/incorrect label {{ label.key }} on {{ plat.name }} (got={{ inst.labels | default({}) }})" - From 508a02d70003ed8b1eeb2401ea2d7d1199833ed8 Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Mon, 8 Sep 2025 16:31:23 +0300 Subject: [PATCH 4/7] Add task name for the test's converge phase --- test/gce/scenarios/label-verify/molecule/default/converge.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/gce/scenarios/label-verify/molecule/default/converge.yml b/test/gce/scenarios/label-verify/molecule/default/converge.yml index 42c5902a..2b212831 100644 --- a/test/gce/scenarios/label-verify/molecule/default/converge.yml +++ b/test/gce/scenarios/label-verify/molecule/default/converge.yml @@ -2,5 +2,6 @@ - name: Converge hosts: all tasks: - - ansible.builtin.debug: + - name: Skip Converge + ansible.builtin.debug: msg: "Skipping converge; labels are tested in verify." From 31b885ec912ea2a1454be8b6780b20207970cfde Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Thu, 25 Sep 2025 17:23:27 +0300 Subject: [PATCH 5/7] feat: Add label support to gce plugin Remove some unnecessary comments --- test/gce/scenarios/label-verify/molecule/default/molecule.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/gce/scenarios/label-verify/molecule/default/molecule.yml b/test/gce/scenarios/label-verify/molecule/default/molecule.yml index 66b0b629..06ffb06e 100644 --- a/test/gce/scenarios/label-verify/molecule/default/molecule.yml +++ b/test/gce/scenarios/label-verify/molecule/default/molecule.yml @@ -5,8 +5,6 @@ driver: name: gce project_id: change-me # if not set, will default to env GCE_PROJECT_ID auth_kind: # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND - #service_account_email: # set to an email associated with the project - if set to null, will default to GCP_SERVICE_ACCOUNT_EMAIL. Should not be set if using auth_kind serviceaccount. - #service_account_file: # set to the path to the JSON credentials file - if set to null, will default to env GCP_SERVICE_ACCOUNT_FILE region: us-central1 # REQUIRED. example: us-central1 external_access: false # chose whether to create a public IP for the VM or not - default is private IP only instance_os_type: linux # will be considered linux by default, but can be explicitely set to windows From dff225fd14c610ea8ca0d839878416f12d13d734 Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Thu, 25 Sep 2025 17:28:13 +0300 Subject: [PATCH 6/7] Update molecule.yml --- test/gce/scenarios/label-verify/molecule/default/molecule.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gce/scenarios/label-verify/molecule/default/molecule.yml b/test/gce/scenarios/label-verify/molecule/default/molecule.yml index 06ffb06e..da1fc54f 100644 --- a/test/gce/scenarios/label-verify/molecule/default/molecule.yml +++ b/test/gce/scenarios/label-verify/molecule/default/molecule.yml @@ -7,7 +7,7 @@ driver: auth_kind: # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND region: us-central1 # REQUIRED. example: us-central1 external_access: false # chose whether to create a public IP for the VM or not - default is private IP only - instance_os_type: linux # will be considered linux by default, but can be explicitely set to windows + instance_os_type: linux # will be considered linux by default, but can be explicitly set to windows platforms: - name: linuxgce-createdbymolecule # is an instance name machine_type: n1-standard-1 # define your machine type From 3f1543f01b6f0f8bf5d22d8cc9783c76d12be236 Mon Sep 17 00:00:00 2001 From: Strahil Nikolov Date: Thu, 25 Sep 2025 17:31:34 +0300 Subject: [PATCH 7/7] Fix MD014 in test's INSTALL.md --- test/gce/scenarios/label-verify/INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gce/scenarios/label-verify/INSTALL.md b/test/gce/scenarios/label-verify/INSTALL.md index 86c4ad56..2851c820 100644 --- a/test/gce/scenarios/label-verify/INSTALL.md +++ b/test/gce/scenarios/label-verify/INSTALL.md @@ -12,7 +12,7 @@ consider passing the widely recommended ['--user' flag][] when invoking `pip`. ```bash -$ pip install 'molecule_gce' +pip install 'molecule_gce' ``` [Virtual environment]: https://virtualenv.pypa.io/en/latest/