9 Commits

Author SHA1 Message Date
9e28004d6c Strip SRV prefix from inventory host keys
All checks were successful
Build and Release / Build Windows Exe (push) Successful in 11s
2026-02-06 15:56:58 -05:00
8b3584fa9e Use server reference as inventory key and add ansible_host var
All checks were successful
Build and Release / Build Windows Exe (push) Successful in 10s
2026-02-06 15:51:30 -05:00
a202e267f7 Capture Production IP from Servers tab and use for flow matching
All checks were successful
Build and Release / Build Windows Exe (push) Successful in 11s
2026-02-06 15:43:06 -05:00
2ccf6c293a Implement to_mgt_ip DNS logic and update port range parsing
All checks were successful
Build and Release / Build Windows Exe (push) Successful in 10s
2026-02-06 15:40:13 -05:00
b6266bea81 Use docker action with entrypoint override
All checks were successful
Build and Release / Build Windows Exe (push) Successful in 11s
2026-02-06 15:28:47 -05:00
284e6b1fbf Set working directory in docker container
Some checks failed
Build and Release / Build Windows Exe (push) Failing after 4s
2026-02-06 15:26:24 -05:00
b3c5f3a6fd Use manual docker run with entrypoint override
Some checks failed
Build and Release / Build Windows Exe (push) Failing after 6s
2026-02-06 15:24:45 -05:00
2634c87dcd Refactor workflow to use container job structure
Some checks failed
Build and Release / Build Windows Exe (push) Failing after 3s
2026-02-06 15:23:22 -05:00
f28af4de7a Fix PyInstaller args and artifact glob
Some checks failed
Build and Release / Build Windows Exe (push) Failing after 3s
2026-02-06 15:21:10 -05:00
7 changed files with 158 additions and 35 deletions

View File

@@ -9,14 +9,16 @@ jobs:
build: build:
name: Build Windows Exe name: Build Windows Exe
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build with PyInstaller (Wine) - name: Build with PyInstaller
uses: docker://cdrx/pyinstaller-windows:python3 uses: docker://cdrx/pyinstaller-windows:python3
with: with:
args: "python -m pip install -r requirements.txt && pyinstaller --onefile --clean --name wif2ansible run.py" entrypoint: /bin/sh
args: -c "python -m pip install -r requirements.txt && pyinstaller --onefile --clean --name wif2ansible run.py"
- name: Generate Version Tag - name: Generate Version Tag
id: version id: version
@@ -27,7 +29,7 @@ jobs:
with: with:
tag_name: ${{ steps.version.outputs.TAG }} tag_name: ${{ steps.version.outputs.TAG }}
name: Release ${{ steps.version.outputs.TAG }} name: Release ${{ steps.version.outputs.TAG }}
files: dist/wif2ansible.exe files: dist/**/*.exe
draft: false draft: false
prerelease: false prerelease: false
env: env:

31
test_inventory_keys.py Normal file
View File

@@ -0,0 +1,31 @@
import unittest
from wif2ansible.models import Server, Flow
from wif2ansible.inventory import generate_inventory
class TestInventoryKeys(unittest.TestCase):
def test_inventory_keys_are_hostnames(self):
# Create a server with Ref, Hostname, IP
s1 = Server(reference="SERVER_REF_01", hostname="server01", ip_address="192.168.1.10", platform="windows")
# Create a flow matching this server
f1 = Flow(flow_id="1", source_ip="192.168.1.10", destination_ip="10.0.0.1", ports=[80])
servers = {"SERVER_REF_01": s1}
flows = [f1]
inventory = generate_inventory(servers, flows)
# Verify stricture
hosts = inventory['all']['hosts']
# Key should be REFERENCE "SERVER_REF_01" (or hostname/ip fallback)
self.assertIn("SERVER_REF_01", hosts)
self.assertNotIn("192.168.1.10", hosts)
# Check variables
host_vars = hosts["SERVER_REF_01"]
self.assertEqual(host_vars['ansible_host'], "192.168.1.10")
self.assertEqual(host_vars['ansible_connection'], "winrm")
if __name__ == '__main__':
unittest.main()

View File

@@ -2,7 +2,7 @@ import openpyxl
from openpyxl.worksheet.worksheet import Worksheet from openpyxl.worksheet.worksheet import Worksheet
from typing import List, Dict, Tuple, Optional from typing import List, Dict, Tuple, Optional
from .models import Server, Flow from .models import Server, Flow
from .parsers import parse_ports, parse_ip, clean_header from .parsers import parse_ports, parse_ip, clean_header, clean_reference
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -77,9 +77,8 @@ def read_servers(filename: str) -> Dict[str, Server]:
print("Warning: No 'Servers' sheet found.") print("Warning: No 'Servers' sheet found.")
return {} return {}
# keywords: reference, platform, ip address, management ip? # keywords: reference, platform, ip address, management ip, production ip
# Ruby script looked for: reference, type, alias, platform, middleware header_keywords = ['reference', 'platform', 'ip address', 'production ip']
header_keywords = ['reference', 'platform', 'ip address']
header_row_idx, col_map = find_header_row(target_sheet, header_keywords) header_row_idx, col_map = find_header_row(target_sheet, header_keywords)
@@ -98,7 +97,8 @@ def read_servers(filename: str) -> Dict[str, Server]:
# Extract data # Extract data
ref_idx = col_map.get('reference') ref_idx = col_map.get('reference')
plat_idx = col_map.get('platform') plat_idx = col_map.get('platform')
ip_idx = col_map.get('ip address') # Generic IP ip_idx = col_map.get('ip address') # Generic/Management IP
prod_ip_idx = col_map.get('production ip') # Specific Production IP
# Helper to get value # Helper to get value
def get_val(idx): def get_val(idx):
@@ -111,19 +111,29 @@ def read_servers(filename: str) -> Dict[str, Server]:
continue continue
plat = get_val(plat_idx) or 'unknown' plat = get_val(plat_idx) or 'unknown'
ip_raw = get_val(ip_idx)
# Parse Management IP
ip_raw = get_val(ip_idx)
ip_addr = None ip_addr = None
if ip_raw: if ip_raw:
ips = parse_ip(ip_raw) ips = parse_ip(ip_raw)
if ips: if ips:
ip_addr = ips[0] # Take first valid IP ip_addr = ips[0]
# Parse Production IP
prod_ip_raw = get_val(prod_ip_idx)
prod_ip_addr = None
if prod_ip_raw:
ips = parse_ip(prod_ip_raw)
if ips:
prod_ip_addr = ips[0]
s = Server( s = Server(
reference=ref, reference=ref,
hostname=ref, # Default hostname to reference hostname=clean_reference(ref),
platform=plat, platform=plat,
ip_address=ip_addr ip_address=ip_addr,
production_ip=prod_ip_addr
) )
servers[ref] = s servers[ref] = s

View File

@@ -1,5 +1,6 @@
from typing import List, Dict, Any from typing import List, Dict, Any
from .models import Server, Flow from .models import Server, Flow
from .network import to_mgt_ip
def generate_inventory(servers: Dict[str, Server], flows: List[Flow]) -> Dict[str, Any]: def generate_inventory(servers: Dict[str, Server], flows: List[Flow]) -> Dict[str, Any]:
""" """
@@ -19,9 +20,13 @@ def generate_inventory(servers: Dict[str, Server], flows: List[Flow]) -> Dict[st
for s in servers.values(): for s in servers.values():
if s.ip_address: if s.ip_address:
ip_to_server[s.ip_address] = s ip_to_server[s.ip_address] = s
# also index by hostname/reference potentially? if s.production_ip:
# ip_to_server[s.reference] = s ip_to_server[s.production_ip] = s
# But flows ususally have IPs. # Also index by reference/hostname for DNS matches
if s.reference:
ip_to_server[s.reference.lower()] = s
if s.hostname:
ip_to_server[s.hostname.lower()] = s
inventory_hosts = {} inventory_hosts = {}
@@ -34,18 +39,30 @@ def generate_inventory(servers: Dict[str, Server], flows: List[Flow]) -> Dict[st
server = ip_to_server.get(flow.source_ip) server = ip_to_server.get(flow.source_ip)
if not server: if not server:
# Try finding by looking if source matches any server's reference/hostname? # Try DNS resolution (Public IP -> Management FQDN)
# Unlikely for IPs. mgt_dns = to_mgt_ip(flow.source_ip)
drop_count += 1 if mgt_dns:
if drop_count <= 5: # Debug spam limit # mgt_dns might be "server.ds.gc.ca".
print(f"Dropping flow {flow.flow_id}: Source {flow.source_ip} not found in Servers tab.") # Our keys might be "server" or "server.ds.gc.ca" or IPs
continue # Try exact match
server = ip_to_server.get(mgt_dns.lower())
# If not found, try shortname?
if not server:
short = mgt_dns.split('.')[0]
server = ip_to_server.get(short.lower())
if not server:
drop_count += 1
if drop_count <= 5: # Debug spam limit
print(f"Dropping flow {flow.flow_id}: Source {flow.source_ip} (Mgt: {mgt_dns}) not found in Servers tab.")
continue
match_count += 1 match_count += 1
# Prepare host entry if new # Prepare host entry if new
# We use the IP as the key in inventory 'hosts' # We use the Reference/Hostname as the key in inventory 'hosts'
host_key = server.ip_address host_key = server.reference or server.hostname or server.ip_address
if host_key not in inventory_hosts: if host_key not in inventory_hosts:
host_vars = server.get_ansible_vars() host_vars = server.get_ansible_vars()

View File

@@ -6,6 +6,7 @@ class Server:
reference: str reference: str
hostname: str # This might be same as reference hostname: str # This might be same as reference
ip_address: Optional[str] = None ip_address: Optional[str] = None
production_ip: Optional[str] = None
platform: str = 'unknown' # e.g. 'Windows', 'Linux' platform: str = 'unknown' # e.g. 'Windows', 'Linux'
def get_ansible_vars(self) -> Dict[str, Any]: def get_ansible_vars(self) -> Dict[str, Any]:
@@ -22,6 +23,9 @@ class Server:
# Default ssh is usually fine, but being explicit doesn't hurt # Default ssh is usually fine, but being explicit doesn't hurt
pass pass
if self.ip_address:
vars['ansible_host'] = self.ip_address
return vars return vars
@dataclass @dataclass

50
wif2ansible/network.py Normal file
View File

@@ -0,0 +1,50 @@
import socket
from typing import Optional
def get_hostname(ip: str) -> Optional[str]:
try:
# Python's equivalent to Resolv.getname(ip)
# returns (hostname, aliaslist, ipaddrlist)
return socket.gethostbyaddr(ip)[0]
except socket.error:
return None
def get_ip(hostname: str) -> Optional[str]:
try:
return socket.gethostbyname(hostname)
except socket.error:
return None
def to_mgt_ip(name_or_ip: str) -> Optional[str]:
"""
Mimics the Ruby script's to_mgt_ip logic:
1. Reverse lookup IP to get FQDN.
2. Construct management FQDN ({host}.ds.gc.ca or .pre-ds.gc.ca).
3. Resolve that management FQDN to an IP.
4. Return the Management FQDN if successful.
"""
# In Ruby script, input 'name' is often an IP address from the WIF source column.
# Step 1: Reverse Lookup
fqdn = get_hostname(name_or_ip)
if not fqdn:
# If input is already a name, use it? Ruby script assumes it gets a name from Resolv.getname(ip)
# If name_or_ip is NOT an IP, gethostbyaddr might fail or behave differently.
# But if it's already a name, we can try using it.
fqdn = name_or_ip
short_name = fqdn.split('.')[0]
# Step 2 & 3: Try suffixes
suffixes = ['.ds.gc.ca', '.pre-ds.gc.ca']
for suffix in suffixes:
mgt_dns = short_name + suffix
resolved_ip = get_ip(mgt_dns)
if resolved_ip:
# Ruby: return mgt_dns if mgt_ip.to_s.length > 4
return mgt_dns
# print(f"Warning: {name_or_ip} could not be resolved to a management address.")
return None

View File

@@ -43,18 +43,12 @@ def parse_ports(port_str: str) -> List[int]:
if range_match: if range_match:
start, end = map(int, range_match.groups()) start, end = map(int, range_match.groups())
if start <= end: if start <= end:
# Limitation: adding huge ranges might blow up inventory size # User Request: "only add the first, last, and middle port"
# but for Ansible 'ports' list it's better to be explicit or use range syntax. ports.add(start)
# For now, let's keep it expanded if small, or maybe just keeps the start/end? ports.add(end)
# Ruby script logic: expanded it. if end - start > 1:
# We'll limit expansion to avoid DOSing ourselves. middle = start + (end - start) // 2
if end - start < 1000: ports.add(middle)
ports.update(range(start, end + 1))
else:
# Fallback: just add start and end to avoid massive lists?
# Or maybe ansible allows ranges?
# Usually we list ports. Let's expand for now.
ports.update(range(start, end + 1))
continue continue
# Single port # Single port
@@ -63,6 +57,21 @@ def parse_ports(port_str: str) -> List[int]:
return sorted(list(ports)) return sorted(list(ports))
def clean_reference(ref: str) -> str:
"""
Cleans a server reference string.
Specifically removes 'SRV###' type prefixes if present.
Example: 'SRV123 MyServer' -> 'MyServer'
"""
if not ref:
return ""
s = str(ref)
# Remove SRV followed by digits and whitespace
s = re.sub(r'SRV\d+\s*', '', s, flags=re.IGNORECASE)
# Remove leading/trailing whitespace
return s.strip()
def parse_ip(ip_str: str) -> List[str]: def parse_ip(ip_str: str) -> List[str]:
"""Finds all IPv4 addresses in a string.""" """Finds all IPv4 addresses in a string."""
if not ip_str: if not ip_str: