<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[evan | loves | cloud]]></title><description><![CDATA[Hi, I’m Evan — an engineer focused on automation, security, and cloud infrastructure and I offer consulting services at <a href="https://kairostechsolutions.com]]></description><link>https://evanlovescloud.com</link><generator>RSS for Node</generator><lastBuildDate>Mon, 05 Oct 2026 11:07:43 GMT</lastBuildDate><atom:link href="https://evanlovescloud.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The Nervous System Architecture: Privacy with Agentic AI Without Sacrificing Intelligence]]></title><description><![CDATA[photo credit: Growtika, Unsplash
The Nervous System Architecture: Privacy with Agentic AI Without Sacrificing Intelligence
At first, integrating agentic AI into my workflow seemed inevitable, yet some]]></description><link>https://evanlovescloud.com/the-nervous-system-architecture-privacy-with-agentic-ai-without-sacrificing-intelligence</link><guid isPermaLink="true">https://evanlovescloud.com/the-nervous-system-architecture-privacy-with-agentic-ai-without-sacrificing-intelligence</guid><category><![CDATA[agentic AI]]></category><category><![CDATA[ai security]]></category><category><![CDATA[data privacy]]></category><category><![CDATA[infrastructure]]></category><category><![CDATA[self-hosted]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Wed, 08 Jul 2026 18:21:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/66b14255487d6e546b82e697/081a0334-2384-4467-bfbd-a021dd19b8b8.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>photo credit: Growtika, Unsplash</p>
<h1>The Nervous System Architecture: Privacy with Agentic AI Without Sacrificing Intelligence</h1>
<p>At first, integrating agentic AI into my workflow seemed inevitable, yet something held me back from beginning my learning journey. I desired the intelligence these agents provided, but as I delved into their autonomous nature and features, my paranoia about the fate of my sensitive data grew. I experimented with hosting local models, which offered peace of mind regarding information security, but, without sufficient hardware resources, I found that they lacked the efficiency required for certain tasks. So, when I turned to the cloud for more horsepower, I asked myself a straightforward question: how do I keep my sensitive data private?</p>
<p>This article is as much a record of my self-study on securing Agentic AI infrastructure as it is a guide on how to start to building one yourself. Over time, I developed a repeatable baseline and I've decided to call it <strong>The Nervous System Architecture.</strong> It's a framework I built for myself—one hybrid infrastructure where "local" and "cloud" stop being mutually exclusive and start working together under my control.</p>
<p>Agentic AI is powerful, but without rigorous guardrails, it invites critical data leaks. A local model is not a panacea. Even behind a firewall, a model with persistent memory could still be exploited by a single malformed prompt into releasing proprietary secrets.</p>
<p>As I began experimenting with agentic AI, it became clear over time that true data leakage often occurs not at the model, but through its tools. Integrating external APIs or third-party search services creates gaps in your defense. These are the essential "crumbs" of data—IP addresses, credentials, API keys, and URLs—that can potentially exit your secure perimeter.</p>
<p>The core idea I'm presenting in this piece is simple: privacy isn't about where the model sits—it's about controlling where the data goes. Map your data flows, separate intelligence from execution, and you build the foundation for a true privacy architecture.</p>
<p>This leads to the nervous system model—a framework in which the "brain" can be any capable API, while the nervous system—the data, the tools, the environment—stays entirely under your control. In this model, efficiency no longer requires exposure.</p>
<p>To make this work, I needed to understand where the line actually is.</p>
<h2><strong>The Fallacy of the Local Boundary</strong></h2>
<p>It is a common mistake to view the firewall as the finish line. When we grant an agent the power to act, we must be mindful of where its knowledge travels and who—or what—may be listening and collecting it at every stop, regardless of where the model is hosted.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b14255487d6e546b82e697/9c2e73f1-c58b-4389-b11a-256065f3ad58.jpg" alt="" style="display:block;margin:0 auto" />

<p>photo credit: Ted Balmer, Unsplash</p>
<p>Privacy, therefore, is not a static state of being "local"—it is the cumulative result of the entire data flow.</p>
<p>This realization shifts the equation: an agent is only as useful as its security posture is comprehensive. For those operating environments within strict governance and compliance frameworks, this complexity is a significant barrier. While agentic AI promises to automate the most repetitive, time-consuming workflows for organizations, its adoption is lagging. The hesitation isn't due to a lack of capability, but because of the sheer complexity these agents add to a modern information security program.</p>
<p>This leads us to a paradox of deployment. The very tools that make agentic AI a "force multiplier"—web scraping, browser automation, and real-time API integrations—are the same tools that can puncture the local perimeter. The appeal is obvious: by leveraging these tools, a business can avoid the massive overhead of hiring a team to fine-tune a proprietary in-house model just to achieve basic agency.</p>
<p>But here is the friction: the model is only one part of the stack. When an agent is capable enough to interact with the cloud to get the job done, it is capable of carrying your data with it.</p>
<p>This leaves us with the central question of the modern AI architect: Is it possible to maintain absolute privacy without sacrificing the function that makes agentic AI valuable in the first place?</p>
<h2><strong>Mapping the Flow: From Blind Deployment to Structured Infrastructure</strong></h2>
<p>The answer is yes, but it requires a fundamental paradigm shift.</p>
<h3>True privacy is not achieved by simply locking the door to the room where the model lives; it is achieved by controlling every pipe, wire, and signal that enters and exits that room.</h3>
<p>When you shift your focus from the <em>location</em> of the model to the <em>pathway</em> of the data, the binary choice between "local" and "cloud" disappears.</p>
<p>The first step in this transition is moving from blind deployment to a mapped infrastructure. You cannot secure a flow you cannot see. This requires a ruthless inventory of your AI stack—not just the model, but the retrieval mechanisms, the tool-calling interfaces, and the persistent storage buffers.</p>
<p>It is during this mapping process that the "local" illusion truly shatters. You discover that while the model your agent is leveraging is on-premise, its tools may not be. Every time your local agent calls a third-party search API or a cloud-based scraper to gather information, it could be leaving a trail of sensitive data with every tool call. These are the essential "crumbs" of data—queries and URLs—that can potentially exit your secure perimeter. These crumbs may seem trivial, but in the aggregate, they form a legible map of your proprietary research, your internal interests, and your strategic direction.</p>
<p>To stop the leak without killing the function, we must first audit the flow and choose our partners wisely. Start by mapping your data path visually—draw a diagram or network topography that notes every single point along the route, from prompt generation to retrieval, tool execution, and output. Think critically about each hop; don't just mail it in.</p>
<p><em>You can read more about self-auditing your AI environments in this</em> <a href="https://evanlovescloud.com/securing-generative-ai-workflows"><em>article</em></a><em>.</em></p>
<p>If you choose a cloud model, pair that map with a ruthless review of data sovereignty. You need to know whether that provider is training on your prompts or retaining your data. I recently discovered that even API services like OpenRouter, which pride themselves on minimal log collection for their services, will still route through backend providers like Anthropic or Google that can be training away on your prompts without you really knowing it. Look for <a href="https://openrouter.ai/docs/guides/features/zdr">zero-data-retention</a> support and backend settings—tools like OpenRouter offer privacy toggles. <strong>Read</strong> the privacy statements. Choose providers that actually value your privacy and demonstrate it by offering solutions.</p>
<p>Before designing the boundary, it helps to name who's on the other side. Three threat profiles matter here:</p>
<p><strong>The provider</strong> — the API operator who could log or train on your prompts. This is what I'm solving for with zero-data-retention policies and backend opt-outs.</p>
<p><strong>The network</strong> — traffic between your agent and the API could be intercepted. TLS handles this at baseline, but for high-assurance work, a private network or trusted egress point is better.</p>
<p><strong>The compromised endpoint</strong> — a malicious webpage, poisoned tool response, or supply-chain attack that exfiltrates data through the agent's own tools. This one cuts both ways: it doesn't matter if the brain is local or cloud if the nerves are compromised.</p>
<p>This article focuses on the first one — the provider — because that's where the nervous system architecture delivers the most utility for a jump start into implementing agentic AI. The other two are real problems, but it should be noted that they need additional layers on top of this foundation.</p>
<h2><strong>The Nervous System Architecture</strong></h2>
<p>Instead of trying to force every single component to be local—which often results in a crippled, low-capability agent—you architect a hybrid system. You utilize a high-capability neural model through an API connection for the "brain," providing the reasoning and intelligence. But you keep the "nervous system"—the environment where the agent lives, the sensitive data it accesses, and the tools it uses entirely private and local.</p>
<p>In this model, the "brain" processes the logic, but it never touches the raw, unshielded perimeter. The nervous system handles the interaction with the world, ensuring that the data crumbs never leave your sight. You no longer have to choose between the power of the cloud and the privacy of the local; you simply define where the boundary actually lies.</p>
<p>Here is a simple example of how this architecture works in practice.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66b14255487d6e546b82e697/be467eda-af30-4b5d-a4d3-eaa37fb01b1e.png" alt="" style="display:block;margin:0 auto" />

<p>My Hermes agent runs on a Fedora VM that lives in my homelab—or could eventually live in the cloud (Azure, AWS). This VM is the nervous system's central node: it holds the files, manages the sessions, and controls the environment. No one else has visibility into what happens here. For web searching, I self-host Firecrawl in a Docker container right alongside it, and it performs wonderfully. The agent itself is linked to OpenRouter via API, which routes my requests to the various Qwen and Deepseek models. I chose OpenRouter because I like that they have published a clear policy: any data collection is opt-in. They do not share, sell, or license underlying prompt data to any third party, and they make it transparent which of their model providers support zero-data retention (so you know which ones you can safely send prompts to). The "brain" gets the intelligence it needs through an API connection, but every tool call, every search query, every file it reads or writes stays within the system I control. The brain thinks, the nerves touch, and the data stays home.</p>
<h2>Conclusion</h2>
<p>The nervous system architecture didn't start as a thesis. It started as a practical question: <em>how do I build this for myself without giving up control?</em>  </p>
<p>Today my Hermes agent runs on a Fedora VM in my lab, queries the web through a Firecrawl container I own, and talks to models through a provider whose privacy policy I've actually read and agreed with. The intelligence comes from the cloud. The data never leaves my perimeter. I didn't compromise capability for privacy, I just stopped treating them as a trade-off. Putting it together myself made all the difference in how I understand the technology.  </p>
<p>That's the whole point of this article. I know there are others out there considering these kind of tools for themselves and their businesses. I started this journey with (what I believe to be) a reasonable amount of paranoia. I'm hoping that sharing this architecture might help bridge a mental gap.</p>
<p>If you're considering building with agents, you might be facing the same question. You don't have to choose between intelligence and privacy. You just have to decide where the line really is—and then protect it.</p>
]]></content:encoded></item><item><title><![CDATA[Securing Generative AI Workflows]]></title><description><![CDATA[Generative AI is changing how businesses work, but it also brings new security, privacy, and compliance challenges. Think of it like adding a powerful new team member—you want to make sure it’s doing the right things safely. Securing these AI workflo...]]></description><link>https://evanlovescloud.com/securing-generative-ai-workflows</link><guid isPermaLink="true">https://evanlovescloud.com/securing-generative-ai-workflows</guid><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Fri, 17 Oct 2025 15:40:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1760717128522/3c37f097-6778-411a-87ed-2ed81bdcc4de.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Generative AI is changing how businesses work, but it also brings new security, privacy, and compliance challenges. Think of it like adding a powerful new team member—you want to make sure it’s doing the right things safely. Securing these AI workflows means taking a thoughtful, structured approach: discovering where AI is used, assessing risks, putting the right controls in place, and keeping an eye on it over time. Let’s walk through a useful strategy to use generative AI confidently while keeping your organization protected.</p>
<h2 id="heading-discover">Discover</h2>
<p><strong>The first step in securing generative AI workflows is to discover all instances of AI agents across the environment. Without a clear picture of where these tools are being used, businesses cannot effectively manage risk.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760714464941/bfa62f34-8ee5-4940-8141-77941d36df41.png" alt class="image--center mx-auto" /></p>
<p>Discovery starts with building a comprehensive inventory of systems, applications, and binaries where AI models, libraries, or agent frameworks may be deployed. Vulnerability scanning should extend beyond just network-level scans. Effective discovery for generative AI requires file system–level inspection to capture details about individual binaries, dependencies, and libraries.</p>
<p>Specialized tools that generate a Software Bill of Materials (SBOM)—such as the open-source tool <a target="_blank" href="https://github.com/anchore/syft"><em>Syft</em></a>—can play a critical role in this process. An SBOM provides visibility into every component that makes up an application, making it easier to identify outdated or vulnerable dependencies. While SBOM tools are excellent for software composition analysis, discovery should also leverage more traditional endpoint detection and response (EDR) and vulnerability management tools. These solutions perform deep scans of endpoints and cloud VMs, and while they may not natively generate SBOMs, many support exporting their generated results in open standards like CycloneDX and SPDX. These outputs can then be fed back into SBOM tools for further enrichment and analysis.</p>
<p>Technical scanning, however, only tells part of the story. Many employees use generative AI in ways that may not be captured by endpoint scans or SBOM analysis—for example, through their web browser or approved software that interacts with an LLM model via API calls. For some businesses, especially those with heavy SaaS (software-as-a-service) adoption, this may actually be the most common way generative AI is used day to day. These activities often remain invisible, which is where we get the term “shadow AI”.</p>
<p>To fill this gap, organizations should supplement technical discovery with employee surveys or interviews. By asking staff how they use AI in their daily work, security teams can uncover shadow AI practices and gain insight into business-driven use cases that technical scans alone would miss.</p>
<h2 id="heading-assess">Assess</h2>
<p><strong>Once discovery is complete, the next step is to assess the AI security posture (AI-SPM) of your environment.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760714797594/fbcebd40-bea6-46fb-bf3e-74f389fc179f.png" alt class="image--center mx-auto" /></p>
<p>This means evaluating the overall state of an organization’s security configuration, readiness, and risk exposure, and then establishing a systematic way to continuously monitor, assess, and improve that state. Think of it as a “health check” or ongoing “fitness plan” for your environment, including generative AI workflows.</p>
<p>Assessing posture requires a comprehensive approach to maintaining the integrity, confidentiality, and availability (CIA) of generative artificial intelligence and machine learning systems. It involves building a strategy for continuous monitoring and improvement of the security posture of the AI models, services, and workflows found in the infrastructure. AI-SPM focuses on spotting and fixing weak spots, misconfigurations, and risks associated with AI usage while also ensuring and/or maintaining compliance with privacy, security, and ethical standards for the organization’s industry.</p>
<p>Several frameworks and tools can help structure this process:</p>
<ul>
<li><p><em>NIST AI Risk Management Framework (AI RMF)</em> – provides a foundation for identifying and managing AI-specific risks.</p>
</li>
<li><p><em>OWASP AI Maturity Assessment (AIMA)</em> – a useful resource for measuring the maturity of AI security practices.</p>
</li>
<li><p><em>SBOM scanning and remediation reviews</em> – ensures that vulnerabilities found in AI-related components are addressed, tracked, and prioritized. Automation is certainly encouraged here wherever possible to make assessments repeatable and auditable.</p>
</li>
</ul>
<p>Assessment should not stop at static analysis. Grey-box penetration testing provides an additional layer of assurance by simulating real-world attack scenarios. A well-scoped pentest can help uncover vulnerabilities that are specific to the environment, validate whether the remediation efforts were effective, and probe the resilience of AI systems against bad actors trying to manipulate the system or execute model-specific exploits.</p>
<p>Ultimately, assessment acts as the bridge between discovery and control. This step transforms raw visibility into actionable security intelligence.</p>
<h2 id="heading-control">Control</h2>
<p><strong>After discovery and assessment, the next step is to apply security controls to generative AI workflows.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760713867530/6d2145b2-6dd2-46bf-818e-5df90c407fd7.png" alt class="image--center mx-auto" /></p>
<p>Controls should incorporate prompt-level security—intercepting user inputs and analyzing them for prompt injection or jailbreak attempts.</p>
<p>An AI prompt is the input given (by the user) to a large language model via a generative AI platform.</p>
<p>Prompt injection is an attempt to manipulate an LLM model to make it do something harmful. This is the number one method bad actors use to manipulate generative AI according to the OWASP Top Ten. An attacker might use social engineering techniques to guide generative AI, giving instructions to override its original commands. This could lead to unintended actions, such as leaking sensitive internal information or executing unauthorized operations. By exploiting vulnerabilities in the prompt handling, attackers can potentially bypass security measures altogether.</p>
<p>Tools like Cloudflare’s WAF can act a proxy, inspecting layer 7 traffic and enforce gen-AI specific rulesets, blocking malicious prompts before they reach in-house or third-party models. This same visibility also helps address shadow AI, where unapproved AI tools may bypass traditional defenses.</p>
<p>Equally important are privacy safeguards. Generative AI must be configured to protect sensitive data, such as PII or PHI, and ensure compliance with the framework adopted during the assessment phase.</p>
<p>Finally, organizations should enforce outbound controls to monitor and restrict what data leaves the environment, reducing the risk of unintentional disclosure through AI services.</p>
<p>In short, controls provide the guardrails that keep AI secure, compliant, and aligned with organizational risk objectives.</p>
<h2 id="heading-reporting">Reporting</h2>
<p><strong>The final step in securing generative AI workflows is risk management and compliance reporting.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1760714703192/ea03a75a-31b9-4068-afd2-690cb7252c54.jpeg" alt class="image--center mx-auto" /></p>
<p>Organizations should create dashboards that visualize and prioritize AI-related risks, making it easier to track vulnerabilities, remediation efforts, and overall security posture.</p>
<p>For industries with regular compliance requirements, these reports are especially valuable, simplifying audit preparation and demonstrating adherence to regulatory standards. A structured reporting process ensures that stakeholders have clear visibility into both the current state of AI security and ongoing mitigation efforts.</p>
]]></content:encoded></item><item><title><![CDATA[A guide on automating CIS Compliance for Ubuntu with Ansible Lockdown]]></title><description><![CDATA[Today I’m taking a look at MindPoint Group’s Ansible Lockdown project—a powerful, open-source tool for automating security compliance. Maintained as part of their broader Lockdown Enterprise suite, this project provides prebuilt Ansible roles aligned...]]></description><link>https://evanlovescloud.com/a-guide-on-automating-cis-compliance-for-ubuntu-with-ansible-lockdown</link><guid isPermaLink="true">https://evanlovescloud.com/a-guide-on-automating-cis-compliance-for-ubuntu-with-ansible-lockdown</guid><category><![CDATA[Ansible Lockdown]]></category><category><![CDATA[DevSecOps]]></category><category><![CDATA[compliance ]]></category><category><![CDATA[hardening]]></category><category><![CDATA[ansible]]></category><category><![CDATA[ansible-playbook]]></category><category><![CDATA[Ubuntu]]></category><category><![CDATA[Hybrid Cloud]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Tue, 03 Jun 2025 13:51:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748957621781/bc13b9b4-9179-43fb-9770-41f641a5cea4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Today I’m taking a look at MindPoint Group’s Ansible Lockdown project—a powerful, open-source tool for automating security compliance. Maintained as part of their broader Lockdown Enterprise suite, this project provides prebuilt Ansible roles aligned with industry-standard benchmarks like CIS and STIG. It’s a solid starting point for hardening systems across a range of platforms, including Ubuntu, RHEL, Amazon Linux, and Windows.</p>
<p>I will be demonstrating the CIS benchmark role with a newly installed Ubuntu 22.04 host, implementing the level 1 and some level 2 controls. If you’d like to dig deeper, I’ve included links to the project’s <a class="post-section-overview" href="#">GitHub repository</a> <a class="post-section-overview" href="#">and official documentation</a>.</p>
<p>This guide assumes you are familiar with Ansible and the CIS benchmarks, and that Ansible is already installed and working on your machine.</p>
<h2 id="heading-why-automate-compliance">Why automate compliance?</h2>
<p>So, why might you be looking to automate compliance in the first place?</p>
<p>As more organizations shift workloads to the cloud—especially in SaaS—security becomes a critical focus. Compliance requirements are increasingly strict, particularly in regulated industries like healthcare, finance, and government. To meet these demands, many teams are choosing to integrate preconfigured cloud system images in these environments since they come with software packages and/or security-focused configurations that provide a jump start to defining the base configuration for system resources.</p>
<p>In AWS, for example, you’ll find <strong>CIS Hardened Images</strong>—AMIs (Amazon Machine Images) that are purpose-built for secure, compliant environments. For example, you can find the CIS Hardened Image Level 1 for Ubuntu in the AWS marketplace. These images are incredibly convenient, but they come at a cost: higher hourly operating fees and annual license costs. For large-scale or cost-sensitive environments, this pricing model can quickly become a burden. This not a viable solution for hybrid environments where AMIs are simply not available to deploy for the on-premise machines.</p>
<p>System administrators who have tried it know that hardening a system by hand is very tedious work and is very time-consuming. Doing it manually across multiple systems? That is a tremendous headache.</p>
<p><strong>Enter Ansible.</strong></p>
<p>Ansible gives you the power to define and enforce your security baselines from a single control node, pushing configuration changes across hundreds—or thousands—of machines. This is why Ansible is now considered a necessary skill for DevSecOps engineers. Automation not only speeds up deployment but it makes compliance repeatable, auditable, and scalable.</p>
<p>Automating security hardening is a practical approach to compliance. Unlike using a pre-configured image, it allows for complete customization and greater control right from the beginning.</p>
<p>The <strong>Ansible Lockdown</strong> project takes this a step further by offering compliance-as-code.</p>
<p>What stood out to me is how accessible and transparent it is. As an engineer who likes to test and experiment in my home lab first, I appreciate being able to pull the roles directly from GitHub and get a clear overview from the documentation. It’s well-organized, actively maintained, and offers solid coverage for a range of operating systems.</p>
<p>This tool is particularly attractive if you're seeking an open-source alternative to prebuilt cloud images or costly compliance solutions and are already using Ansible. It's flexible for hybrid environments and, being based on Ansible, integrates smoothly with existing automation workflows.</p>
<p>This post is my first hands-on experience with the role, and I’m exploring it from the perspective of someone who’s considering it for use on future projects.</p>
<p>Let’s get started.</p>
<h2 id="heading-setup">Setup</h2>
<p>The Ansible Lockdown role works best when applied to a freshly installed system with a base configuration. Running it on a production server, or any system that already has applications in place, can and likely will cause services to break. This is because the role enforces strict security configurations that can interfere with existing setups.</p>
<p>Before running the benchmark, make sure your server is set up with the required disk partitions, as specified in the CIS Benchmark controls. For level 2, you’ll want the following mount points:</p>
<ul>
<li><p><code>/var</code></p>
</li>
<li><p><code>/var/tmp</code></p>
</li>
<li><p><code>/var/log</code></p>
</li>
<li><p><code>/var/log/audit</code></p>
</li>
<li><p><code>/home</code></p>
</li>
</ul>
<p>These partitions are required so the benchmark can enforce mount options like <code>nodev</code>, <code>nosuid</code>, and <code>noexec</code>, which play a role in preventing privilege escalation and unauthorized code execution.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748629138073/d3c3710f-87cb-4a15-8fbe-c8b70b103955.png" alt class="image--center mx-auto" /></p>
<p>Before running the playbook, make sure that root access is configured with a password on the target host and that SSH key-based authentication from your Ansible control node is working properly.</p>
<p>You might also consider setting a GRUB password to harden the bootloader. While the Lockdown role can configure this for you, it’s worth noting that this control (along with a few others) is disabled by default. That’s because improperly setting a GRUB password can lock you out of the system if it’s misconfigured.</p>
<h2 id="heading-installation">Installation</h2>
<p>You can install the Ansible Lockdown role directly from the project’s Git repository using the <code>ansible-galaxy</code> command. This tool is used to manage Ansible roles and collections, making it easy to pull in reusable automation code from external sources.</p>
<p>The Ansible Lockdown project is structured as a single repository that contains multiple roles for different operating systems. When you install a role using <code>ansible-galaxy</code>, it organizes the role’s tasks, handlers, and defaults into a structure that Ansible can manage.</p>
<p>Here’s how you can install the Ubuntu 22 role from GitHub:</p>
<pre><code class="lang-bash">ansible-galaxy install git+https://github.com/ansible-lockdown/UBUNTU22-CIS.git
</code></pre>
<p>This will place the role in the current user's <code>~/.ansible/roles/</code> directory.</p>
<h2 id="heading-auditing">Auditing</h2>
<p>To evaluate the effectiveness of the role, I’ve used <a target="_blank" href="https://wazuh.com/"><strong>Wazuh</strong></a> to monitor my Ubuntu 22.04 instance. Wazuh is a free and open-source security platform used for threat prevention, detection, and response across various environments, including on-premises, virtualized, containerized, and cloud-based workloads. Wazuh includes its own CIS compliance assessment module, which runs checks based on the CIS benchmark.</p>
<p>Before applying the Lockdown role, the system scored a <strong>48%</strong> compliance rating out of the box. I’ll be using Wazuh again after applying the playbook to see how much that score improves.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748621691747/a7737910-99b4-41e6-a305-f7ade183f863.png" alt class="image--center mx-auto" /></p>
<p>One of the most useful features of the Lockdown role is the ability to run it in <strong>audit-only mode</strong>. This allows you to check your system against the CIS benchmark without making any changes, which is great for testing.</p>
<p>You can configure audit mode either by passing variables directly on the command line with <code>-e</code> like this:</p>
<pre><code class="lang-bash">ansible-playbook -i /etc/ansible/hosts -e <span class="hljs-string">'{ "setup_audit":true,"run_audit":true, "audit_only":true, "grub_user_pass":  }'</span> ./site.yml -K
</code></pre>
<p>Or, you can directly modify the role’s configuration file.</p>
<pre><code class="lang-bash">~/.ansible/roles/UBUNTU22-CIS/defaults/main.yml
</code></pre>
<p>Set the following variables to <code>true</code>:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">setup_audit:</span> <span class="hljs-literal">true</span>
<span class="hljs-attr">run_audit:</span> <span class="hljs-literal">true</span>
<span class="hljs-attr">audit_only:</span> <span class="hljs-literal">true</span>
</code></pre>
<p>Also, set the <code>get_audit_binary_method</code> to <code>"download"</code> so that the required <a target="_blank" href="https://github.com/goss-org/goss">GOSS</a> binary (used for audit checks) can be fetched automatically from GitHub:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">get_audit_binary_method:</span> <span class="hljs-string">"download"</span>
</code></pre>
<p>Once these settings are in place, you’ll be ready to perform your first audit without making any system modifications.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748629188687/f2d981b8-90e3-4e4b-a965-1328af65341b.png" alt="a peek at the main.yml for Ubuntu 22" class="image--center mx-auto" /></p>
<p>One of the many configurable options in <code>main.yml</code> is whether to set a GRUB password.</p>
<p>However, I ran into an issue when I left the <code>grub_user_pass</code> variable unspecified. The playbook failed to run, which may be a bug, though it’s possible I misunderstood the intended behavior. I plan to report this on the project’s GitHub repository.</p>
<p>Fortunately, there’s a simple workaround:</p>
<ul>
<li><p>If no GRUB password is configured on your system, you can pass an empty value for <code>grub_user_pass</code> when running the playbook:</p>
<pre><code class="lang-bash">  -e <span class="hljs-string">'{ "setup_audit":true,"run_audit":true, "audit_only":true, "grub_user_pass":  }'</span>
</code></pre>
</li>
<li><p>If there is GRUB password set, pass it in like so:</p>
<pre><code class="lang-bash">  -e <span class="hljs-string">'{ "setup_audit":true,"run_audit":true, "audit_only":true, "grub_user_pass":your_secure_password  }'</span>
</code></pre>
</li>
</ul>
<p>This avoids the failure and allows the role to handle GRUB settings correctly based on your input.</p>
<p>Alternatively, you can run the GOSS audit from the host using the <code>run_audit.sh</code> script. This is done after running the playbook once to set up the audit files. By default, these files are placed in the host’s <code>/opt</code> directory, but you can change this in <code>main.yml</code>.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748544127724/3ddbf4af-d049-4f3b-9d70-eabda5ea5b0d.png" alt class="image--center mx-auto" /></p>
<p>Here is an example of an audit summary after the playbook completes:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748623343492/ec63d685-882d-4aa8-a230-c3c85f0a9f48.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-remediation">Remediation</h2>
<p>To move from audit mode to actual <strong>remediation</strong>, you can run the playbook using a similar command as before—just make sure to set <code>audit_only</code> to <code>false</code>.</p>
<pre><code class="lang-bash">ansible-playbook -i /etc/ansible/hosts -e <span class="hljs-string">'{ "run_audit":true, "audit_only":false, "grub_user_pass":  }'</span> ./site.yml -K
</code></pre>
<p>Alternatively, you can update the <code>main.yml</code> file directly.</p>
<p>As the playbook runs, there may be errors that come up such as this error regarding the <code>auditd</code> service:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748634260162/7ee06ef5-3f9c-4e08-8e0c-582b3b5a7617.png" alt /></p>
<p>My experience was that I encountered some errors whenever a control could not be enforced due to a conflicting system state or a missing dependency.</p>
<p>In these cases the options are to:</p>
<ul>
<li><p><em>Fix the issue</em>, and then re-run the playbook. On subsequent runs, Ansible will skip controls that have already been applied.</p>
</li>
<li><p><em>Temporarily disable</em> problematic controls in <code>main.yml</code>, and circle back to resolve them manually later.</p>
</li>
</ul>
<p>In my case, after just a couple of remediation runs (each taking about 3–4 minutes), the system's compliance score improved significantly. Wazuh now reports <strong>75%</strong> compliance, up from the initial 48%.</p>
<p>We can now see the benefit of automation in the hardening process—what could have taken days to implement manually is handled in minutes with repeatable, testable code.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748719431079/cdbb4006-5a54-46f7-a1dc-d607a635e0e7.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-audit-again">Audit again</h2>
<p>To get a more comprehensive view of the system’s compliance, I will scan this instance using <a target="_blank" href="https://cisofy.com/lynis/">Lynis</a>, another automated hardening and auditing tool. Lynis is a well-established, open-source project licensed under the GPL and first released back in 2007. It’s widely respected and actively maintained.</p>
<p>What makes Lynis especially useful is that it evaluates your system against <strong>multiple security standards</strong>—including <strong>CIS</strong>, <strong>NIST</strong>, <strong>NSA</strong>, and <strong>OpenSCAP</strong>—providing a broader, standards-based perspective beyond what a single benchmark offers.</p>
<p>In a real-world environment, it’s not enough to say a system is “probably secure”—you need proof. Demonstrating compliance is make-or-break for passing audits, especially in regulated sectors. That’s why tools like Wazuh and Lynis are part of my workflow here. They give you third-party visibility into how well the hardening actually aligns with the standards. Running both gives you a fuller picture of where your host stands and helps catch anything that might’ve slipped through. And Lynis is lightweight and fast to get going, which makes it a no-brainer.</p>
<p>Installing Lynis and running a quick scan is straightforward and lightweight. Here’s how to get started.</p>
<pre><code class="lang-bash">git <span class="hljs-built_in">clone</span> https://github.com/CISOfy/lynis

<span class="hljs-built_in">cd</span> ./lynis

./lynis audit system -Q
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748721118917/8533030b-ecd0-482b-aaa5-9e3520528d91.gif" alt class="image--center mx-auto" /></p>
<p>Lynis also reports similar results, highlighting the hardening index. This helps confirm that several controls are indeed implemented on this system.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748722067672/92c73ee9-d19f-46f3-8e7e-b8447d243d07.png" alt class="image--center mx-auto" /></p>
<p>Once the Ansible Lockdown playbook completes, it generates a <code>.json</code> report in the host’s <code>/opt</code> directory. This file includes the results of every control that was tested, giving you a clear view of what passed, what didn’t, and what still needs attention.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748722246071/59f7375d-b2b1-410a-84cb-844666b635b9.png" alt class="image--center mx-auto" /></p>
<p>This is very helpful for fine-tuning your configuration, validating changes, and identifying anything you might want to address manually.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748554197968/e563d8e6-3881-47e5-bea6-cdf49e266890.png" alt /></p>
<p>After just a few more minutes of manual tuning, I saw another bump in compliance—<strong>Wazuh is now reporting 81%</strong> against the CIS benchmark. That’s a significant jump from the original state, and it highlights just how effective this kind of automation and iterative testing can be in creating a new baseline.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748723194917/be1bdaed-143e-4dca-907a-d3731053e71f.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The <strong>Ansible Lockdown project by Mindpoint Group</strong> is a solid way to jump-start the hardening process in Linux environments. It’s flexible, accessible, and fits naturally into an infrastructure-as-code approach.</p>
<p>But let’s be clear—compliance isn’t a one-size-fits-all checklist. It needs to be considered from the start of the system’s lifecycle and shaped around the organization’s unique business rules and regulatory obligations. Automation should support that strategy, not define it.</p>
<p>With a bit of planning and customization, this role can help enforce real-world compliance standards in a way that’s repeatable, scalable, and cost-effective.</p>
]]></content:encoded></item><item><title><![CDATA[Using Terraform to create Lamba Functions in  a VPC using AWS Lambda Layers]]></title><description><![CDATA[This post is a follow up to my last article “Traversing Complex and Deeply Nested API Responses with Python”.
In that piece, I demonstrated how we can use the pandas library in python to traverse a deeply nested .json object, extract data from specif...]]></description><link>https://evanlovescloud.com/using-terraform-to-create-lamba-functions-in-a-vpc-using-aws-lambda-layers</link><guid isPermaLink="true">https://evanlovescloud.com/using-terraform-to-create-lamba-functions-in-a-vpc-using-aws-lambda-layers</guid><category><![CDATA[Terraform]]></category><category><![CDATA[lambda]]></category><category><![CDATA[Python]]></category><category><![CDATA[vpc]]></category><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Wed, 23 Apr 2025 14:31:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1745416268063/927d4589-847b-48b4-a8df-1fc7df83a9b4.gif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This post is a follow up to my last article “<a target="_blank" href="https://evanlovescloud.com/traversing-complex-and-deeply-nested-api-responses-with-python">Traversing Complex and Deeply Nested API Responses with Python</a>”.</p>
<p>In that piece, I demonstrated how we can use the <code>pandas</code> library in python to traverse a deeply nested .json object, extract data from specific dictionary keys, and restructure it as a flattened pandas dataframe. From there, we can use <code>sqlalchemy</code> to open a connection to a MySQL database and insert the data into a table.</p>
<p>Now, I want to go further by invoking a Lambda function that can run this Python code, allowing the data collection, transformation, and loading to be completed within seconds using the power of serverless. To get this working, the Lambda function will need to connect to my VPC so that it can talk to an RDS instance in a private subnet. It will also need internet access so that it can gather the source files from an S3 bucket in my account.</p>
<p>My goal in this post will be to walk through how to use Terraform to configure a VPC with public and private subnets, and create a Lambda function that is ready to go— with its deployment package, VPC access, and connectivity to private resources. I will confirm the Lambda function’s connectivity to the RDS instance with a simple python script that utilizes <code>pymysql</code>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This guide assumes that you already have some working knowledge of AWS, the Lambda service, and Terraform. If you want to follow along, you'll need to have an AWS account set up and have Terraform and the AWS CLI installed on your computer.</p>
<p>We will need to set up these prerequisites from the AWS console before writing any .HCL code:</p>
<ul>
<li><p>Set up an AWS user account for Terraform.</p>
</li>
<li><p>Create an IAM role for the Terraform account (with a trust policy that allows the Terraform user account to assume it).</p>
</li>
<li><p>Set up the appropriate permission policies for the Terraform IAM role.</p>
</li>
</ul>
<h2 id="heading-provider-and-authentication-configuration">Provider and authentication configuration</h2>
<p>If you’re not familiar with using Terraform, this is quick overview on how Terraform connects to the cloud and creates our infrastructure. Terraform uses what’s known as a “provider” to connect to orchestrate the environment. Providers are a logical abstraction of an upstream API. They are responsible for understanding API interactions and exposing resources.</p>
<p>In this case, we need to create a providers.tf file or write a provider block in the code. For my demonstration, I am going to create a providers.tf file.</p>
<pre><code class="lang-json">terraform {
  required_providers {
    aws = {
      source  = <span class="hljs-attr">"hashicorp/aws"</span>
      version = <span class="hljs-attr">"~&gt; 5.0"</span>
    }
  }
}

provider <span class="hljs-string">"aws"</span> {
  region  = <span class="hljs-attr">"us-east-2"</span>
  shared_credentials_files = [<span class="hljs-attr">"path/to/my/.aws/credentials"</span>]
  profile = <span class="hljs-attr">"terraformuser"</span>

  assume_role {
    role_arn = <span class="hljs-attr">"arn:aws:iam::###:role/demo_role"</span>
    session_name = <span class="hljs-attr">"demo_role_session"</span>
    }
}
</code></pre>
<p>In this file, I have specified <code>shared_credentials_files</code> to pass the credentials that Terraform will use for authentication as well as specified the IAM role Terraform will use.</p>
<p>After this is done, run the command <code>terraform init</code> to initialize Terraform in your working directory.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745351559890/e70732b8-31b9-4def-a6c2-c267e109a7ab.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-vpc-configuration">VPC configuration</h2>
<p>By default, Lambda functions are launched in the Lambda managed VPC which has internet access. In order to set up Lambda functions that can access our resources in a VPC, we need to assign the Lambda function to our VPC. Attaching the function to the VPC means that it will be assigned a private IP address and the Lambda function will not have internet access. This is true even if the function is in a public subnet (a subnet with a route to an internet gateway). If the Lambda resource needs internet access from the VPC, it must use a NAT Gateway or a VPC endpoint for AWS managed services. For this demonstration I will use a NAT Gateway.</p>
<p>Let’s start by writing out the VPC configuration. Create a file in your working directory called <code>main.tf</code>. The following creates a VPC, an internet gateway, a NAT gateway, an elastic IP, a public subnet, and two private subnets (I will also be deploying an RDS instance).</p>
<pre><code class="lang-json">#VPC
resource <span class="hljs-string">"aws_vpc"</span> <span class="hljs-string">"demo_vpc"</span> {
  cidr_block           = <span class="hljs-attr">"10.20.0.0/16"</span>
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = <span class="hljs-attr">"demo vpc"</span>
  }
}

#subnets
resource <span class="hljs-string">"aws_subnet"</span> <span class="hljs-string">"demo_public_subnet"</span> {
  vpc_id                  = aws_vpc.demo_vpc.id
  map_public_ip_on_launch = true
  cidr_block              = <span class="hljs-attr">"10.20.1.0/24"</span>
  availability_zone       = <span class="hljs-attr">"us-east-2a"</span>

  tags = {
    Name = <span class="hljs-attr">"public-subnet"</span>
  }
}

resource <span class="hljs-string">"aws_subnet"</span> <span class="hljs-string">"demo_private_subnet"</span> {
  vpc_id            = aws_vpc.demo_vpc.id
  cidr_block        = <span class="hljs-attr">"10.20.2.0/24"</span>
  availability_zone = <span class="hljs-attr">"us-east-2a"</span>

  tags = {
    Name = <span class="hljs-attr">"private-subnet"</span>
  }
}

resource <span class="hljs-string">"aws_subnet"</span> <span class="hljs-string">"demo_private_subnet2"</span> {
  vpc_id            = aws_vpc.demo_vpc.id
  cidr_block        = <span class="hljs-attr">"10.20.3.0/24"</span>
  availability_zone = <span class="hljs-attr">"us-east-2b"</span>

  tags = {
    Name = <span class="hljs-attr">"private-subnet2"</span>
  }
}

#igw

resource <span class="hljs-string">"aws_internet_gateway"</span> <span class="hljs-string">"demo_igw"</span> {
  vpc_id = aws_vpc.demo_vpc.id

  tags = {
    Name = <span class="hljs-attr">"demo-IGW"</span>
  }

}

#EIP
resource <span class="hljs-string">"aws_eip"</span> <span class="hljs-string">"elastic_ip"</span> {
  depends_on = [aws_internet_gateway.demo_igw]
  domain     = <span class="hljs-attr">"vpc"</span>
}

#NAT gw
resource <span class="hljs-string">"aws_nat_gateway"</span> <span class="hljs-string">"demo_nat_gateway"</span> {
  allocation_id = aws_eip.elastic_ip.id
  subnet_id     = aws_subnet.demo_public_subnet.id

  tags = {
    Name = <span class="hljs-attr">"demo_nat_gateway"</span>
  }


  # To ensure proper ordering, it is recommended to add an explicit dependency
  # on the Internet Gateway for the VPC.
  depends_on = [aws_internet_gateway.demo_igw]
}
</code></pre>
<p>After this, we will need to create the <a target="_blank" href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table">route tables</a> and <a target="_blank" href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route_table_association">route table associations</a> to complete the VPC configuration. I won't include my code for this step because it's straightforward if you follow the documentation. I also assume that most readers of an article about Terraform configuration have done this before in some way. Still, it's good to provide a complete overview.</p>
<p>This is a good time to test if our Terraform is configured properly and the authentication we set up is working.</p>
<p>Running the <code>terraform plan</code> will command creates an execution plan, which lets you preview the changes that Terraform plans to make to your infrastructure.</p>
<p>When <code>terraform plan</code> runs successfully, it will normally create a <code>terraform.tfstate</code> file and output a preview of the changes it will make in the console. This is what it will look like if there are no changes to make:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745351704407/08894d47-2758-4abc-b460-4216bba0803f.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-aws-lambda-layers">AWS Lambda Layers</h2>
<p>A Lambda function requires you to specify the runtime as well as provide any dependencies that are needed for the code to run. These dependencies are normally delivered with the function code in a .zip file referred to as the package deployment.</p>
<p>As an alternative to this standard approach, you can create a Lambda layer. A Lambda layer is a.zip file archive that contains supplementary code or data. These layers usually contain library dependencies, a <a target="_blank" href="https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html">custom runtime, or configuration files</a>.</p>
<p>However, just zipping the …/site-packages directory of your Python project and uploading it to Lambda may not let the code run because of compatibility issues. If you are working with Python functions, like I am, it's useful to know that Python packages with compiled code are not always compatible with Lambda runtimes. I found this to be the case with <code>pandas</code>. If you are getting a “<em>Unable to import module</em>" error after invoking a Lambda function and after verifying that the module in question is included in the Lambda deployment .zip, it may be a <em>compatibility</em> issue.</p>
<p>In order to work with these kind packages in Lambda, it is recommended to install the <em>manylinux</em> versions of these packages (.whl) in your local environment and create the deployment package with those. This article provides a step-by-step guide on how to do this: <a target="_blank" href="https://repost.aws/knowledge-center/lambda-python-package-compatible">https://repost.aws/knowledge-center/lambda-python-package-compatible</a></p>
<p>To create my deployment, I first checked that pip version 19.3.0 or higher was running on my machine. Then I used this bash command to install the dependencies to the target folder <code>/python</code> in the current directory.</p>
<pre><code class="lang-bash">pip install \        
    --platform manylinux2014_x86_64 \
    --target=python \
    --implementation cp \
    --python-version 3.11 \
    --only-binary=:all: --upgrade \
    pandas pymysql sqlalchemy
</code></pre>
<p>I am not including the function code in the layer so I am not concerned with it for the moment. Once all the necessary packages are installed, run the command <code>zip -r deployment.zip python</code> to create the deployment package.</p>
<p>Go to the AWS Console &gt; Lambda &gt; Layer &gt; Create Layer.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745368605062/e69f597d-0b1b-43f5-a51d-2ff653f8ad29.png" alt class="image--center mx-auto" /></p>
<p>Upload the file from your local filesystem to AWS Lambda and create the layer.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1744831528854/d25d4fb5-123b-478a-a4b0-dcb35f4b1b46.png" alt class="image--center mx-auto" /></p>
<p>Once this is done, copy the arn of the Lambda layer, we will use it later in our <code>main.tf</code> file.</p>
<p>Note: <a target="_blank" href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_layer_version">you can create an <code>aws_lambda_layer_version</code> with Terraform</a>. This is a good option especially if your layer’s .zip package is small. For this to work, you must add a <code>depends_on</code> line in the Lambda function resource block or you may get an error when Terraform is creating the function.</p>
<p>AWS has a 50 MB file size limit for uploading the package deployment file via the AWS CLI. Since my package zip is larger, I chose to use the AWS console and prepare the layer in advance, as shown above. However, since Lambda functions can have up to 5 layers, another valid workaround would be to create multiple, smaller deployment packages.</p>
<h2 id="heading-creating-the-functions-iam-role-and-security-group">Creating the function’s IAM Role and security group</h2>
<p>To ensure the Lambda function has the correct access to private resources, we need to assign it an IAM role and a security group.</p>
<p>The first thing to do is to define the assume role policy. We can do this in Terraform with a data source.</p>
<pre><code class="lang-json">data <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"assume_role_policy"</span> {
  statement {
    sid    = <span class="hljs-attr">"assumerole"</span>
    effect = <span class="hljs-attr">"Allow"</span>

    principals {
      type        = <span class="hljs-attr">"Service"</span>
      identifiers = [<span class="hljs-attr">"lambda.amazonaws.com"</span>]
    }

    actions = [<span class="hljs-string">"sts:AssumeRole"</span>]

  }
}
</code></pre>
<p>We create an IAM role in Terraform with an <code>aws_iam_role</code> resource. <code>aws_iam_role</code> resources require an <code>assume_role_policy</code> which we just created with the data source. We reference this policy document.</p>
<pre><code class="lang-json">resource <span class="hljs-string">"aws_iam_role"</span> <span class="hljs-string">"iam_for_demofunction"</span> {
  name               = <span class="hljs-attr">"iam_for_demofunction"</span>
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json

}
</code></pre>
<p>Next, we can attach the AWS managed policies needed to Lambda to access resources in a VPC using <code>aws_iam_role_policy_attachment</code>.</p>
<pre><code class="lang-json">resource <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"demofunction_vpcexecution_policy_attach"</span> {
  role       = aws_iam_role.iam_for_demofunction.name
  policy_arn = <span class="hljs-attr">"arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"</span>
}

resource <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"demofunction_basicexecution_policy"</span> {
  role       = aws_iam_role.iam_for_demofunction.name
  policy_arn = <span class="hljs-attr">"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"</span>
}
</code></pre>
<p>Because my Lambda function needs to be able to access the project’s S3 bucket and connect to an RDS instance, I opted to include these additional policies separately and attach them in the same way.</p>
<pre><code class="lang-json">data <span class="hljs-string">"aws_iam_policy_document"</span> <span class="hljs-string">"demofunction_inline_policy_document"</span> {

  statement {
    sid    = <span class="hljs-attr">"allRDS"</span>
    effect = <span class="hljs-attr">"Allow"</span>

    actions   = [<span class="hljs-attr">"rds:*"</span>]
    resources = [<span class="hljs-attr">"*"</span>]
  }

  statement {
    sid    = <span class="hljs-attr">"allactionsS3projectbucket"</span>
    effect = <span class="hljs-attr">"Allow"</span>

    actions = [<span class="hljs-attr">"s3:*"</span>]
    resources = [<span class="hljs-attr">"arn:aws:s3:::bucket_name"</span>,
      <span class="hljs-attr">"arn:aws:s3:::bucket_name/*"</span>
    ]
  }

}

resource <span class="hljs-string">"aws_iam_policy"</span> <span class="hljs-string">"demofunction_inline_role_policies"</span> {
  name        = <span class="hljs-attr">"demofunction_inline_role_policies"</span>
  description = <span class="hljs-attr">"Adding additional inline role policies for demofunction"</span>
  policy      = data.aws_iam_policy_document.demofunction_inline_policy_document.json
}

resource <span class="hljs-string">"aws_iam_role_policy_attachment"</span> <span class="hljs-string">"demofunction_inline_role_policies"</span> {
  role       = aws_iam_role.iam_for_demofunction.name
  policy_arn = aws_iam_policy.demofunction_inline_role_policies.arn
}
</code></pre>
<p>Next, we can create a security group for the Lambda function. I will also be creating a security group for my RDS instance (which is the private resource my Lambda function connects to).</p>
<pre><code class="lang-json">resource <span class="hljs-string">"aws_security_group"</span> <span class="hljs-string">"demo_lambdasg"</span> {
  name        = <span class="hljs-attr">"demo_lambdasg"</span>
  description = <span class="hljs-attr">"security group for lambda"</span>
  vpc_id      = aws_vpc.demo_vpc.id
}

resource <span class="hljs-string">"aws_security_group"</span> <span class="hljs-string">"demo_dbsg"</span> {
  name        = <span class="hljs-attr">"demo_dbsg"</span>
  description = <span class="hljs-attr">"security group for database"</span>
  vpc_id      = aws_vpc.demo_vpc.id

}
</code></pre>
<p>Finally, define the egress and ingress rules that allow the Lambda function to connect to the database.</p>
<pre><code class="lang-json">resource <span class="hljs-string">"aws_vpc_security_group_ingress_rule"</span> <span class="hljs-string">"allowtraffictodbfromlambda"</span> {
  referenced_security_group_id = aws_security_group.demo_lambdasg.id
  from_port                    = 3306
  to_port                      = 3306
  ip_protocol                  = <span class="hljs-attr">"tcp"</span>
  security_group_id            = aws_security_group.demo_dbsg.id
}

resource <span class="hljs-string">"aws_vpc_security_group_egress_rule"</span> <span class="hljs-string">"allowoutboundtrafficfromlambda"</span> {
  cidr_ipv4         = <span class="hljs-attr">"0.0.0.0/0"</span>
  ip_protocol       = -1
  security_group_id = aws_security_group.demo_lambdasg.id
}
</code></pre>
<h2 id="heading-creating-the-function">Creating the function</h2>
<p>With the functions layer, IAM role, and security group created, we can write the resource block for the function itself. Here, we have a Lambda deployed with a python 3.11 runtime and a 15 second timeout. You can create a zip file of the function’s code and enter the path using the <code>filename</code> argument. This could also be <code>s3_bucket</code> or <code>image_uri</code>. You must select one of these.</p>
<pre><code class="lang-json">resource <span class="hljs-string">"aws_lambda_function"</span> <span class="hljs-string">"demofunction"</span> {
  filename      = <span class="hljs-attr">"/path/to/function/index.zip"</span>
  function_name = <span class="hljs-attr">"demofunction"</span>
  layers        = [<span class="hljs-attr">"arn:aws:lambda:us-east-2:######:layer:layer_name:#"</span>]
  role          = aws_iam_role.iam_for_demofunction.arn
  handler       = <span class="hljs-attr">"index.lambda_handler"</span>

  runtime = <span class="hljs-attr">"python3.11"</span>
  timeout = 15

  vpc_config {
    subnet_ids         = [aws_subnet.demo_private_subnet.id]
    security_group_ids = [aws_security_group.demo_lambdasg.id]
  }

   environment {
       variables = {
         DBUSER     = ###
         DBPASSWORD = ###
         DBHOST     = your-instance-name.your-region.rds.amazonaws.com
       }
     }
}
</code></pre>
<p>For <code>layers</code>, paste the arn string for the corresponding layer that was collected in the previous section.</p>
<p>Reference the subnet_id for the private subnet and the security group to complete the VPC configuration.</p>
<p>I’ve included environment variables so that Lambda can login to the RDS instance in my VPC. For better security, you could use the AWS SSM parameter store to securely access the DBUSER and DBPASSWORD variable since these values are sensitive. In Terraform, <a target="_blank" href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameter">this can be referenced with a <code>data</code> source</a>. If you also used Terraform to create the RDS instance, you can use the <code>address</code> attribute of the database resource to reference its DNS name.</p>
<p>Alternatively, this looks like:</p>
<pre><code class="lang-json">environment {
       variables = {
         DBUSER     = data.aws_ssm_parameter.DBUSER.value
         DBPASSWORD = data.aws_ssm_parameter.DBPASSWORD.value
         DBHOST     = aws_db_instance.demo_database.address
       }
     }
</code></pre>
<p>Save the file.</p>
<h2 id="heading-apply">Apply</h2>
<p>We can run <code>terraform plan</code> again to see what changes Terraform will make to our state. This time it shows all of the planned changes dictated by the <code>main.tf</code> file.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745360417684/1d2d35b7-8df1-418d-8335-4907a3c3f232.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745360419768/1f6dd632-0da5-4551-8570-6e4c662d0f1f.png" alt class="image--center mx-auto" /></p>
<p>Now, run <code>terraform apply -auto-approve</code> and wait for Terraform to provision the resources.</p>
<p>I will run a quick test to ensure that this is working as intended.</p>
<p>My Lambda function will run this simple script to connect to the RDS instance and create a table named “users”.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> pymysql
<span class="hljs-keyword">import</span> os

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">lambda_handler</span>(<span class="hljs-params">event, context</span>):</span>

    connection = pymysql.connect(
        host=os.getenv(<span class="hljs-string">"DBHOST"</span>),         
        user=os.getenv(<span class="hljs-string">"DBUSER"</span>),     
        password=os.getenv(<span class="hljs-string">"DBPASSWORD"</span>), 
        database=<span class="hljs-string">"test"</span>, 
        charset=<span class="hljs-string">"utf8mb4"</span>,        
    )

    <span class="hljs-keyword">try</span>:
        <span class="hljs-comment"># Open a cursor to perform database operations</span>
        <span class="hljs-keyword">with</span> connection.cursor() <span class="hljs-keyword">as</span> cursor:
            <span class="hljs-comment"># Write an SQL statement to create a table if it doesn't exist</span>
            create_table_query = <span class="hljs-string">"""
            CREATE TABLE IF NOT EXISTS users (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                email VARCHAR(100),
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            """</span>
            <span class="hljs-comment"># Execute the SQL statement</span>
            cursor.execute(create_table_query)

        <span class="hljs-comment"># Commit the transaction to apply changes</span>
        connection.commit()
        print(<span class="hljs-string">"Table 'users' created successfully!"</span>)
        <span class="hljs-keyword">return</span> <span class="hljs-number">200</span>

    <span class="hljs-keyword">except</span> pymysql.MySQLError <span class="hljs-keyword">as</span> error:
        print(<span class="hljs-string">"Error while connecting to MySQL or executing the query:"</span>, error)
        <span class="hljs-keyword">return</span> <span class="hljs-number">500</span>

    <span class="hljs-keyword">finally</span>:
        connection.close()
</code></pre>
<p>Calling the function from the AWS console returns a 200 "OK," indicating that the connection was successful and the table was created.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745363166638/bb2adc4a-d3cc-44e4-99df-87e091c7ffbf.png" alt class="image--center mx-auto" /></p>
<p>The end.</p>
]]></content:encoded></item><item><title><![CDATA[Traversing Complex and Deeply Nested API Responses with Python]]></title><description><![CDATA[Deeply nested JSON data can be difficult to view and analyze, making it challenging to understand and work with. Recently, I wrote about automating data collection using AWS Lambda. Check out “Automating Data Collection from Dynamic Websites: My Jour...]]></description><link>https://evanlovescloud.com/traversing-complex-and-deeply-nested-api-responses-with-python</link><guid isPermaLink="true">https://evanlovescloud.com/traversing-complex-and-deeply-nested-api-responses-with-python</guid><category><![CDATA[Python]]></category><category><![CDATA[nested]]></category><category><![CDATA[APIs]]></category><category><![CDATA[MariaDB]]></category><category><![CDATA[SQL]]></category><category><![CDATA[pandas]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Fri, 14 Mar 2025 19:10:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1741979330162/a0798291-3ede-49d2-b400-bdb54a4a9d11.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Deeply nested JSON data can be difficult to view and analyze, making it challenging to understand and work with. Recently, I wrote about automating data collection using AWS Lambda. Check out <a target="_blank" href="https://hashnode.com/post/cm2d53338000209mpg9ov0eg8">“Automating Data Collection from Dynamic Websites: My Journey with Puppeteer, API Fetching, and AWS Lambda”</a>.</p>
<p>To sum up, the Lambda function triggers logic that queries the jawa.gg API for its current GPU listings and collects the API response as a JSON file, which is then uploaded to an S3 bucket. I chose jawa.gg for this project because it intersects with a couple of my hobbies. <a target="_blank" href="http://jawa.gg"><strong><em>jawa.gg</em></strong></a> is a marketplace for second-hand PC components. I enjoy playing games in my spare time, and I also like learning about and collecting PC hardware. Over the years, I've built several gaming rigs and servers.</p>
<p><em>This project was undertaken solely for educational purposes.</em></p>
<h3 id="heading-the-problem">The problem</h3>
<p>The lambda function is running fine but I’ve decided it time to learn about how I could use this data, after all, what’s the point of collecting this kind of data without the ability analyze it? This is what the raw response looks like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741903196100/b82453ff-6db5-49ea-9ef0-9e01386eeb66.png" alt class="image--center mx-auto" /></p>
<p><em>Yuck.</em></p>
<p>In this post, I will show how I wrote python code that will upload this data to an SQL database where it can be queried and more easily read.</p>
<p>I am using Jupyter Notebook to work with the data. <a target="_blank" href="https://jupyter.org/install">Jupyter Notebook</a> is an interactive web-based tool designed for data science and programming. It offers a versatile environment where a user can write and execute python code in cells. This approach really helps with exploring of the data, and is essentially allowing me to write my code in very small stages while being able to refer to previous cell output.</p>
<h3 id="heading-understanding-the-data-structure">Understanding the data structure</h3>
<p>Let’s get started. In my notebook, I load the file contents and print it so that I can analyze it:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741793873409/b2abe65e-8719-4a07-bac9-6dbae2cccae9.png" alt class="image--center mx-auto" /></p>
<p>Here is a snippet of the file contents (which is now in variable <code>datafile</code> ).</p>
<pre><code class="lang-json">[[{'blocked_at': '', 'buyer_protection_policy': None, 'category': {'id': <span class="hljs-number">6</span>, 'name': 'GPUs'}, 'condition': 'used', 'created_at': '<span class="hljs-number">2024</span><span class="hljs-number">-10</span><span class="hljs-number">-17</span>T00:<span class="hljs-number">44</span>:<span class="hljs-number">00.193</span>Z', 'description': 'Card has been kept in near perfect condition, with no scratches or signs of wear and tear.\nBox is included, along with the anti-static bag, PCIE pin cover and heavy duty memory foam for protection. Power cables not included.\n\nPurchased from EVGA in March 2022, recently upgraded in May 2024.\n\nRecommended for up to 1440p gaming, where most games achieved around 120fps on max settings for me. Easily overclockable with MSI Afterburner or EVGA software.\n\nAny questions at all, feel free to message me here or at null\n\nThanks!', 'expired_at': '', 'featured_at': '<span class="hljs-number">2024</span><span class="hljs-number">-10</span><span class="hljs-number">-17</span>T00:<span class="hljs-number">46</span>:<span class="hljs-number">29.640</span>Z', 'height': <span class="hljs-number">9.5</span>, 'id': <span class="hljs-number">46535</span>, 'images': {'ids': ['production/listings/nxyjg6c3lrausbvjejcx', 'production/listings/ankxxvb2xrrsjrw4071q', 'production/listings/rnqo4zpslnqpi9osc2nt', 'production/listings/xgehov2xcqosfrz1x01r', 'production/listings/yhbvlhftz4zt058q3zjz', 'production/listings/jmeqfhhy2uzgloh2h0kg', 'production/listings/kxlg1l2jzuubruetc7rz', 'production/listings/glo3rnzp78liniqhmofo'], 'source': 'cloudinary'}, 'is_insured': True, 'is_on_sale': False, 'is_private_listing': False, 'is_published': True, 'is_sold_out': False, 'labels': [], 'last_featured_at': '', 'last_published_at': '<span class="hljs-number">2024</span><span class="hljs-number">-10</span><span class="hljs-number">-17</span>T00:<span class="hljs-number">44</span>:<span class="hljs-number">00.189</span>Z', 'last_sold_at': '', 'length': <span class="hljs-number">15.5</span>, 'listing_code': '', 'listing_expires_at': '<span class="hljs-number">2024</span><span class="hljs-number">-11</span><span class="hljs-number">-16</span>T00:<span class="hljs-number">00</span>:<span class="hljs-number">00.000</span>Z', 'minimum_offer_amount': None, 'name': 'EVGA GeForce RTX <span class="hljs-number">3070</span> Ti FTW3 ULTRA GAMING, 8GB GDDR6X, ARGB LED, Metal Backplate', 'number_sold': <span class="hljs-number">0</span>, 'original_price': <span class="hljs-number">45000</span>, 'price': <span class="hljs-number">45000</span>, 'price_last_changed_at': '<span class="hljs-number">2024</span><span class="hljs-number">-10</span><span class="hljs-number">-17</span>T00:<span class="hljs-number">44</span>:<span class="hljs-number">00.193</span>Z', 'published_at': '<span class="hljs-number">2024</span><span class="hljs-number">-10</span><span class="hljs-number">-17</span>T00:<span class="hljs-number">44</span>:<span class="hljs-number">00.189</span>Z', 'quantity': <span class="hljs-number">1</span>, 'quantity_available': <span class="hljs-number">1</span>, 'return_policy': None, 'shipping_jawa_manual_price': <span class="hljs-number">0</span>, 'shipping_option': 'jawa_fulfilled', 'shipping_seller_manual_price': <span class="hljs-number">0</span>, 'sku': '', 'specs': {'Chipset': 'Nvidia', 'Brand': 'EVGA', 'Series': 'GeForce RTX <span class="hljs-number">3070</span> Ti', 'Memory': '<span class="hljs-number">8</span>GB'}, 'status': 'available', 'url': '/product/<span class="hljs-number">46535</span>/evga-geforce-rtx<span class="hljs-number">-3070</span>-ti-ftw3-ultra-gaming<span class="hljs-number">-8</span>gb-gddr6x-argb-led-metal-backplate', 'usage': 'TWO_Y', 'user': {'badges': [], 'displayed_name': 'Impassioned-Jawa#<span class="hljs-number">4838</span>', 'facebook_ids': ['facd13fa<span class="hljs-number">-48</span>f5<span class="hljs-number">-4816</span><span class="hljs-number">-98</span>a2<span class="hljs-number">-68382</span>f3cf9df'], 'id': <span class="hljs-number">293620</span>, 'images': {'avatar': 'production/avatars/e0d2yifarjwoggcd4x70', 'source': 'cloudinary'}, 'is_business': False, 'is_guest': False, 'is_on_vacation': False, 'is_verified': False, 'store_name': '', 'tag_line': '', 'username': 'Impassioned-Jawa#<span class="hljs-number">4838</span>', 'website': ''}, 'user_id': <span class="hljs-number">293620</span>, 'video_url': '', 'watch_count': <span class="hljs-number">0</span>, 'weight': <span class="hljs-number">2</span>, 'width': <span class="hljs-number">4.5</span>},
</code></pre>
<p>If we use <code>type</code> , we can further confirm that the very top level of this data is one big list. The first index, <code>datafile[0]</code>, is also list. However, <code>datafile[0][0]</code> is a dictionary. So the first realization here is that the data that I am interested in extracting is two levels down. Each dictionary represents one product listing.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741794365608/0459bb6d-fc46-43e6-8e78-d648e7c1f899.png" alt class="image--center mx-auto" /></p>
<p>Here is another look at the file’s structure from Jupyter Notebook. Here, <code>datafile</code> = root.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741793671370/42249fe2-4938-453a-aec2-f49616d2ba54.png" alt class="image--center mx-auto" /></p>
<p>Here are the dictionary keys:</p>
<pre><code class="lang-python">datafile[<span class="hljs-number">0</span>][<span class="hljs-number">0</span>].keys()
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741794920800/4f18df2f-2701-47f6-8610-ee2ef8cc8f5c.png" alt class="image--center mx-auto" /></p>
<p>So, I need to extract the data from these keys and upload it to the database. The dictionary keys will become the database columns, and each row will represent one GPU listing.</p>
<h3 id="heading-pandas-library">Pandas library</h3>
<p>This is where I learned about the pandas utility! I want to shout out YouTuber Panda's Data Diaries. While researching, I found <a target="_blank" href="https://www.youtube.com/watch?v=wVkw-HyTfOQ&amp;ab_channel=Panda%27sDataDiaries">his video</a> and realized that Pandas would be very useful here. Pandas is a python library that can flatten .JSON and turn it into a Pandas “data frame”. The data frame is a <em>relational</em> representation of the data structure. This is very similar to how data shows up in an SQL database. What’s more, Pandas can connect to an SQL database and push the data frame to the database. Perfect! This library is exactly what I’m looking for.</p>
<p>Using the <code>json_normalize</code> method I am able to print out the structure in columns.</p>
<pre><code class="lang-python"><span class="hljs-comment">#datafile[0] is a list of 20 dictionaries which are individual products</span>
<span class="hljs-keyword">import</span> pandas <span class="hljs-keyword">as</span> pd
df = pd.json_normalize(datafile[<span class="hljs-number">0</span>])
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741795771528/06a6b419-4a59-4f7e-84a9-d2e7561f8ccb.png" alt class="image--center mx-auto" /></p>
<p>I noticed that some columns are still being obscured by an ellipses.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741796190968/6335f69b-83f8-4606-987b-e19084a8be1e.png" alt class="image--center mx-auto" /></p>
<p>Adding the options <code>pd.set_option('display.max_columns', None)</code> and <code>pd.set_option('display.max_colwidth', None)</code> fix this issue. Now I can see important columns, i.e. the “price” column.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741796338313/b65c81d9-0c46-4d24-b2ba-b7cca7fc4d19.png" alt class="image--center mx-auto" /></p>
<p>I can use Pandas to connect to my SQL database and directly copy the data to a table, or even create a new table if it does not exist yet.</p>
<h3 id="heading-transforming-the-data">Transforming the data</h3>
<p>Before doing that, I need to transform the data. I don't need certain columns from the data frame, so I will exclude those. Secondly, I need to perform a numerical operation on the numbers in the "price" column so that they appear as float values or the actual price in USD.</p>
<p>I can select a single column by using <code>display</code> and putting the name of the column as a string in-between a set of brackets.</p>
<pre><code class="lang-python">df = pd.json_normalize(datafile[<span class="hljs-number">0</span>])
display(df[<span class="hljs-string">'condition'</span>])
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741804368148/5f5607b8-e266-4aca-a6f8-c89ca710a4b8.png" alt class="image--center mx-auto" /></p>
<p>Here is the data frame again with only the columns that I want to keep (some do not appear on screen):</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741804463353/7bea0828-123e-4891-9a10-c4f8a73829b2.png" alt class="image--center mx-auto" /></p>
<p>To clean this up further, I later define a list called “selected_columns”. Then, calling <code>display(df[selected_columns])</code> can produce the same result.</p>
<p>Now that is done, I need to deal with that issue with the “price” column. The “price” column is a <em>string</em> type and it shows as a factor of 100 above the actual price value. So, for example, where the price may show up as “45000” as a string, the actual price is “450.00” as a float.</p>
<p>Here is how I fixed that:</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> pandas <span class="hljs-keyword">as</span> pd
pd.set_option(<span class="hljs-string">'display.max_columns'</span>, <span class="hljs-literal">None</span>)
pd.set_option(<span class="hljs-string">'display.max_colwidth'</span>, <span class="hljs-literal">None</span>)

<span class="hljs-comment">#flatten JSON</span>
df = pd.json_normalize(datafile[<span class="hljs-number">0</span>])

<span class="hljs-comment"># Select specific columns</span>
selected_columns = [
    <span class="hljs-string">'condition'</span>, <span class="hljs-string">'description'</span>, <span class="hljs-string">'id'</span>, <span class="hljs-string">'images.ids'</span>, <span class="hljs-string">'listing_expires_at'</span>,
    <span class="hljs-string">'name'</span>, <span class="hljs-string">'price'</span>, <span class="hljs-string">'published_at'</span>, <span class="hljs-string">'return_policy'</span>, <span class="hljs-string">'shipping_option'</span>,
    <span class="hljs-string">'specs.Chipset'</span>, <span class="hljs-string">'specs.Brand'</span>, <span class="hljs-string">'specs.Series'</span>, <span class="hljs-string">'specs.Memory'</span>, <span class="hljs-string">'weight'</span>
]

<span class="hljs-comment"># Convert 'price' to numeric and handle any non-numeric values just in case</span>
df[<span class="hljs-string">'price'</span>] = pd.to_numeric(df[<span class="hljs-string">'price'</span>], errors=<span class="hljs-string">'coerce'</span>)

<span class="hljs-comment">#divide 'price' by 100 so that a float value appears for the price</span>
df[<span class="hljs-string">'price'</span>] = df[<span class="hljs-string">'price'</span>] / <span class="hljs-number">100</span>

df[<span class="hljs-string">'price'</span>]
</code></pre>
<p>Result!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741806463170/e83aa28a-68d3-4886-822a-733bc9ab73ad.png" alt class="image--center mx-auto" /></p>
<p>This completes the data transformation. Now I can work on inserting the data into the SQL database.</p>
<h3 id="heading-connecting-to-mariadb">Connecting to MariaDB</h3>
<p>For my initial testing, I set up a MariaDB server on a raspberry pi in a Docker container. When I'm ready to scale up, this will become an AWS RDS instance in the cloud, but for now, I wanted to experiment in a local environment. The database container uses port 3306 on the host network. I deployed the container using the docker compose template available on the official <a target="_blank" href="https://hub.docker.com/_/mariadb">Docker Hub</a>, which also deploys the Adminer container. Adminer is a tool for managing content in SQL databases and features a user-friendly Apache-based GUI, similar to phpMyAdmin.</p>
<p>I am using standard password authentication and everything is set up and ready to go. The database name is ‘gpulistings’, it’s empty with no tables created.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741877070716/4f93643e-db42-48fa-ae2d-ced42df5ad28.png" alt class="image--center mx-auto" /></p>
<p>I am following along with the steps in this <a target="_blank" href="https://www.geeksforgeeks.org/connecting-pandas-to-a-database-with-sqlalchemy/">article</a> showing how we can use sqlalchemy to connect Pandas to an SQL database. I should mention that this article uses a postgres database but I am using MariaDB (MySQL). And so I needed to ensure that I installed <a target="_blank" href="https://pypi.org/project/PyMySQL/"><code>pymysql</code></a> and changed the connection string in the code to use the correct pymysql connector.</p>
<p>The tutorial suggests to write out the db username and password in the command you will use to initiate the db connection. However, this is a bad security practice and I would absolutely avoid this if I was running this in production. So, I will create python environment variables instead so that I can hide these sensitive credentials.</p>
<p>I’ve created a “.env” file in my Python virtual environment and wrote out the variables in that file.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741877733292/a97174a0-0718-4158-a1f2-c24b051feafd.png" alt class="image--center mx-auto" /></p>
<p>Then after installing python-dotenv, I can import the necessary modules into my code and I’ll do a quick test to check if the variables are available. This is working:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741877929643/9c210aab-f399-4bd5-9533-9cd885dd8466.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741877887703/83ad5b87-70a5-4a08-874e-adaf6bcb9db0.png" alt class="image--center mx-auto" /></p>
<p>Now I’ll write the code that will create the db connection and insert the data. sqlalchemy really makes this simple.</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> sqlalchemy <span class="hljs-keyword">import</span> create_engine 
<span class="hljs-keyword">from</span> dotenv <span class="hljs-keyword">import</span> load_dotenv
<span class="hljs-keyword">import</span> psycopg2 
<span class="hljs-keyword">import</span> os


load_dotenv()
db_user = os.getenv(<span class="hljs-string">"DB_USER"</span>)
db_pw = os.getenv(<span class="hljs-string">"DB_PASSWORD"</span>)
db_host = os.getenv(<span class="hljs-string">"DB_HOST"</span>)


<span class="hljs-comment">#create a new data frame from the selected data so that I can use the ".to_sql" method</span>
newdf = pd.DataFrame(df[selected_columns])

load_dotenv()

<span class="hljs-comment">#define env variables</span>
db_user = os.getenv(<span class="hljs-string">"DB_USER"</span>)
db_pw = os.getenv(<span class="hljs-string">"DB_PASSWORD"</span>)
db_host = os.getenv(<span class="hljs-string">"DB_HOST"</span>)


<span class="hljs-comment"># establish connection with the database </span>
engine = create_engine( 
    <span class="hljs-string">f"mysql+pymysql://<span class="hljs-subst">{db_user}</span>:<span class="hljs-subst">{db_pw}</span>@<span class="hljs-subst">{db_host}</span>:3306/gpulistings"</span>) 

<span class="hljs-comment">#create a table named products and use 'APPEND’ to add data to the existing table.</span>
newdf.to_sql(<span class="hljs-string">'products'</span>, engine, if_exists=<span class="hljs-string">'append'</span>)
</code></pre>
<p>Result!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741881739869/92a54b86-64a8-47a8-9fd3-78042b23170f.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741881982492/e16c84fd-c4c9-4c0b-93c2-b4ca292d11e8.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-putting-it-all-together">Putting it all together</h3>
<p>This is working brilliantly. However, it's only a small part of our data, just 20 product listings. There are over 300 in this file. To finish this, I will write this code as a couple of functions that will go through each list index in the JSON data, create a data frame from the keys in the dictionaries, and then insert that data into the database.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741882424608/51d14aab-85df-423b-9f97-fe110d7fe301.png" alt class="image--center mx-auto" /></p>
<p>All together this is what it looks like:</p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> sqlalchemy <span class="hljs-keyword">import</span> create_engine 
<span class="hljs-keyword">from</span> dotenv <span class="hljs-keyword">import</span> load_dotenv
<span class="hljs-keyword">import</span> json
<span class="hljs-keyword">import</span> pandas <span class="hljs-keyword">as</span> pd
<span class="hljs-keyword">import</span> os

<span class="hljs-comment">#fetchedarray.json is my testing file</span>
<span class="hljs-keyword">with</span> open(<span class="hljs-string">"fetchedarray.json"</span>, <span class="hljs-string">"r"</span>) <span class="hljs-keyword">as</span> f:
    datafile = json.load(f)

load_dotenv()

<span class="hljs-comment">#define env variables</span>
db_user = os.getenv(<span class="hljs-string">"DB_USER"</span>)
db_pw = os.getenv(<span class="hljs-string">"DB_PASSWORD"</span>)
db_host = os.getenv(<span class="hljs-string">"DB_HOST"</span>)

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">get_dataframe</span>(<span class="hljs-params">index</span>):</span>
    pd.set_option(<span class="hljs-string">'display.max_columns'</span>, <span class="hljs-literal">None</span>)
    pd.set_option(<span class="hljs-string">'display.max_colwidth'</span>, <span class="hljs-literal">None</span>)

    <span class="hljs-comment">#flatten JSON</span>
    df = pd.json_normalize(datafile[index])

    <span class="hljs-comment"># Select specific columns</span>
    selected_columns = [
        <span class="hljs-string">'condition'</span>, <span class="hljs-string">'id'</span>,<span class="hljs-string">'listing_expires_at'</span>,
        <span class="hljs-string">'name'</span>, <span class="hljs-string">'price'</span>, <span class="hljs-string">'published_at'</span>, <span class="hljs-string">'shipping_option'</span>,
        <span class="hljs-string">'specs.Chipset'</span>, <span class="hljs-string">'specs.Brand'</span>, <span class="hljs-string">'specs.Series'</span>, <span class="hljs-string">'specs.Memory'</span>
    ]

    <span class="hljs-comment"># Convert 'price' to numeric and handle any non-numeric values</span>
    df[<span class="hljs-string">'price'</span>] = pd.to_numeric(df[<span class="hljs-string">'price'</span>], errors=<span class="hljs-string">'coerce'</span>)

    <span class="hljs-comment">#divide 'price' by 100 so that a float value appears for the price</span>
    df[<span class="hljs-string">'price'</span>] = df[<span class="hljs-string">'price'</span>] / <span class="hljs-number">100</span>

    newdf = pd.DataFrame(df[selected_columns])

    <span class="hljs-keyword">return</span> newdf


<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">insertdb</span>():</span>

    <span class="hljs-comment"># establish connection with the database </span>
    engine = create_engine( 
        <span class="hljs-string">f"mysql+pymysql://<span class="hljs-subst">{db_user}</span>:<span class="hljs-subst">{db_pw}</span>@<span class="hljs-subst">{db_host}</span>:3306/gpulistings"</span>) 

    <span class="hljs-comment">#I found that each API response .json has one empty list at the end. So, length needs to be " -1"</span>
    <span class="hljs-comment">#to avoid errors caused by the empty list. it just works :)</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> range(len(datafile) - <span class="hljs-number">1</span>):
        get_dataframe(i).to_sql(<span class="hljs-string">'products'</span>, engine, if_exists=<span class="hljs-string">'append'</span>) 

<span class="hljs-comment">#call main insertdb() function</span>
insertdb()
</code></pre>
<p>Result!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1741977006312/730f69b1-7009-4e99-b22b-99cf9f43eba4.png" alt class="image--center mx-auto" /></p>
<p>Now I have a populated SQL database with the product’s name, price, shipping details, listing date, and more. The picture above is after I changed out the .JSON file and ran it again, resulting in 698 rows.</p>
<h3 id="heading-conclusion">Conclusion</h3>
<p>With this proof of concept completed, I plan to make some tweaks to the code to accommodate AWS Lambda and RDS so that this can be truly automated. Thanks for checking out and check back for me soon!</p>
]]></content:encoded></item><item><title><![CDATA[Automating Data Collection from Dynamic Websites: My Journey with Puppeteer, API Fetching, and AWS Lambda]]></title><description><![CDATA[Introduction

I embarked on this project to deepen my hands-on experience with AWS Lambda and JavaScript. Despite having no prior background in JavaScript, I decided to dive in headfirst because I learn best through practice. I was eager to develop n...]]></description><link>https://evanlovescloud.com/automating-data-collection-from-dynamic-websites-my-journey-with-puppeteer-api-fetching-and-aws-lambda</link><guid isPermaLink="true">https://evanlovescloud.com/automating-data-collection-from-dynamic-websites-my-journey-with-puppeteer-api-fetching-and-aws-lambda</guid><category><![CDATA[jawa.gg]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[aws lambda]]></category><category><![CDATA[fetch API]]></category><category><![CDATA[AWS SDK]]></category><category><![CDATA[AWS s3]]></category><category><![CDATA[automation]]></category><category><![CDATA[json]]></category><category><![CDATA[puppeteer]]></category><category><![CDATA[webscraping ]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Thu, 17 Oct 2024 10:09:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1729129580786/0e533d71-64b8-4caf-8945-699864c65814.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3 id="heading-introduction">Introduction</h3>
<hr />
<p>I embarked on this project to deepen my hands-on experience with AWS Lambda and JavaScript. Despite having no prior background in JavaScript, I decided to dive in headfirst because I learn best through practice. I was eager to develop new skills, especially in automation, which is crucial for a cloud engineer. It's important to thoroughly understand automation concepts, even at a foundational level, to effectively manage cloud-based infrastructures.</p>
<p>AWS Lambda, a serverless compute service, allows you to run code without the need to manage or provision the underlying servers. For this project, my main goal was to scrape product listings from an online marketplace using JavaScript code executed in a Lambda function, and then upload the collected data to an S3 bucket. I chose a publicly available website for this task, <em>and I want to emphasize that the project was undertaken solely for educational purposes.</em></p>
<p>As a hobbyist gamer with a strong interest in PC hardware and building custom gaming rigs, I selected <a target="_blank" href="http://jawa.gg"><em>jawa.gg</em></a>, a marketplace for second-hand PC components, as the target website. My script scrapes current GPU listings, processes the data, and uploads the raw results in JSON format to an AWS S3 bucket, where it’s ready for further analysis and transformation.</p>
<p>As I worked through the data collection process, I discovered that the raw JSON output from the API fetch is a deeply nested object. This means that the data will need to be transformed before it can be uploaded to an SQL database for querying. I’ll provide a brief preview of the data structure at the end, however, I plan to post on this in-depth in a different post.</p>
<p>The project breaks down into two key parts: writing a JavaScript program to collect the data and setting up an AWS stack that includes Lambda for execution, S3 for storage, IAM for authentication, and CloudWatch for monitoring.</p>
<p>When I began this project, I initially opted for a traditional web scraping approach using Puppeteer. However, I quickly realized that this method wasn't well-suited for <a target="_blank" href="http://jawa.gg"><em>jawa.gg</em></a>, as it's a dynamic website. This challenge prompted me to shift to using the API fetch method, which proved to be far more effective for retrieving the data I needed.</p>
<p>The journey has been both challenging and rewarding, and I’m excited to share the insights I’ve gained!</p>
<p>This article is geared towards those with a beginner to intermediate understanding of JavaScript and a working knowledge of AWS at a practitioner level. With that in mind, let’s dive in!</p>
<h3 id="heading-the-web-scraping-challenge-using-puppeteer">The Web Scraping Challenge: Using Puppeteer</h3>
<p>The <strong>Puppeteer</strong> library is a Node.js tool that provides a high-level API for controlling headless Chrome or Chromium browsers programmatically. It can also run Chrome or Chromium in full (non-headless) mode. Puppeteer is commonly used for tasks such as web scraping, automated testing, generating PDFs, capturing screenshots, and interacting with web pages in a way that simulates user behavior.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727987183277/996e16d6-c4a3-4fdf-ae26-19769733ae4a.png" alt class="image--center mx-auto" /></p>
<p>I began by writing code for Puppeteer, but eventually, I moved away from it because it couldn't deliver the results I needed. I can show why at the end.</p>
<p>I kicked off coding in a streamlined dev setup, which was a Node.js installation running on my local Windows 11 machine.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">//import the puppeteer module. I have included it in the package.json</span>
<span class="hljs-keyword">import</span> puppeteer <span class="hljs-keyword">from</span> <span class="hljs-string">'puppeteer'</span>;

<span class="hljs-comment">//this is the website URL</span>
<span class="hljs-keyword">const</span> url = <span class="hljs-string">'https://www.jawa.gg/shop/pc-parts-and-components/graphics-cards-G2XFDKAS'</span>

<span class="hljs-comment">/* the main function launches a new browser instance
extracts GPU product information (title, price, image) 
by interacting with the page’s DOM.
Logs the extracted GPU data to the console (without a headless 
browser for debugging purposes). */</span>

<span class="hljs-keyword">const</span> main = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">const</span> browser = <span class="hljs-keyword">await</span> puppeteer.launch({ <span class="hljs-attr">headless</span>: <span class="hljs-literal">false</span>})
    <span class="hljs-keyword">const</span> page = <span class="hljs-keyword">await</span> browser.newPage()
    <span class="hljs-keyword">await</span> page.goto(url)

    <span class="hljs-keyword">const</span> gpuListing = <span class="hljs-keyword">await</span> page.evaluate(<span class="hljs-function">(<span class="hljs-params">url</span>) =&gt;</span> {
        <span class="hljs-comment">//convert the price string to a number</span>
        <span class="hljs-keyword">const</span> convertPrice = <span class="hljs-function">(<span class="hljs-params">price</span>) =&gt;</span> {
            <span class="hljs-keyword">return</span> <span class="hljs-built_in">parseFloat</span>(price.replace(<span class="hljs-string">'$'</span>, <span class="hljs-string">''</span>))
        }
        <span class="hljs-comment">//find a specific image URL that I found manually after </span>
        <span class="hljs-comment">//inspecting the website. I am selecting the URL for the width of 384 px and return it.</span>
        <span class="hljs-keyword">const</span> convertimgSrc = <span class="hljs-function">(<span class="hljs-params">srcset</span>) =&gt;</span> {
            <span class="hljs-keyword">if</span> (!srcset) {
                <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
            }
              <span class="hljs-keyword">const</span> urls = srcset.split(<span class="hljs-string">' '</span>); <span class="hljs-comment">// Split on spaces to separate URLs</span>

              <span class="hljs-keyword">return</span> urls.find(<span class="hljs-function"><span class="hljs-params">url</span> =&gt;</span> url.includes(<span class="hljs-string">'w_384'</span>)); <span class="hljs-comment">// Find the URL with "w_384"</span>


        }
        <span class="hljs-comment">/* maps over the array gpuPods and for each GPU listing (gpu)
           extact the title pthe listing from the HTML element, the price,and the 
            image URL*/</span>
        <span class="hljs-keyword">const</span> gpuPods = <span class="hljs-built_in">Array</span>.from(<span class="hljs-built_in">document</span>.querySelectorAll(<span class="hljs-string">'.tw-group.tw-relative'</span>))
        <span class="hljs-keyword">const</span> productData = gpuPods.map(<span class="hljs-function"><span class="hljs-params">gpu</span> =&gt;</span> ({
            <span class="hljs-attr">title</span>: gpu.querySelector(<span class="hljs-string">'.tw-paragraph-m-bold.tw-mt-3.tw-line-clamp-1.tw-overflow-ellipsis'</span>).innerText,
            <span class="hljs-attr">price</span>: convertPrice(gpu.querySelector(<span class="hljs-string">'.tw-paragraph-m-bold.tw-order-2.tw-font-bold.tw-text-jawa-blue-500'</span>).innerText),
            <span class="hljs-attr">imgSrc</span>: convertimgSrc(gpu.querySelector(<span class="hljs-string">'img'</span>).getAttribute(<span class="hljs-string">'srcset'</span>))

            }));


        <span class="hljs-keyword">return</span> productData

    }, url);

    <span class="hljs-built_in">console</span>.log(gpuListing)

    <span class="hljs-keyword">await</span> browser.close()
}

main();
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727988972514/1777e821-ba2c-4884-bdc5-fd6841f830e7.gif" alt class="image--center mx-auto" /></p>
<p>The program exits cleanly, however, these results are incomplete because it only returns the first page of the available listings.</p>
<p>Sample of the data:</p>
<pre><code class="lang-javascript">[
  {
    <span class="hljs-attr">title</span>: <span class="hljs-string">'ZOTAC RTX 2080 Ti TWIN FAN'</span>,
    <span class="hljs-attr">price</span>: <span class="hljs-number">330</span>,
    <span class="hljs-attr">imgSrc</span>: <span class="hljs-string">'https://res.cloudinary.com/jawa/image/upload/f_auto,ar_5:4,c_fill,w_384,q_auto/production/listings/n92a3ylsspxyzouwsriw'</span>
  },
  {
    <span class="hljs-attr">title</span>: <span class="hljs-string">'ROG Strix GeForce RTX™ 4070 Ti 12GB GDDR6X OC Edition'</span>,
    <span class="hljs-attr">price</span>: <span class="hljs-number">785</span>,
    <span class="hljs-attr">imgSrc</span>: <span class="hljs-string">'https://res.cloudinary.com/jawa/image/upload/f_auto,ar_5:4,c_fill,w_384,q_auto/production/listings/d7dqykw5lczm8wld3kxf'</span>
  },
  {
    <span class="hljs-attr">title</span>: <span class="hljs-string">'Sapphire Pulse RX 6600XT'</span>,
    <span class="hljs-attr">price</span>: <span class="hljs-number">165</span>,
    <span class="hljs-attr">imgSrc</span>: <span class="hljs-string">'https://res.cloudinary.com/jawa/image/upload/f_auto,ar_5:4,c_fill,w_384,q_auto/production/listings/fu6rlj1c3iu0h6is0pga'</span>
  },

<span class="hljs-comment">//...</span>
</code></pre>
<p>I attempted to progress the program so that the puppeteer agent would click on the next page button like a real person would, log the page, and continue this pattern until it had reached the end of the product listings in a loop. However, I didn’t have any success with this approach. After identifying the HTML element that contained the “next page” button on the site, I assigned it to a variable and wrote a while loop that would check if the element was on the page and then use the <code>.click()</code> method to click on it before running the main function again. But I would either run into an error that said …</p>
<p><code>throw new Error('Node is either not clickable or not an Element');</code></p>
<p>… or the browser session would only loop between pages 1 &amp; 2 endlessly. I was sure to include .<code>waitForNavigation()</code> but it made no difference.</p>
<p>So, I investigated the website more closely. I noticed two important clues. For one, I could not manually navigate to a page on the site by simply adding the page number at the end of the URL. It always bounced back to the first page.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728065483904/29d2e9d1-e918-4cf8-9f65-8c9a7d415872.gif" alt class="image--center mx-auto" /></p>
<p>That prompted me to take a closer look and understand how the site was designed. I began to analyze the underlying logic for navigating pages so I could try to replicate it in my code in the browser dev tools. I noticed that when the user clicks on the next page, instead of loading a new page, it starts a fetch request.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727991337209/993361d0-6285-4a53-a27e-0751c3688b3d.gif" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1727991431747/a148ac98-deaf-4b23-9862-ae41bb822640.png" alt class="image--center mx-auto" /></p>
<p>This discovery helped me to confirm that this webpage is actually being loaded dynamically rather than through using traditional URL-based navigation. When you click a page number or "Next" button, a <code>fetch</code> call is made to the backend API (at the <code>/api</code> endpoint) to retrieve the new set of products. This data is then likely injected into the current page without reloading it. Apparently, this technique is called API pagination. If you want to learn about it more in depth, I have linked a resource <a target="_blank" href="https://www.merge.dev/blog/rest-api-pagination">here</a> that discusses in much more detail.</p>
<p>So, after I had a better understanding of how the website was displaying the product listings, I adjusted my approach to access the site’s API directly and abandoned the traditional web scraping approach with Puppeteer.</p>
<h3 id="heading-shifting-gears-querying-the-api-directly">Shifting Gears: Querying the API Directly</h3>
<p>I rewrote the program to use <code>fetch()</code> to query the API directly.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">//At the end I write a .JSON file with the output  </span>
<span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> fs <span class="hljs-keyword">from</span> <span class="hljs-string">'fs'</span>;

<span class="hljs-keyword">const</span> apiUrl = <span class="hljs-string">'https://www.jawa.gg/api/search?categoryIds[]'</span>;  <span class="hljs-comment">// the API endpoint</span>
<span class="hljs-keyword">let</span> initialPageNumber = <span class="hljs-number">1</span>  <span class="hljs-comment">// The starting page number for the request is "1", "0" returns code 400</span>
<span class="hljs-keyword">let</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${apiUrl}</span>=6&amp;page=<span class="hljs-subst">${initialPageNumber}</span>`</span>);
<span class="hljs-keyword">let</span> dataJson = <span class="hljs-keyword">await</span> response.json();
<span class="hljs-keyword">const</span> now = <span class="hljs-built_in">Date</span>.now();


<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getApiData</span> (<span class="hljs-params"></span>) </span>{
    <span class="hljs-comment">//an array that will be updated with the product listings from each page</span>
  <span class="hljs-keyword">let</span> productData = [];

  <span class="hljs-keyword">let</span> state = <span class="hljs-built_in">Object</span>.keys(dataJson.products).length !== <span class="hljs-number">0</span>;
  <span class="hljs-built_in">console</span>.log(state);


  <span class="hljs-keyword">while</span> (state) {

    response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${apiUrl}</span>=6&amp;page=<span class="hljs-subst">${initialPageNumber}</span>`</span>);
    dataJson = <span class="hljs-keyword">await</span> response.json();
    state = <span class="hljs-built_in">Object</span>.keys(dataJson.products).length !== <span class="hljs-number">0</span>;

    <span class="hljs-keyword">let</span> nextPageNumber = initialPageNumber;
    initialPageNumber++;
    <span class="hljs-comment">//this console log is for debugging.</span>
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`The page number is: <span class="hljs-subst">${nextPageNumber}</span>`</span>);
    productData.push(dataJson.products);

  }

  <span class="hljs-keyword">if</span> (!state) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'No more products'</span>);
  }

  <span class="hljs-keyword">return</span> productData;

}



<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">cleanData</span>(<span class="hljs-params">data</span>) </span>{
<span class="hljs-comment">//write the output as 'fetchedarray.json'</span>
 fs.writeFile((<span class="hljs-string">`<span class="hljs-subst">${now}</span>_fetchedarray.json`</span>), <span class="hljs-built_in">JSON</span>.stringify(data), <span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span>(err) <span class="hljs-keyword">throw</span> err;
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Successfully saved JSON!"</span>)});

}

<span class="hljs-keyword">const</span> apiData = <span class="hljs-keyword">await</span> getApiData();

cleanData(apiData);
</code></pre>
<p>The <code>apiUrl</code> variable holds the API endpoint, and we kick things off by setting the page number to 1—starting at 0 throws a ‘400’ error. We grab the first batch of data using <code>fetch</code> and convert the response into a JSON object. The core logic is inside of the <code>getApiData</code> function. Here, we create an empty <code>productData</code> array to store product listings as we loop through the pages. To keep the loop going, the <code>state</code> variable checks if there are still products available.</p>
<p>As the loop runs, each page is fetched, and we log the page number in the console for some helpful debugging—just to confirm it's pulling every page correctly. The product data is added to the <code>productData</code> array, and once we hit a page with no more products, the loop breaks and console logs <code>“No more products”</code>. Finally, the <code>cleanData</code> function steps in to save everything into a <code>fetchedarray.json</code> file. <a target="_blank" href="http://Date.now"><code>Date.now</code></a><code>()</code> is used to capture the current timestamp in milliseconds (since January 1, 1970). This value is being used in the <code>fs.writeFile</code> function to create a unique filename for saving the JSON data. Any errors during the file writing process will be caught and thrown, and a success message is logged when the data is saved.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729121536700/760d1a47-8193-43fa-9721-f48f422bd028.gif" alt class="image--center mx-auto" /></p>
<h3 id="heading-using-aws-lambda-for-automation-and-s3-for-storage">Using AWS Lambda for Automation and S3 for Storage</h3>
<p>This is where the fun in the cloud begins! The first step is to create the Lambda function that will run our Javascript program.</p>
<p>This function's configuration is basic. It's important to assign the correct permissions with IAM. The function's execution role should allow it to access S3 (<code>AmazonS3FullAccess</code>) and write logs to CloudWatch for monitoring (<code>AWSLambdaBasicExecutionRole</code>).</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729124481258/5c10fede-be23-46cf-812f-87919a89bd45.png" alt class="image--center mx-auto" /></p>
<p>The next thing I needed to do was adjust the execution time of the Lambda function. The default timeout is 3 seconds but this function runs anywhere from about 7-15 seconds depending on how many listings are being fetched. Trying to run the function without adjusting the default timeout setting will result in this error:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728329090208/584e76d7-07f0-4338-a202-3eca1a5af00b.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729124730669/ade9dfcf-67b4-4461-bb78-9bea08ad48c5.png" alt class="image--center mx-auto" /></p>
<p>Rewriting the code for Lambda using the AWS JavaScript SDK was not too complicated. I followed the AWS documentation. However, calling the AWS S3 submodule from SDK v3 was not as straightforward as I expected. Many guides I consulted suggested using <code>var AWS = require('aws-sdk');</code>, which calls the entire SDK v2. But since version 3 of the SDK is essentially “modularized” to reduce it’s size, you must call the submodules or you will see a “not found” error. Ultimately, this documentation was helpful in the end:</p>
<p><a target="_blank" href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/introduction/#modularized-packages">https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/introduction/#modularized-packages</a></p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> AWS = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@aws-sdk/client-s3'</span>);
<span class="hljs-comment">//AWS javascript SDK v3</span>
<span class="hljs-keyword">const</span> s3 = <span class="hljs-keyword">new</span> AWS.S3();
<span class="hljs-comment">//create a new S3 client</span>


<span class="hljs-built_in">exports</span>.handler = <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">event</span>) </span>{
  <span class="hljs-keyword">const</span> apiUrl = <span class="hljs-string">'https://www.jawa.gg/api/search?categoryIds[]'</span>; 
  <span class="hljs-keyword">let</span> initialPageNumber = <span class="hljs-number">1</span>  
  <span class="hljs-keyword">let</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${apiUrl}</span>=6&amp;page=<span class="hljs-subst">${initialPageNumber}</span>`</span>);
  <span class="hljs-keyword">let</span> dataJson = <span class="hljs-keyword">await</span> response.json();
  <span class="hljs-keyword">const</span> now = <span class="hljs-built_in">Date</span>.now();


  <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getApiData</span> (<span class="hljs-params"></span>) </span>{

    <span class="hljs-keyword">let</span> productData = [];

    <span class="hljs-keyword">let</span> state = <span class="hljs-built_in">Object</span>.keys(dataJson.products).length !== <span class="hljs-number">0</span>;
    <span class="hljs-comment">//console.log(state);</span>


    <span class="hljs-keyword">while</span> (state) {

      response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`<span class="hljs-subst">${apiUrl}</span>=6&amp;page=<span class="hljs-subst">${initialPageNumber}</span>`</span>);
      dataJson = <span class="hljs-keyword">await</span> response.json();
      state = <span class="hljs-built_in">Object</span>.keys(dataJson.products).length !== <span class="hljs-number">0</span>;


      <span class="hljs-keyword">let</span> nextPageNumber = initialPageNumber;
      <span class="hljs-comment">//turn in to JSON</span>
      <span class="hljs-comment">//let dataJson = await response.json();</span>
      initialPageNumber++;
      <span class="hljs-comment">//console.log(dataJson.products);</span>
      <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`The page number is: <span class="hljs-subst">${nextPageNumber}</span>`</span>);
      <span class="hljs-comment">//console.log(state);</span>
      productData.push(dataJson.products);
      <span class="hljs-comment">//console.log(productData);</span>

    }

    <span class="hljs-keyword">if</span> (!state) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'No more products'</span>);
    }

    <span class="hljs-keyword">return</span> productData;

  }

  <span class="hljs-keyword">const</span> apiData = <span class="hljs-keyword">await</span> getApiData();

  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">var</span> params = {
      <span class="hljs-attr">Bucket</span> : <span class="hljs-string">'fetcheddata'</span>,
      <span class="hljs-attr">Key</span>: <span class="hljs-string">`<span class="hljs-subst">${now}</span>_fetchedarray.json`</span>,
      <span class="hljs-attr">Body</span> : <span class="hljs-built_in">JSON</span>.stringify(apiData)
    };

    <span class="hljs-keyword">await</span> s3.putObject (params);
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">statusCode</span>: <span class="hljs-number">200</span>, <span class="hljs-attr">body</span>: <span class="hljs-string">'File uploaded successfully'</span> };
  } <span class="hljs-keyword">catch</span> (err) {
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Error uploading file:"</span>, err);
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">statusCode</span>: <span class="hljs-number">500</span>, <span class="hljs-attr">body</span>: <span class="hljs-string">'Error uploading file'</span> }
  };



}
</code></pre>
<p>The getApiData function logic has not changed. The biggest differences here are adding the Lambda function handler and the try catch block where the PUT operation is initiated. Some highlights:</p>
<ul>
<li><p>This imports the AWS SDK client for S3, which allows interaction with Amazon S3.</p>
</li>
<li><p>A new instance of the S3 client (<code>s3</code>) is created, which will be used to upload the raw JSON file to my S3 bucket.</p>
</li>
<li><p>Once the API data is collected (<code>apiData</code>), the function tries to upload it to an S3 bucket.</p>
</li>
<li><p>The <code>params</code> object defines my S3 bucket, the file key (which includes the current timestamp), and the file body (the JSON string of the fetched data).</p>
</li>
<li><p><code>s3.putObject</code> uploads the file to S3.</p>
</li>
<li><p>If successful, it returns a 200 status with a success message.</p>
</li>
<li><p>If an error occurs during upload, it logs the error and returns a 500 status with an error message.</p>
</li>
</ul>
<p>Let’s see it in action!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729126820753/64860c09-a164-4657-855e-9d3260f0eab5.gif" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729126841018/0601e638-d52c-4f44-a8cf-78572279b060.gif" alt class="image--center mx-auto" /></p>
<p>Here is the file, ready and waiting in the S3 bucket!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729126940989/41dd756c-7100-43ed-b487-71d50d20fff2.png" alt class="image--center mx-auto" /></p>
<p>And, here are the logs in CloudWatch.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1729128971640/b8f66c73-5669-4da7-a6de-60411482f114.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-analyzing-the-data">Analyzing the Data</h3>
<p>Taking a closer look at the kind of data I am getting from the API, this is an example of a single product listing. There are over 250 products listings in this fetch.</p>
<pre><code class="lang-json">{'blocked_at': '', 
'buyer_protection_policy': None, 
'category': {'id': <span class="hljs-number">6</span>, 'name': 'GPUs'}, 
'condition': 'used-good', 
'created_at': '<span class="hljs-number">2024</span><span class="hljs-number">-07</span><span class="hljs-number">-24</span>T21:<span class="hljs-number">35</span>:<span class="hljs-number">14.356</span>Z', 
'description': 'This AMD Radeon HD <span class="hljs-number">5870</span> <span class="hljs-number">1</span>GB GDDR5 Graphics Card features DisplayPort, HDMI, and DVI ports. It has been tested, cleaned, and confirmed to be in good working condition, with potential signs of previous use.', 
'expired_at': '', 
'featured_at': '', 
'height': <span class="hljs-number">0</span>, 
'id': <span class="hljs-number">37723</span>, 
'images': 
    {'ids': ['production/listings/qgry6fkvna5423moqtbx', 'production/listings/rhr6groalzpbispxazse', 'production/listings/cmeqwppvlbsqctc8iywh', 'production/listings/d37tvncxckowxh2myb0p'], 'source': 'cloudinary'}, 
'is_insured': False, 
'is_on_sale': True, 
'is_private_listing': False, 
'is_published': True, 
'is_sold_out': False, 
'labels': [{'id': <span class="hljs-number">-1</span>, 'name': 'Free Shipping'}], 
'last_featured_at': '', 
'last_published_at': '<span class="hljs-number">2024</span><span class="hljs-number">-07</span><span class="hljs-number">-24</span>T21:<span class="hljs-number">35</span>:<span class="hljs-number">14.356</span>Z', 
'last_sold_at': '', 
'length': <span class="hljs-number">0</span>, 
'listing_code': '', 
'listing_expires_at': '', 
'minimum_offer_amount': <span class="hljs-number">2039</span>, 
'name': 'AMD Radeon HD <span class="hljs-number">5870</span> <span class="hljs-number">1</span>GB GDDR5 Graphics Card -DisplayPort, HDMI, DVI', 
'number_sold': <span class="hljs-number">0</span>, 
'original_price': <span class="hljs-number">2599</span>, 
'price': <span class="hljs-number">2399</span>, 
'price_last_changed_at': 
'<span class="hljs-number">2024</span><span class="hljs-number">-07</span><span class="hljs-number">-24</span>T21:<span class="hljs-number">35</span>:<span class="hljs-number">14.356</span>Z', 
'published_at': '<span class="hljs-number">2024</span><span class="hljs-number">-07</span><span class="hljs-number">-24</span>T21:<span class="hljs-number">35</span>:<span class="hljs-number">14.356</span>Z', 
'quantity': <span class="hljs-number">2</span>, 
'quantity_available': <span class="hljs-number">2</span>, 
'return_policy': {'restocking_fee_percent': <span class="hljs-number">0</span>, 'return_window': <span class="hljs-number">30</span>}, 
'shipping_jawa_manual_price': <span class="hljs-number">0</span>, 
'shipping_option': 'seller_manual', 
'shipping_seller_manual_price': <span class="hljs-number">0</span>, 
'sku': 'AMD HD <span class="hljs-number">5870</span> <span class="hljs-number">1</span>G GDDR5 (<span class="hljs-number">2</span>,3)', 
'specs': {'Memory': '<span class="hljs-number">1</span>GB'}, 
'status': 'available', 
'url': '/product/<span class="hljs-number">37723</span>/amd-radeon-hd<span class="hljs-number">-5870</span><span class="hljs-number">-1</span>gb-gddr5-graphics-card-displayport-hdmi-dvi', 
'user': {'badges': [], 'displayed_name': 'TechCastle', 'facebook_ids': ['fd8bd165<span class="hljs-number">-53</span>b5<span class="hljs-number">-4</span>c9c-a710-da5b6589ae07'], 'id': <span class="hljs-number">215231</span>, 'images': {'avatar': 'production/avatars/deiaybd5arnm9tzr0yam', 'source': 'cloudinary'}, 
'is_business': False, 'is_guest': False, 
'is_on_vacation': False, 
'is_verified': True, 
'store_name': 'TechCastle', 
'tag_line': '', 
'username': 'TechCastle', 
'website': ''}, 
'user_id': <span class="hljs-number">215231</span>, 
'video_url': '', 
'watch_count': <span class="hljs-number">0</span>, 
'weight': <span class="hljs-number">0</span>, 
'width': <span class="hljs-number">0</span>}
</code></pre>
<p>This dataset is deeply nested, so extra steps are needed to properly extract the product listing data and prepare it for querying. Additionally, there are a few quirks in the data that must be addressed. For example, while some of the key-value pairs, like 'tag_line,' may be interesting, they are not particularly useful and can be removed. Furthermore, the 'price' value is not displayed in a readable format.</p>
<p>To address these issues, I plan to use a secondary Lambda function to transform the data, leveraging Python which is better suited for the task. We'll reduce it down to only the necessary fields—'name,' 'description,' and 'price.' The price will be converted to an integer and divided by 100 to represent the amount in USD.</p>
<p>The result is a much more useable (this is not the same product show above):</p>
<pre><code class="lang-json">{'name': 'MSI GTX <span class="hljs-number">1650</span> D6 AERO ITX OC GeForce Graphics Card <span class="hljs-number">4</span>GB GDDR6', 
'description': 'MSI GTX <span class="hljs-number">1650</span> D6 AERO ITX OC GeForce Graphics Card <span class="hljs-number">4</span>GB GDDR6.\n\nCard was used in an church office computer for around <span class="hljs-number">2</span> years (only two days a week), just to get 3 monitor support. It was never used for gaming or anything taxing on the GPU.\n\nPulled from that working system and tested to verify functionality.\n\nCard does not require any external power connection. It draws all needed power from the PCIe slot.\n\nDoes not include original box, packaging, or documentation.', 
'price': <span class="hljs-number">89.99</span>},
</code></pre>
<h3 id="heading-reflections-and-key-insights-from-the-process">Reflections and Key Insights from the Process</h3>
<p>Facing issues with the Puppeteer approach at first was disappointing, but I'm glad I could switch to a new method for collecting data that worked much better. I discovered that I really enjoy writing JavaScript, and I plan to keep learning more about it. In the coming days, I will need to think like a data scientist and learn how to further transform the data to make it more useful. Incorporating a database would be the best option for data queries.</p>
<p>Stay tuned for more updates on my progress. Thanks for learning with me! If you found this blog interesting, please check out some of my other posts.</p>
]]></content:encoded></item><item><title><![CDATA[Vaultwarden: an excellent self-hosted password manager]]></title><description><![CDATA[Introduction
Previously, I wrote an article about installing Vaultwarden in a Docker container to securely store my passwords and secret data.
In part 2, I will discuss how I configured the vault and share my experience using it. If you haven’t alrea...]]></description><link>https://evanlovescloud.com/using-vaultwarden</link><guid isPermaLink="true">https://evanlovescloud.com/using-vaultwarden</guid><category><![CDATA[vaultwarden]]></category><category><![CDATA[Docker]]></category><category><![CDATA[password manager]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Wed, 11 Sep 2024 23:24:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1726094900297/e4a1a5b2-9237-42b2-afc4-abae8cd8152a.gif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-introduction">Introduction</h2>
<p>Previously, I wrote an article about installing Vaultwarden in a Docker container to securely store my passwords and secret data.</p>
<p>In part 2, I will discuss how I configured the vault and share my experience using it. If you haven’t already, be sure to <a target="_blank" href="https://hashnode.com/post/clzhrrz1s000409kz8a5b6ses">check out part 1.</a></p>
<h2 id="heading-test-drive">Test drive</h2>
<p>This is what the vault looks like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490200052/4c5edafa-bef6-427e-8ad0-55f6f4ad9840.png" alt class="image--center mx-auto" /></p>
<p>The UI is very user-friendly. To start testing this, I created a log-in item for my Synology NAS.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490206607/a9693f58-f25b-44f0-a61d-9fbbafb586e8.png" alt class="image--center mx-auto" /></p>
<p>Vaultwarden is an implementation of the Bitwarden server API and it is compatible with <a target="_blank" href="https://bitwarden.com/download/#downloads-web-browser">Bitwarden's upstream clients</a>. An example of one of those clients is Bitwarden's browser extension (available for most browsers). There are also options for mobile, devices, desktop applications, and even CLI.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725491715583/1ced212d-106e-4220-a0e7-e91dd6c28b1d.png" alt class="image--center mx-auto" /></p>
<p>Once the browser extension is installed it is time to flex that API and connect it to my server. When logging in, be sure to choose the option for "self-hosted".</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490216052/5ca797ba-d411-4b39-a4cf-44b47e07022d.png" alt class="image--center mx-auto" /></p>
<p>Enter your server's address and your user credentials.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490888603/f0ab1e90-9d7c-468c-9022-2ed59d40fc52.png" alt class="image--center mx-auto" /></p>
<p>Now you can see your vault in the browser extension and you are ready to use it as your password manager. If you never wanted to log in to the server again you wouldn't have to as many of the features involving password and secret management are available right here. <strong>Very easy.</strong></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490894372/c5245fb8-2ae0-495c-b119-3fa7e9071c0f.png" alt class="image--center mx-auto" /></p>
<p>Bitwarden's blue &amp; white shield will now appear for fields that will accept log-in credentials.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490901054/05a257e2-cd52-46d1-a182-a594e6e83bea.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-migrating-passwords-from-google-chrome-and-icloud-keychain">Migrating passwords from Google Chrome and iCloud keychain</h2>
<p>If you are coming from another password manager, you will likely have hundreds of passwords that need to go into your vault. Fortunately migrating to Vaultwarden is also pretty easy.</p>
<p>Google Chrome let's you export passwords saved in the password manager to a .csv file.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725493065967/72b2e5f2-6f68-4f1a-863e-c48e1cc7be16.png" alt class="image--center mx-auto" /></p>
<p>Once you have the file downloaded, in Vaultwarden, go to <strong>Tools &gt; Import Data</strong> from the menu on the left. Select the correct file format (Chrome csv) and then upload the file.</p>
<p>I also needed to upload passwords from my iCloud keychain. You can <a target="_blank" href="https://support.apple.com/guide/keychain-access/import-and-export-keychain-items-kyca35961/mac">only export passwords from the keychain in macOS.</a> This will also output your passwords in a single .csv file and you can upload this to Vaultwarden.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725493442569/770d1b82-591e-491e-b542-16b976a0544a.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-avoid-using-conflicting-password-managers">Avoid using conflicting password managers</h2>
<p>Don't forget to disable your previous password manager in your browser's settings. It seems like a “no-brainer,” but it is an important step. Otherwise, you will get some funky user interface presentation, especially in Chrome, and while trivial, this was a little annoying to deal with at first.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1726096100678/94ad05f0-085c-41a5-b633-71267d238887.png" alt class="image--center mx-auto" /></p>
<p>I do wish that Bitwarden had an option to disable the Chrome password manager entirely. I use multiple Google accounts and on each one I had to turn off the Chrome password manager. If that does exist, I wasn’t able to find it.</p>
<h2 id="heading-passkeys">Passkeys</h2>
<p>I was pleasantly surprised to find that Bitwarden manages passkeys! This allows for much faster log ins for platforms that accept them like you Google account.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1726096367052/ae81b56b-d14d-43d2-bc6d-8deeedb46ae7.gif" alt class="image--center mx-auto" /></p>
<h2 id="heading-bitwarden-on-iphone">Bitwarden on iPhone</h2>
<p>To begin using Bitwarden on iOS, go to <strong>Settings &gt; Passwords &gt; password options</strong> and <em>enable</em> Bitwarden. Disable any other password managers you’re not planning on using.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1725490904171/303bd0fd-b393-404e-87c4-518a1c1544ff.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Overall, this has been a <em>smooth</em> experience and I would recommend setting up and Vaultwarden if you’re looking for a secure &amp; convenient way to store your passwords and secrets!</p>
<p>GitHub: <a target="_blank" href="https://github.com/dani-garcia/vaultwarden">https://github.com/dani-garcia/vaultwarden</a></p>
]]></content:encoded></item><item><title><![CDATA[Migrating VM's from Microsoft Hyper-V to ProxmoxVE]]></title><description><![CDATA[I'd like to thank Hetman Software on YouTube! I followed their awesome tutorial (link here: https://www.youtube.com/watch?v=ZvpQCk-8NKU) as a guide.
So, why Proxmox?
Up until now, I've been using a Windows server with Hyper-V to run all my virtual ma...]]></description><link>https://evanlovescloud.com/migrating-to-proxmox</link><guid isPermaLink="true">https://evanlovescloud.com/migrating-to-proxmox</guid><category><![CDATA[vhdx]]></category><category><![CDATA[hyper-v]]></category><category><![CDATA[proxmox]]></category><category><![CDATA[virtualization]]></category><category><![CDATA[Linux]]></category><category><![CDATA[sysadmin]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Thu, 15 Aug 2024 23:20:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1723763906095/0846df81-b161-48e6-bb37-f7db885140e2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>I'd like to thank Hetman Software on YouTube!</strong> I followed their awesome tutorial (link here: <a target="_blank" href="https://www.youtube.com/watch?v=ZvpQCk-8NKU">https://www.youtube.com/watch?v=ZvpQCk-8NKU</a>) <a target="_blank" href="https://www.youtube.com/watch?v=ZvpQCk-8NKU"></a>as a guide.</p>
<h3 id="heading-so-why-proxmox"><strong>So, why Proxmox?</strong></h3>
<p>Up until now, I've been using a Windows server with Hyper-V to run all my virtual machines. It worked fine, but lately, <a target="_blank" href="https://www.proxmox.com/en/">Proxmox</a> has been catching my eye for a few reasons.</p>
<p>First of all, it can be used totally for free and it is an open-source project. That's a major plus for me. Along with that it has an engaged and active community as far as I can tell after reviewing YouTube videos and the forums. On top of that, Proxmox lets you cluster your servers and even migrate VMs while they're running. This means way less downtime and better fault tolerance, which is exactly what I'm looking for.</p>
<p>Networking bells and whistles can wait<strong>.</strong> Right now, my network setup is pretty basic. But down the line, I'm will explore Proxmox's support for VLANs and bonding (link aggregation).</p>
<h2 id="heading-back-up-the-vm"><strong>Back up the VM</strong></h2>
<p>Let's start from the beginning. The first step is to back up the VM.</p>
<p>If your VM has snapshots, you need to merge the desired snapshot file (.avhdx) with the main .vhdx file. Alternatively, you can delete the snapshots in Hyper-V, which will merge them together. Once that's done, right-click on the VM in Hyper-V and choose "export".</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723406421998/9ef38da1-676f-4d64-a920-747358b52f35.png" alt class="image--center mx-auto" /></p>
<p>I exported my VM to my Synology NAS. This VM is a headless Debian 12 instance that hosts a Docker environment.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723752407874/5bcb17cf-f786-4fec-8778-be7e18867852.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723752430274/12c0bafa-58c9-4741-af3c-f4c5b55949c5.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-transfer-the-vhdx-file-to-proxmox"><strong>Transfer the .vhdx file to Proxmox</strong></h2>
<p>Now we need to transfer the .vhdx file to the local Proxmox volume. I used FileStation in DSM to transfer the file using SFTP, but you can use any FTP client to do this.</p>
<p><a target="_blank" href="https://kb.synology.com/en-ca/DSM/help/FileStation/connect?version=7"><em>Remote connections in Synology DSM.</em></a></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723753141909/2739e71c-eacc-4962-bf3a-5ffb86093bbd.png" alt class="image--center mx-auto" /></p>
<p>After the connection is made, in FileStation, select the .vhdx file and copy it to the the <code>/var/lib/vz/images</code> directory on the server.</p>
<h2 id="heading-create-a-new-vm-in-proxmox"><strong>Create a new VM in Proxmox</strong></h2>
<p>It's time to create a new virtual machine in Proxmox.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723754316579/22933e1e-04b9-4653-8e90-980b2c814807.gif" alt class="image--center mx-auto" /></p>
<p>You do not need to select the installation media.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723754720412/a0123e11-2864-4f22-a17a-e838fe3ba1e4.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723754821814/b8075c34-ecab-41cb-bfb5-4fcc49b870d1.png" alt class="image--center mx-auto" /></p>
<p>After the VM is created, select it and go to "Hardware", "Hard Disk", and then click on "Detach". Select the "Unused Disk 0" and click "Remove".</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723754924607/d5c69c8b-815b-4f3d-80e8-5f2d5f29ec75.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723755143335/a2784dab-b846-438e-bb02-54619c3bb756.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-convert-the-vhdx-to-a-img-file"><strong>Convert the .vhdx to a .img file</strong></h2>
<p>Now we will need to convert the .vhdx file we imported earlier into a usable format for Proxmox. We can do this from the Proxmox shell. The utility we will use to do is this is called qemu-utils.</p>
<p>Run <code>apt install qemu-utils</code>. If you see this warning, it may seem like the utility failed to install.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723757674571/70414e8a-0514-4602-876c-b06d59d51bdc.png" alt class="image--center mx-auto" /></p>
<p>To verify, run <code>which qemu-img</code>. If <code>which</code> outputs the location the executable, then the utility has installed successfully.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723756957744/633a55a2-f130-42a5-a65a-21b3bff0a7fc.png" alt class="image--center mx-auto" /></p>
<p>Now run a command to convert the .vhdx to the .img. The format for this command is <code>qemu-img convert -f vhdx -O raw /path/to/vhdx/file/ /path/to/img/file</code>.</p>
<p>In my case it looked like this -</p>
<pre><code class="lang-plaintext">qemu-img convert -f vhdx -O raw /var/lib/vz/images/Ontario.vhdx /var/lib/vz/images/Ontario.img
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723757644050/9c973b8c-b19f-4785-ba63-0b746ee14e91.png" alt class="image--center mx-auto" /></p>
<p>Now we need to import the disk into the Proxmox storage directory. Replace the placeholder VM ID in the command below with the actual ID of your virtual machine. You can find the VM ID by navigating to the Proxmox web interface, selecting the desired VM, and looking at the numerical value on the left side of the VM's name.</p>
<pre><code class="lang-plaintext">qm importdisk 102 /var/lib/vz/images/Ontario.img local-lvm
</code></pre>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723758167222/38ac2bfc-5c1e-4bf8-8960-08ade19a147a.png" alt class="image--center mx-auto" /></p>
<p>Go back to Proxmox, select the "Unused Disk 0" and click "Edit", then click "Add".</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723758218205/2bce4007-4694-497f-87fa-54fb629723fa.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723758322537/1a8a1419-92d9-4749-8ef3-a15f0cbd0bad.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723758348393/0502bb3e-812a-4032-a0ce-d7457fac4927.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-run-and-test"><strong>Run and test!</strong></h2>
<p>After adding the hard disk we can run the VM and check to make sure it is working properly</p>
<p>You may need to open the boot menu with the "ESC" key and select the correct boot device.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723760619216/78d1b39c-2fc2-44e8-ae38-db9e65a1b6a2.png" alt class="image--center mx-auto" /></p>
<p>You can also do this from the Proxmox interface.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723761129378/b1f87bc4-e340-406a-b824-caa2b7b611cb.png" alt class="image--center mx-auto" /></p>
<h1 id="heading-the-end"><strong>The end.</strong></h1>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1723761971877/2598cf28-c62a-4471-a55a-c3bc3cddf3c1.gif" alt class="image--center mx-auto" /></p>
]]></content:encoded></item><item><title><![CDATA[This self-hosted password manager changes everything!]]></title><description><![CDATA[Introduction
Google's Chrome browser is great, but I have some issues with it. The feature I used the most was the password manager. Combined with the iCloud keychain on my iOS and macOS devices, I had a haphazard system for saving my credentials. Ho...]]></description><link>https://evanlovescloud.com/vaultwarden</link><guid isPermaLink="true">https://evanlovescloud.com/vaultwarden</guid><category><![CDATA[vaultwarden]]></category><category><![CDATA[Docker]]></category><category><![CDATA[tailscale]]></category><dc:creator><![CDATA[Evan Oliver]]></dc:creator><pubDate>Tue, 06 Aug 2024 01:56:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1722898412899/ee21fbe3-0456-4d65-8e16-e550c01e908a.gif" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-introduction">Introduction</h2>
<p>Google's Chrome browser is great, but I have some issues with it. The feature I used the most was the password manager. Combined with the iCloud keychain on my iOS and macOS devices, I had a haphazard system for saving my credentials. However, using multiple Google accounts for different activities and devices quickly became a disorganized mess.</p>
<p>Google's password manager is per account, and I often found myself searching for credentials in another account. This was especially annoying on iOS, where switching accounts required logging out and back in. Sometimes, none of my saved passwords worked because, in my hurry from the last login session, I failed to update them properly.</p>
<p>I kept telling myself I'd fix this one day, but it took some time before I finally found the right solution.</p>
<h2 id="heading-vaultwarden-as-a-self-hosted-option">Vaultwarden as a self-hosted option</h2>
<p>I wanted to self-host my password manager for security reasons. Sure, I had already let Google and Apple handle my sensitive data because it was easy and built-in (and hard to avoid these days quite honestly). But, I wasn't ready to trust another company like Nord or LastPass to do that for me, and I definitely wasn't interested in paying for this kind of service.</p>
<p>After some research, I found the <a target="_blank" href="https://github.com/dani-garcia/vaultwarden">Vaultwarden project on GitHub.</a></p>
<p>I was very pleased to see clear documentation and an active community of users. I found out that my Vaultwarden server would be hosted in a docker container. That server would communicate with Bitwarden clients via the implementation of its API so that I can access my passwords from any browser or mobile device. I would need to set up a Bitwarden account to use the clients (but Bitwarden will not see my sensitive data).</p>
<p>That sounded good to me. After reviewing the installation instructions, I went ahead and attempted to deploy this solution for myself.</p>
<h2 id="heading-preparation-for-installation">Preparation for Installation</h2>
<p>I planned on using one of my Linux Debian virtual instances for this project.</p>
<p><a target="_blank" href="https://docs.docker.com/engine/install/debian/">I didn't have Docker installed</a> on that instance so I had to take care of that first.</p>
<p>Once I ensured that the Docker engine was working ...</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722901162407/c4940178-6ef4-42ca-a6e5-8bc654cd7f4f.png" alt class="image--center mx-auto" /></p>
<p>... it was time to set up the Vaultwarden container.</p>
<h2 id="heading-vaultwarden-installation">Vaultwarden installation</h2>
<p>You can install Vaultwarden by running the docker command as specified in the Github. This will pull the container image and mount the volume for the container. The <code>--restart unless-stopped</code> option is a Docker restart policy that ensures a container is automatically restarted unless it is explicitly stopped by the user.</p>
<p>Vaultwarden will map to port 80 on the host machine.</p>
<pre><code class="lang-plaintext">docker pull vaultwarden/server:latest
docker run -d --name vaultwarden -v /vw-data/:/data/ --restart unless-stopped -p 80:80 vaultwarden/server:latest
</code></pre>
<p>Running <code>sudo docker ps</code> showed that Vaultwarden was up and running.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722902011732/d321e4d8-b314-48a2-bd0e-646eb0450a44.png" alt class="image--center mx-auto" /></p>
<p>Sure enough, when I went to port 80 on my host I saw the login screen.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722902071348/ebe9f9a2-807c-497e-b321-0974c55381a4.png" alt class="image--center mx-auto" /></p>
<p><strong>So far, so good.</strong></p>
<h2 id="heading-configuring-an-https-proxy">Configuring an HTTPS proxy</h2>
<p>I clicked on the option to create my Bitwarden account at the bottom. After entering my user info, I got an error.</p>
<p><code>"This browser requires HTTPS to use the web vault"</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722902514040/a3fc3971-d8cd-47a1-8263-26ebd4799f6b.png" alt class="image--center mx-auto" /></p>
<p>The <a target="_blank" href="https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS">wiki</a> explains the error:</p>
<p><em>"For proper operation of vaultwarden, enabling</em><a target="_blank" href="https://en.wikipedia.org/wiki/HTTPS"><em>HTTPSis pr</em></a><em>etty much required nowadays, since the Bitwarden web vault uses</em><a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto"><em>web crypto APIsthat most brows</em></a><em>ers only make available in HTTPS contexts."</em></p>
<p>This explanation is alluding to the solution which is an HTTPS-to-HTTP reverse proxy. In fact, the wiki mentions services like CloudFlare for proxying the traffic. I went about this a bit differently.</p>
<p>I have set up a hub-and-spoke VPN with <a target="_blank" href="https://tailscale.com/blog/how-tailscale-works">Tailscale</a> that connects many of my devices in my homelab securely on my own mesh VPN. This was working great for remote access without exposing these devices to the public web. However, for this project, I would need to expose the container to web.</p>
<p>Tailscale <a target="_blank" href="https://tailscale.com/kb/1223/funnel?q=funn">Funnel</a> works beautifully for this. The funnel allows you to route traffic from the wider internet to a local service running on a machine and there is a reverse proxy mode.</p>
<p>There are some important things you need to take care of first and if you want to use Funnel in Tailscale. I am not going to be covering Tailscale in this post. You can reference their excellent documentation (linked above) if you want to learn more.</p>
<p>I had already enabled Funnel on my node so I didn't need to do those steps again.</p>
<p><code>sudo tailscale funnel 3000</code> creates the funnel and you will see this output with the domain name if it the command is successful:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722904223888/1aac701a-55cd-4278-b15f-7b90eb6a12a4.png" alt class="image--center mx-auto" /></p>
<p>However, I later found out that the best way to do this is run the command with the</p>
<p><code>-bg</code> option to ensure that the process will run in the background. Otherwise, sending SIGTERM (Ctrl + C) to recover your prompt in the shell will immediately close your funnel.</p>
<p>So I adjusted the command to <code>sudo tailscale funnel -bg 3000</code></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722904628338/f567a352-cad1-409b-967e-0b5934ebccb6.png" alt class="image--center mx-auto" /></p>
<p><code>sudo tailscale funnel -bg localhost:80</code> enables the reverse proxy to the container.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722905108492/d54d3992-70a0-4137-9b05-d9800778ed73.png" alt class="image--center mx-auto" /></p>
<p>With that done, I could successfully reach the container via HTTPS. I went on to create my Bitwarden account and view my empty vault.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1722905719803/a97c2620-817b-4a6e-9a43-880e84a58d2a.png" alt class="image--center mx-auto" /></p>
<p><strong>Success!</strong></p>
<p>This is part 1 of the tutorial. In part 2, I will continue where I have left off with configuring and setting up the vault.</p>
<p>Coming soon...</p>
]]></content:encoded></item></channel></rss>