Cloud Resource Tag Validator: Part 2

Build a Linux CLI that validates cloud tags and creates resilient audit reports.

Introduction

30 Second Summary

Rules can look clear on paper until data changes or evidence goes missing. A trustworthy check needs to adapt without losing its history.

In this project, you will turn your Part 1 tag validator into a configurable Python CLI that runs on Ubuntu over SSH. The finished tool turns external policy files into durable compliance evidence.

What You'll Build

From Windows PowerShell, you will connect to your Linux validator over SSH to watch a production policy check finish with clear terminal results plus a machine-readable JSON report.

By the end of this project, you'll have:

  • A one-command validation that gives every simulated resource a clear pass or fail result under the selected policy.
  • A switchable policy mode controlled through VALIDATOR_ENV, so you can choose development or production rules without editing Python.
  • A durable JSON Lines audit trail that survives a malformed record and still creates output/summary.json.
  • Secret Mission: Add an optional failure exit code that prepares the validator for CI/CD and future AWS automation.

Are there any prerequisites?

You need a Windows computer plus the Python skills from Part 1, including collections, loops, functions, scope, conversion, and exceptions. No AWS account is required because every cloud resource stays simulated on your computer.

Before We Start

Before any hands-on work begins, this step locks in the Part 1 limitation you are removing. You are committing to a configurable JSON-backed validator operated as a Linux CLI for a stronger cloud-role portfolio.

Prepare a Linux Workspace Over SSH

Cloud operations often happen through Linux shells on remote hosts. Running the validator this way proves you can operate it outside a local Windows session.

Windows Subsystem for Linux 2 gives your Windows computer an Ubuntu userspace. In this step, you'll reach that workspace over SSH from Windows PowerShell.

In this step, get ready to:
  • Prepare Ubuntu 24.04 LTS under WSL 2.
  • Install the Linux tools that power remote validation.
  • Connect over SSH to create the project directories.
Install Ubuntu 24.04 with WSL 2

WSL 2 hosts Ubuntu locally, so you can practice a cloud-style Linux workflow without creating a paid cloud server. The first check shows whether the required distribution already exists.

  • Press the Windows key to open Windows Search.
  • Type PowerShell into the search field.
  • Start Windows PowerShell with administrator privileges using the administrator option beside the search result.
  • Check your installed WSL distributions by running this command:
wsl --list --verbose

What does this check show?

This command lists each installed WSL distribution. It also shows whether each distribution uses WSL version 1 or 2.

Your target is an Ubuntu-24.04 entry with version 2.

✔️ I see Ubuntu-24.04 with version 2

Ubuntu 24.04 is already running on the correct WSL version. Confirm the Linux account you use for this distribution.

  • Press the Windows key to open Windows Search.
  • Type Ubuntu 24.04 into the search field.
  • Open the Ubuntu result to reach its Linux shell.
  • Create a Linux username if Ubuntu requests first-time setup.
  • Create a password if Ubuntu requests first-time setup.
  • Record the Linux username you use here: <ubuntu-user>.

ⓧ Ubuntu-24.04 uses version 1

The required Ubuntu release exists, but it uses WSL version 1. Convert that distribution to WSL version 2.

  • Convert the existing distribution by running this command in Administrator PowerShell:
wsl --set-version Ubuntu-24.04 2

What does this command change?

This command changes the existing Ubuntu-24.04 distribution to WSL version 2. Your Linux files remain inside the same distribution.

  • Press the Windows key after the conversion finishes.
  • Type Ubuntu 24.04 into the search field.
  • Open the Ubuntu result to reach its Linux shell.
  • Record the Linux username you use here: <ubuntu-user>.

ⓧ Ubuntu-24.04 is missing

The Ubuntu download can take several minutes, so a quiet progress period is expected. Windows may request a restart before Ubuntu can finish setup.

  • Install Ubuntu 24.04 through WSL by running this command in Administrator PowerShell:
wsl --install Ubuntu-24.04

What does this command install?

This command installs an Ubuntu-24.04 WSL distribution. It launches the distribution so you can complete the Linux account setup.

  • Restart Windows if the installer requests it.
  • Press the Windows key after installation or restart.
  • Type Ubuntu 24.04 into the search field.
  • Open the Ubuntu result to continue setup.
  • Create the prompted Linux username.
  • Create the prompted Linux password.
  • Record your new Linux username here: <ubuntu-user>.
Install the Linux tools and start SSH

The Ubuntu workspace needs OpenSSH Server for remote access. It also needs Python plus GNU nano for the project work.

  • Switch back to the Ubuntu window from the WSL setup.
  • Use your Linux password if sudo requests authentication during the installs.
  • Approve each package confirmation prompt when it appears.
  • Install the required Linux tools by running these commands:
sudo apt install openssh-server
sudo apt install -y python3-full
sudo apt install nano

What did these installs add?

  • The openssh-server package lets Ubuntu accept SSH connections.
  • The python3-full package provides the Python interpreter needed by the validator.
  • The nano package provides a terminal-based editor for the project files.

Package downloads may take a few minutes. Let each command finish before starting the next one.

Package install failed?

Confirm that Ubuntu has internet access. Check that you entered your Linux password when sudo requested authentication.

Still blocked? Help me troubleshoot the Ubuntu package installation.

  • Check the installed Python version plus the installed nano version by running:
python3 --version
nano --version

What do these checks prove?

The first command confirms that Ubuntu can run Python 3. The second command confirms that GNU nano is available for editing files.

✔️ I see Python 3.12 or higher and a nano version

Your Python version matches the Ubuntu 24.04 workspace. GNU nano is ready too.

ⓧ I see a Python version lower than 3.12

An older Python version usually points to a different Ubuntu distribution. Confirm that the earlier WSL check lists Ubuntu-24.04.

  • Install the Ubuntu 24.04 Python package again by running:
sudo apt install -y python3-full

Why repeat this install?

The python3-full package provides the default complete Python installation for the active Ubuntu release. Ubuntu 24.04 supplies Python 3.12 through this package.

ⓧ Python or nano is missing

One of the package installations did not complete. Reinstall both tools before continuing.

  • Install Python plus GNU nano by running these commands:
sudo apt install -y python3-full
sudo apt install nano

What should happen now?

Ubuntu installs any missing package. An already installed package remains available.

OpenSSH Server must be active before Windows can connect. Restarting the service applies the installed server configuration.

  • Restart the SSH service plus check its state by running:
sudo systemctl restart ssh.service
systemctl is-active ssh.service

What do these service commands do?

The first command restarts ssh.service under Ubuntu's service manager. The second command asks whether that service is currently running.

You should see active. That result confirms Ubuntu is ready to accept an SSH connection.

SSH service is not active?

Confirm that the openssh-server installation completed without a package error. Run the restart command again after the installation finishes.

Need another set of eyes? Help me diagnose the inactive SSH service.

Connect from PowerShell and create the workspace

Ubuntu can now accept remote connections. Windows still needs its OpenSSH Client before PowerShell can start the SSH session.

  • Return to Administrator PowerShell from earlier.
  • Check the Windows OpenSSH capabilities by running:
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

What does this capability check show?

PowerShell lists the Windows OpenSSH components. Use the OpenSSH Client entry to decide whether an installation is required.

✔️ OpenSSH Client is installed

The Windows client is ready. PowerShell can now start the connection to Ubuntu.

ⓧ OpenSSH Client shows NotPresent

Windows includes an OpenSSH Client capability, but this device has not installed it yet. Add the capability before connecting.

  • Install the OpenSSH Client plus repeat the capability check by running:
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

What should change?

The first command installs the Windows OpenSSH Client capability. The repeated check should no longer show NotPresent for the client.

The default WSL networking workflow lets Windows reach a service inside Ubuntu through localhost. SSH uses the Linux username plus the password you created during Ubuntu setup.

  • Prepare to confirm the Ubuntu host identity if SSH asks during the first connection.
  • Use your Linux password when SSH requests authentication.
  • Connect to Ubuntu through localhost by running:
ssh [[UBUNTU_USER="<ubuntu-user>"]]@localhost

Why does localhost work?

WSL networking forwards Windows connections to services running inside the Linux environment. The SSH server listening in Ubuntu receives this localhost connection.

Your command prompt now belongs to Ubuntu. You are operating the Linux workspace from Windows PowerShell.

Can't reach Ubuntu through localhost?

Another local service can occupy the SSH port. WSL also provides the Ubuntu virtual machine address as a fallback.

  • Return to PowerShell if the localhost connection fails.
  • Look up the WSL 2 address by running:
wsl hostname -I

This command prints the address assigned to the WSL 2 virtual machine.

  • Record the WSL address here: your WSL IP address.
  • Connect through the fallback address by running:
ssh [[UBUNTU_USER="<ubuntu-user>"]]@[[WSL_IP="your WSL IP address"]]

This uses the same Ubuntu account against the WSL address.

Still unable to connect? Help me troubleshoot the Windows-to-WSL SSH connection.

The validator keeps simulated resource data separate from policy configuration. Two directories establish that boundary before any project files are added.

  • Create the project directory plus its data and configuration directories by running this inside the SSH session:
mkdir -p ~/cloud-tag-validator/data ~/cloud-tag-validator/config

What did mkdir create?

The -p option creates missing parent directories. It also avoids an error when a directory already exists.

The data directory holds simulated resources. The config directory holds policy configuration.

  • Confirm both project directories exist by running:
ls ~/cloud-tag-validator

What should the directory check show?

The listing should contain config plus data. These names confirm the project structure is ready.

Before you run the final checks, decide which line you expect to prove the SSH service is ready.

  • Verify the Linux tools plus the SSH service from inside your SSH session by running:
python3 --version
nano --version
systemctl is-active ssh.service

What should the final checks confirm?

  • The python3 --version command should print a Python 3 version.
  • The nano --version command should print a GNU nano version.
  • The systemctl is-active ssh.service command should print active.

That's the remote groundwork complete. Your Windows shell can now operate the Ubuntu workspace over SSH.

Your Linux workspace now has the tools plus directories the validator needs. Next up, you'll move the simulated resources into external JSON data.

Load Resources from JSON

Your SSH workspace is ready for the validator. You can now build the first runnable upgrade inside the Ubuntu project directory.

Hardcoded resource dictionaries force operators to change the Python script whenever the inventory changes. Moving the inventory into JSON gives the data its own editable file.

In this step, get ready to:
  • Create a JSON inventory with three simulated cloud resources.
  • Build a Python loader that reads the inventory from disk.
  • Run the loader over SSH to confirm the resource count.
Create the resource inventory

The inventory uses a JSON list to hold separate resource objects. Each object contains an identifier plus a resource type plus its current tags.

  • Return to ~/cloud-tag-validator in the SSH session from earlier.
  • Create data/resources.json with GNU nano by running this command:
nano data/resources.json

What does this command do?

The nano command opens data/resources.json inside the existing data directory. Saving the buffer creates the inventory file on disk.

  • Paste this inventory into data/resources.json:
[
  {
    "resource_id": "vm-web-01",
    "resource_type": "virtual-machine",
    "tags": {
      "Environment": "prod",
      "Owner": "platform-team",
      "CostCenter": "CC-100"
    }
  },
  {
    "resource_id": "bucket-app-logs",
    "resource_type": "object-bucket",
    "tags": {
      "Environment": "production",
      "Owner": "",
      "CostCenter": "CC-200"
    }
  },
  {
    "resource_id": "function-reporting",
    "resource_type": "serverless-function",
    "tags": {
      "Environment": "prod",
      "Owner": "cloud-team"
    }
  }
]

How is the inventory structured?

  • The outer list gives the loader one collection of resources to process.
  • Each resource_id identifies a simulated cloud resource.
  • Each resource_type describes the kind of resource represented by the object.
  • The nested tags object stores the values that later policy checks evaluate.
  • Press Ctrl+O to write the inventory to disk.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to close nano.

Good progress. Your simulated cloud inventory now lives outside the application code.

Inventory file not saved?

  • Confirm the save prompt shows data/resources.json before pressing Enter.
  • Confirm the existing data directory is inside ~/cloud-tag-validator.
  • Recheck every quote plus comma if nano reports a problem while you paste the inventory.

Still stuck? Help me check why my resources.json file is not saving in the data directory.

✔️ Awesome, I've got everything!

Your resource inventory is saved with all three simulated resources.

ⓧ I'd like to double check the full code

[
  {
    "resource_id": "vm-web-01",
    "resource_type": "virtual-machine",
    "tags": {
      "Environment": "prod",
      "Owner": "platform-team",
      "CostCenter": "CC-100"
    }
  },
  {
    "resource_id": "bucket-app-logs",
    "resource_type": "object-bucket",
    "tags": {
      "Environment": "production",
      "Owner": "",
      "CostCenter": "CC-200"
    }
  },
  {
    "resource_id": "function-reporting",
    "resource_type": "serverless-function",
    "tags": {
      "Environment": "prod",
      "Owner": "cloud-team"
    }
  }
]
Build the JSON loader

The loader needs a reliable filesystem path plus a safe way to deserialize the file. Python's Path class handles the path while json.load() converts the file contents into Python objects.

  • Create main.py inside ~/cloud-tag-validator by running this command:
nano main.py

What does this command do?

The nano command opens a buffer for main.py in the current project directory. Saving the buffer creates the loader script.

  • Paste this loader into main.py:
import json
from pathlib import Path


resource_path = Path("data/resources.json")
with resource_path.open(encoding="utf-8") as file:
    resources = json.load(file)

print(f"Loaded {len(resources)} resources from {resource_path}")

What does this code do?

  • The imports provide JSON deserialization plus filesystem path handling.
  • The resource_path variable points to the inventory inside the data directory.
  • The with block opens the file with UTF-8 encoding plus closes it after reading.
  • The resources variable receives the deserialized list from json.load().
  • The final line prints the list length plus the source path as visible proof.
  • Press Ctrl+O to write main.py to disk.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to close nano.

Loader file not saved?

  • Confirm the save prompt shows main.py without an extra file extension.
  • Confirm the file sits directly inside ~/cloud-tag-validator.
  • Reopen main.py if you exited nano before writing the buffer.

Need a hand? Help me verify that main.py was saved in the correct directory.

✔️ Awesome, I've got everything!

Your loader is saved with the imports plus the file-reading logic plus the count message.

ⓧ I'd like to double check the full code

import json
from pathlib import Path


resource_path = Path("data/resources.json")
with resource_path.open(encoding="utf-8") as file:
    resources = json.load(file)

print(f"Loaded {len(resources)} resources from {resource_path}")
Run the loader over SSH

Before you run it, predict whether the outer JSON list contains one resource plus three resources plus nine tag values.

  • Run the loader from ~/cloud-tag-validator by running this command:
python3 main.py

What does this run prove?

The command executes main.py inside the Linux workspace. The script reads the inventory from disk during that run.

A count of three proves that json.load() produced the outer resource list instead of using a Python literal.

You'll see Loaded 3 resources from data/resources.json in the terminal.

You've removed the first major limitation from Part 1. Operators can now change the simulated inventory without editing the loader.

Loader did not print the count?

  • Confirm your SSH prompt is inside ~/cloud-tag-validator before using python3.
  • Confirm data/resources.json matches the complete inventory above.
  • Check the quotes plus commas plus braces in the JSON if Python reports a parsing problem.

Still blocked? Help me diagnose why my Python loader cannot read resources.json.

Your validator now reads its resource inventory from a separate JSON file. Next, you'll move policy selection plus validation into reusable modules.

Build a Modular Policy CLI

Your inventory now lives in JSON. Putting policy selection inside main.py would make each responsibility harder to test or replace.

This step separates configuration from validation through Python modules. You will expose those modules through a CLI controlled by an environment variable.

In this step, get ready to:
  • Define development and production tag policies in a configuration file.
  • Move configuration loading and policy evaluation into separate modules.
  • Run the production policy through a validate subcommand.
Define the policy and configuration loader

The policy file gives operators one place to change governance rules without editing Python. The loader centralizes file access and reads the active environment from VALIDATOR_ENV.

  • Use GNU nano in the SSH shell from earlier to create config/policy.json with the following content:
{
  "required_tags": {
    "dev": ["Environment", "Owner"],
    "prod": ["Environment", "Owner", "CostCenter"]
  },
  "allowed_values": {
    "Environment": ["dev", "test", "prod"]
  }
}

What does this policy define?

  • The required_tags object gives development and production separate requirements.
  • The production policy adds CostCenter to the tags required for every resource.
  • The allowed_values object limits Environment to the three accepted values.
  • Save config/policy.json in GNU nano.
  • Confirm the existing inventory loader still works by running this command:
python3 main.py

You should still see Loaded 3 resources from data/resources.json. This confirms the inventory remains intact while you add its policy file.

  • Use GNU nano in the same SSH shell to create config_loader.py with the following code:
import json
import os
from pathlib import Path


def load_json(path: Path):
    with path.open(encoding="utf-8") as file:
        return json.load(file)


def get_active_environment():
    return os.getenv("VALIDATOR_ENV", "dev")

What does this module do?

  • The load_json() function opens any supplied path with UTF-8 encoding and returns the decoded data.
  • The get_active_environment() function reads VALIDATOR_ENV from the shell.
  • The value dev becomes the default when the environment variable has not been set.
  • Save config_loader.py in GNU nano.
  • Protect the working resource loader by running its current version again:
python3 main.py

You should see the same three-resource count. The new configuration boundary now exists without disrupting the inventory from the previous step.

Having trouble loading the files?

  • Check that policy.json sits inside the existing config directory.
  • Check that config_loader.py sits directly inside ~/cloud-tag-validator.
  • Compare every quotation mark and brace in config/policy.json with the code above.

Still stuck? Help me check my policy and configuration loader files.

Build the validation module

A validator needs the resources and the selected policy as inputs. It returns one result record per resource so command handling can remain separate from policy logic.

  • Use GNU nano in the SSH shell to create validator.py with this required-tag validation:
def validate_resources(resources, policy, environment):
    required_tags = policy["required_tags"].get(environment)
    if required_tags is None:
        raise ValueError(f"Unknown environment: {environment}")

    allowed_values = policy.get("allowed_values", {})
    results = []

    for resource in resources:
        tags = resource.get("tags", {})
        issues = []

        for tag in required_tags:
            if not tags.get(tag):
                issues.append({
                    "code": "MISSING_TAG",
                    "tag": tag,
                    "message": f"{tag} is required",
                })

        results.append({
            "resource_id": resource["resource_id"],
            "resource_type": resource["resource_type"],
            "compliant": len(issues) == 0,
            "issues": issues,
        })

    return results

How does required-tag validation work?

  • The policy lookup selects the required tags for the active environment.
  • An unknown environment raises a clear exception before any resource is checked.
  • Each empty or absent required tag adds a MISSING_TAG issue to that resource.
  • The compliant value becomes true only when the issue list is empty.
  • Save the first version of validator.py in GNU nano.
  • Confirm the existing script remains runnable by running:
python3 main.py

You should still see the three-resource count. The validation module is now available for the CLI to import.

Required tags cover missing data. Allowed values catch populated tags whose contents fall outside policy.

  • Return to validator.py in GNU nano.
  • Find the line that begins the result record:
        results.append({

This line starts the final result record for the current resource.

  • Insert the following allowed-value check immediately above that line:
        for tag, allowed in allowed_values.items():
            value = tags.get(tag)
            if value and value not in allowed:
                issues.append({
                    "code": "INVALID_VALUE",
                    "tag": tag,
                    "message": f"{value} is not allowed for {tag}",
                })

What does the second check catch?

The loop compares each configured tag value with its allowed list. A populated value outside that list adds an INVALID_VALUE issue.

  • Save the completed validator.py file.
  • Confirm the original inventory script still runs by using:
python3 main.py

You should see Loaded 3 resources from data/resources.json again. Your inventory and policy logic now live behind separate boundaries.

Seeing a Python syntax problem?

  • Check that the allowed-value loop sits inside the resource loop.
  • Check that results.append({ remains aligned with both policy-checking loops.
  • Compare the closing braces and parentheses in each issue record with the snippets above.

Need another pair of eyes? Help me check the indentation in validator.py.

Wire the validate command

The command parser turns the modules into an operator-facing tool. Its validate subcommand accepts resource and policy paths while keeping useful defaults.

  • Use GNU nano in the SSH shell to replace the current contents of main.py with these imports and parser definitions:
import argparse
from pathlib import Path

from config_loader import get_active_environment, load_json
from validator import validate_resources


def build_parser():
    parser = argparse.ArgumentParser(
        description="Validate simulated cloud resource tags"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    validate_parser = subparsers.add_parser("validate")
    validate_parser.add_argument(
        "--resources",
        type=Path,
        default=Path("data/resources.json"),
    )
    validate_parser.add_argument(
        "--policy",
        type=Path,
        default=Path("config/policy.json"),
    )

    return parser

What does the parser configure?

  • The parser requires a subcommand so every invocation states its purpose.
  • The validate subcommand provides default paths for the existing inventory and the new policy file.
  • Both path arguments become Path objects before validation begins.
  • Continue in main.py by adding the validation runner and entry point below the parser:
def run_validation(args):
    resources = load_json(args.resources)
    policy = load_json(args.policy)
    environment = get_active_environment()
    results = validate_resources(resources, policy, environment)
    print(f"Resources checked: {len(results)}")
    print(f"Compliant: {sum(result['compliant'] for result in results)}")
    print(f"Non-compliant: {sum(not result['compliant'] for result in results)}")


def main():
    args = build_parser().parse_args()
    run_validation(args)


if __name__ == "__main__":
    main()

How does the command flow work?

  • The runner loads resources and policy through the same load_json() boundary.
  • The active environment selects the matching required-tag policy.
  • The validator returns result records that the runner counts as compliant or non-compliant.
  • The entry-point guard starts command parsing when you execute main.py directly.
  • Save the completed main.py file.

Before you run the new command, how many resources do you expect the stricter production policy to accept?

  • Select the production policy and run the validator by using these commands:
export VALIDATOR_ENV=prod
python3 main.py validate

You should see Resources checked: 3. The next two lines should show Compliant: 1 and Non-compliant: 2.

Why do two resources fail?

The production policy requires Environment, Owner, and CostCenter. The result records capture empty or absent required values as MISSING_TAG.

The value production falls outside the configured environment list. That result also contains an INVALID_VALUE issue.

Production counts do not match?

  • Check that the production policy includes Environment, Owner, and CostCenter.
  • Check that VALIDATOR_ENV is spelled consistently in the shell and config_loader.py.
  • Check that main.py loads config/policy.json before calling validate_resources().

Still seeing different counts? Help me trace my production policy validation.

✔️ Awesome, I've got everything!

Great work. Double-check that each file is saved before moving on.

ⓧ I'd like to double check the full code

Compare your four new or updated files with these complete versions.

{
  "required_tags": {
    "dev": ["Environment", "Owner"],
    "prod": ["Environment", "Owner", "CostCenter"]
  },
  "allowed_values": {
    "Environment": ["dev", "test", "prod"]
  }
}
import json
import os
from pathlib import Path


def load_json(path: Path):
    with path.open(encoding="utf-8") as file:
        return json.load(file)


def get_active_environment():
    return os.getenv("VALIDATOR_ENV", "dev")
def validate_resources(resources, policy, environment):
    required_tags = policy["required_tags"].get(environment)
    if required_tags is None:
        raise ValueError(f"Unknown environment: {environment}")

    allowed_values = policy.get("allowed_values", {})
    results = []

    for resource in resources:
        tags = resource.get("tags", {})
        issues = []

        for tag in required_tags:
            if not tags.get(tag):
                issues.append({
                    "code": "MISSING_TAG",
                    "tag": tag,
                    "message": f"{tag} is required",
                })

        for tag, allowed in allowed_values.items():
            value = tags.get(tag)
            if value and value not in allowed:
                issues.append({
                    "code": "INVALID_VALUE",
                    "tag": tag,
                    "message": f"{value} is not allowed for {tag}",
                })

        results.append({
            "resource_id": resource["resource_id"],
            "resource_type": resource["resource_type"],
            "compliant": len(issues) == 0,
            "issues": issues,
        })

    return results
import argparse
from pathlib import Path

from config_loader import get_active_environment, load_json
from validator import validate_resources


def build_parser():
    parser = argparse.ArgumentParser(
        description="Validate simulated cloud resource tags"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    validate_parser = subparsers.add_parser("validate")
    validate_parser.add_argument(
        "--resources",
        type=Path,
        default=Path("data/resources.json"),
    )
    validate_parser.add_argument(
        "--policy",
        type=Path,
        default=Path("config/policy.json"),
    )

    return parser


def run_validation(args):
    resources = load_json(args.resources)
    policy = load_json(args.policy)
    environment = get_active_environment()
    results = validate_resources(resources, policy, environment)
    print(f"Resources checked: {len(results)}")
    print(f"Compliant: {sum(result['compliant'] for result in results)}")
    print(f"Non-compliant: {sum(not result['compliant'] for result in results)}")


def main():
    args = build_parser().parse_args()
    run_validation(args)


if __name__ == "__main__":
    main()

That is the structural upgrade complete. Your CLI now selects a policy at runtime and reports compliance through reusable modules.

Next, you will preserve each validation run as operational evidence and give the CLI a log analyzer.

Record Audit Events

Your modular validator can show the current compliance result. Operators also need historical evidence from earlier runs.

This step records each run as an event in a JSON Lines audit log. It also adds a deliberately naive analyzer so you can test what happens when one record is malformed.

In this step, get ready to:
  • Move result formatting into a reporting module.
  • Record each validation run as an audit event.
  • Expose the analyzer to a malformed log record.
Create the reporting module

The reporting module separates operator-facing output from policy evaluation. It also owns the files that preserve audit evidence.

  • Create reporter.py in the ~/cloud-tag-validator directory by opening it in GNU nano with this command:
nano reporter.py

What does this command do?

GNU nano opens a new buffer for reporter.py. Saving the buffer creates the reporting module beside main.py.

  • Paste these imports at the top of reporter.py:
import json
from datetime import datetime, timezone
from pathlib import Path

What do these imports provide?

  • The json module serializes audit events and later deserializes log records.
  • The datetime tools create a UTC timestamp for each validation run.
  • The Path class manages log and report locations.
  • Paste print_results() below the imports:
def print_results(results, environment):
    compliant_count = sum(result["compliant"] for result in results)
    print(f"Environment: {environment}")
    print(f"Resources checked: {len(results)}")
    print(f"Compliant: {compliant_count}")
    print(f"Non-compliant: {len(results) - compliant_count}")

    for result in results:
        status = "PASS" if result["compliant"] else "FAIL"
        print(f"{status} {result['resource_id']} ({result['resource_type']})")
        for issue in result["issues"]:
            print(f"  - {issue['code']}: {issue['message']}")

What does this function do?

  • The compliant_count value totals the resources that passed validation.
  • The summary lines show the active environment plus the overall compliance totals.
  • The nested loops print each resource status plus every associated issue.
  • Paste append_audit_event() below print_results():
def append_audit_event(path: Path, results, environment):
    issue_codes = [
        issue["code"]
        for result in results
        for issue in result["issues"]
    ]
    event = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "environment": environment,
        "resources_checked": len(results),
        "issue_codes": issue_codes,
    }

    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as file:
        file.write(json.dumps(event) + "\n")
    print(f"Audit event written to {path}")

How is the audit event built?

  • The issue_codes list keeps the compact policy outcomes needed for later analysis.
  • The event dictionary captures the UTC timestamp plus the environment.
  • The same event records the resource count plus the issue codes.
  • Append mode preserves earlier runs by writing one JSON object per line.
  • Paste the first version of analyze_log() below append_audit_event():
def analyze_log(path: Path):
    events_analyzed = 0
    resources_checked = 0
    skipped_lines = 0
    issue_counts = {}

    with path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue

            event = json.loads(line)
            events_analyzed += 1
            resources_checked += event.get("resources_checked", 0)
            for code in event.get("issue_codes", []):
                issue_counts[code] = issue_counts.get(code, 0) + 1

    return {
        "events_analyzed": events_analyzed,
        "resources_checked": resources_checked,
        "skipped_lines": skipped_lines,
        "issue_counts": issue_counts,
    }

How does the analyzer work?

  • The counters collect valid event totals plus recurring issue codes.
  • The loop ignores empty lines before passing each remaining line to json.loads().
  • The returned dictionary gives the report writer one machine-readable summary.
  • Paste write_report() below analyze_log():
def write_report(path: Path, report):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as file:
        json.dump(report, file, indent=2)
    print(f"Analysis report written to {path}")

What does the report writer do?

The function creates the parent directory when needed. It writes the summary with indentation so output/summary.json remains readable by people plus other tools.

  • Press Ctrl+O to start saving reporter.py.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to return to the SSH terminal.
Connect reporting to the CLI

The command-line interface now needs paths for the audit log plus the analysis report. It also needs separate routes for validation and analysis.

  • Return to main.py in GNU nano by running this command:
nano main.py

What happens here?

GNU nano opens the existing main.py file. You can now connect its command routing to the functions in reporter.py.

  • Replace the import section at the top of main.py with this version:
import argparse
from pathlib import Path

from config_loader import get_active_environment, load_json
from reporter import analyze_log, append_audit_event, print_results, write_report
from validator import validate_resources

Why do these imports change?

The reporter import exposes result printing plus audit logging to the validation path. It also exposes log analysis plus report writing to the new analysis path.

  • Find the --policy argument inside build_parser().
  • Paste this log argument immediately below the closing parenthesis for --policy:
    validate_parser.add_argument(
        "--log",
        type=Path,
        default=Path("output/validator.log"),
    )

What does the log argument control?

The --log argument gives validation a default audit destination. A future command can still supply a different path.

  • Find return parser inside build_parser().
  • Paste this analyzer configuration immediately above return parser:
    analyze_parser = subparsers.add_parser("analyze")
    analyze_parser.add_argument(
        "--log",
        type=Path,
        default=Path("output/validator.log"),
    )
    analyze_parser.add_argument(
        "--report",
        type=Path,
        default=Path("output/summary.json"),
    )

What does the analyze subcommand need?

The analyzer reads audit events from output/validator.log. It sends the resulting dictionary to output/summary.json.

  • Replace the existing run_validation() function with this version:
def run_validation(args):
    resources = load_json(args.resources)
    policy = load_json(args.policy)
    environment = get_active_environment()
    results = validate_resources(resources, policy, environment)
    print_results(results, environment)
    append_audit_event(args.log, results, environment)

How does validation change?

Validation still loads the same resources plus policy. The completed results now flow through the reporting boundary for display plus audit storage.

  • Paste run_analysis() below run_validation():
def run_analysis(args):
    summary = analyze_log(args.log)
    write_report(args.report, summary)

What does the analysis route do?

The function passes the log path to analyze_log(). It then sends the returned summary to write_report().

  • Replace the existing main() function with this routed version:
def main():
    args = build_parser().parse_args()
    if args.command == "validate":
        run_validation(args)
    else:
        run_analysis(args)

How does command routing work?

The parsed subcommand decides which workflow runs. The validate path checks resources while the analyze path processes saved history.

  • Press Ctrl+O to save main.py.
  • Press Enter to confirm the filename.
  • Press Ctrl+X to return to the SSH terminal.
  • Select the production policy plus run one validation by executing:
export VALIDATOR_ENV=prod
python3 main.py validate

What should you see?

You should see three resource results. The totals show one compliant resource plus two non-compliant resources.

The final line confirms that an audit event was written to output/validator.log. That event contains the production environment plus the three issue codes.

  • Run the analyzer while the audit log contains only the valid event:
python3 main.py analyze

What does a clean analysis prove?

You should see confirmation that the analysis report was written to output/summary.json. The analyzer has successfully processed the valid audit event.

Audit event missing?

  • Check that append_audit_event(args.log, results, environment) appears inside run_validation().
  • Check that the --log argument belongs to validate_parser.

If the command still fails, ask for help tracing the reporting path.

Your validator now produces durable evidence after every run. That turns a terminal result into an operational record you can analyze later.

✔️ Awesome, I've got everything!

Great work. Double-check that reporter.py is saved before continuing.

ⓧ I'd like to double check the full code

import json
from datetime import datetime, timezone
from pathlib import Path


def print_results(results, environment):
    compliant_count = sum(result["compliant"] for result in results)
    print(f"Environment: {environment}")
    print(f"Resources checked: {len(results)}")
    print(f"Compliant: {compliant_count}")
    print(f"Non-compliant: {len(results) - compliant_count}")

    for result in results:
        status = "PASS" if result["compliant"] else "FAIL"
        print(f"{status} {result['resource_id']} ({result['resource_type']})")
        for issue in result["issues"]:
            print(f"  - {issue['code']}: {issue['message']}")


def append_audit_event(path: Path, results, environment):
    issue_codes = [
        issue["code"]
        for result in results
        for issue in result["issues"]
    ]
    event = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "environment": environment,
        "resources_checked": len(results),
        "issue_codes": issue_codes,
    }

    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as file:
        file.write(json.dumps(event) + "\n")
    print(f"Audit event written to {path}")


def analyze_log(path: Path):
    events_analyzed = 0
    resources_checked = 0
    skipped_lines = 0
    issue_counts = {}

    with path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue

            event = json.loads(line)
            events_analyzed += 1
            resources_checked += event.get("resources_checked", 0)
            for code in event.get("issue_codes", []):
                issue_counts[code] = issue_counts.get(code, 0) + 1

    return {
        "events_analyzed": events_analyzed,
        "resources_checked": resources_checked,
        "skipped_lines": skipped_lines,
        "issue_counts": issue_counts,
    }


def write_report(path: Path, report):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as file:
        json.dump(report, file, indent=2)
    print(f"Analysis report written to {path}")

How should you use this reference?

Compare this file with your saved reporter.py. Pay close attention to indentation inside each function.

✔️ Awesome, I've got everything!

Your command routing is ready. Confirm that main.py is saved before testing the malformed record.

ⓧ I'd like to double check the full code

import argparse
from pathlib import Path

from config_loader import get_active_environment, load_json
from reporter import analyze_log, append_audit_event, print_results, write_report
from validator import validate_resources


def build_parser():
    parser = argparse.ArgumentParser(
        description="Validate simulated cloud resource tags"
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    validate_parser = subparsers.add_parser("validate")
    validate_parser.add_argument(
        "--resources",
        type=Path,
        default=Path("data/resources.json"),
    )
    validate_parser.add_argument(
        "--policy",
        type=Path,
        default=Path("config/policy.json"),
    )
    validate_parser.add_argument(
        "--log",
        type=Path,
        default=Path("output/validator.log"),
    )

    analyze_parser = subparsers.add_parser("analyze")
    analyze_parser.add_argument(
        "--log",
        type=Path,
        default=Path("output/validator.log"),
    )
    analyze_parser.add_argument(
        "--report",
        type=Path,
        default=Path("output/summary.json"),
    )

    return parser


def run_validation(args):
    resources = load_json(args.resources)
    policy = load_json(args.policy)
    environment = get_active_environment()
    results = validate_resources(resources, policy, environment)
    print_results(results, environment)
    append_audit_event(args.log, results, environment)


def run_analysis(args):
    summary = analyze_log(args.log)
    write_report(args.report, summary)


def main():
    args = build_parser().parse_args()
    if args.command == "validate":
        run_validation(args)
    else:
        run_analysis(args)


if __name__ == "__main__":
    main()

How should you use this reference?

Compare this file with your saved main.py. Confirm that both subcommands use their matching arguments plus functions.

Expose the fragile analyzer

Operational logs can contain incomplete or corrupted records. You will now add one deliberately malformed line to test the analyzer's assumptions.

  • Append the malformed record to output/validator.log by running:
echo 'not-json' >> output/validator.log

What does this command change?

The append redirection preserves the valid audit event. It adds not-json as the next line in the same file.

Before you run the analyzer, consider this question: what will it do when json.loads() reaches not-json.

  • Test the analyzer against both records by running:
python3 main.py analyze

The failure is intentional

You should see a traceback ending in json.decoder.JSONDecodeError. The direct json.loads(line) call stops the entire analysis when it reaches the malformed record.

This proves that one damaged audit line can block access to valid operational history. The next step adds recovery around each individual line.

You now have a real audit trail plus a reproduced resilience gap. Next, you will keep valid events moving even when one log line cannot be decoded.

Make Log Analysis Resilient

Your JSON Lines audit log now contains one valid event plus the malformed record that stopped the analyzer. A reliable operations tool must preserve valid history when one write is damaged.

This step contains each parsing failure inside the line-reading loop. The surviving event can then produce a terminal summary.

The same counters will be stored in an indented JSON report at output/summary.json.

In this step, get ready to:
  • Catch malformed JSON records without losing valid audit history.
  • Aggregate valid events into operational counters.
  • Verify the terminal summary plus the reusable JSON report.
Skip malformed audit lines

Per-line exception handling gives every audit record its own recovery boundary. A damaged record increases the skipped counter while later records remain available for analysis.

  • Launch GNU nano for reporter.py by running this command:
nano reporter.py

What does this command do?

GNU nano opens reporter.py in your connected SSH terminal. This is the module that owns audit-log analysis.

  • Inside reporter.py, replace the entire analyze_log() function with this resilient version:
def analyze_log(path: Path):
    events_analyzed = 0
    resources_checked = 0
    skipped_lines = 0
    issue_counts = {}
    with path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue
            try:
                event = json.loads(line)
            except json.JSONDecodeError:
                skipped_lines += 1
                continue
            events_analyzed += 1
            resources_checked += event.get("resources_checked", 0)
            for code in event.get("issue_codes", []):
                issue_counts[code] = issue_counts.get(code, 0) + 1
    return {"events_analyzed": events_analyzed, "resources_checked": resources_checked, "skipped_lines": skipped_lines, "issue_counts": issue_counts}

How does recovery work?

  • The try block attempts to decode one audit line.
  • The except json.JSONDecodeError block catches invalid JSON from that line.
  • The skipped_lines counter records the malformed entry.
  • The continue statement moves the loop to the next record.
  • The counters below the exception block receive data only from successfully decoded events.
  • Save reporter.py using the same GNU nano save flow from earlier.
  • Return to the connected SSH terminal.

Before you test the change, do you expect the malformed record to stop the whole analyzer or increase the skipped counter?

  • Test the recovery boundary by running the analyzer again:
python3 main.py analyze

What should I see?

You should see Analysis report written to output/summary.json. The command should finish without the earlier parsing failure.

That recovery boundary now protects the valid audit event from the damaged line.

Analyzer still stops?

  • Check that try: sits inside the loop directly before json.loads(line).
  • Check that except json.JSONDecodeError: aligns with try:.
  • Check that both skipped_lines += 1 and continue are indented inside the exception block.

Still stuck? Help me debug the per-line JSON recovery in analyze_log().

✔️ Awesome, I've got everything!

Your resilient reporter.py is saved. Continue when the analyzer reaches the report-writing message.

ⓧ I'd like to double check the full code

import json
from datetime import datetime, timezone
from pathlib import Path


def print_results(results, environment):
    compliant_count = sum(result["compliant"] for result in results)
    print(f"Environment: {environment}")
    print(f"Resources checked: {len(results)}")
    print(f"Compliant: {compliant_count}")
    print(f"Non-compliant: {len(results) - compliant_count}")

    for result in results:
        status = "PASS" if result["compliant"] else "FAIL"
        print(f"{status} {result['resource_id']} ({result['resource_type']})")
        for issue in result["issues"]:
            print(f"  - {issue['code']}: {issue['message']}")


def append_audit_event(path: Path, results, environment):
    issue_codes = [issue["code"] for result in results for issue in result["issues"]]
    event = {"timestamp": datetime.now(timezone.utc).isoformat(), "environment": environment, "resources_checked": len(results), "issue_codes": issue_codes}
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as file:
        file.write(json.dumps(event) + "\n")
    print(f"Audit event written to {path}")


def analyze_log(path: Path):
    events_analyzed = 0
    resources_checked = 0
    skipped_lines = 0
    issue_counts = {}
    with path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue
            try:
                event = json.loads(line)
            except json.JSONDecodeError:
                skipped_lines += 1
                continue
            events_analyzed += 1
            resources_checked += event.get("resources_checked", 0)
            for code in event.get("issue_codes", []):
                issue_counts[code] = issue_counts.get(code, 0) + 1
    return {"events_analyzed": events_analyzed, "resources_checked": resources_checked, "skipped_lines": skipped_lines, "issue_counts": issue_counts}


def write_report(path: Path, report):
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as file:
        json.dump(report, file, indent=2)
    print(f"Analysis report written to {path}")

How should I use this reference?

Compare this file with your saved reporter.py. Pay close attention to the indentation inside analyze_log().

Print the aggregated summary

The analyzer now returns the valid-event counters to run_analysis(). Printing those counters gives operators immediate evidence that recovery succeeded.

  • Launch GNU nano for main.py by running this command:
nano main.py

What does this command do?

GNU nano opens the CLI orchestration file. The run_analysis() function already receives the summary from reporter.py.

  • Inside main.py, replace the entire run_analysis() function with this version:
def run_analysis(args):
    summary = analyze_log(args.log)
    write_report(args.report, summary)
    print(f"Events analyzed: {summary['events_analyzed']}")
    print(f"Resources checked: {summary['resources_checked']}")
    print(f"Skipped lines: {summary['skipped_lines']}")
    for code, count in sorted(summary["issue_counts"].items()):
        print(f"{code}: {count}")

What does this code expose?

  • The first three print statements expose the event count, checked-resource total, and skipped-line count.
  • The sorted loop prints each issue code with its frequency.
  • The call to write_report() preserves the same summary in output/summary.json.
  • Save main.py using the same GNU nano save flow from earlier.
  • Return to the connected SSH terminal.

✔️ Awesome, I've got everything!

Your main.py now prints the analyzer's counters. Continue to the final verification.

ⓧ I'd like to double check the full code

import argparse
from pathlib import Path

from config_loader import get_active_environment, load_json
from reporter import analyze_log, append_audit_event, print_results, write_report
from validator import validate_resources


def build_parser():
    parser = argparse.ArgumentParser(description="Validate simulated cloud resource tags")
    subparsers = parser.add_subparsers(dest="command", required=True)
    validate_parser = subparsers.add_parser("validate")
    validate_parser.add_argument("--resources", type=Path, default=Path("data/resources.json"))
    validate_parser.add_argument("--policy", type=Path, default=Path("config/policy.json"))
    validate_parser.add_argument("--log", type=Path, default=Path("output/validator.log"))
    analyze_parser = subparsers.add_parser("analyze")
    analyze_parser.add_argument("--log", type=Path, default=Path("output/validator.log"))
    analyze_parser.add_argument("--report", type=Path, default=Path("output/summary.json"))
    return parser


def run_validation(args):
    resources = load_json(args.resources)
    policy = load_json(args.policy)
    environment = get_active_environment()
    results = validate_resources(resources, policy, environment)
    print_results(results, environment)
    append_audit_event(args.log, results, environment)


def run_analysis(args):
    summary = analyze_log(args.log)
    write_report(args.report, summary)
    print(f"Events analyzed: {summary['events_analyzed']}")
    print(f"Resources checked: {summary['resources_checked']}")
    print(f"Skipped lines: {summary['skipped_lines']}")
    for code, count in sorted(summary["issue_counts"].items()):
        print(f"{code}: {count}")


def main():
    args = build_parser().parse_args()
    if args.command == "validate":
        run_validation(args)
    else:
        run_analysis(args)


if __name__ == "__main__":
    main()

How should I use this reference?

Compare this file with your saved main.py. Confirm that run_analysis() writes the report before printing its counters.

Verify the resilient report

Before you rerun the analyzer, do you expect the malformed line to erase the valid event or appear in the skipped count?

  • Run the completed analyzer to verify its terminal summary:
python3 main.py analyze

What should I see?

You should see Analysis report written to output/summary.json.

The analyzer reports Events analyzed: 1. It also reports Resources checked: 3.

The malformed record appears as Skipped lines: 1.

The issue totals are INVALID_VALUE: 1. The second total is MISSING_TAG: 2.

  • Inspect output/summary.json with Python's JSON formatter by running this command:
python3 -m json.tool output/summary.json

What should the report contain?

You should see events_analyzed set to 1. You should also see resources_checked set to 3.

The skipped_lines value is 1.

Inside issue_counts, MISSING_TAG is 2. The INVALID_VALUE value is 1.

You have closed the fragile parsing path. Your validator now preserves useful operational evidence when one audit record is damaged.

Summary values look wrong?

  • Confirm output/validator.log still contains one valid production event plus the malformed not-json line.
  • Check whether another validation run added extra valid events to the log.
  • Confirm the issue-count loop reads event.get("issue_codes", []).

Need help tracing a mismatch? Help me compare my audit log with the summary counters.

Secret mission

Return Automation-Friendly Exit Codes

Your validator already explains policy failures to a person. Add an optional flag that returns a nonzero exit code when production policy issues exist. The normal interactive command keeps its successful status.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs on your Windows computer, so there are no cloud charges or ongoing service costs. Choose whether to keep the workspace available, pause WSL for now, or remove the project directory.

Resources you used:

  • Your ~/cloud-tag-validator directory in Ubuntu 24.04. It contains the validator modules, JSON policy, simulated inventory, audit log, and summary report.

Keep everything running

No action needed. Choose this if you plan to continue into Part 3 with the same Linux workspace.

  • Leave your current SSH session connected while you keep working.
  • Keep ~/cloud-tag-validator in place so Part 3 can build on your validator.

Pause - I'll come back to this later

Pause the workspace when you want to free its local runtime resources without deleting your files. You will close the remote session before shutting down Windows Subsystem for Linux 2.

  • Close the current SSH connection by running this command:
exit

You will return to the Windows PowerShell prompt when the SSH connection closes.

  • Stop all WSL distributions plus the WSL 2 virtual machine by running this command in Windows PowerShell:
wsl --shutdown

This stops the local Linux runtime. Your Ubuntu files remain on disk for your next session.

Delete - I don't want to use this again

Deleting the project directory permanently removes your validator code plus its generated audit evidence. Ubuntu 24.04 stays installed for later projects. The OpenSSH service stays installed too.

  • Remove only the ~/cloud-tag-validator directory by running this command in the current Ubuntu SSH session:
rm -r ~/cloud-tag-validator

This recursively removes the project directory plus every file inside it. Your Ubuntu distribution remains available for future Linux projects.

Nice Work!

Nice Work!

You did it! Your Python CLI now turns tag policy checks into durable audit evidence from a remote Linux shell.

You've learned how to:

  • Built a configurable tag validator that loads resource inventories plus environment-specific policies from JSON files.
  • Separated configuration, validation, reporting, and orchestration into reusable Python modules with a resource-loading boundary ready for future cloud data.
  • Made JSON Lines audit analysis resilient to malformed records while still producing output/summary.json.
  • Completed the Secret Mission by adding an optional nonzero exit status for failed policy checks in future CI/CD automation.

Ready to quiz yourself?