<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://erom-teknas.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://erom-teknas.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-18T18:01:48-04:00</updated><id>https://erom-teknas.github.io/feed.xml</id><title type="html">Sanket More</title><subtitle>Hands-on DevOps guides to AWS, Kubernetes, Terraform and CI/CD, written by Sanket More.</subtitle><entry><title type="html">explainer-engine: a fact-checked, closed-cases-only true-crime video pipeline</title><link href="https://erom-teknas.github.io/posts/explainer-engine-fact-checked-true-crime/" rel="alternate" type="text/html" title="explainer-engine: a fact-checked, closed-cases-only true-crime video pipeline" /><published>2026-09-18T11:00:00-04:00</published><updated>2026-09-18T18:01:23-04:00</updated><id>https://erom-teknas.github.io/posts/explainer-engine-fact-checked-true-crime</id><content type="html" xml:base="https://erom-teknas.github.io/posts/explainer-engine-fact-checked-true-crime/"><![CDATA[<h2 id="overview-">Overview 📝</h2>
<p>explainer-engine is an automated pipeline that turns one subject into a 40 to 60 second vertical explainer video. It picks a subject, pins a Wikipedia article, writes a script from it, fact-checks every sentence against the article, narrates with ElevenLabs, renders with Remotion, and uploads to YouTube.</p>

<p>The genre it runs now is true crime, and that raises the stakes. A made-up date in a science explainer is embarrassing. A made-up detail about a real person and a crime is an accusation. So most of the engineering isn’t the rendering at all. It’s the set of gates that decide what the language model is <em>allowed</em> to say, and the rule that the pipeline only covers cases a court has closed.</p>

<p>Like the other projects in this category, I built it by directing AI coding agents rather than writing the code myself. It has 35 commits over three days, and 33 carry a <code class="language-plaintext highlighter-rouge">Co-Authored-By: Claude</code> trailer. It’s about 34k lines of TypeScript, Python and Swift, with about 240 tests. My part was deciding the rules, and watching and <em>listening</em> to the output until it was good enough.</p>

<h2 id="the-pipeline-">The pipeline 🏭</h2>
<p>Most of the steps below are gates: points where the pipeline can say no. Select one to see what it checks and what happens when a subject or a draft fails, or press Play to walk a subject all the way to an upload.</p>

<figure class="flow" id="flow-explainer-engine" data-flow="" data-loops="[{&quot;from&quot;:&quot;factcheck&quot;,&quot;to&quot;:&quot;write&quot;,&quot;label&quot;:&quot;Findings go back to the writer, who rewrites. Sentences that already passed stay settled. Up to three rounds.&quot;}]" aria-labelledby="flow-explainer-engine-title">
<div class="flow-head">
<p class="flow-title" id="flow-explainer-engine-title">How a subject becomes a checked, narrated video</p>
<div class="flow-controls"><button type="button" class="flow-play" data-flow-play="" hidden=""><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-play"></use></svg>
<span>Play</span></button>
</div>
</div>
<div class="flow-body">
<ol class="flow-list" data-flow-list=""><li class="flow-step" data-step="subject" data-kind="stage" data-label="Pick a subject"><p class="flow-phase">Research</p><button type="button" class="flow-node" id="flow-explainer-engine-subject" aria-controls="flow-explainer-engine-subject-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Pick a subject</span><span class="flow-where">Wikipedia + Wikidata</span></button>
<div class="flow-panel" id="flow-explainer-engine-subject-panel">
<p class="flow-summary">Crawls crime categories, then keeps only people and cases Wikidata records as convicted.</p>
<div class="flow-detail"><ul>
  <li>Crawls English Wikipedia crime categories two levels deep, drops articles under 40,000 bytes and deduplicates by Wikidata ID.</li>
  <li>A person must be human with <code class="language-plaintext highlighter-rouge">P1399</code> “convicted of” set, and have died at least two years ago. A case needs a date and a named perpetrator who has <code class="language-plaintext highlighter-rouge">P1399</code>.</li>
  <li>Political violence, mass-casualty events, sexual crimes and crimes against children are excluded outright.</li>
  <li>In the first measured build, 6,260 articles went in and 83 subjects came out. This is a convenience filter, not the safety guarantee.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>candidate subject</p></li><li class="flow-step" data-step="source" data-kind="stage" data-label="Pin the source"><button type="button" class="flow-node" id="flow-explainer-engine-source" aria-controls="flow-explainer-engine-source-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Pin the source</span><span class="flow-where">MediaWiki Action API</span></button>
<div class="flow-panel" id="flow-explainer-engine-source-panel">
<p class="flow-summary">Stores the article’s exact revision id. Everything after this works against that revision.</p>
<div class="flow-detail"><ul>
  <li>Saves the revision id and an <code class="language-plaintext highlighter-rouge">oldid=</code> permalink in <code class="language-plaintext highlighter-rouge">source.json</code>.</li>
  <li>Block quotations are marked as unusable evidence.</li>
  <li>Paragraphs are numbered (<code class="language-plaintext highlighter-rouge">[P1]</code>, <code class="language-plaintext highlighter-rouge">[P2]</code>, …) so every later claim can cite one.</li>
</ul>
</div><p class="flow-outcome is-fail"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-prohibit"></use></svg>
<span>Disambiguation pages, weak title matches and thin articles (under 700 words or 6 paragraphs) are refused.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>numbered article at one revision</p></li><li class="flow-step" data-step="verdict" data-kind="gate" data-label="Verdict gate"><button type="button" class="flow-node" id="flow-explainer-engine-verdict" aria-controls="flow-explainer-engine-verdict-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Verdict gate<span class="visually-hidden"> (gate)</span></span><span class="flow-where">code, then one LLM call</span><span class="flow-note">Refuses unless a court closed the case</span></button>
<div class="flow-panel" id="flow-explainer-engine-verdict-panel">
<p class="flow-summary">Before a word is written, decides whether the case is closed. It fails closed.</p>
<div class="flow-detail"><ul>
  <li>Layer one is plain code: patterns for conviction, unresolved and contested language. No conviction phrase, or any unresolved phrase, is refused with no model call.</li>
  <li>Layer two is one call at temperature 0 with a strict JSON schema. Code then checks every quote is verbatim, in the cited paragraph and not from a block quotation.</li>
  <li>Any doubt the model reports is a refusal. The Zodiac Killer fails in layer one, the Lindbergh kidnapping in layer two; the 1911 Mona Lisa theft is confirmed.</li>
</ul>
</div><p class="flow-outcome is-fail"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-prohibit"></use></svg>
<span>Refused permanently, with the reasons written to <code class="language-plaintext highlighter-rouge">verdict.md</code>.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>confirmed case</p></li><li class="flow-step" data-step="write" data-kind="stage" data-label="Write"><p class="flow-phase">Script</p><button type="button" class="flow-node" id="flow-explainer-engine-write" aria-controls="flow-explainer-engine-write-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Write</span><span class="flow-where">Gemini, schema-constrained JSON</span></button>
<div class="flow-panel" id="flow-explainer-engine-write-panel">
<p class="flow-summary">Writes the narration from the numbered article, its only source, and cites every fact.</p>
<div class="flow-detail"><ul>
  <li>Returns a <code class="language-plaintext highlighter-rouge">facts[]</code> list alongside the narration: each fact has a claim, its beat, a paragraph id and a verbatim quote as evidence.</li>
  <li>Also writes 2 or 3 alternative openings for the hook judge to compare against.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>draft + cited facts</p></li><li class="flow-step" data-step="hook" data-kind="gate" data-label="Hook review"><button type="button" class="flow-node" id="flow-explainer-engine-hook" aria-controls="flow-explainer-engine-hook-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Hook review<span class="visually-hidden"> (gate)</span></span><span class="flow-where">code, then an LLM judge</span></button>
<div class="flow-panel" id="flow-explainer-engine-hook-panel">
<p class="flow-summary">The opening has to earn the first two seconds, on the page before any voice credits are spent.</p>
<div class="flow-detail"><ul>
  <li>No opening on a date, a place, a subordinate clause or “It was…”. No questions, no “Imagine”, no stative verbs.</li>
  <li>At most 22 words per sentence, and at most 12 words of setup before the surprise.</li>
  <li>The judge compares the opening with the alternatives. Code overrides a “strong” verdict unless the opening is concrete, tense, surprising and clear, and leaves a question.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>chosen opening</p></li><li class="flow-step" data-step="factcheck" data-kind="gate" data-label="Fact-check and revise"><button type="button" class="flow-node" id="flow-explainer-engine-factcheck" aria-controls="flow-explainer-engine-factcheck-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Fact-check and revise<span class="visually-hidden"> (gate)</span></span><span class="flow-where">LLM checker, audited in code</span><span class="flow-note">Loops back to Write, up to 3 rounds</span></button>
<div class="flow-panel" id="flow-explainer-engine-factcheck-panel">
<p class="flow-summary">Splits the narration into atomic claims and checks each one against the pinned article.</p>
<div class="flow-detail"><ul>
  <li>Every date, number, name, place and causal link is its own claim, marked <code class="language-plaintext highlighter-rouge">supported</code>, <code class="language-plaintext highlighter-rouge">unsupported</code> or <code class="language-plaintext highlighter-rouge">contradicted</code> with a paragraph and a quote.</li>
  <li>Code checks the checker: quotes must really be in the article, numbers must appear in the evidence, and qualifiers (“more than”), hedges (“believed to”) and names must survive.</li>
  <li>Every sentence must be covered by at least one claim.</li>
</ul>
</div><p class="flow-outcome is-fail"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-prohibit"></use></svg>
<span>A draft still failing after three rounds is rejected, and the audit is saved to <code class="language-plaintext highlighter-rouge">factcheck.md</code>.</span></p><p class="flow-outcome is-loop"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-arrow-counter-clockwise"></use></svg>
<span><strong>Back to Write.</strong> Findings go back to the writer, who rewrites. Sentences that already passed stay settled. Up to three rounds.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>accepted narration</p></li><li class="flow-step" data-step="plan" data-kind="stage" data-label="Plan shots"><p class="flow-phase">Produce</p><button type="button" class="flow-node" id="flow-explainer-engine-plan" aria-controls="flow-explainer-engine-plan-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Plan shots</span><span class="flow-where">VideoScript JSON</span></button>
<div class="flow-panel" id="flow-explainer-engine-plan-panel">
<p class="flow-summary">Turns the accepted narration into scenes and shots, one template per shot from a library of 55.</p>
<div class="flow-detail"><ul>
  <li>Visual notes that name anything the narration doesn’t are dropped, so a picture can’t add an unchecked claim.</li>
  <li>The end card goes through its own hedge check (<code class="language-plaintext highlighter-rouge">endCardHedges</code>), after one once called the Mona Lisa “the most famous painting in the world”.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>scenes and shots</p></li><li class="flow-step" data-step="assets" data-kind="stage" data-label="Assets"><button type="button" class="flow-node" id="flow-explainer-engine-assets" aria-controls="flow-explainer-engine-assets-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Assets</span><span class="flow-where">stills and cover</span></button>
<div class="flow-panel" id="flow-explainer-engine-assets-panel">
<p class="flow-summary">Gathers the images each shot template calls for, and the cover.</p>
<div class="flow-detail"><ul>
  <li>Only the checked visual notes drive what’s gathered.</li>
  <li>A cover is required: an unattended publish refuses to go ahead without one.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>stills + cover</p></li><li class="flow-step" data-step="voice" data-kind="stage" data-label="Voice"><button type="button" class="flow-node" id="flow-explainer-engine-voice" aria-controls="flow-explainer-engine-voice-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Voice</span><span class="flow-where">ElevenLabs + local aligner</span></button>
<div class="flow-panel" id="flow-explainer-engine-voice-panel">
<p class="flow-summary">One take per scene, levelled so the twist doesn’t fade out.</p>
<div class="flow-detail"><ul>
  <li><code class="language-plaintext highlighter-rouge">eleven_v3_conversational</code>. The standard v3 model read at 0.48 to 0.50 s per word and broke every timing constant.</li>
  <li>Narration is levelled along its envelope, which took the hook sentence’s spread from 14 dB to 5.</li>
  <li>Word timings come from a local <code class="language-plaintext highlighter-rouge">faster-whisper</code> forced aligner matched back to the script with <code class="language-plaintext highlighter-rouge">difflib</code>. Words it can’t recover are recorded as <code class="language-plaintext highlighter-rouge">misheard</code>.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>narration + word timings</p></li><li class="flow-step" data-step="compile" data-kind="gate" data-label="Compile"><button type="button" class="flow-node" id="flow-explainer-engine-compile" aria-controls="flow-explainer-engine-compile-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Compile<span class="visually-hidden"> (gate)</span></span><span class="flow-where">hook timing, measured</span></button>
<div class="flow-panel" id="flow-explainer-engine-compile-panel">
<p class="flow-summary">Measures the hook on the recorded narration and mixes the music under it.</p>
<div class="flow-detail"><ul>
  <li>First word heard by 1.0 s, 4 words said by 2.0 s, and the twist landing by 5.0 s.</li>
  <li>A capitalised name the aligner mishears fails.</li>
  <li>Every music bed is scaled from its own measured loudness and ducked under each spoken word.</li>
</ul>
</div><p class="flow-outcome is-fail"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-prohibit"></use></svg>
<span>For a crime video, a failed timing check throws. Nothing renders with a warning.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>timed VideoScript</p></li><li class="flow-step" data-step="render" data-kind="stage" data-label="Render"><button type="button" class="flow-node" id="flow-explainer-engine-render" aria-controls="flow-explainer-engine-render-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Render</span><span class="flow-where">Remotion, 1080x1920</span></button>
<div class="flow-panel" id="flow-explainer-engine-render-panel">
<p class="flow-summary">One Remotion composition renders the JSON, timed in word indices rather than frames.</p>
<div class="flow-detail"><ul>
  <li>A re-voiced line re-times itself, because shots are anchored to words.</li>
  <li>Apple Vision face detection checks every crop of a still and refuses any that cuts off a head.</li>
  <li>Captions stay inside the measured Shorts safe zone: 240 px top, 440 px bottom, 220 px on the right.</li>
  <li>H.264 at CRF 20, loudness-normalised to -16 LUFS.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>40 to 60 s MP4</p></li><li class="flow-step" data-step="publish" data-kind="gate" data-label="Publish"><p class="flow-phase">Publish</p><button type="button" class="flow-node" id="flow-explainer-engine-publish" aria-controls="flow-explainer-engine-publish-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Publish<span class="visually-hidden"> (gate)</span></span><span class="flow-where">YouTube Data API</span></button>
<div class="flow-panel" id="flow-explainer-engine-publish-panel">
<p class="flow-summary">Uploads only with an accepted fact-check, ElevenLabs narration, a 40 to 60 s runtime and a cover.</p>
<div class="flow-detail"><ul>
  <li>Resumable upload, then <code class="language-plaintext highlighter-rouge">thumbnails/set</code>.</li>
  <li>Reads the privacy status back afterwards and fails on a mismatch.</li>
  <li>LLM calls are capped per day, per story and across the sibling shorts-engine pipeline.</li>
</ul>
</div><p class="flow-outcome is-fail"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-prohibit"></use></svg>
<span>Any missing precondition stops an unattended publish.</span></p></div></li></ol>
</div>
<figcaption class="flow-caption">
<span class="flow-legend" aria-hidden="true">
<span class="flow-key" data-kind="stage"><span class="flow-marker"></span>Stage</span>
<span class="flow-key" data-kind="gate"><span class="flow-marker"></span>Gate</span><span class="flow-key" data-kind="loop"><span class="flow-marker"></span>Loop</span></span>
<span class="flow-hint" data-flow-hint="" hidden="">Select a step, use the arrow keys, or press Play.</span>
</figcaption>
</figure>

<h3 id="picking-a-subject-">Picking a subject 🔎</h3>
<p>The autopilot crawls English Wikipedia crime categories two levels deep through the MediaWiki API (<code class="language-plaintext highlighter-rouge">generator=categorymembers</code>), with hard caps on how many listings and pages it reads. It drops articles under 40,000 bytes, because a thin article can’t back a script, and deduplicates subjects by their Wikidata ID.</p>

<p>Wikidata then narrows the list to a <em>pool</em>:</p>
<ul>
  <li>A person must be a human (<code class="language-plaintext highlighter-rouge">P31 = Q5</code>) with <strong><code class="language-plaintext highlighter-rouge">P1399</code> “convicted of”</strong> set, who died at least two years ago.</li>
  <li>A case must have a date (<code class="language-plaintext highlighter-rouge">P585</code>) and a named perpetrator (<code class="language-plaintext highlighter-rouge">P8032</code>) who has <code class="language-plaintext highlighter-rouge">P1399</code> on their own item.</li>
  <li>Some offence types are excluded outright: political violence, mass-casualty events, sexual crimes and crimes against children.</li>
</ul>

<p>In the first measured build, 6,260 articles went in and 83 subjects came out.</p>

<blockquote class="prompt-warning">
  <p>The pool is only a convenience filter; it is not the safety guarantee. Wikidata can be wrong or out of date. The guarantee is the verdict gate, which reads the actual article.</p>
</blockquote>

<h3 id="pinning-the-source-">Pinning the source 📌</h3>
<p><code class="language-plaintext highlighter-rouge">source.ts</code> fetches the article through the MediaWiki Action API with <code class="language-plaintext highlighter-rouge">prop=extracts|info|pageprops|revisions</code> and stores the <strong>revision id</strong> plus an <code class="language-plaintext highlighter-rouge">oldid=</code> permalink in <code class="language-plaintext highlighter-rouge">source.json</code>. Everything after this step works against that exact revision. If the article is edited tomorrow, you can still say exactly what the video was checked against.</p>

<p>Some other rules at this stage:</p>
<ul>
  <li><strong>Disambiguation pages and weak title matches are refused</strong>, not guessed at.</li>
  <li><strong>Thin articles are refused:</strong> at least 700 words and 6 paragraphs.</li>
  <li><strong>Block quotations are marked as unusable evidence.</strong> The pipeline reads the same revision’s wikitext to find them. A quote the article attributes to someone else isn’t the article saying it’s true.</li>
  <li><strong>Paragraphs are numbered</strong> (<code class="language-plaintext highlighter-rouge">[P1]</code>, <code class="language-plaintext highlighter-rouge">[P2]</code>, and so on) so every later claim can point to where it came from.</li>
</ul>

<h2 id="gate-1-the-confirmed-conviction-verdict-️">Gate 1: the confirmed-conviction verdict ⚖️</h2>
<p>Before a single word of script is written, <code class="language-plaintext highlighter-rouge">verdict.ts</code> reads the pinned article and decides whether the case is closed. It works in two layers and fails closed.</p>

<p><strong>Layer one is plain code, with no model involved.</strong> Regex families look for:</p>
<ul>
  <li><em>Conviction language</em>, including older wordings like “was sent to jail”. The Mona Lisa thief’s article never actually uses the word “convicted”.</li>
  <li><em>Unresolved language</em>: unsolved, cold case, awaiting trial, appeal pending.</li>
  <li><em>Contested language</em>: overturned, pardoned, exonerated, acquitted, retrial, recanted.</li>
</ul>

<p>No conviction phrase, or any unresolved phrase, is an instant refusal:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">check</span><span class="p">.</span><span class="nx">conviction</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">check</span><span class="p">.</span><span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(</span><span class="dl">'</span><span class="s1">the article never states that anyone was convicted, pleaded guilty or was sentenced; ...</span><span class="dl">'</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">check</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">if </span><span class="p">(</span><span class="nx">check</span><span class="p">.</span><span class="nx">unresolved</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">check</span><span class="p">.</span><span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...</span><span class="nx">check</span><span class="p">.</span><span class="nx">unresolved</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">u</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`</span><span class="p">${</span><span class="nx">u</span><span class="p">.</span><span class="nx">token</span><span class="p">}</span><span class="s2"> ("</span><span class="p">${</span><span class="nx">u</span><span class="p">.</span><span class="nx">sentence</span><span class="p">}</span><span class="s2">")`</span><span class="p">));</span>
  <span class="k">return</span> <span class="nx">check</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Contested phrases become questions the model has to answer.</p>

<p><strong>Layer two is one LLM call at temperature 0 with a strict JSON schema.</strong> The model returns the outcome, the defendants with verbatim evidence, whether the conviction still stands, and an answer to every flagged phrase. Then code audits the model:</p>
<ul>
  <li>Every quote must be verbatim.</li>
  <li>It must be in the paragraph it cites.</li>
  <li>It must not come from a block quotation.</li>
  <li>The conviction quote must itself match the conviction patterns.</li>
</ul>

<p>Any doubt the model reports is a refusal:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="p">[</span><span class="dl">'</span><span class="s1">convicted</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">guilty plea</span><span class="dl">'</span><span class="p">].</span><span class="nf">includes</span><span class="p">(</span><span class="nx">reading</span><span class="p">.</span><span class="nx">outcome</span><span class="p">))</span> <span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...);</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">reading</span><span class="p">.</span><span class="nx">convictionStands</span><span class="p">)</span> <span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">reading</span><span class="p">.</span><span class="nx">appealPending</span><span class="p">)</span> <span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">reading</span><span class="p">.</span><span class="nx">guiltDisputed</span><span class="p">)</span> <span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">reading</span><span class="p">.</span><span class="nx">unsolved</span><span class="p">)</span> <span class="nx">reasons</span><span class="p">.</span><span class="nf">push</span><span class="p">(...);</span>
</code></pre></div></div>

<p>A refusal is permanent and is written to <code class="language-plaintext highlighter-rouge">verdict.md</code> with its reasons. Three real examples:</p>
<ul>
  <li><strong>Zodiac Killer:</strong> refused in layer one, with no model call.</li>
  <li><strong>Lindbergh kidnapping:</strong> refused in layer two, because the guilt is disputed.</li>
  <li><strong>1911 theft of the Mona Lisa:</strong> confirmed, on the wording of one specific paragraph.</li>
</ul>

<h2 id="gate-2-every-sentence-is-checked-against-the-article-">Gate 2: every sentence is checked against the article 🧾</h2>
<p>The writer (Gemini by default, with schema-constrained JSON output) gets the numbered article and is told it is its <em>only</em> source. Along with the narration, it has to return a <code class="language-plaintext highlighter-rouge">facts[]</code> list. Each fact has a claim, the beat it belongs to, the paragraph id, and a <strong>verbatim quote</strong> as evidence.</p>

<p>A separate checker call then splits the narration into atomic claims. Every date, number, name, place, relationship and causal link is its own claim, and each gets <code class="language-plaintext highlighter-rouge">supported</code>, <code class="language-plaintext highlighter-rouge">unsupported</code> or <code class="language-plaintext highlighter-rouge">contradicted</code>, with a paragraph id and a quote.</p>

<p>Nobody trusts the checker either. <strong>Code checks the checker:</strong></p>
<ul>
  <li>Every quote must actually be in the article. The comparison ignores case, accents and dash variants, and quotes from block quotations don’t count.</li>
  <li>Every number in a sentence must appear in that sentence’s evidence.</li>
  <li>Qualifiers must survive: “more than 20,000” is not “about 20,000”.</li>
  <li>Hedges must survive: if the article says “believed to” or “often called”, so must the script.</li>
  <li>Every name must appear in the paragraph the claim cites.</li>
  <li>Every sentence must be covered by at least one claim.</li>
</ul>

<p>Even before the model call, a cheap pass flags any number, capitalised name or quoted phrase in the narration that the article doesn’t literally contain.</p>

<p>When something fails, the findings go back to the writer, who rewrites. Sentences that already passed are “settled” and not re-checked. This runs for up to three rounds. A draft that still fails is rejected, and the whole audit is saved to <code class="language-plaintext highlighter-rouge">factcheck.md</code>. The accepted narration is audited one more time, and visual notes that name things the narration doesn’t are dropped, so the pictures can’t sneak in an unchecked claim either.</p>

<p>One near miss shows how small the gaps can be. The shot planner’s end card, which the narration checks never saw, said “The most famous painting in the world”. The article says “one of the best known”. End cards now go through their own hedge check (<code class="language-plaintext highlighter-rouge">endCardHedges</code>), and that wording fails.</p>

<h2 id="gate-3-the-hook-on-the-page-and-in-the-ear-">Gate 3: the hook, on the page and in the ear 🎣</h2>
<p>Short-form video lives or dies in the first two seconds, so the opening has its own gate, again in two parts.</p>

<p><strong>Text rules, in code, before any voice credits are spent:</strong></p>
<ul>
  <li>No opening on a date or place phrase, a subordinate clause, or “It was…”.</li>
  <li>No questions, and no “Have you ever”, “Imagine” or “Did you know”.</li>
  <li>No stative verbs like “was born” or “became interested”.</li>
  <li>At most 22 words per sentence.</li>
  <li>At most 12 words of setup before the sentence that delivers the surprise.</li>
</ul>

<p>An LLM judge compares the writer’s opening with 2 or 3 alternatives the writer also had to provide. A “strong” verdict is overridden in code unless the opening is concrete, has tension, is surprising and clear, and leaves the viewer with a question.</p>

<p><strong>Timing rules, measured on the recorded narration:</strong></p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">const</span> <span class="nx">HOOK_AUDIBLE_SECONDS</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">;</span>  <span class="c1">// first word heard by 1.0s</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">HOOK_POINT_SECONDS</span> <span class="o">=</span> <span class="mf">2.0</span><span class="p">;</span>    <span class="c1">// ...and 4 words said by 2.0s</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">HOOK_POINT_WORDS</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">HOOK_SETUP_WORDS</span> <span class="o">=</span> <span class="mi">12</span><span class="p">;</span>
<span class="k">export</span> <span class="kd">const</span> <span class="nx">HOOK_TURN_SECONDS</span> <span class="o">=</span> <span class="mf">5.0</span><span class="p">;</span>     <span class="c1">// the twist lands by 5.0s</span>
</code></pre></div></div>

<p>For a crime video, a failed timing check throws at compile time. It does not render with a warning.</p>

<h3 id="how-that-gate-got-its-timing-rules">How that gate got its timing rules</h3>
<p>The first crime video opened: <em>“A detective finished writing his report leaning on a table in Vincenzo Peruggia’s Paris apartment. The stolen Mona Lisa was hidden under that table.”</em> It passed every text rule, and I still couldn’t follow it on listening. Measuring the take showed three separate problems:</p>

<ol>
  <li><strong>The narration faded out on the twist.</strong> ElevenLabs ends a statement on a falling pitch. “Mona” came out at -14.0 dBFS and “table.” at -27.8 dBFS, 14 dB apart within one sentence.</li>
  <li><strong>The music was as loud as the reveal.</strong> The bed played at a fixed gain with no ducking. In the speech band, the reveal word ended 0.2 dB <em>above</em> the music.</li>
  <li><strong>The twist arrived at 5.88 s</strong>, behind fifteen words of setup and a name the aligner itself heard as “Peruzio’s”.</li>
</ol>

<p>The fix went into the pipeline, not just that one script:</p>
<ul>
  <li>Narration is levelled along its envelope, which brought the hook sentence’s spread from 14 dB down to 5.</li>
  <li>Every music bed is scaled from its own measured loudness and ducked under each spoken word.</li>
  <li>The setup and turn limits above were added, plus a rule that fails any capitalised name the aligner mishears.</li>
</ul>

<p>The rewritten opening leads with the Mona Lisa, and its turn now lands at 3.93 s.</p>

<h2 id="voice-and-render-️">Voice and render 🎙️</h2>
<ul>
  <li><strong>Narration:</strong> ElevenLabs <code class="language-plaintext highlighter-rouge">eleven_v3_conversational</code>, one take per scene, levelled to a fixed speech loudness. The standard v3 model was tried and rejected after listening. It reads at 0.48 to 0.50 s per word against 0.37 to 0.41, which broke every runtime and hook constant.</li>
  <li><strong>Word timing:</strong> it doesn’t come from ElevenLabs. A local <code class="language-plaintext highlighter-rouge">faster-whisper</code> model is used as a forced aligner, and what it hears is matched back to the script with <code class="language-plaintext highlighter-rouge">difflib</code>. Words it can’t recover are recorded as <code class="language-plaintext highlighter-rouge">misheard</code>, and the hook gate uses that.</li>
  <li><strong>Render:</strong> a video is a JSON <code class="language-plaintext highlighter-rouge">VideoScript</code> (scenes, then shots, one template per shot, from a library of 55) rendered by a single 1080x1920 Remotion composition. Timing is in word indices, not frames, so a re-voiced line re-times itself. The output is H.264 at CRF 20, loudness-normalised to -16 LUFS.</li>
  <li><strong>Faces:</strong> Apple Vision face detection (a small Swift helper) checks every crop of a still image. The render refuses any crop that cuts off a head, after a painting in an early video showed a torso with no head. Checked against real face boxes, 11 of 14 portrait crops had failed.</li>
  <li><strong>Publishing:</strong> a YouTube Data API resumable upload, then <code class="language-plaintext highlighter-rouge">thumbnails/set</code>. Afterwards it reads the privacy status back and fails on a mismatch. An unattended publish requires an accepted fact-check, ElevenLabs narration, a runtime of 40 to 60 s (±1 s) and a cover.</li>
</ul>

<h2 id="what-broke-on-the-way-">What broke on the way 🐛</h2>
<ul>
  <li><strong>The gates contradicted each other.</strong> The first live autopilot subject used up the whole day’s LLM budget and was then rejected. The script shape rule required a title to be written out as “Pepper No. 30”, and then the name check and the fact-checker both refused “Pepper Number 30”. The runtime cap of 30 to 40 s also forced drafts down to 53 words. Widening the runtime to 40 to 60 s and reconciling the rules fixed it. With several independent validators, you need a test that they can all pass <em>together</em>.</li>
  <li><strong>Captions under the Subscribe button.</strong> The first upload’s captions sat under the Shorts UI. The safe zone was measured again: 240 px at the top, 440 px at the bottom, and a 220 px rail on the right.</li>
  <li><strong>The wrong cover.</strong> Remotion shallow-merges <code class="language-plaintext highlighter-rouge">--props</code> over <code class="language-plaintext highlighter-rouge">defaultProps</code>, so the first unattended video inherited an older video’s cover. Input props are now validated in <code class="language-plaintext highlighter-rouge">calculateMetadata</code>.</li>
  <li><strong>A budget that’s enforced in code.</strong> LLM calls are capped per day for this project and across it and a sibling pipeline (shorts-engine). A subject has a fixed per-story call budget. Autopilot defers work instead of overspending.</li>
</ul>

<h2 id="takeaways-">Takeaways 💡</h2>
<ul>
  <li><strong>Ground the model, then check the grounding in code.</strong> Every step that uses an LLM (verdict, write, check, hook) is followed by a code audit of what the model claims to have found. A quote that isn’t verbatim is a failure, however confident the model sounds.</li>
  <li><strong>Fail closed where being wrong hurts people.</strong> An article that doesn’t clearly say a court closed the case is refused. Losing a good story costs nothing compared with covering an innocent person.</li>
  <li><strong>Pin your sources.</strong> A revision id turns “the article said so” into something you can check later.</li>
  <li><strong>Measure what the viewer experiences.</strong> The hook passed on paper and failed in the ear. The fixes came from measuring the audio, not from rereading the script.</li>
  <li><strong>AI agents made this possible in three days, and the rules made it safe.</strong> The agents built what I specified quickly and with thorough tests. The judgement about what must never be published was the part I couldn’t hand off.</li>
</ul>]]></content><author><name></name></author><category term="🧠 AI Engineering" /><category term="🎬 Video Pipelines" /><category term="ai" /><category term="vibe coding" /><category term="typescript" /><category term="remotion" /><category term="elevenlabs" /><category term="gemini" /><category term="wikipedia" /><category term="wikidata" /><category term="youtube" /><summary type="html"><![CDATA[How an automated pipeline researches, scripts, narrates and renders short true-crime explainers, and the gates that keep an LLM from inventing facts or covering an open case.]]></summary></entry><entry><title type="html">Pact: a group habit tracker with a live leaderboard, built in a day with AI agents</title><link href="https://erom-teknas.github.io/posts/pact-group-habit-pwa/" rel="alternate" type="text/html" title="Pact: a group habit tracker with a live leaderboard, built in a day with AI agents" /><published>2026-09-18T10:00:00-04:00</published><updated>2026-09-18T18:01:23-04:00</updated><id>https://erom-teknas.github.io/posts/pact-group-habit-pwa</id><content type="html" xml:base="https://erom-teknas.github.io/posts/pact-group-habit-pwa/"><![CDATA[<h2 id="overview-">Overview 📝</h2>
<p><a href="https://pact-deploy.vercel.app">Pact</a> is a small PWA for group habit challenges. Friends join a group with a six character PIN, agree on five daily habits, and compete on a leaderboard that updates live while everyone checks in. A done habit is one point, and finishing all of your habits for the day adds a one point “perfect day” bonus.</p>

<p>The first commit and the tenth merged PR are about 25 hours apart. I did not write most of that code by hand. I directed autonomous AI coding agents (Claude Opus 5 worker agents, run and supervised by firstmate, an open source orchestrator for a crew of coding agents). I made the product calls, reviewed the PRs, and tested on the live app myself. Every feature and fix commit carries a <code class="language-plaintext highlighter-rouge">Co-authored-by: Claude Opus 5</code> trailer. This post covers the design decisions that held up and the bugs that got through anyway.</p>

<blockquote class="prompt-info">
  <p>“Vibe coding” is a fair label for how the code got typed. It is not a fair label for how it got checked. Every PR had to pass unit tests, database tests against the real migrations, and Playwright end to end runs, both against a local stand-in and against a real Supabase stack.</p>
</blockquote>

<h2 id="how-it-fits-together-">How it fits together 🧭</h2>
<p>A group goes through setup once per challenge, then plays the same loop every day: check in, update the ledger, and refresh everyone’s leaderboard. Switch the habit mode to see how shared and personal challenges pick their habits, and select a step for the detail.</p>

<figure class="flow" id="flow-pact" data-flow="" data-loops="[{&quot;from&quot;:&quot;leaderboard&quot;,&quot;to&quot;:&quot;checkin&quot;,&quot;label&quot;:&quot;The next check-in from anyone in the group runs the same path, until the challenge finishes.&quot;}]" aria-labelledby="flow-pact-title" data-mode="shared">
<div class="flow-head">
<p class="flow-title" id="flow-pact-title">How a check-in reaches everyone's leaderboard</p>
<div class="flow-controls"><div class="flow-modes" role="group" aria-label="Habit mode" data-flow-modes="" hidden=""><button type="button" class="flow-mode" data-mode="shared" aria-pressed="true">Shared vote</button><button type="button" class="flow-mode" data-mode="personal" aria-pressed="false">Personal</button></div><button type="button" class="flow-play" data-flow-play="" hidden=""><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-play"></use></svg>
<span>Play</span></button>
</div>
</div>
<div class="flow-body">
<ol class="flow-list" data-flow-list=""><li class="flow-step" data-step="group" data-kind="stage" data-label="Group"><p class="flow-phase">Set up</p><button type="button" class="flow-node" id="flow-pact-group" aria-controls="flow-pact-group-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Group</span><span class="flow-where">RPC in Postgres</span></button>
<div class="flow-panel" id="flow-pact-group-panel">
<p class="flow-summary">Friends join a group with a six character PIN.</p>
<div class="flow-detail"><ul>
  <li>PINs match <code class="language-plaintext highlighter-rouge">^[2-9A-HJKMNP-Z]{6}$</code>: 31 symbols with the look-alikes removed, about 887 million codes.</li>
  <li><code class="language-plaintext highlighter-rouge">create_group</code> retries the insert on a <code class="language-plaintext highlighter-rouge">unique_violation</code>.</li>
  <li>Guessing is limited to 10 misses per user per 15 minutes. A miss is recorded rather than raised, so the attempt still counts.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>members</p></li><li class="flow-step" data-step="challenge" data-kind="stage" data-label="Challenge"><button type="button" class="flow-node" id="flow-pact-challenge" aria-controls="flow-pact-challenge-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Challenge</span><span class="flow-where">Postgres</span></button>
<div class="flow-panel" id="flow-pact-challenge-panel">
<p class="flow-summary">A challenge fixes the habit mode and a time zone. Its phase is derived from the clock, never stored.</p>
<div class="flow-detail"><ul>
  <li>Phases (<code class="language-plaintext highlighter-rouge">proposing</code>, <code class="language-plaintext highlighter-rouge">voting</code>, <code class="language-plaintext highlighter-rouge">upcoming</code>, <code class="language-plaintext highlighter-rouge">active</code>, <code class="language-plaintext highlighter-rouge">finished</code>) come from a <code class="language-plaintext highlighter-rouge">case</code> over the timestamps and <code class="language-plaintext highlighter-rouge">now()</code>.</li>
  <li>Every read RPC calls <code class="language-plaintext highlighter-rouge">sync_challenge()</code> first. A per-minute pg_cron job is only a backstop.</li>
  <li>Days are calendar dates in the challenge’s IANA time zone.</li>
  <li>A personal challenge is created already resolved, with no voting deadlines.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>habit mode</p></li><li class="flow-step" data-step="propose" data-kind="stage" data-label="Propose" data-modes="shared"><button type="button" class="flow-node" id="flow-pact-propose" aria-controls="flow-pact-propose-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Propose</span><span class="flow-where">RPC in Postgres</span></button>
<div class="flow-panel" id="flow-pact-propose-panel">
<p class="flow-summary">Each member proposes up to five habit ideas.</p>
<div class="flow-detail"><ul>
  <li>Proposals are ordered, and that order later breaks ties in the vote.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>the group's ideas</p></li><li class="flow-step" data-step="vote" data-kind="gate" data-label="Vote" data-modes="shared"><button type="button" class="flow-node" id="flow-pact-vote" aria-controls="flow-pact-vote-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Vote<span class="visually-hidden"> (gate)</span></span><span class="flow-where">RPC in Postgres</span><span class="flow-note">Needs at least 2 voters to close</span></button>
<div class="flow-panel" id="flow-pact-vote-panel">
<p class="flow-summary">Everyone casts up to five picks, and the top five become the whole group’s habits.</p>
<div class="flow-detail"><ul>
  <li>Totals stay hidden until voting closes, so nobody bandwagons.</li>
  <li>Ties go to the earliest proposal: <code class="language-plaintext highlighter-rouge">row_number() over (order by votes desc, seq)</code>.</li>
  <li>Voting closes at the deadline, or early once everyone has voted. <code class="language-plaintext highlighter-rouge">challenge_min_voters()</code> is 2, so a group of one can’t vote itself into tracking.</li>
  <li>Each winner is a <code class="language-plaintext highlighter-rouge">challenge_habits</code> row with <code class="language-plaintext highlighter-rouge">user_id</code> null.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>five shared habits</p></li><li class="flow-step" data-step="pick" data-kind="stage" data-label="Pick your five" data-modes="personal"><button type="button" class="flow-node" id="flow-pact-pick" aria-controls="flow-pact-pick-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Pick your five</span><span class="flow-where">RPC in Postgres</span></button>
<div class="flow-panel" id="flow-pact-pick-panel">
<p class="flow-summary">Each member picks their own five habits. There is no vote.</p>
<div class="flow-detail"><ul>
  <li>The same <code class="language-plaintext highlighter-rouge">challenge_habits</code> table, with <code class="language-plaintext highlighter-rouge">user_id</code> set. A check constraint (<code class="language-plaintext highlighter-rouge">challenge_habits_origin</code>) and partial unique indexes keep the two kinds apart.</li>
  <li>Nobody can check in until all five slots are filled (<code class="language-plaintext highlighter-rouge">habits_incomplete</code>), so both modes play for the same maximum points.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>five habits per member</p></li><li class="flow-step" data-step="checkin" data-kind="stage" data-label="Check in"><p class="flow-phase">Play</p><button type="button" class="flow-node" id="flow-pact-checkin" aria-controls="flow-pact-checkin-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Check in</span><span class="flow-where">Browser, then a granted RPC</span></button>
<div class="flow-panel" id="flow-pact-checkin-panel">
<p class="flow-summary">A tap calls an RPC. The only optimistic update in the app is your own tick.</p>
<div class="flow-detail"><ul>
  <li>The browser has no write path of its own: every table write is revoked, and RPCs are granted back by name as <code class="language-plaintext highlighter-rouge">security definer</code> with <code class="language-plaintext highlighter-rouge">search_path = ''</code>.</li>
  <li>Timed habits start on the server, and <code class="language-plaintext highlighter-rouge">complete_timed_session</code> checks both the server’s clock and the client’s foreground time.</li>
  <li>A day stays open for late logging until 10:00 the next morning.</li>
  <li>A done check-in may trigger a random spot-check with a 15 minute window.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>check-in row</p></li><li class="flow-step" data-step="ledger" data-kind="store" data-label="Points ledger"><button type="button" class="flow-node" id="flow-pact-ledger" aria-controls="flow-pact-ledger-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Points ledger<span class="visually-hidden"> (store)</span></span><span class="flow-where">Postgres</span></button>
<div class="flow-panel" id="flow-pact-ledger-panel">
<p class="flow-summary"><code class="language-plaintext highlighter-rouge">reconcile_day_points()</code> works out what you should have for the day and appends the difference.</p>
<div class="flow-detail"><ul>
  <li>One point per done habit, plus one for a perfect day.</li>
  <li>Append-only: a trigger rejects updates, so an undo is just a negative entry.</li>
  <li>A per-member, per-day advisory lock stops two taps from both awarding the bonus.</li>
  <li>Scoring counts <em>your</em> habits via <code class="language-plaintext highlighter-rouge">member_habit_count</code>, never the challenge’s.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>new ledger rows</p></li><li class="flow-step" data-step="realtime" data-kind="stage" data-label="Realtime"><button type="button" class="flow-node" id="flow-pact-realtime" aria-controls="flow-pact-realtime-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Realtime</span><span class="flow-where">Supabase postgres_changes</span></button>
<div class="flow-panel" id="flow-pact-realtime-panel">
<p class="flow-summary">Every open client hears that something changed, filtered by challenge and by RLS.</p>
<div class="flow-detail"><ul>
  <li>One channel per challenge. The client never applies the row payloads.</li>
  <li>Any event triggers a debounced (250 ms) <code class="language-plaintext highlighter-rouge">invalidateQueries</code> on <code class="language-plaintext highlighter-rouge">['challenge', id]</code>, so five people checking in at once cost one refetch.</li>
  <li>It refetches on every <code class="language-plaintext highlighter-rouge">SUBSCRIBED</code>, polls every 20 s when the channel isn’t joined, and refreshes when the tab is visible again.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>refetch</p></li><li class="flow-step" data-step="leaderboard" data-kind="stage" data-label="Leaderboard"><button type="button" class="flow-node" id="flow-pact-leaderboard" aria-controls="flow-pact-leaderboard-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Leaderboard</span><span class="flow-where">get_leaderboard RPC</span></button>
<div class="flow-panel" id="flow-pact-leaderboard-panel">
<p class="flow-summary">Always <code class="language-plaintext highlighter-rouge">sum(points)</code> over the ledger, recomputed on every read.</p>
<div class="flow-detail"><ul>
  <li>Ties are broken by <code class="language-plaintext highlighter-rouge">verified_share</code>, the share of check-ins backed by more than the honor system.</li>
  <li><code class="language-plaintext highlighter-rouge">previous_rank</code> is the same query over ledger rows before today, which drives the “moved up” arrow.</li>
  <li>Streaks are computed with gaps-and-islands, not stored.</li>
</ul>
</div><p class="flow-outcome is-loop"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-arrow-counter-clockwise"></use></svg>
<span><strong>Back to Check in.</strong> The next check-in from anyone in the group runs the same path, until the challenge finishes.</span></p></div></li></ol>
</div>
<figcaption class="flow-caption">
<span class="flow-legend" aria-hidden="true">
<span class="flow-key" data-kind="stage"><span class="flow-marker"></span>Stage</span>
<span class="flow-key" data-kind="gate"><span class="flow-marker"></span>Gate</span><span class="flow-key" data-kind="store"><span class="flow-marker"></span>Store</span><span class="flow-key" data-kind="loop"><span class="flow-marker"></span>Loop</span></span>
<span class="flow-hint" data-flow-hint="" hidden="">Select a step, use the arrow keys, or press Play.</span>
</figcaption>
</figure>

<h2 id="stack-">Stack 🧰</h2>
<ul>
  <li><strong>Frontend:</strong> React 19, Vite 8, TypeScript 6 and React Router. TanStack Query holds all server state; there is no Redux or Zustand. Styling is one token based CSS file (no Tailwind).</li>
  <li><strong>PWA:</strong> <code class="language-plaintext highlighter-rouge">vite-plugin-pwa</code> with <code class="language-plaintext highlighter-rouge">injectManifest</code> and a hand written service worker. It precaches only the app shell. Data always comes from Supabase over the network and is never served from the cache. Updates use <code class="language-plaintext highlighter-rouge">registerType: 'prompt'</code>, so a new version never swaps in under someone mid check-in.</li>
  <li><strong>Backend:</strong> Supabase: Postgres, Auth (email OTP plus guest sign-in) and Realtime.</li>
  <li><strong>Hosting:</strong> Vercel, git connected. <code class="language-plaintext highlighter-rouge">main</code> deploys to production and every PR gets a preview. <code class="language-plaintext highlighter-rouge">vercel.json</code> sets a strict CSP (<code class="language-plaintext highlighter-rouge">script-src 'self'</code>, <code class="language-plaintext highlighter-rouge">worker-src 'self'</code>, and <code class="language-plaintext highlighter-rouge">connect-src</code> limited to the Supabase host). That CSP comes back later as a bug.</li>
  <li><strong>Tests:</strong> Vitest with two projects. One runs unit tests in jsdom pinned to <code class="language-plaintext highlighter-rouge">TZ=UTC</code>. The other runs the real SQL migrations on PGlite, an in-process Postgres. Playwright covers the end to end flows.</li>
</ul>

<h2 id="all-the-rules-live-in-postgres-">All the rules live in Postgres 🐘</h2>
<p>The main architectural decision is that the client is dumb. Every business rule is a Postgres function, every function is <code class="language-plaintext highlighter-rouge">security definer</code> with <code class="language-plaintext highlighter-rouge">search_path = ''</code>, and the platform migration takes every write path away from the client before granting the RPCs back one by one:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">revoke</span> <span class="k">insert</span><span class="p">,</span> <span class="k">update</span><span class="p">,</span> <span class="k">delete</span><span class="p">,</span> <span class="k">truncate</span> <span class="k">on</span> <span class="k">all</span> <span class="n">tables</span> <span class="k">in</span> <span class="k">schema</span> <span class="k">public</span> <span class="k">from</span> <span class="n">anon</span><span class="p">,</span> <span class="n">authenticated</span><span class="p">;</span>
<span class="k">revoke</span> <span class="k">all</span> <span class="k">on</span> <span class="k">all</span> <span class="n">tables</span> <span class="k">in</span> <span class="k">schema</span> <span class="k">public</span> <span class="k">from</span> <span class="n">anon</span><span class="p">;</span>
<span class="c1">-- Private bookkeeping tables are not readable at all.</span>
<span class="k">revoke</span> <span class="k">all</span> <span class="k">on</span> <span class="k">public</span><span class="p">.</span><span class="n">pin_attempts</span> <span class="k">from</span> <span class="n">authenticated</span><span class="p">;</span>
<span class="k">revoke</span> <span class="k">execute</span> <span class="k">on</span> <span class="k">all</span> <span class="n">functions</span> <span class="k">in</span> <span class="k">schema</span> <span class="k">public</span> <span class="k">from</span> <span class="k">public</span><span class="p">,</span> <span class="n">anon</span><span class="p">,</span> <span class="n">authenticated</span><span class="p">;</span>
<span class="c1">-- ...then each RPC is granted by name</span>
<span class="k">alter</span> <span class="k">default</span> <span class="k">privileges</span> <span class="k">in</span> <span class="k">schema</span> <span class="k">public</span> <span class="k">revoke</span> <span class="k">execute</span> <span class="k">on</span> <span class="n">functions</span> <span class="k">from</span> <span class="k">public</span><span class="p">,</span> <span class="n">anon</span><span class="p">,</span> <span class="n">authenticated</span><span class="p">;</span>
</code></pre></div></div>

<p>The last line matters most: a function added in a later migration is not callable until someone grants it on purpose. RLS policies are all select only, scoped through helpers like <code class="language-plaintext highlighter-rouge">my_group_ids()</code>. The app reads through RPCs anyway, but the policies still matter because Supabase Realtime applies them to <code class="language-plaintext highlighter-rouge">postgres_changes</code>.</p>

<p>This made the AI assisted workflow much safer. An agent editing a React component cannot give out points by mistake, because the browser has no write path to the points.</p>

<h3 id="pin-joins">PIN joins</h3>
<p>Groups are joined with a code like <code class="language-plaintext highlighter-rouge">K7QX3M</code>. The column check is <code class="language-plaintext highlighter-rouge">^[2-9A-HJKMNP-Z]{6}$</code>: 31 symbols with the look-alikes (0/O, 1/I/L) removed, which is about 887 million codes. On a collision, <code class="language-plaintext highlighter-rouge">create_group</code> retries the insert on <code class="language-plaintext highlighter-rouge">unique_violation</code>. Guessing is rate limited to 10 misses per user per 15 minutes. The miss is recorded instead of raised, so the attempt row still commits:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">if</span> <span class="p">(</span><span class="k">select</span> <span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">from</span> <span class="k">public</span><span class="p">.</span><span class="n">pin_attempts</span>
    <span class="k">where</span> <span class="n">user_id</span> <span class="o">=</span> <span class="n">v_uid</span> <span class="k">and</span> <span class="k">not</span> <span class="n">succeeded</span>
      <span class="k">and</span> <span class="n">attempted_at</span> <span class="o">&gt;</span> <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">interval</span> <span class="s1">'15 minutes'</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">10</span> <span class="k">then</span>
  <span class="n">raise</span> <span class="n">exception</span> <span class="s1">'pin_rate_limited'</span><span class="p">;</span>
<span class="k">end</span> <span class="n">if</span><span class="p">;</span>
</code></pre></div></div>

<p>The generator does <code class="language-plaintext highlighter-rouge">get_byte % 31</code> on random bytes. 256 is not a multiple of 31, so the first eight characters of the alphabet come up slightly more often. It doesn’t matter at this scale, but it’s worth knowing before quoting an entropy figure.</p>

<h2 id="points-are-a-ledger-never-a-total-">Points are a ledger, never a total 📒</h2>
<p>The leaderboard is always <code class="language-plaintext highlighter-rouge">sum(points)</code> over an append-only <code class="language-plaintext highlighter-rouge">ledger_entries</code> table. A trigger rejects updates. Undoing a check-in or losing a dispute is just another entry with negative points. That makes every score auditable and every change reversible.</p>

<p>The core is <code class="language-plaintext highlighter-rouge">reconcile_day_points()</code>. It never adds points directly. It works out what the member <em>should</em> have for that day, compares that with what the ledger <em>does</em> have, and appends the difference:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">v_want_habit</span> <span class="p">:</span><span class="o">=</span> <span class="n">v_done</span> <span class="o">*</span> <span class="k">public</span><span class="p">.</span><span class="n">points_per_habit</span><span class="p">();</span>
<span class="n">v_want_bonus</span> <span class="p">:</span><span class="o">=</span> <span class="k">case</span> <span class="k">when</span> <span class="n">v_slots</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="k">and</span> <span class="n">v_done</span> <span class="o">=</span> <span class="n">v_slots</span>
                     <span class="k">then</span> <span class="k">public</span><span class="p">.</span><span class="n">perfect_day_bonus</span><span class="p">()</span> <span class="k">else</span> <span class="mi">0</span> <span class="k">end</span><span class="p">;</span>
<span class="n">if</span> <span class="n">v_want_bonus</span> <span class="o">&lt;&gt;</span> <span class="n">v_have_bonus</span> <span class="k">then</span>
  <span class="k">insert</span> <span class="k">into</span> <span class="k">public</span><span class="p">.</span><span class="n">ledger_entries</span> <span class="p">(...,</span> <span class="n">points</span><span class="p">,</span> <span class="n">reason</span><span class="p">,</span> <span class="p">...)</span>
  <span class="k">values</span> <span class="p">(...,</span> <span class="n">v_want_bonus</span> <span class="o">-</span> <span class="n">v_have_bonus</span><span class="p">,</span>
    <span class="k">case</span> <span class="k">when</span> <span class="n">v_want_bonus</span> <span class="o">&gt;</span> <span class="n">v_have_bonus</span> <span class="k">then</span> <span class="s1">'perfect_day'</span>
         <span class="k">else</span> <span class="s1">'perfect_day_lost'</span> <span class="k">end</span><span class="p">,</span> <span class="p">...);</span>
</code></pre></div></div>

<p>A per-member-per-day advisory lock serialises concurrent check-ins, so two taps at once can’t both see “4 of 5 done” and both award the bonus.</p>

<p>Streaks are computed rather than stored, with the classic gaps-and-islands trick. Subtracting a row number from each perfect day’s date gives every consecutive run the same group key:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">islands</span> <span class="k">as</span> <span class="p">(</span>
  <span class="k">select</span> <span class="n">pf</span><span class="p">.</span><span class="n">user_id</span><span class="p">,</span> <span class="n">pf</span><span class="p">.</span><span class="k">day</span><span class="p">,</span>
         <span class="n">pf</span><span class="p">.</span><span class="k">day</span> <span class="o">-</span> <span class="p">(</span><span class="n">row_number</span><span class="p">()</span> <span class="n">over</span> <span class="p">(</span><span class="k">partition</span> <span class="k">by</span> <span class="n">pf</span><span class="p">.</span><span class="n">user_id</span> <span class="k">order</span> <span class="k">by</span> <span class="n">pf</span><span class="p">.</span><span class="k">day</span><span class="p">))::</span><span class="nb">int</span> <span class="k">as</span> <span class="n">grp</span>
  <span class="k">from</span> <span class="n">perfect</span> <span class="n">pf</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Ties on points are broken by <code class="language-plaintext highlighter-rouge">verified_share</code>, the share of your check-ins backed by something stronger than the honor system. <code class="language-plaintext highlighter-rouge">previous_rank</code> is the same query restricted to ledger rows before today, which drives the “moved up since yesterday” arrow.</p>

<p>Challenge phases (<code class="language-plaintext highlighter-rouge">proposing</code>, <code class="language-plaintext highlighter-rouge">voting</code>, <code class="language-plaintext highlighter-rouge">upcoming</code>, <code class="language-plaintext highlighter-rouge">active</code>, <code class="language-plaintext highlighter-rouge">finished</code>) aren’t stored either. A <code class="language-plaintext highlighter-rouge">case</code> over the timestamps derives them from <code class="language-plaintext highlighter-rouge">now()</code>. Every read RPC calls <code class="language-plaintext highlighter-rouge">sync_challenge()</code> first. A pg_cron job runs every minute as a backstop, but nothing depends on it, which is why the whole schema still loads on PGlite, where there is no cron.</p>

<h2 id="two-habit-modes-in-one-schema-️">Two habit modes in one schema 🗳️</h2>
<p>Pact started with one mode. Each member proposes up to five ideas, everyone casts a ballot of up to five picks, and the top five become the group’s habits. Vote totals stay hidden until voting closes so nobody bandwagons. Ties go to the earliest proposal (<code class="language-plaintext highlighter-rouge">row_number() over (order by votes desc, seq)</code>). Voting closes at the deadline, or early once everyone has voted.</p>

<p>Voting is fun, but it assumes the group wants the <em>same</em> habits. Plenty of groups want to compete while each person works on their own things. So PR #7 added a personal mode, where each member picks their own five and there is no vote.</p>

<p>The tempting version is a second set of tables. What shipped is one table with a nullable owner:</p>

<ul>
  <li>A shared habit is a <code class="language-plaintext highlighter-rouge">challenge_habits</code> row with <code class="language-plaintext highlighter-rouge">user_id</code> null, a <code class="language-plaintext highlighter-rouge">candidate_id</code> and a vote count.</li>
  <li>A personal habit is the same row with <code class="language-plaintext highlighter-rouge">user_id</code> set.</li>
  <li>A check constraint (<code class="language-plaintext highlighter-rouge">challenge_habits_origin</code>) enforces which fields each kind must have, and partial unique indexes keep the two kinds of slot apart.</li>
  <li>A personal challenge is created already resolved, with no voting deadlines, and another check constraint ties <code class="language-plaintext highlighter-rouge">habit_mode</code> to that schedule.</li>
</ul>

<p>Check-ins, timers, spot-checks, disputes and the ledger needed no mode-specific code at all. The one rule that makes it fair lives in <code class="language-plaintext highlighter-rouge">member_habit_count(challenge, user)</code>. Every scoring query counts <em>your</em> habits, never the challenge’s. The project’s <code class="language-plaintext highlighter-rouge">AGENTS.md</code> states it plainly for future agent sessions: “never count a challenge’s habits without the member.” Personal members can’t check in until they have filled all five slots (<code class="language-plaintext highlighter-rouge">habits_incomplete</code>). So in both modes everyone plays for the same maximum points per day.</p>

<h2 id="the-live-leaderboard-">The live leaderboard ⚡</h2>
<p><code class="language-plaintext highlighter-rouge">useLive</code> opens one Realtime channel per challenge and listens to <code class="language-plaintext highlighter-rouge">postgres_changes</code> on the tables that affect scores, filtered by <code class="language-plaintext highlighter-rouge">challenge_id</code>. It doesn’t use Presence or Broadcast.</p>

<p>The important part is what the client does with an event: nothing clever. It never applies row payloads. Any event triggers a debounced (250 ms) <code class="language-plaintext highlighter-rouge">invalidateQueries</code> on the <code class="language-plaintext highlighter-rouge">['challenge', id]</code> key prefix, and TanStack Query refetches <code class="language-plaintext highlighter-rouge">get_leaderboard</code>. The ledger stays the only source of truth, and a flood of events from five people checking in at once collapses into one refetch.</p>

<p>For the cases where the websocket isn’t there:</p>
<ul>
  <li>It refetches on every <code class="language-plaintext highlighter-rouge">SUBSCRIBED</code>, to catch up after a reconnect.</li>
  <li>It polls every 20 seconds whenever the channel isn’t joined, and shows a “polling” status.</li>
  <li>It refreshes when the tab becomes visible again.</li>
</ul>

<p>The only optimistic update is the tick on your own check-in. Points and ranks are never guessed on the client.</p>

<p>Days are calendar dates in the challenge’s IANA time zone, which is validated against <code class="language-plaintext highlighter-rouge">pg_timezone_names</code>. A day stays open for late logging until 10:00 the next morning (<code class="language-plaintext highlighter-rouge">late_log_grace()</code>). A timed habit counts for the day it was <em>started</em>. Countdowns in the browser correct for clock skew using the <code class="language-plaintext highlighter-rouge">server_now</code> returned by <code class="language-plaintext highlighter-rouge">get_challenge</code>.</p>

<h2 id="keeping-the-honor-system-honest-️">Keeping the honor system honest 🕵️</h2>
<ul>
  <li><strong>Random spot-checks.</strong> After a done check-in, Pact may ask a question like “Share a detail only someone who did it would know” with a 15 minute window. There is at most one per member per day. The per-check-in chance is solved so a member who completes everything is checked on <code class="language-plaintext highlighter-rouge">spot_checks_per_week / 7</code> of their days: <code class="language-plaintext highlighter-rouge">1 - (1 - rate)^(1 / slots)</code>. Answering changes no points. The counts show on the leaderboard, and there is an “Open book” award at the end.</li>
  <li><strong>Timed habits.</strong> The server records when a timer starts. <code class="language-plaintext highlighter-rouge">complete_timed_session</code> requires the server’s wall-clock time since the start to be at least the target, and the client’s reported active time to be at least the target and no more than the elapsed time plus 2 seconds. The client only counts foreground time: <code class="language-plaintext highlighter-rouge">performance.now()</code> deltas on a 250 ms tick, reset on <code class="language-plaintext highlighter-rouge">visibilitychange</code>, with a cap per tick so a suspended tab can’t bank time.</li>
  <li><strong>Flags and disputes.</strong> Each member gets 3 anonymous flags per rolling week. Flagging an honor check-in only triggers an extra spot-check. Flagging a timed one opens a dispute: the accused has 12 hours to respond, then everyone else votes anonymously. A quorum of half the group is required, and the check-in is voided only if “fake” votes outnumber “legit”. Anonymity comes from RLS: the <code class="language-plaintext highlighter-rouge">flags</code> and <code class="language-plaintext highlighter-rouge">dispute_votes</code> tables are only readable by whoever wrote the row.</li>
</ul>

<h2 id="bugs-that-got-through-anyway-">Bugs that got through anyway 🐛</h2>
<p>The tests were thorough, and bugs still showed up within hours of the first deploy, found by clicking through the live app. Each of these is a lesson about <em>where</em> the tests weren’t looking.</p>

<p><strong>A one person group voted itself into tracking (#3).</strong> I created a group alone, proposed five ideas and voted. “Everyone has voted” was true with one member, so the challenge resolved instantly. Fifty seconds later a second account joined a challenge that was already in the tracking phase, with no vote to cast. The fix was <code class="language-plaintext highlighter-rouge">challenge_min_voters() = 2</code> for opening and closing voting early. The PR also added three SQL tests that fail without the change, and a Playwright spec that replays the exact sequence.</p>

<p><strong>Production sign-in was unreachable (#5).</strong> Signing in to the live app ended in “Something went wrong”. The edge logs showed six 429s from <code class="language-plaintext highlighter-rouge">/auth/v1/otp</code> and not a single POST to <code class="language-plaintext highlighter-rouge">/auth/v1/verify</code>. Three problems had stacked up:</p>
<ul>
  <li>The hosted project issued 8-digit codes, but the input had <code class="language-plaintext highlighter-rouge">maxLength={6}</code>, which silently cut off the emailed code.</li>
  <li>Supabase Auth reports failures on <code class="language-plaintext highlighter-rouge">error.code</code>, not in the message, so every auth error fell through to the generic text.</li>
  <li>No test covered email sign-in, because every e2e journey started with “Continue as guest”.</li>
</ul>

<p>The lasting lesson is from the commit message: a hosted project’s auth settings are “a second source of truth that nothing in the repo checks”. Both sides now match the committed <code class="language-plaintext highlighter-rouge">supabase/config.toml</code>, and the local Supabase stand-in can simulate a project’s OTP length and email budget.</p>

<p><strong>A challenge couldn’t start today (#4).</strong> Both the date picker and the server required voting to close before day 1’s midnight, which is already in the past for a same-day start. The rule now is that voting must close before day 1 is <em>over</em>, and day 1 is logged retroactively inside the late-log window.</p>

<p><strong>The perfect day confetti was blank in production (#9).</strong> <code class="language-plaintext highlighter-rouge">canvas-confetti</code> draws in a <code class="language-plaintext highlighter-rouge">blob:</code> web worker by default, and the production CSP says <code class="language-plaintext highlighter-rouge">worker-src 'self'</code>. Locally it was fine; on the live site the canvas stayed empty. It now draws on the main thread, and the e2e spec applies the same CSP directive and asserts that pixels actually appear.</p>

<h2 id="testing-the-real-thing-">Testing the real thing 🧪</h2>
<p>Two parts of the test setup did most of the work:</p>

<ol>
  <li><strong>Real migrations on PGlite.</strong> The <code class="language-plaintext highlighter-rouge">db</code> Vitest project runs every migration on an in-process Postgres, so about 60 database tests exercise the actual SQL (triggers, RLS, advisory locks) with no Docker.</li>
  <li><strong>A local Supabase stand-in.</strong> <code class="language-plaintext highlighter-rouge">local/server.mjs</code> (about 1.1k lines) serves Auth, <code class="language-plaintext highlighter-rouge">/rest/v1/rpc</code> and Realtime <code class="language-plaintext highlighter-rouge">postgres_changes</code> on top of PGlite. Playwright runs against it on every PR, and a second CI job runs the same suite against a real <code class="language-plaintext highlighter-rouge">supabase start</code> stack, which is how RLS and Realtime behaviour gets checked for real.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">tests/e2e/live-leaderboard.spec.ts</code> shows the idea. Two browsers check in, the spec watches websocket <code class="language-plaintext highlighter-rouge">framereceived</code> events, and it asserts that the viewer’s leaderboard changed <em>without</em> a reload or navigation, in both habit modes.</p>

<p>In total that’s about 19k lines: 10k of TypeScript and CSS, 3.9k of SQL across eight migrations, 3.6k of tests, and around 130 unit, database and end to end tests.</p>

<h2 id="takeaways-">Takeaways 💡</h2>
<ul>
  <li><strong>Put the rules where the agent can’t route around them.</strong> With every write going through a granted RPC, and points kept as an append-only ledger, a UI change has no way to corrupt the scores.</li>
  <li><strong>Derive what you can instead of storing it</strong>: totals, streaks, phases and ranks. There’s less state to go out of sync, and less for an agent to forget to update.</li>
  <li><strong>Your tests only cover what they can see.</strong> Hosted auth settings, a production CSP and a group of one were all outside the test environment until a bug put them in it. Each of those fixes brought that part of production into the tests.</li>
  <li><strong>AI agents move fast, and the review and test gates are what make the speed safe.</strong> A day of directed agent work produced a working, deployed app, but only because each PR had to prove itself first.</li>
</ul>

<p>Try it: <a href="https://pact-deploy.vercel.app">pact-deploy.vercel.app</a></p>]]></content><author><name></name></author><category term="🧠 AI Engineering" /><category term="📱 Web Apps" /><category term="ai" /><category term="vibe coding" /><category term="react" /><category term="supabase" /><category term="postgres" /><category term="pwa" /><category term="vercel" /><summary type="html"><![CDATA[How Pact keeps a shared habit leaderboard honest with an append-only Postgres ledger, two habit modes in one schema, and Supabase Realtime.]]></summary></entry><entry><title type="html">shorts-engine: an autonomous pipeline that turns long videos into shorts</title><link href="https://erom-teknas.github.io/posts/shorts-engine-autonomous-video-pipeline/" rel="alternate" type="text/html" title="shorts-engine: an autonomous pipeline that turns long videos into shorts" /><published>2026-09-18T09:00:00-04:00</published><updated>2026-09-18T18:01:23-04:00</updated><id>https://erom-teknas.github.io/posts/shorts-engine-autonomous-video-pipeline</id><content type="html" xml:base="https://erom-teknas.github.io/posts/shorts-engine-autonomous-video-pipeline/"><![CDATA[<h2 id="overview-">Overview 📝</h2>
<p>shorts-engine takes long-form video (podcasts, interviews, streams) and turns it into vertical 9:16 shorts with word-timed captions. It finds the moments worth clipping, frames the active speaker, cuts between wide and close shots, burns in captions, and publishes on a daily schedule without me touching it. It runs on a single 16 GB Mac mini.</p>

<p>It is also the biggest thing I’ve built by directing AI agents instead of typing the code. The repo has 157 commits over 11 days, with about 50k lines of Python in the package and 38k lines of tests (1,791 test functions). 134 of those commits carry a <code class="language-plaintext highlighter-rouge">Co-authored-by: Claude</code> trailer: mostly Sonnet 5, with Opus 5 and Fable 5.1 on the rest. My job was the one agents are worst at: watching the output and saying “no, that clip is boring”, “that flash has no sound”, “why is there 30 seconds of silence at the end?”.</p>

<blockquote class="prompt-info">
  <p>The repo’s <code class="language-plaintext highlighter-rouge">AGENTS.md</code> is the project’s memory. It’s written as dated incident notes (symptom, root cause, fix, tests) so the next agent session doesn’t repeat the last one’s mistake. It is now 5,000+ lines, which says something about both the approach and its cost.</p>
</blockquote>

<h2 id="the-pipeline-">The pipeline 🏭</h2>
<p>Here is the whole flow, from a source video to an upload. Select a stage to see what it does, or press Play to follow one clip through it. The dashed lines are the two loops: a clip that scores too low goes back to be re-rendered, and the shelf pulls in new sources as it empties.</p>

<figure class="flow" id="flow-shorts-engine" data-flow="" data-loops="[{&quot;from&quot;:&quot;gates&quot;,&quot;to&quot;:&quot;render&quot;,&quot;label&quot;:&quot;Scored below 70, so the clip is repaired (start or end extended) or swapped for a reserve candidate, then judged again. At most 2 attempts per clip.&quot;},{&quot;from&quot;:&quot;shelf&quot;,&quot;to&quot;:&quot;ingest&quot;,&quot;label&quot;:&quot;Every 4 hours, fill-shelf processes newly discovered sources until about 10 clips are ready again.&quot;}]" aria-labelledby="flow-shorts-engine-title">
<div class="flow-head">
<p class="flow-title" id="flow-shorts-engine-title">How a long video becomes a published short</p>
<div class="flow-controls"><button type="button" class="flow-play" data-flow-play="" hidden=""><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-play"></use></svg>
<span>Play</span></button>
</div>
</div>
<div class="flow-body">
<ol class="flow-list" data-flow-list=""><li class="flow-step" data-step="ingest" data-kind="stage" data-label="Ingest"><p class="flow-phase">Understand</p><button type="button" class="flow-node" id="flow-shorts-engine-ingest" aria-controls="flow-shorts-engine-ingest-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Ingest</span><span class="flow-where">yt-dlp, local</span></button>
<div class="flow-panel" id="flow-shorts-engine-ingest-panel">
<p class="flow-summary">Downloads the source and prepares clean audio for every stage after it.</p>
<div class="flow-detail"><ul>
  <li>Downloads the long-form video with <code class="language-plaintext highlighter-rouge">yt-dlp</code>.</li>
  <li>Extracts 16 kHz mono audio, normalised to -16 LUFS (EBU R128).</li>
  <li>Detects scene cuts, which framing uses later.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>normalised audio + scene cuts</p></li><li class="flow-step" data-step="transcribe" data-kind="stage" data-label="Transcribe"><button type="button" class="flow-node" id="flow-shorts-engine-transcribe" aria-controls="flow-shorts-engine-transcribe-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Transcribe</span><span class="flow-where">faster-whisper, local</span></button>
<div class="flow-panel" id="flow-shorts-engine-transcribe-panel">
<p class="flow-summary">Turns speech into words with timings exact enough to drive the captions.</p>
<div class="flow-detail"><ul>
  <li><code class="language-plaintext highlighter-rouge">faster-whisper</code> with <code class="language-plaintext highlighter-rouge">large-v3-turbo</code> and word timestamps on.</li>
  <li>Each word is snapped to a 20 ms energy envelope, so a caption appears when the word is actually heard.</li>
  <li>Speakers come from pyannote when it’s installed, and a heuristic acoustic diarizer otherwise.</li>
  <li>Cached by the audio’s hash, so a re-run skips a 3 hour transcription.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>word-timed transcript, speakers</p></li><li class="flow-step" data-step="curate" data-kind="stage" data-label="Curate"><button type="button" class="flow-node" id="flow-shorts-engine-curate" aria-controls="flow-shorts-engine-curate-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Curate</span><span class="flow-where">Gemini, with fallbacks</span><span class="flow-note">4-step fallback, rules alone never publish</span></button>
<div class="flow-panel" id="flow-shorts-engine-curate-panel">
<p class="flow-summary">One LLM call reads the whole transcript and picks the moments worth clipping.</p>
<div class="flow-detail"><ul>
  <li>Sentences are numbered and tagged with local acoustic cues (<code class="language-plaintext highlighter-rouge">LOUD</code>, <code class="language-plaintext highlighter-rouge">SPIKE</code>, <code class="language-plaintext highlighter-rouge">REACT</code>, <code class="language-plaintext highlighter-rouge">FAST</code>), because a shout or a laugh is often the best clue.</li>
  <li>Returns up to five ranked moments, each with a title, hook line, hashtags and 0 to 100 scores for hook, payoff and loop strength.</li>
  <li>Duration limits (10 to 25 s, aiming for 15) are enforced in code after the model answers.</li>
</ul>

<p>If Gemini is unavailable: sliding-window candidates scored by rules, then batches of 150 to Gemini, then a local Ollama <code class="language-plaintext highlighter-rouge">qwen3:8b</code>, then rules alone. The <code class="language-plaintext highlighter-rouge">min_acceptable_curator_backend</code> floor stops a rules-only pick from ever reaching the publish queue.</p>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>up to 5 ranked moments</p></li><li class="flow-step" data-step="vision" data-kind="stage" data-label="Vision and framing"><p class="flow-phase">Make</p><button type="button" class="flow-node" id="flow-shorts-engine-vision" aria-controls="flow-shorts-engine-vision-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Vision and framing</span><span class="flow-where">YuNet, face mesh, local</span><span class="flow-note">Runs after curation, 746 s down to 23 s</span></button>
<div class="flow-panel" id="flow-shorts-engine-vision-panel">
<p class="flow-summary">Finds faces and the active speaker, then directs the camera inside each chosen window only.</p>
<div class="flow-detail"><ul>
  <li>Faces from YuNet, falling back to MediaPipe BlazeFace, then Haar cascades.</li>
  <li>The active speaker comes from mouth movement, matched against word timing and audio level.</li>
  <li>Each shot is <code class="language-plaintext highlighter-rouge">single_speaker</code>, <code class="language-plaintext highlighter-rouge">dual_split</code> or <code class="language-plaintext highlighter-rouge">wide_letterbox</code>, with the crop path smoothed by Kalman and Bezier filters.</li>
  <li>Wide shots and punch-ins alternate every 1.8 to 6 s. When unsure who is talking, it stays wide.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>crop path + shot plan</p></li><li class="flow-step" data-step="render" data-kind="stage" data-label="Render"><button type="button" class="flow-node" id="flow-shorts-engine-render" aria-controls="flow-shorts-engine-render-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Render</span><span class="flow-where">FFmpeg filtergraphs</span></button>
<div class="flow-panel" id="flow-shorts-engine-render-panel">
<p class="flow-summary">Plain FFmpeg cuts, frames, captions and mixes each clip.</p>
<div class="flow-detail"><ul>
  <li>Captions are ASS subtitles burned in with the <code class="language-plaintext highlighter-rouge">ass=</code> filter.</li>
  <li>Gaps between words over 350 ms shrink to 120 ms with jump cuts.</li>
  <li>A 1 to 5 s flash-forward cold open teases the payoff, over a CC0 music bed with sound effects and bleeps.</li>
  <li>A visual flash can only exist at the onset of a validated sound effect; any orphan is stripped.</li>
  <li>Every output gets <code class="language-plaintext highlighter-rouge">-map_chapters -1 -dn -sn</code>, after a music file’s chapter became a phantom 30 second track.</li>
  <li>Encodes with <code class="language-plaintext highlighter-rouge">h264_videotoolbox</code>, falling back to NVENC, then libx264.</li>
</ul>
</div></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>captioned 9:16 MP4</p></li><li class="flow-step" data-step="gates" data-kind="gate" data-label="Quality gates"><button type="button" class="flow-node" id="flow-shorts-engine-gates" aria-controls="flow-shorts-engine-gates-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Quality gates<span class="visually-hidden"> (gate)</span></span><span class="flow-where">read-back + Gemini judge</span><span class="flow-note">Below 70, repair and judge again</span></button>
<div class="flow-panel" id="flow-shorts-engine-gates-panel">
<p class="flow-summary">Checks the finished file, not what the code meant to produce.</p>
<div class="flow-detail"><ul>
  <li>Read-back: exactly one video and one audio stream, with durations within 0.5 s of each other.</li>
  <li>The watchability judge watches a 360p, 24 fps proxy and answers four checkable questions: does it open without context, is there a reason to keep watching, does the payoff land, and where would a viewer drop off.</li>
  <li>It returns a <code class="language-plaintext highlighter-rouge">watchability_score</code> calibrated like a retention percentage. At most 10 judge calls per source, about $0.002 to $0.003 per clip.</li>
</ul>
</div><p class="flow-outcome is-loop"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-arrow-counter-clockwise"></use></svg>
<span><strong>Back to Render.</strong> Scored below 70, so the clip is repaired (start or end extended) or swapped for a reserve candidate, then judged again. At most 2 attempts per clip.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>judged clip</p></li><li class="flow-step" data-step="shelf" data-kind="store" data-label="Shelf"><p class="flow-phase">Ship</p><button type="button" class="flow-node" id="flow-shorts-engine-shelf" aria-controls="flow-shorts-engine-shelf-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Shelf<span class="visually-hidden"> (store)</span></span><span class="flow-where">launchd, every 4 h</span></button>
<div class="flow-panel" id="flow-shorts-engine-shelf-panel">
<p class="flow-summary">A buffer of about 10 rendered, judged clips, so a publish slot never waits on a render.</p>
<div class="flow-detail"><ul>
  <li><code class="language-plaintext highlighter-rouge">discover</code> runs nightly and checks tracked channels for uploads using the 1-unit <code class="language-plaintext highlighter-rouge">playlistItems</code> and <code class="language-plaintext highlighter-rouge">videos.list</code> calls, not the 100-unit <code class="language-plaintext highlighter-rouge">search.list</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">fill-shelf</code> runs every 4 hours to keep the shelf topped up.</li>
  <li>Every read-modify-write of the shelf happens under a blocking <code class="language-plaintext highlighter-rouge">flock</code>.</li>
</ul>
</div><p class="flow-outcome is-loop"><svg class="icon" aria-hidden="true" focusable="false"><use href="#i-arrow-counter-clockwise"></use></svg>
<span><strong>Back to Ingest.</strong> Every 4 hours, fill-shelf processes newly discovered sources until about 10 clips are ready again.</span></p></div><p class="flow-edge"><span class="visually-hidden">Passes on: </span>next clip due</p></li><li class="flow-step" data-step="publish" data-kind="stage" data-label="Publish"><button type="button" class="flow-node" id="flow-shorts-engine-publish" aria-controls="flow-shorts-engine-publish-panel">
<span class="flow-marker" aria-hidden="true"></span>
<span class="flow-label"><span class="flow-index" aria-hidden="true"></span>Publish</span><span class="flow-where">YouTube Data API</span></button>
<div class="flow-panel" id="flow-shorts-engine-publish-panel">
<p class="flow-summary">Five slots a day, one clip per slot, uploaded exactly once.</p>
<div class="flow-detail"><ul>
  <li><code class="language-plaintext highlighter-rouge">publish-slot</code> fires five times a day and makes a resumable upload.</li>
  <li>It reads the privacy status back from the API instead of assuming it. English sources go public, everything else stays private.</li>
  <li>An atomic <code class="language-plaintext highlighter-rouge">fcntl.flock</code> lock, never unlinked, keeps two schedulers from uploading the same clip.</li>
  <li>Rows are matched on <code class="language-plaintext highlighter-rouge">(pool_video_id, clip_id)</code>, and the channel’s real uploads are checked before any retry.</li>
</ul>
</div></div></li></ol>
</div>
<figcaption class="flow-caption">
<span class="flow-legend" aria-hidden="true">
<span class="flow-key" data-kind="stage"><span class="flow-marker"></span>Stage</span>
<span class="flow-key" data-kind="gate"><span class="flow-marker"></span>Gate</span><span class="flow-key" data-kind="store"><span class="flow-marker"></span>Store</span><span class="flow-key" data-kind="loop"><span class="flow-marker"></span>Loop</span></span>
<span class="flow-hint" data-flow-hint="" hidden="">Select a step, use the arrow keys, or press Play.</span>
</figcaption>
</figure>

<p>Curation runs <em>before</em> vision on purpose. Early on, vision ran first and scanned the whole 40 minute source for faces. Moving it after curation, so it only looks at the chosen windows, took the vision stage from 746 s to 23 s and a full run from about 30 minutes to 18.</p>

<h3 id="1-ingest-">1. Ingest 📥</h3>
<ul>
  <li>Downloads with <code class="language-plaintext highlighter-rouge">yt-dlp</code>.</li>
  <li>Extracts 16 kHz mono audio and normalises it to -16 LUFS (EBU R128).</li>
  <li>Detects scene cuts, which the framing stage uses later.</li>
</ul>

<h3 id="2-transcription-️">2. Transcription 🎙️</h3>
<ul>
  <li>Uses <code class="language-plaintext highlighter-rouge">faster-whisper</code> with <code class="language-plaintext highlighter-rouge">large-v3-turbo</code> and word timestamps on.</li>
  <li>Snaps each word’s timing to a 20 ms energy envelope, so a caption appears when the word is actually heard. Overlapping words are split at the quietest frame.</li>
  <li>Diarization uses pyannote when it’s installed and a heuristic acoustic diarizer otherwise.</li>
  <li>Results are cached by the audio’s hash, so a re-run doesn’t redo a 3 hour transcription.</li>
</ul>

<h3 id="3-curation-an-llm-that-reads-the-whole-transcript-">3. Curation: an LLM that reads the whole transcript 🧠</h3>
<p>The default curator is one Gemini call that sees the <em>whole</em> transcript as numbered sentences. Each sentence is tagged with acoustic cues computed locally: an energy percentile plus <code class="language-plaintext highlighter-rouge">LOUD</code>, <code class="language-plaintext highlighter-rouge">SPIKE</code>, <code class="language-plaintext highlighter-rouge">REACT</code> and <code class="language-plaintext highlighter-rouge">FAST</code> flags. On a stream, a shout or a burst of laughter is often the best clue that something clip-worthy just happened, and text alone doesn’t show that.</p>

<p>The model returns up to five ranked moments. Each has a title, a hook line, hashtags, 0 to 100 scores for hook, payoff and loop strength, and the sentence ranges to keep. The prompt tells the model outright that duration limits are enforced in code after it responds, and they are. A clip may not open on a greeting, filler or a sentence fragment. The default profile targets 15 s clips, within 10 to 25 s.</p>

<p>If the model is unavailable, the fallbacks are loud:</p>
<ol>
  <li>A sliding-window segmenter builds candidates (about 8,500 on a 3.5 hour source) and scores them with rules.</li>
  <li>Batches of up to 150 candidates go to Gemini.</li>
  <li>If that fails, a local Ollama <code class="language-plaintext highlighter-rouge">qwen3:8b</code> curates.</li>
  <li>If that fails too, the rule-based score is used on its own.</li>
</ol>

<p>There is a hard floor, <code class="language-plaintext highlighter-rouge">min_acceptable_curator_backend="local_llm"</code>, so a clip picked by rules alone is never published. Cloud calls are capped at 30 a day and tracked in a spend ledger. A local YAMNet classifier flags clips with too much copyrighted music.</p>

<h3 id="4-vision-and-framing-">4. Vision and framing 🎯</h3>
<p>All of this runs locally:</p>
<ul>
  <li><strong>Faces:</strong> YuNet (a 232 KB OpenCV ONNX model), falling back to MediaPipe BlazeFace, then Haar cascades.</li>
  <li><strong>Active speaker:</strong> mouth movement from face-mesh landmarks, correlated with word timing and audio RMS, then bound to the diarized speakers.</li>
  <li><strong>Layout:</strong> the director chooses per shot between <code class="language-plaintext highlighter-rouge">single_speaker</code>, <code class="language-plaintext highlighter-rouge">dual_split</code> and <code class="language-plaintext highlighter-rouge">wide_letterbox</code>. The crop path is smoothed with Kalman and Bezier filters so it never jitters.</li>
  <li><strong>Pacing:</strong> it alternates wide shots and punch-ins, each 1.8 to 6 s long, on speaker changes and emphasis. Punch-ins require enough confidence. When it isn’t sure who is talking, it stays wide rather than zooming in on the wrong person.</li>
</ul>

<h3 id="5-render-">5. Render 🎬</h3>
<p>Rendering is plain FFmpeg filtergraphs, not a browser renderer:</p>
<ul>
  <li>Captions are ASS subtitles burned in with the <code class="language-plaintext highlighter-rouge">ass=</code> filter, in five bundled OFL fonts.</li>
  <li>Dead air is removed with jump cuts: any gap between words over 350 ms shrinks to 120 ms, using <code class="language-plaintext highlighter-rouge">select</code>/<code class="language-plaintext highlighter-rouge">aselect</code> plus <code class="language-plaintext highlighter-rouge">setpts</code>.</li>
  <li>It adds a 1 to 5 s “flash-forward” cold open that teases the payoff, a CC0 music bed, meme sound effects and profanity bleeps.</li>
  <li>Encoding uses <code class="language-plaintext highlighter-rouge">h264_videotoolbox</code> on the Mac, falling back to NVENC, then libx264.</li>
</ul>

<h3 id="6-the-watchability-judge-">6. The watchability judge 👀</h3>
<p>Curation guesses from text. The watchability judge checks the <em>finished</em> clip. A 360p, 24 fps proxy goes to Gemini, which answers four questions that can be checked:</p>
<ul>
  <li>Does it open without needing context?</li>
  <li>Is there a reason to keep watching?</li>
  <li>Does the payoff land?</li>
  <li>At what second would a viewer drop off?</li>
</ul>

<p>Then it gives one <code class="language-plaintext highlighter-rouge">watchability_score</code>. The prompt asks for it “calibrated like a real retention percentage”, not a virality guess.</p>

<p>At first the judge only logged a prediction (accept at 55 or above). Now it closes the loop. Below a target of 70, the pipeline repairs the clip by extending the start or end, or swaps in a reserve candidate, and asks again. It is capped at 2 attempts per clip and 10 judge calls per source, and costs about $0.002 to $0.003 per clip.</p>

<h3 id="7-autopilot-and-publishing-">7. Autopilot and publishing 📅</h3>
<p>There is no custom daemon. <code class="language-plaintext highlighter-rouge">autopilot</code> is a set of CLI verbs (<code class="language-plaintext highlighter-rouge">discover</code>, <code class="language-plaintext highlighter-rouge">fill-shelf</code>, <code class="language-plaintext highlighter-rouge">publish-slot</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">cleanup</code>) run by macOS launchd:</p>
<ul>
  <li><strong>Discovery</strong> runs nightly and checks tracked channels for new uploads. It uses the 1-unit <code class="language-plaintext highlighter-rouge">playlistItems</code> and <code class="language-plaintext highlighter-rouge">videos.list</code> calls, not the 100-unit <code class="language-plaintext highlighter-rouge">search.list</code>, to stay inside the YouTube API quota.</li>
  <li><strong>Fill-shelf</strong> runs every 4 hours and keeps a shelf of about 10 rendered, judged clips ready.</li>
  <li><strong>Publish-slot</strong> fires five times a day and uploads the next clip with a resumable upload. It reads the privacy status back from the API instead of assuming it. English sources publish publicly; everything else stays private.</li>
</ul>

<h2 id="war-stories-">War stories 🔥</h2>
<p>The best way to understand a system is through what broke in production. These are the ones I found most interesting.</p>

<h3 id="two-schedulers-one-clip-two-uploads">Two schedulers, one clip, two uploads</h3>
<p>On 17 September the same clip went up twice at 18:00, and again at 20:00.</p>

<p>That afternoon both launchd flavours had been installed: a LaunchAgent (a per-user job) at 16:30 and a LaunchDaemon (system-wide) two minutes later. So every publish slot started <strong>two</strong> <code class="language-plaintext highlighter-rouge">publish-slot</code> processes in the same second. There was a lock, but it was the classic check-then-act kind:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">lock_path</span><span class="p">.</span><span class="nf">exists</span><span class="p">():</span>
    <span class="n">existing_pid</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">lock_path</span><span class="p">.</span><span class="nf">read_text</span><span class="p">(...).</span><span class="nf">strip</span><span class="p">())</span>
    <span class="k">if</span> <span class="n">existing_pid</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="ow">and</span> <span class="nf">_pid_is_alive</span><span class="p">(</span><span class="n">existing_pid</span><span class="p">):</span>
        <span class="k">raise</span> <span class="nc">LockHeld</span><span class="p">(...)</span>
<span class="n">lock_path</span><span class="p">.</span><span class="nf">write_text</span><span class="p">(</span><span class="nf">str</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="nf">getpid</span><span class="p">()),</span> <span class="n">encoding</span><span class="o">=</span><span class="sh">"</span><span class="s">utf-8</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Two processes starting together both see no live pid, both write their own, and both upload. The fix is to let the kernel decide:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fd</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="nf">str</span><span class="p">(</span><span class="n">lock_path</span><span class="p">),</span> <span class="n">os</span><span class="p">.</span><span class="n">O_RDWR</span> <span class="o">|</span> <span class="n">os</span><span class="p">.</span><span class="n">O_CREAT</span><span class="p">,</span> <span class="mo">0o644</span><span class="p">)</span>
<span class="k">try</span><span class="p">:</span>
    <span class="n">fcntl</span><span class="p">.</span><span class="nf">flock</span><span class="p">(</span><span class="n">fd</span><span class="p">,</span> <span class="n">fcntl</span><span class="p">.</span><span class="n">LOCK_EX</span> <span class="o">|</span> <span class="n">fcntl</span><span class="p">.</span><span class="n">LOCK_NB</span><span class="p">)</span>
<span class="k">except</span> <span class="nb">OSError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
    <span class="n">os</span><span class="p">.</span><span class="nf">close</span><span class="p">(</span><span class="n">fd</span><span class="p">)</span>
    <span class="bp">...</span>
    <span class="k">raise</span> <span class="nc">LockHeld</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s"> is already running (pid </span><span class="si">{</span><span class="n">holder</span><span class="si">}</span><span class="s">, lock </span><span class="si">{</span><span class="n">lock_path</span><span class="si">}</span><span class="s">)</span><span class="sh">"</span><span class="p">)</span> <span class="k">from</span> <span class="n">exc</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">flock</code> is atomic, and the kernel releases it when the holder exits or is killed, so a crash leaves no stale lock to clean up. The subtle part is that the lock file is <strong>never unlinked</strong>. Deleting it on release brings the race back in a new form: one process opens the old inode just before the unlink and locks it, a third process creates a new file at the same path and locks that, and both run.</p>

<p>The rest of the fix was operational. The daemon installer now removes the LaunchAgents, the agent installer refuses to run while the daemons exist, and <code class="language-plaintext highlighter-rouge">autopilot status</code> flags more than one installed publish job. The regression tests race real OS processes: 6 contenders for the lock (the old lock failed 5 out of 5 rounds) and 4 <code class="language-plaintext highlighter-rouge">publish-slot</code> processes on one shelf (the old code uploaded the clip up to 4 times; now it’s exactly once).</p>

<p>This was actually the <em>second</em> duplicate-upload bug. The first was a lookup by <code class="language-plaintext highlighter-rouge">clip_id</code> alone. <code class="language-plaintext highlighter-rouge">clip_001</code> is only unique within one source video, so publishing marked the wrong source’s row as published, the real row stayed due, and the next tick uploaded the file again. The fix matches on <code class="language-plaintext highlighter-rouge">(pool_video_id, clip_id)</code> and raises on any ambiguous match. Before any retry, a separate crash-safety layer also checks the channel’s real uploads for a matching title.</p>

<h3 id="the-phantom-track-30-seconds-of-silence-from-a-music-file">The phantom track: 30 seconds of silence from a music file</h3>
<p>Some renders played 22 to 25 seconds of real content, then dead air until 48 to 60 seconds. Other clips from the same run were fine.</p>

<p><code class="language-plaintext highlighter-rouge">ffprobe</code> showed nothing wrong at first: the video stream, audio stream and <code class="language-plaintext highlighter-rouge">format.duration</code> were all correct. But the file had a <strong>third stream</strong>: <code class="language-plaintext highlighter-rouge">codec_tag=text</code>, SubtitleHandler, one frame, about 50 seconds long. macOS QuickTime and Finder take the longest track as the file’s length.</p>

<p>That third stream turned out to be <strong>chapters</strong>. FFmpeg’s <code class="language-plaintext highlighter-rouge">-map_chapters</code> defaults to copying chapters from the first input that has any, and explicit <code class="language-plaintext highlighter-rouge">-map</code> flags don’t affect it because chapters aren’t a stream. The mov muxer then writes them out as a text track. Two of the 46 bundled CC0 music beds had a leftover <code class="language-plaintext highlighter-rouge">Tempo: 120.0</code> chapter spanning 0 to 75.007 s, from whatever tool had cut them. The music rotation starts each bed at a random offset, so only clips that drew one of those two tracks got a phantom track, with a length of 75.007 s minus the offset.</p>

<p>The fix is three flags on every output:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">CONTAINER_HYGIENE_FLAGS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sh">"</span><span class="s">-map_chapters</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-1</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">-dn</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">-sn</span><span class="sh">"</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-map_chapters -1</code> is the real fix; <code class="language-plaintext highlighter-rouge">-dn</code> and <code class="language-plaintext highlighter-rouge">-sn</code> (drop data and subtitle streams) are defence in depth. There’s also a read-back gate: every render must contain exactly one video and one audio stream, with durations within 0.5 s of each other. The lesson I keep coming back to is that a container-vs-video duration check would <em>not</em> have caught this, because those two agreed. Only a per-stream check could.</p>

<h3 id="kernel-panics-on-a-16-gb-mac">Kernel panics on a 16 GB Mac</h3>
<p>The Mac mini had three watchdog kernel panics in about 18 hours. The Whisper model stayed loaded through later stages and could overlap with Ollama, which was measured at 6.6 GB resident while idle, leaving about 0.5 GB free. FFmpeg had no thread cap either.</p>

<p>The fix:</p>
<ul>
  <li>Release the ASR engine as soon as transcription finishes.</li>
  <li>Unload Ollama with <code class="language-plaintext highlighter-rouge">keep_alive=0</code>.</li>
  <li>Cap FFmpeg at 4 threads.</li>
  <li>Add <code class="language-plaintext highlighter-rouge">ensure_stage_headroom</code>, which refuses to start a stage without enough free memory.</li>
</ul>

<p>For a pipeline that runs unattended, failing to start is much better than taking the whole machine down.</p>

<h3 id="launchd-has-no-login-shell">launchd has no login shell</h3>
<p>The overnight run had no Gemini key, because launchd doesn’t source your shell profile. The fallback chain quietly shelved clips picked by the rule-based scorer. The log even printed a key source, because it rendered the environment variable’s <em>name</em> unconditionally.</p>

<p>The fix:</p>
<ul>
  <li>A small env shim resolves the key at run time. It is never written into the world-readable plist.</li>
  <li>The <code class="language-plaintext highlighter-rouge">min_acceptable_curator_backend</code> floor, so a degraded curator can never feed the publish queue again.</li>
</ul>

<p>The same PR fixed a shelf write race, where every writer staged through the same <code class="language-plaintext highlighter-rouge">.tmp</code> file. Each read-modify-write now happens under a blocking <code class="language-plaintext highlighter-rouge">flock</code>.</p>

<h3 id="llm-reasoning-leaked-into-public-descriptions">LLM reasoning leaked into public descriptions</h3>
<p>The whole-transcript curator set <code class="language-plaintext highlighter-rouge">headline = reasoning[:200]</code>. So two public videos went out with descriptions that referred to “Sentence 141”, with an unmasked swear word included. It was an easy fix and a good reminder: anything a model writes for <em>you</em> has to be kept apart from anything it writes for the audience.</p>

<h3 id="flashes-with-no-sound">Flashes with no sound</h3>
<p>I rejected a batch where one clip had 7 visual flashes and 1 sound effect, because flashes and sounds were scheduled independently. The fix turned the relationship around: a flash can only exist at the onset of a validated sound, and <code class="language-plaintext highlighter-rouge">enforce_flash_sound_invariant</code> strips any orphan. Encode an aesthetic rule as an invariant, not a tuning knob.</p>

<h2 id="what-directing-agents-at-this-scale-taught-me-">What directing agents at this scale taught me 💡</h2>
<ul>
  <li><strong>Taste doesn’t delegate.</strong> Agents wrote well-tested code for every rule I stated. Almost every bug that reached production was a rule nobody had stated yet: no silent tail, no flash without a sound, no clip picked by rules alone.</li>
  <li><strong>Make the agents write down every incident.</strong> The dated sections in <code class="language-plaintext highlighter-rouge">AGENTS.md</code> are why later sessions knew not to re-cut the music files (their SHA-256 hashes are pinned for licence credits) or unlink a lock file.</li>
  <li><strong>Prefer invariants checked on the output.</strong> Stream counts, flash/sound pairing and the curator floor are all checked on what actually gets produced, not on what the code meant to do.</li>
  <li><strong>Races are the same in hobby projects.</strong> Two schedulers and a check-then-act lock is a distributed systems bug on a single Mac mini. <code class="language-plaintext highlighter-rouge">flock</code>, idempotent reconciliation and tests that race real processes fixed it, as they would anywhere.</li>
</ul>]]></content><author><name></name></author><category term="🧠 AI Engineering" /><category term="🎬 Video Pipelines" /><category term="ai" /><category term="vibe coding" /><category term="python" /><category term="ffmpeg" /><category term="whisper" /><category term="gemini" /><category term="youtube" /><category term="launchd" /><summary type="html"><![CDATA[The stages, the LLM judges and the production bugs of a self-hosted pipeline that clips podcasts and streams into captioned 9:16 shorts and publishes them on a schedule.]]></summary></entry><entry><title type="html">Azure Pipelines - Self hosted agent</title><link href="https://erom-teknas.github.io/posts/azure-self-hosted-agent/" rel="alternate" type="text/html" title="Azure Pipelines - Self hosted agent" /><published>2024-05-01T00:00:00-04:00</published><updated>2024-05-01T11:47:22-04:00</updated><id>https://erom-teknas.github.io/posts/azure-self-hosted-agent</id><content type="html" xml:base="https://erom-teknas.github.io/posts/azure-self-hosted-agent/"><![CDATA[<h2 id="overview-">Overview 📝</h2>
<p>This documentation gives the details of steps to be followed for configuring self hosted agents for Azure pipelines.</p>

<h2 id="pre-requisite--">Pre-requisite  🚧</h2>
<ul>
  <li>Already have Azure pipeline account / organization.</li>
  <li>WSL Ubuntu is installed in your windows machine or using Ubuntu out of the box to be used as self-hosted agent.</li>
</ul>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<ul>
  <li>Login to Azure devOps portal <a href="https://dev.azure.com">here</a></li>
  <li>Goto organization settings present on the bottom left.</li>
  <li>Goto pipelines –&gt; Agent pools –&gt; Click Default and New Agent.</li>
  <li>Click on the Linux tab from the options, as in my case I want to install the agent in my WSL Ubuntu system.</li>
  <li>Click on Download the agent, it will download (current version)
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  vsts-agent-linux-x64-3.238.0.tar.gz
</code></pre></div>    </div>
  </li>
  <li>Move the downloaded archive to /tmp folder, in my case it was downloaded under /mnt/h/downloads
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">mkdir</span> <span class="nt">-p</span> ~/tmp
  <span class="nb">mv</span> /mnt/h/downloads/vsts-agent-linux-x64-3.238.0.tar.gz ~/tmp
</code></pre></div>    </div>
  </li>
  <li>Follow the instructions on the tab (under Linux)
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>   <span class="nb">cd</span> ~
   <span class="nb">mkdir </span>myagent <span class="o">&amp;&amp;</span> <span class="nb">cd </span>myagent
   <span class="nb">tar </span>zxvf ~/tmp/vsts-agent-linux-x64-3.238.0.tar.gz
</code></pre></div>    </div>
  </li>
  <li>Create PAT token from your Azure pipeline Organization following the below screenshots.
    <blockquote class="prompt-warning">
      <p>Don’t forget to keep the copy of PAT token somewhere securely in your system.</p>
    </blockquote>
    <p><img src="../assets/images/azure/pat-gen.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/azure/pat-gen-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/azure/pat-gen-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/azure/pat-gen-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li>Go back to the Ubuntu command line and run the configuration script.
    <blockquote class="prompt-info">
      <p>Once you run configuration it will prompt for PAT token and other details like Agent-name and Agent-pool etc, give any name but keep it default for other options.</p>
    </blockquote>
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c">#Server URL: https://dev.azure.com/{your-organization}</span>
  <span class="nb">cd</span> ~/myagent
  ./config.sh
</code></pre></div>    </div>
  </li>
  <li>That’s it now you should see the agent under your Default Agent pool but in offline state.
      <img src="../assets/images/azure/agent.png" alt="alt text" width="700" height="400" class="shadow light" />
      <img src="../assets/images/azure/agent-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>Now you can start the agent using the run script.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cd</span> ~/myagent
  ./run.sh
</code></pre></div>    </div>
  </li>
</ul>

<p><img src="../assets/images/azure/agent-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/azure/agent-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>

<ul>
  <li>That’s it now you have your own agent running, you can use this to build artifacts, in my case I wanted to build npm project, so installed npm
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">sudo </span>apt <span class="nb">install </span>npm
</code></pre></div>    </div>
  </li>
  <li>
    <p>Try building the project with Azure pipelines, with pool configured as default. example:</p>

    <div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">trigger</span><span class="pi">:</span> 
  <span class="pi">-</span> <span class="s">main</span>

  <span class="na">pool</span><span class="pi">:</span> 
  <span class="na">name</span><span class="pi">:</span> <span class="s">default</span>
  <span class="na">stages</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Build_stage"</span>
  <span class="na">jobs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Build"</span>
      <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">Npm@1</span>
      <span class="na">inputs</span><span class="pi">:</span>
          <span class="na">command</span><span class="pi">:</span> <span class="s1">'</span><span class="s">install'</span>
</code></pre></div>    </div>
  </li>
  <li>Now try running the build manual using the run button of the pipeline and check the status of the build in your agent run session.
<img src="../assets/images/azure/agent-run.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/azure/agent-run-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>Congratulations, you have configured your own self-hosted runner for builds.
    <h2 id="resources-">Resources 📚</h2>
  </li>
  <li><a href="https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/linux-agent?view=azure-devops">Azures Docs</a></li>
</ul>]]></content><author><name></name></author><category term="🤖 Azure" /><category term="⚙️ Hosted Agents" /><category term="azure" /><category term="agents" /><category term="pipeline" /><summary type="html"><![CDATA[Overview 📝 This documentation gives the details of steps to be followed for configuring self hosted agents for Azure pipelines.]]></summary></entry><entry><title type="html">Linux Administrator</title><link href="https://erom-teknas.github.io/posts/linux/" rel="alternate" type="text/html" title="Linux Administrator" /><published>2024-04-22T00:00:00-04:00</published><updated>2024-04-22T00:00:00-04:00</updated><id>https://erom-teknas.github.io/posts/linux</id><content type="html" xml:base="https://erom-teknas.github.io/posts/linux/"><![CDATA[<h2 id="overview-">Overview 📝</h2>
<p>This documentation talks about the various commands that are used as part of Linux administration in day 2 day activities.</p>

<h2 id="pre-requisite-setup-instructions-">Pre-requisite Setup Instructions 🚧</h2>
<p>Download Oracle VM Virtual Box from <a href="https://www.virtualbox.org/wiki/Downloads">Click here</a>.<br />
Download RedHat Linux ISO <a href="https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.4/x86_64/product-software">Click here</a>.<br /></p>

<h2 id="important-commands-️">Important Commands ⌨️</h2>
<ul>
  <li>Adding a User
    <ul>
      <li>Add Operation
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  useradd ant
</code></pre></div>        </div>
      </li>
      <li>Verification, check if a folder ant is created in the /home directory
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cd</span> /home/
  <span class="nb">ls </span>ant
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Adding a Group
    <ul>
      <li>Add Operation
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  groupadd insects
</code></pre></div>        </div>
      </li>
      <li>Verification, check if an entry is present in /etc/group file
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/group | <span class="nb">grep </span>insects
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Assigning User to Group
    <ul>
      <li>Modify User
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  usermod <span class="nt">-G</span> insects ant
</code></pre></div>        </div>
      </li>
      <li>Verify if User is added to the Group
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/group | <span class="nb">grep </span>insects
  <span class="c">#insects:x:1004:ant</span>
  <span class="c">#group:encryptedpasswd:groupid:userid</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Checking Different files under /etc
    <ul>
      <li>passwd [UserFile]
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/passwd | <span class="nb">grep </span>ant
  <span class="c">#ant:x:1002:1003::/home/ant:/bin/bash</span>
  <span class="c">#user:encryptedpasswd:userid:groupid:homedirectory:shell</span>
</code></pre></div>        </div>
      </li>
      <li>group [GroupFile]
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/group | <span class="nb">grep </span>ant
  <span class="c">#insects:x:1004:ant</span>
  <span class="c">#group:encryptedpasswd:groupid:userid</span>
</code></pre></div>        </div>
      </li>
      <li>shadow [PasswordFile]
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/shadow | <span class="nb">grep </span>ant
  <span class="c">#ant:!!:19835:0:99999:7:::</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>One line command to create the User and Assign it to a Group
    <ul>
      <li>Create User and add to Group
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      useradd <span class="nt">-G</span> insects <span class="nt">-s</span> /bin/bash <span class="nt">-c</span> <span class="s2">"This is a bug"</span> <span class="nt">-m</span> <span class="nt">-d</span> /home/bug bug
      <span class="c"># -G GROUP</span>
      <span class="c"># -s SHELL</span>
      <span class="c"># -c COMMENT</span>
      <span class="c"># -m CREATE HOME DIRECTORY</span>
      <span class="c"># -d HOME DIRECTORY</span>
</code></pre></div>        </div>
      </li>
      <li>Create password for the User
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  passwd bug
</code></pre></div>        </div>
      </li>
      <li>Verification
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/shadow | <span class="nb">grep </span>bug
  <span class="c">#bug:$6$tipJYGx11g3arh2U$R7nEyhEIy90wDEtPf32Zr4T0EWHVtcrH6I6h1Nd8IpRbrHd1v4mc1Mbn4XY2A.If/OuCnW.BPshjbOLL1uIMv1:19835:0:99999:7:::</span>
  <span class="c">#This is an encrypted password in shadow file</span>

  <span class="nb">cat</span> /etc/passwd | <span class="nb">grep </span>bug
  <span class="c">#bug:x:1003:1005:This is bug:/home/bug:/bin/bash</span>

  <span class="nb">cat</span> /etc/group | <span class="nb">grep </span>bug
  <span class="c">#insects:x:1004:ant,bug</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Delete User, Group
    <ul>
      <li>User
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  userdel <span class="nt">-r</span> ant
  <span class="c">#Remove home directory as well</span>
  groupdel insects
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Changing Password policy
    <ul>
      <li>Per User
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  chage <span class="nt">-m</span> mindays <span class="nt">-M</span> maxdays <span class="nt">-d</span> lastday <span class="nt">-I</span> inactive <span class="nt">-E</span> expiredate <span class="nt">-W</span> warndays user
  <span class="c">#bug:$6$tipJYGx11g3arh2U$R7nEyhEIy90wDEtPf32Zr4T0EWHVtcrH6I6h1Nd8IpRbrHd1v4mc1Mbn4XY2A.If/OuCnW.BPshjbOLL1uIMv1:19835:0:99999:7:::</span>

  <span class="c">#So from the above let's consider the following 19835:0:99999:7::: </span>
  <span class="c"># -d lastday: days since 1 Jan 1970 the password was change so we see 19835 days (April 22, 2024).</span>
  <span class="c"># -m mindays: After how many days can a user change the password, currently no restriction hence 0, if set to 5, the User can change password after 5 days.</span>
  <span class="c"># -M maxdays: After how many days User should be prompted to change password, Current is 99999 that is alot, hence we have to change it to something meaningful.</span>
  <span class="c"># -W warning: Warn User when password is going to expire.</span>
  <span class="c"># -I Inactive: Set User as inactive after number of days Password expires.</span>
  <span class="c"># -E Expire: Days after which the User to expire from 1 Jan 1970.</span>

  <span class="c">#Let's apply this to the user bug using chage</span>
  chage bug <span class="nt">-m</span> 5 <span class="nt">-M</span> 90 <span class="nt">-W</span> 10 <span class="nt">-I</span> 3 
  <span class="c">#bug:$6$tipJYGx11g3arh2U$R7nEyhEIy90wDEtPf32Zr4T0EWHVtcrH6I6h1Nd8IpRbrHd1v4mc1Mbn4XY2A.If/OuCnW.BPshjbOLL1uIMv1:19835:5:90:10:3::</span>
</code></pre></div>        </div>
      </li>
      <li>All Users
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="nb">cat</span> /etc/login.defs
  <span class="c">#PASS_MAX_DAYS   99999</span>
  <span class="c">#PASS_MIN_DAYS   0</span>
  <span class="c">#PASS_MIN_LEN    5</span>
  <span class="c">#PASS_WARN_AGE   7</span>
  <span class="c">#UMASK 077</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Sudo Commands
    <ul>
      <li>Switch User to root
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  su
  <span class="c">#Prompts for root password</span>
</code></pre></div>        </div>
      </li>
      <li>Switch User to bug
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  su bug
</code></pre></div>        </div>
      </li>
      <li>Add users to sudoer group using wheel Group to execute certain commands like dmidecode which are accessible only to root
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  usermod <span class="nt">-aG</span> wheel smore

  <span class="c">#Test using the sudo command</span>
  <span class="nb">sudo </span>dmidecode
  <span class="nb">sudo </span>fdisk <span class="nt">-l</span>
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>
    <p>Other important commands - Monitoring and Manage processes</p>

    <ul>
      <li>Filesystem Usage
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">df</span> <span class="nt">-h</span>
<span class="c">#h is human readable format</span>
</code></pre></div>        </div>
      </li>
      <li>Disk Usage
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">du</span> <span class="nt">-h</span>
<span class="c">#Details of each file in the filesystem</span>
</code></pre></div>        </div>
      </li>
      <li>Check system uptime
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">uptime</span>
<span class="c">#Gives basic information like since when is the system up and running etc.</span>
</code></pre></div>        </div>
      </li>
      <li>Checking processes running in server
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>top
</code></pre></div>        </div>
      </li>
      <li>Check the swap and memory
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>free
</code></pre></div>        </div>
      </li>
      <li>Check list of open files
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lsof
</code></pre></div>        </div>
      </li>
      <li>Networking command, incoming network
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tcpdump <span class="nt">-i</span> enp0s3
</code></pre></div>        </div>
      </li>
      <li>Network statistics using netstat
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>netstat
</code></pre></div>        </div>
      </li>
      <li>Check processes runnning using ps -ef
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ps <span class="nt">-ef</span> | <span class="nb">grep </span>java
</code></pre></div>        </div>
      </li>
      <li>Force kill a process using kill command
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">kill</span> <span class="nt">-9</span> 47474
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Controlling Services and daemons using systemctl
    <ul>
      <li>Get all the services running
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl <span class="nt">--all</span>
</code></pre></div>        </div>
      </li>
      <li>Check a particular service status
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl status firewalld.service
</code></pre></div>        </div>
      </li>
      <li>Stop the service - i.e. it will change the status to inactive
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl stop firewalld.service
</code></pre></div>        </div>
      </li>
      <li>Restarting the firewall service
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl restart firewalld.service
</code></pre></div>        </div>
      </li>
      <li>Reload the cofiguration of the service
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl reload firewalld.service
<span class="c">#Reload the config without impacting the service</span>
</code></pre></div>        </div>
      </li>
      <li>Configure the service to start during linux start time and vice-versa
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl <span class="nb">enable </span>firewalld.service
<span class="c">#This will add the service to start at restart</span>
systemctl disable firewalld.service
<span class="c">#This will remove from the startup list</span>
</code></pre></div>        </div>
      </li>
      <li>Prevent accidental starts of the service by masking the service
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>systemctl mask firewalld.service
<span class="c">#The service is masked now you wont be able to start it until it is unmasked.</span>
systemctl unmask firewallId.service
</code></pre></div>        </div>
      </li>
    </ul>
  </li>
  <li>Secure SSH login using keys
    <ul>
      <li>If Machine A wants to login to Machine B without providing username/password.
        <ul>
          <li>Generate the key pair in Machine A
            <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen
<span class="c">#Enter password if you want to else you can ignore</span>
<span class="nb">cd</span> /home/usera/.ssh
<span class="c">#You will find two new files, id_rsa.pub [public key] and id_rsa [private key], copy the public key</span>
</code></pre></div>            </div>
          </li>
          <li>Copy the public key of the USER ONE from Machine A to Machine B USER TWO’s authorized_keys file
            <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> /home/userb/.ssh
<span class="c">#Login to server B, as USET TWO</span>
vi authorized_keys
<span class="c">#Create a new file if not present and paste the content of public key copied in the above step</span>
</code></pre></div>            </div>
            <p><img src="../assets/images/linux/linux-perm.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/linux/linux-perm-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
          </li>
        </ul>
      </li>
    </ul>
  </li>
</ul>

<h2 id="resources-">Resources 📚</h2>
<ul>
  <li><a href="https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/configuring_basic_system_settings/index">RedHat Doc</a></li>
</ul>]]></content><author><name></name></author><category term="🐧 Linux" /><category term="👩‍💻 SysAdmin" /><category term="linux" /><category term="admin" /><category term="sysadmin" /><summary type="html"><![CDATA[Overview 📝 This documentation talks about the various commands that are used as part of Linux administration in day 2 day activities.]]></summary></entry><entry><title type="html">AWS EKS Pods Access to AWS Services</title><link href="https://erom-teknas.github.io/posts/aws-eks-oidc-iam/" rel="alternate" type="text/html" title="AWS EKS Pods Access to AWS Services" /><published>2024-04-20T00:00:00-04:00</published><updated>2024-04-20T00:00:00-04:00</updated><id>https://erom-teknas.github.io/posts/aws-eks-oidc-iam</id><content type="html" xml:base="https://erom-teknas.github.io/posts/aws-eks-oidc-iam/"><![CDATA[<h2 id="aws-eks-pods-access-to-aws-services">AWS EKS Pods Access to AWS Services🌱</h2>
<p>There are instances where EKS Pods needs access to AWS services like S3, this tutorial talks about the different services like serviceaccounts, OIDC provider, IAM roles/policies and STS used to accomplish this goal.</p>

<h2 id="overview-">Overview 📝</h2>
<p><img src="../assets/images/aws/eks-oidc/diagram.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks-oidc/diagram-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<ul>
  <li>Create a dummy S3 bucket in your specific region, I am creating it in ca-central-1.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  aws s3api create-bucket <span class="se">\</span>
  <span class="nt">--bucket</span> my-bucket-dummy-eks <span class="se">\</span>
  <span class="nt">--region</span> ca-central-1 <span class="se">\</span>
  <span class="nt">--create-bucket-configuration</span> <span class="nv">LocationConstraint</span><span class="o">=</span>ca-central-1
</code></pre></div>    </div>
  </li>
  <li>Create the EKS cluster using the below command which has EC2 nodes as cluster nodes. (It takes approximately 15 minutes for the cluster creation)
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  eksctl create cluster <span class="nt">--name</span> demo-s3-cluster <span class="nt">--region</span> ca-central-1 <span class="nt">--with-oidc</span> <span class="nt">--nodes</span> 2 <span class="nt">--instance-types</span><span class="o">=</span>t2.micro
</code></pre></div>    </div>
  </li>
  <li>Once cluster is created, run a kubectl command to get nodes, you should see the EC2 nodes.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  kubectl get nodes <span class="nt">-o</span><span class="o">=</span>wide
</code></pre></div>    </div>
  </li>
  <li>Let’s create the OIDC provider, policies and roles from the console for the EKS cluster (Note: by default it will be create if you use the above EKS creation command)
    <ul>
      <li>Go to the EKS console and get the OIDC provider URL of the cluster
<img src="../assets/images/aws/eks-oidc/oidc-url.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks-oidc/oidc-url-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
      <li>Configuring the OIDC provider using IAM console (IAM console &gt; Access Management &gt; Identity Providers &gt; Add providers)
<img src="../assets/images/aws/eks-oidc/createoidc.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks-oidc/createoidc-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
      <li>Create policy to grant access to S3 buckets (IAM console &gt; Access Management &gt; Policies ), I have named it as pod-access-to-s3.
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">{</span>
      <span class="s2">"Version"</span>: <span class="s2">"2012-10-17"</span>,
      <span class="s2">"Statement"</span>: <span class="o">[</span>
          <span class="o">{</span>
              <span class="s2">"Sid"</span>: <span class="s2">"VisualEditor0"</span>,
              <span class="s2">"Effect"</span>: <span class="s2">"Allow"</span>,
              <span class="s2">"Action"</span>: <span class="o">[</span>
                  <span class="s2">"s3:ListBucket"</span>,
                  <span class="s2">"s3:ListAllMyBuckets"</span>
              <span class="o">]</span>,
              <span class="s2">"Resource"</span>: <span class="s2">"*"</span>
          <span class="o">}</span>
      <span class="o">]</span>
  <span class="o">}</span>
</code></pre></div>        </div>
      </li>
      <li>Create a role and attach the policy created above to it, create the role as per below screenshot
<img src="../assets/images/aws/eks-oidc/role-creation.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks-oidc/role-creation-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/eks-oidc/role-creation-2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks-oidc/role-creation-2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
        <ul>
          <li>Make sure that the trusted policy is as mentioned below, it includes the service account name (which we are yet to create) and the OIDC ID which was created above
            <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">{</span>
  <span class="s2">"Version"</span>: <span class="s2">"2012-10-17"</span>,
  <span class="s2">"Statement"</span>: <span class="o">[</span>
      <span class="o">{</span>
          <span class="s2">"Effect"</span>: <span class="s2">"Allow"</span>,
          <span class="s2">"Principal"</span>: <span class="o">{</span>
              <span class="s2">"Federated"</span>: <span class="s2">"arn:aws:iam::&lt;&lt;ACCOUNTID&gt;&gt;:oidc-provider/oidc.eks.ca-central-1.amazonaws.com/id/&lt;&lt;OIDC ID&gt;&gt;"</span>
          <span class="o">}</span>,
          <span class="s2">"Action"</span>: <span class="s2">"sts:AssumeRoleWithWebIdentity"</span>,
          <span class="s2">"Condition"</span>: <span class="o">{</span>
              <span class="s2">"StringEquals"</span>: <span class="o">{</span>
                  <span class="s2">"oidc.eks.ca-central-1.amazonaws.com/id/&lt;&lt;OIDC ID&gt;&gt;:aud"</span>: <span class="s2">"sts.amazonaws.com"</span>,
                  <span class="s2">"oidc.eks.ca-central-1.amazonaws.com/id/&lt;&lt;OIDC ID&gt;&gt;:sub"</span>: <span class="s2">"system:serviceaccount:&lt;&lt;NAME SPACE&gt;&gt;:&lt;&lt;SERVICE-ACCOUNT&gt;&gt;"</span>
              <span class="o">}</span>
          <span class="o">}</span>
      <span class="o">}</span>
  <span class="o">]</span>
  <span class="o">}</span>        
</code></pre></div>            </div>
          </li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Let’s create a pod and service account attached to it in the default namespace.
    <ul>
      <li>Service account needs to be annotated with the role that we have created above:
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  apiVersion: v1
  kind: ServiceAccount
  metadata:
  annotations:
      eks.amazonaws.com/role-arn: 
              arn:aws:iam::891377161155:role/pod-access-s3-role
  name: sa-access-s3
  namespace: default
</code></pre></div>        </div>
      </li>
      <li>
        <p>Create a Pod which will use the above service account.</p>

        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  apiVersion: v1
  kind: Pod
  metadata:
  creationTimestamp: null
  labels:
          run: access-s3-pod
  name: access-s3-pod
  spec:
  containers:
  - args:
          - <span class="nb">help
  </span>image: amazon/aws-cli
  name: access-s3-pod
  serviceAccount: sa-access-s3
</code></pre></div>        </div>
      </li>
      <li>Once the pod is create run the below command to make sure that the Pod has the web identity token file mount.
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  kubectl describe pod access-s3-pod | <span class="nb">grep </span>AWS_WEB_IDENTITY_TOKEN_FILE:

  <span class="c">#Example output</span>
  <span class="c">#AWS_WEB_IDENTITY_TOKEN_FILE:  /var/run/secrets/eks.amazonaws.com/serviceaccount/token</span>
</code></pre></div>        </div>
        <h2 id="testing-">Testing 🧪</h2>
      </li>
    </ul>
  </li>
  <li>Let’s test and see if the pod can now access the S3 bucket or not.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  kubectl <span class="nb">exec</span> <span class="nt">-it</span> access-s3-pod <span class="nt">--</span> sh
  <span class="c">#Run the aws s3 ls to list the buckets</span>
  aws s3 <span class="nb">ls</span>
  <span class="c">#You will see the buckets that are present in your account</span>
  <span class="c">#2024-04-20 18:28:50 my-bucket-dummy-eks</span>
  <span class="c">#2024-04-20 17:16:38 s3-bucket-eks-demo</span>
</code></pre></div>    </div>
    <h2 id="cleanup-">Cleanup 🧹</h2>
  </li>
  <li>Make sure to delete the cluster.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  eksctl delete cluster <span class="nt">--name</span> demo-s3-cluster
</code></pre></div>    </div>
    <h2 id="resources-">Resources 📚</h2>
  </li>
  <li><a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html">AWS EKS</a></li>
</ul>]]></content><author><name></name></author><category term="🌀 AWS" /><category term="🌱 EKS" /><category term="🎭 OIDC" /><category term="aws" /><category term="kubernetes" /><summary type="html"><![CDATA[AWS EKS Pods Access to AWS Services🌱 There are instances where EKS Pods needs access to AWS services like S3, this tutorial talks about the different services like serviceaccounts, OIDC provider, IAM roles/policies and STS used to accomplish this goal.]]></summary></entry><entry><title type="html">AWS EKS</title><link href="https://erom-teknas.github.io/posts/aws-eks/" rel="alternate" type="text/html" title="AWS EKS" /><published>2024-04-16T00:00:00-04:00</published><updated>2024-04-20T15:19:22-04:00</updated><id>https://erom-teknas.github.io/posts/aws-eks</id><content type="html" xml:base="https://erom-teknas.github.io/posts/aws-eks/"><![CDATA[<h2 id="aws-eks-">AWS EKS 🌱</h2>
<p>Amazon Elastic Kubernetes Service (EKS) is a managed Kubernetes service that simplifies the deployment, management, and scaling of containerized applications using Kubernetes on AWS infrastructure.</p>

<h2 id="overview-">Overview 📝</h2>
<p>This project showcases the seamless experience of utilizing Amazon Elastic Kubernetes Service (EKS) by deploying a delightful application game called ‘pod-birds’ 🐦 onto the EKS cluster.</p>

<h2 id="features-">Features ✨</h2>
<p><strong>Managed Kubernetes Service:</strong> Enjoy the benefits of a fully managed Kubernetes service with Amazon EKS, eliminating the need for manual setup and maintenance of Kubernetes control plane components, enabling you to focus on building and deploying your applications.</p>

<p><strong>Simplified Deployment with kubectl: :</strong> Streamline the deployment process of containerized applications with Amazon EKS, using the familiar kubectl command-line tool to interact with your Kubernetes cluster.</p>

<p><strong>Integration with AWS Services:</strong> Seamlessly integrate Amazon EKS with other AWS services such as AWS IAM and AWS LB enhancing the overall efficiency and capabilities of your Kubernetes deployment.</p>

<p><strong>Cost Optimization:</strong>  Optimize your infrastructure costs with Amazon EKS using serverless - Fargate.</p>

<p><strong>High Availability:</strong> Ensure high availability for your applications with Amazon EKS, leveraging multi-AZ deployment options.</p>

<h2 id="pre-requisites">pre-requisites</h2>
<ul>
  <li><strong>awscli</strong> - Command line tool for working with AWS services <a href="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html">here</a></li>
  <li><strong>eksctl</strong> - Command line tool for working with EKS clusters <a href="https://eksctl.io/installation/">here</a>.</li>
  <li><strong>kubectl</strong> - Command line tool for working with kubernetes API <a href="https://kubernetes.io/docs/tasks/tools/">here</a>.</li>
</ul>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<ul>
  <li>Create an access key for your user and from IAM user section better to use user which has admin access.</li>
  <li>Configure the access key in the system using aws cli
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c">#The below command will prompt for ACCESSKEY and PASSWORD</span>
  aws configure
</code></pre></div>    </div>
  </li>
  <li>Now that the aws cli configuration setup is done, let’s create the (fargate) cluster from the command line, this will take sometime:
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  eksctl create cluster <span class="nt">--name</span> &lt;your-cluster-name&gt; <span class="nt">--region</span> &lt;region&gt; <span class="nt">--fargate</span>
</code></pre></div>    </div>

    <blockquote class="prompt-tip">
      <p>This creates a VPC by itself with public/private subnet hence we don’t have to manually create it.</p>
    </blockquote>
  </li>
  <li>
    <p>Let’s check if the cluster is created, goto AWS EKS -&gt; Cluster
<img src="../assets/images/aws/eks/cluster.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks/cluster-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li>Check if the kubeconfig file is having the correct details i.e. it is aws eks context, server settings. Run below to configure kube config.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  aws eks update-kubeconfig <span class="nt">--region</span> &lt;region&gt; <span class="nt">--name</span> my-cluster
</code></pre></div>    </div>
  </li>
  <li>Create fargate profile to attach to the namespace
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  eksctl create fargateprofile <span class="nt">--cluster</span> &lt;your-cluster-name&gt; <span class="nt">--namespace</span> development <span class="nt">--name</span>  flying-bird-fargate
</code></pre></div>    </div>
    <blockquote class="prompt-danger">
      <p>If fargate profile is not created, the pods which are supposed to be deployed to development namespace, will not be deployed, it is to link the fargetprofile with the namespace.</p>
    </blockquote>
  </li>
  <li>
    <p>Fargate profile can be verified from the console
<img src="../assets/images/aws/eks/f-profile.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks/f-profile-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li>Now Let’s deploy the application, it will create deployment, service and ingress
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  curl <span class="nt">-O</span> https://raw.githubusercontent.com/erom-teknas/pod-birds/main/bird-pods.yml
  kubectl <span class="nt">-f</span> bird-pods.yaml
</code></pre></div>    </div>
    <blockquote class="prompt-warning">
      <p>Note the ingress will not be in effect as we have not deployed the ingress controller, which we will see in below steps.</p>
    </blockquote>
  </li>
  <li>Check if the deployment of the bird-pod is done.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="c"># You should see bird-pod-xx running</span>
  kubectl get pods <span class="nt">-n</span> development
</code></pre></div>    </div>
  </li>
  <li>Now let’s download the IAM policy provided by aws-loadbalancer-controller.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  curl <span class="nt">-O</span> https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/main/docs/install/iam_policy.json
</code></pre></div>    </div>
  </li>
  <li>Create the policy using the iam_policy.json
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  aws iam create-policy <span class="nt">--policy-name</span> AWSLoadBalancerControllerIAMPolicy <span class="nt">--policy-document</span> file://iam_policy.json
</code></pre></div>    </div>
  </li>
  <li>Create the service account using eksctl for the aws-load-balancer-controller in EKS and a role for it, using the below command.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  eksctl create iamserviceaccount <span class="se">\</span>
  <span class="nt">--cluster</span><span class="o">=</span>&lt;your-cluster-name&gt; <span class="se">\</span>
  <span class="nt">--namespace</span><span class="o">=</span>kube-system <span class="se">\</span>
  <span class="nt">--name</span><span class="o">=</span>aws-load-balancer-controller <span class="se">\</span>
  <span class="nt">--role-name</span> AmazonEKSLoadBalancerControllerRole <span class="se">\</span>
  <span class="nt">--attach-policy-arn</span><span class="o">=</span>arn:aws:iam::&lt;your-aws-account-id&gt;:policy/AWSLoadBalancerControllerIAMPolicy <span class="se">\</span>
  <span class="nt">--approve</span>
</code></pre></div>    </div>
  </li>
  <li>Install the ALB controller in the kube-system namespace for ingress to work.
    <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  helm repo add eks https://aws.github.io/eks-charts
  helm <span class="nb">install </span>aws-load-balancer-controller eks/aws-load-balancer-controller <span class="se">\ </span>           
  <span class="nt">-n</span> kube-system <span class="se">\</span>
  <span class="nt">--set</span> <span class="nv">clusterName</span><span class="o">=</span>&lt;your-cluster-name&gt; <span class="se">\</span>
  <span class="nt">--set</span> serviceAccount.create<span class="o">=</span><span class="nb">false</span> <span class="se">\</span>
  <span class="nt">--set</span> serviceAccount.name<span class="o">=</span>aws-load-balancer-controller <span class="se">\</span>
  <span class="nt">--set</span> <span class="nv">region</span><span class="o">=</span>&lt;region&gt; <span class="se">\</span>
  <span class="nt">--set</span> <span class="nv">vpcId</span><span class="o">=</span>&lt;your-vpc-id&gt;
</code></pre></div>    </div>
  </li>
  <li>Once the ALB controller is installed correctly, the ALB controller will create the ALB load balancer based on the rules set in the ingress object as part of (kubectl -f bird-pods.yaml)
<img src="../assets/images/aws/eks/lb.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks/lb-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
    <h2 id="testing-">Testing 🧪</h2>
  </li>
  <li>Verify using the ALB URL if you are able to access the browser
<img src="../assets/images/aws/eks/birds.gif" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/eks/birds.gif" alt="alt text" width="700" height="400" class="shadow dark" />
    <h2 id="resources-">Resources 📚</h2>
  </li>
  <li><a href="/posts/aws-eks-oidc-iam/">AWS EKS OIDC</a></li>
  <li><a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html">AWS EKS</a></li>
</ul>

<h2 id="git-repo-">Git repo 📁</h2>
<p><a href="https://github.com/erom-teknas/pod-birds">REPO</a></p>]]></content><author><name></name></author><category term="🌀 AWS" /><category term="🌱 EKS" /><category term="aws" /><category term="kubernetes" /><summary type="html"><![CDATA[AWS EKS 🌱 Amazon Elastic Kubernetes Service (EKS) is a managed Kubernetes service that simplifies the deployment, management, and scaling of containerized applications using Kubernetes on AWS infrastructure.]]></summary></entry><entry><title type="html">JIRA and GITHUB Actions integration</title><link href="https://erom-teknas.github.io/posts/jira-github-workflow-integration/" rel="alternate" type="text/html" title="JIRA and GITHUB Actions integration" /><published>2024-04-12T00:00:00-04:00</published><updated>2024-04-12T00:00:00-04:00</updated><id>https://erom-teknas.github.io/posts/jira-github-workflow-integration</id><content type="html" xml:base="https://erom-teknas.github.io/posts/jira-github-workflow-integration/"><![CDATA[<h2 id="jira-github-integration-">JIRA-GITHUB Integration 🔗</h2>
<p>Seamlessly integrate JIRA with GitHub using our 🔗 integration.</p>

<h2 id="overview-">Overview 📝</h2>
<p>This project demonstrates a straightforward method to integrate JIRA with GitHub. Any changes made to a JIRA ticket, such as transitioning its status, will automatically trigger GitHub actions.</p>

<h2 id="features-">Features ✨</h2>
<ul>
  <li><strong>Seamless Integration:</strong> Effortlessly connect JIRA with GitHub to streamline your workflow.</li>
  <li><strong>Automated Triggering:</strong> Automatically trigger GitHub actions in response to any changes made to a JIRA ticket.</li>
  <li><strong>Real-Time Updates:</strong> Stay up-to-date with real-time notifications in GitHub when a JIRA ticket’s status is transitioned.</li>
  <li><strong>Enhanced Collaboration:</strong> Facilitate collaboration between development and project management teams by integrating JIRA and GitHub seamlessly.</li>
  <li><strong>Efficient Workflow:</strong> Simplify your workflow by automating processes and reducing manual interventions between JIRA and GitHub.</li>
</ul>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<ul>
  <li>Create PAT token under GITHUB account Goto Profile –&gt; Developer settings –&gt; PAT (with workflow access) and copy the PAT token and store it securely for now.
<img src="/assets/images/jira-github/g-pat.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="/assets/images/jira-github/g-pat-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>Now let’s create the JIRA automation (Requires access to project automation) Goto JIRA project –&gt; settings –&gt; Automation –&gt; Click create Rule (let’s choose when issue is transitioned from in-progress to Done)
<img src="../assets/images/jira-github/j-auto.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/j-auto-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>Add the action as send web request.
<img src="../assets/images/jira-github/j-config.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/j-config-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>Configure the web request URL, the format of the github repository to invoke the workflow is below:
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://api.github.com/repos/[github-user]/[REPO]/actions/workflows/[WORKFLOW_ID]/dispatches
</code></pre></div>    </div>
    <ul>
      <li>How to get the WORKFLOW_ID? hit the below URL in browser and you will get the workflow id
        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://api.github.com/repos/[github-user]/[REPO]/actions/workflows
</code></pre></div>        </div>
        <p><img src="../assets/images/jira-github/workf-id.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/workf-id-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
      </li>
    </ul>
  </li>
  <li>Configure the other settings of the JIRA Automation:
    <ul>
      <li>Set HTTP Method as POST</li>
      <li>
        <p>Web Request body as Custom with the following content (i.e. we are sending JIRA key number to the GITHUB request as my git repo workflow expects input)</p>

        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="o">{</span>
  <span class="s2">"ref"</span>:<span class="s2">"main"</span>,
  <span class="s2">"inputs"</span>:<span class="o">{</span>
      <span class="s2">"issue_key"</span>: <span class="s2">"{{ issue.key }}"</span>
      <span class="o">}</span>
  <span class="o">}</span>
</code></pre></div>        </div>
      </li>
      <li>
        <p>Configure the headers section:</p>

        <table>
          <thead>
            <tr>
              <th>key</th>
              <th>value</th>
              <th>Hidden</th>
            </tr>
          </thead>
          <tbody>
            <tr>
              <td>Authorization</td>
              <td>Bearer «PAT FROM GITHUB»</td>
              <td>✅</td>
            </tr>
            <tr>
              <td>Accept</td>
              <td>application/vnd.github+json</td>
              <td> </td>
            </tr>
            <tr>
              <td>X-GitHub-Api-Version</td>
              <td>2022-11-28</td>
              <td> </td>
            </tr>
          </tbody>
        </table>
      </li>
      <li>
        <p>You can test validate by clicking on validate button, however you will need to set the “issue_key” to some hardcoded value, as you are manually triggering the automation.</p>

        <div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="s2">"issue_key"</span>: <span class="s2">"TEST"</span>
</code></pre></div>        </div>

        <blockquote class="prompt-tip">
          <p>you should see status code as 204</p>
        </blockquote>

        <blockquote class="prompt-warning">
          <p>Remember to revert the issue_key, so that actual JIRA key is sent to GITHUB instead of test.</p>
        </blockquote>
      </li>
      <li>Save the automation</li>
    </ul>
  </li>
</ul>

<h2 id="testing-">Testing 🧪</h2>

<ul>
  <li>Now let’s try to check if our automation is working as expected.</li>
  <li>Create a ticket and move the ticket from in-progress status to Done.
<img src="../assets/images/jira-github/j-done.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/j-done-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>This will trigger GITHUB-ACTIONS, go to the actions tab to verify.
<img src="../assets/images/jira-github/g-trigger.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/g-trigger-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li>The JIRA ticket is present in the GITHUB logs as it was printed
<img src="../assets/images/jira-github/g-actions-done.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/jira-github/g-actions-done-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
    <h2 id="resources-">Resources 📚</h2>
  </li>
  <li>
    <p><a href="https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28#basic-authentication">GTHUB PAT AUTHENTICATION</a></p>
  </li>
  <li><a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch">GITHUB DISPATCH</a></li>
</ul>

<h2 id="git-repo-">Git repo 📁</h2>
<p><a href="https://github.com/erom-teknas/jira-github-webhook">REPO</a></p>

<p>Happy coding! 🎉</p>]]></content><author><name></name></author><category term="🔗 JIRA-GITHUB Integration" /><category term="🎣 Webhook" /><category term="jira" /><category term="github" /><summary type="html"><![CDATA[JIRA-GITHUB Integration 🔗 Seamlessly integrate JIRA with GitHub using our 🔗 integration.]]></summary></entry><entry><title type="html">Terraform Cloud - An Introduction</title><link href="https://erom-teknas.github.io/posts/terraform-cloud-basics/" rel="alternate" type="text/html" title="Terraform Cloud - An Introduction" /><published>2024-04-08T00:00:00-04:00</published><updated>2024-04-12T21:14:12-04:00</updated><id>https://erom-teknas.github.io/posts/terraform-cloud-basics</id><content type="html" xml:base="https://erom-teknas.github.io/posts/terraform-cloud-basics/"><![CDATA[<h2 id="terraform-cloud-️">Terraform Cloud 🌤️</h2>
<p>Streamlining infrastructure management with ease and efficiency.</p>

<h2 id="overview-">Overview 📝</h2>
<p>A collaborative, scalable platform for managing infrastructure as code seamlessly. In this project we will utilize the existing aws single architecture from the existing project, but we will utilize the terraform cloud to create the architecture, the project we will use is <a href="https://github.com/erom-teknas/Terraform-single-az">GITHUB</a></p>

<h2 id="features-">Features ✨</h2>
<ul>
  <li><strong>Infrastructure as Code (IaC)</strong>: Define and manage AWS resources using Terraform configuration files.</li>
  <li><strong>Collaboration</strong>: Work with team members on AWS architecture within Terraform Cloud, enabling version control.</li>
  <li><strong>Remote State Management</strong>: Store and manage Terraform state files securely in Terraform Cloud.</li>
  <li><strong>Automation</strong>: Automate basic provisioning tasks using Terraform Cloud.</li>
  <li><strong>Scalability</strong>: Scale AWS deployments efficiently with Terraform Cloud.</li>
  <li><strong>Cost Management</strong>: Get basic cost insights for AWS infrastructure through Terraform Cloud.</li>
</ul>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<p>Following are the steps for configuring and using terraform cloud:</p>
<ul>
  <li><strong>Checkout Github Repository</strong>: Check out the repositories which we are going to use for this project.
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/erom-teknas/terraform-aws-simple-architecture.git
</code></pre></div>    </div>
  </li>
  <li><strong>Terraform Cloud Signup/Login</strong>: Let’s create an account in terraform cloud <a href="https://app.terraform.io/">Terraform Cloud</a>, I am using GitHub credentials to signup to Terraform cloud. 
<img src="assets/images/terraform-cloud/initial-login.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="assets/images/terraform-cloud/initial-login-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li><strong>Terraform CLI login</strong>: Once you have created the account, you can login using the cli command, which will prompt you to create a token for login and open browser for the token createion, create token and copy it to the console.
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform login
</code></pre></div>    </div>
    <p><img src="../assets/images/terraform-cloud/terraform-token.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/terraform-token-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="assets/images/terraform-cloud/terraform-cli-login.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="assets/images/terraform-cloud/terraform-cli-login-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li><strong>Update backend Configuration</strong>: Update the backend configuration to use the terraform cloud as backend so that the statefile is saved in it.</li>
</ul>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#update backend block of the terraform.tf file, make sure that the organization is already created.</span>
terraform <span class="o">{</span>
  backend <span class="s2">"remote"</span> <span class="o">{</span>
    <span class="nb">hostname</span> <span class="o">=</span> <span class="s2">"app.terraform.io"</span>
    organization <span class="o">=</span> <span class="s2">"Enterprise-Terraform-Teknas"</span>
    workspaces <span class="o">{</span>
      name <span class="o">=</span> <span class="s2">"dev-aws-single-az"</span>
    <span class="o">}</span>
  <span class="o">}</span>
</code></pre></div></div>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform init
</code></pre></div></div>
<ul>
  <li>
    <p><strong>Terraform Plan</strong>: Run terraform plan inside the repository to check the behaviour. This will fail with the following error “No Valid credentials source found”
<img src="../assets/images/terraform-cloud/terraform-plan.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/terraform-plan-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/terraform-cloud/terraform-plan-failure.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/terraform-plan-failure-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li>
    <p><strong>Configure Credentials</strong>: Now as we have changed the backend to remote, our locally configured environment secret variables are not used instead we will have to configure the secrets using the Terraform Cloud console. Goto Organization -&gt; Workspace -&gt; Variables (Add sensitive environment variables)
<img src="../assets/images/terraform-cloud/tf-sec-vars.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/tf-sec-vars-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>

    <ul>
      <li>Re-run terraform plan command and this time it will be successful.
        <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform plan
</code></pre></div>        </div>
        <p><img src="../assets/images/terraform-cloud/tf-plan-success.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/tf-plan-success-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
      </li>
    </ul>
  </li>
  <li>
    <p><strong>Apply the changes</strong>: As the plan is now successful, run the terraform apply to apply the change to the AWS account and approve the apply using the UI.
<img src="../assets/images/terraform-cloud/tf-approve.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/tf-approve-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/terraform-cloud/tf-ap-suc.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/tf-ap-suc-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
  </li>
  <li><strong>Miscellenous</strong>: Once the changes are applied to AWS account, you will see outputs under the overview section of the workspace.
<img src="../assets/images/terraform-cloud/tf-outputs.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/tf-outputs-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
    <ul>
      <li>Similarly, you will see the historical runs that have been executed
<img src="../assets/images/terraform-cloud/runs.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/runs-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
      <li>State file stored in the remote
<img src="../assets/images/terraform-cloud/st-file.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/terraform-cloud/st-file-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
    </ul>
  </li>
  <li><strong>Destruction</strong>: Don’t forget to destroy the AWS architecture, as it might incur cost, using terraform destroy.
    <div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform destroy <span class="nt">-auto-approve</span>
</code></pre></div>    </div>
    <h2 id="resources-">Resources 📚</h2>
  </li>
  <li><a href="https://developer.hashicorp.com/terraform/cloud-docs">Terraform Cloud</a></li>
</ul>

<p>Happy coding! 🎉</p>]]></content><author><name></name></author><category term="🌤️ Terraform" /><category term="☁️ Cloud" /><category term="aws" /><summary type="html"><![CDATA[Terraform Cloud 🌤️ Streamlining infrastructure management with ease and efficiency.]]></summary></entry><entry><title type="html">AWS Cost Optimization</title><link href="https://erom-teknas.github.io/posts/aws-cost-optimization/" rel="alternate" type="text/html" title="AWS Cost Optimization" /><published>2024-04-03T00:00:00-04:00</published><updated>2024-04-12T21:14:12-04:00</updated><id>https://erom-teknas.github.io/posts/aws-cost-optimization</id><content type="html" xml:base="https://erom-teknas.github.io/posts/aws-cost-optimization/"><![CDATA[<h1 id="cost-optimization-in-aws-">Cost Optimization in AWS 💲</h1>
<p>Achieve efficient resource allocation and maximize cost-effectiveness in your AWS infrastructure.</p>

<h2 id="overview-">Overview 📝</h2>
<p>This project provides step by step guide to effectively shutdown the development EC2 instances which are not going to be used after hours and start them during weekdays at 8:00 AM
<img src="../assets/images/aws/cost-op/costop.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/costop-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></p>
<h2 id="features-">Features ✨</h2>

<ul>
  <li><strong>Automated EC2 Management</strong>: Utilize AWS Lambda functions triggered by Amazon EventBridge to automatically start and stop your EC2 instances at specific times, such as 8:00 am and 6:00 pm.</li>
  <li><strong>Event-Driven Workflow</strong>: Set up EventBridge rules to trigger Lambda functions based on scheduled events, enabling seamless automation of EC2 instance management tasks.</li>
  <li><strong>Notification Alerts</strong>: Upon successful start and stop actions of EC2 instances, trigger additional events to send notifications to an Amazon SNS topic, keeping stakeholders informed about the status of instance operations.</li>
</ul>

<h2 id="setup-instructions-">Setup Instructions 🚧</h2>
<p>Following are the steps to setup automated EC2 management system using AWS Lambda, Amazon EventBridge, and Amazon SNS:</p>
<ul>
  <li><strong>VPC</strong>: Let’s create a VPC, with a public, private subnet, a internet gateway and nat gateway.
<img src="../assets/images/aws/cost-op/vpc.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/vpc-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li><strong>EC2 Instances</strong>: Create EC2 instances that you want to manage automatically(in private subnet). Note down the instance tags (env:dev) that will be used to identify these instances in your Lambda functions.
<img src="../assets/images/aws/cost-op/ec2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/ec2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
  <li><strong>Lambda Functions</strong>: Create Lambda functions responsible for starting and stopping EC2 instances. These functions will be triggered by EventBridge rules based on scheduled events.
    <ul>
      <li>stop-ec2-dev-instance
<img src="../assets/images/aws/cost-op/lambda-config-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/lambda-config-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/cost-op/lambda-config-2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/lambda-config-2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
    </ul>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="kn">import</span> <span class="n">boto3</span>
  <span class="k">def</span> <span class="nf">lambda_handler</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
      <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Starting Lambda function execution...</span><span class="sh">"</span><span class="p">)</span>
        
      <span class="c1"># Initialize the EC2 client
</span>      <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Initializing EC2 client...</span><span class="sh">"</span><span class="p">)</span>
      <span class="n">ec2_client</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="nf">client</span><span class="p">(</span><span class="sh">'</span><span class="s">ec2</span><span class="sh">'</span><span class="p">)</span>
        
      <span class="c1"># Define the tag key-value pair to filter instances
</span>      <span class="n">tag_key</span> <span class="o">=</span> <span class="sh">'</span><span class="s">env</span><span class="sh">'</span>
      <span class="n">tag_value</span> <span class="o">=</span> <span class="sh">'</span><span class="s">dev</span><span class="sh">'</span>
        
      <span class="k">try</span><span class="p">:</span>
          <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Describing EC2 instances with the specified tag...</span><span class="sh">"</span><span class="p">)</span>
          <span class="c1"># Describe EC2 instances with the specified tag
</span>          <span class="n">response</span> <span class="o">=</span> <span class="n">ec2_client</span><span class="p">.</span><span class="nf">describe_instances</span><span class="p">(</span>
              <span class="n">Filters</span><span class="o">=</span><span class="p">[</span>
                  <span class="p">{</span><span class="sh">'</span><span class="s">Name</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">instance-state-name</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Values</span><span class="sh">'</span><span class="p">:</span> <span class="p">[</span><span class="sh">'</span><span class="s">running</span><span class="sh">'</span><span class="p">]},</span>
                  <span class="p">{</span><span class="sh">'</span><span class="s">Name</span><span class="sh">'</span><span class="p">:</span> <span class="sa">f</span><span class="sh">'</span><span class="s">tag:</span><span class="si">{</span><span class="n">tag_key</span><span class="si">}</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Values</span><span class="sh">'</span><span class="p">:</span> <span class="p">[</span><span class="n">tag_value</span><span class="p">]}</span>
              <span class="p">]</span>
          <span class="p">)</span>
            
          <span class="c1"># Check if there are running instances with the specified tag
</span>          <span class="k">if</span> <span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">response</span> <span class="ow">and</span> <span class="nf">len</span><span class="p">(</span><span class="n">response</span><span class="p">[</span><span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span><span class="p">])</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
              <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Running instances found with the specified tag. Printing details...</span><span class="sh">"</span><span class="p">)</span>
              <span class="c1"># Iterate over reservations and stop instances
</span>              <span class="k">for</span> <span class="n">reservation</span> <span class="ow">in</span> <span class="n">response</span><span class="p">[</span><span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span><span class="p">]:</span>
                  <span class="k">for</span> <span class="n">instance</span> <span class="ow">in</span> <span class="n">reservation</span><span class="p">[</span><span class="sh">'</span><span class="s">Instances</span><span class="sh">'</span><span class="p">]:</span>
                      <span class="n">instance_id</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">InstanceId</span><span class="sh">'</span><span class="p">]</span>
                      <span class="n">instance_type</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">InstanceType</span><span class="sh">'</span><span class="p">]</span>
                      <span class="n">private_ip</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">PrivateIpAddress</span><span class="sh">'</span><span class="p">]</span>
                      <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Stopping instance: </span><span class="si">{</span><span class="n">instance_id</span><span class="si">}</span><span class="s">, Type: </span><span class="si">{</span><span class="n">instance_type</span><span class="si">}</span><span class="s">, Private IP: </span><span class="si">{</span><span class="n">private_ip</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
                      <span class="c1"># Stop the instance
</span>                      <span class="n">ec2_client</span><span class="p">.</span><span class="nf">stop_instances</span><span class="p">(</span><span class="n">InstanceIds</span><span class="o">=</span><span class="p">[</span><span class="n">instance_id</span><span class="p">])</span>
                        
                      <span class="c1"># Wait until instance state changes to stopped
</span>                      <span class="n">waiter</span> <span class="o">=</span> <span class="n">ec2_client</span><span class="p">.</span><span class="nf">get_waiter</span><span class="p">(</span><span class="sh">'</span><span class="s">instance_stopped</span><span class="sh">'</span><span class="p">)</span>
                      <span class="n">waiter</span><span class="p">.</span><span class="nf">wait</span><span class="p">(</span><span class="n">InstanceIds</span><span class="o">=</span><span class="p">[</span><span class="n">instance_id</span><span class="p">])</span>
                        
                      <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Instance </span><span class="si">{</span><span class="n">instance_id</span><span class="si">}</span><span class="s"> stopped successfully.</span><span class="sh">"</span><span class="p">)</span>
          <span class="k">else</span><span class="p">:</span>
              <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">No running instances found with the specified tag.</span><span class="sh">"</span><span class="p">)</span>
        
      <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
          <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">An error occurred: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

      <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Lambda function execution completed.</span><span class="sh">"</span><span class="p">)</span>

  <span class="c1"># Uncomment the line below for testing locally
</span>  <span class="c1"># lambda_handler(None, None)
</span></code></pre></div>    </div>
    <ul>
      <li>start-ec2-dev-instance: Create a lambda function in the same VPC as EC2 and in the private subnet.
<img src="../assets/images/aws/cost-op/lambda-config-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/lambda-config-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/cost-op/lambda-config-2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/lambda-config-2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
    </ul>

    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="kn">import</span> <span class="n">boto3</span>
  <span class="k">def</span> <span class="nf">lambda_handler</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
  <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Starting Lambda function execution...</span><span class="sh">"</span><span class="p">)</span>
    
  <span class="c1"># Initialize the EC2 client
</span>  <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Initializing EC2 client...</span><span class="sh">"</span><span class="p">)</span>
  <span class="n">ec2_client</span> <span class="o">=</span> <span class="n">boto3</span><span class="p">.</span><span class="nf">client</span><span class="p">(</span><span class="sh">'</span><span class="s">ec2</span><span class="sh">'</span><span class="p">)</span>
    
  <span class="c1"># Define the tag key-value pair to filter instances
</span>  <span class="n">tag_key</span> <span class="o">=</span> <span class="sh">'</span><span class="s">env</span><span class="sh">'</span>
  <span class="n">tag_value</span> <span class="o">=</span> <span class="sh">'</span><span class="s">dev</span><span class="sh">'</span>
    
  <span class="k">try</span><span class="p">:</span>
      <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Describing EC2 instances with the specified tag...</span><span class="sh">"</span><span class="p">)</span>
      <span class="c1"># Describe EC2 instances with the specified tag
</span>      <span class="n">response</span> <span class="o">=</span> <span class="n">ec2_client</span><span class="p">.</span><span class="nf">describe_instances</span><span class="p">(</span>
          <span class="n">Filters</span><span class="o">=</span><span class="p">[</span>
              <span class="p">{</span><span class="sh">'</span><span class="s">Name</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">instance-state-name</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Values</span><span class="sh">'</span><span class="p">:</span> <span class="p">[</span><span class="sh">'</span><span class="s">stopped</span><span class="sh">'</span><span class="p">]},</span>
              <span class="p">{</span><span class="sh">'</span><span class="s">Name</span><span class="sh">'</span><span class="p">:</span> <span class="sa">f</span><span class="sh">'</span><span class="s">tag:</span><span class="si">{</span><span class="n">tag_key</span><span class="si">}</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Values</span><span class="sh">'</span><span class="p">:</span> <span class="p">[</span><span class="n">tag_value</span><span class="p">]}</span>
          <span class="p">]</span>
      <span class="p">)</span>
        
      <span class="c1"># Check if there are stopped instances with the specified tag
</span>      <span class="k">if</span> <span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">response</span> <span class="ow">and</span> <span class="nf">len</span><span class="p">(</span><span class="n">response</span><span class="p">[</span><span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span><span class="p">])</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
          <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Stopped instances found with the specified tag. Printing details...</span><span class="sh">"</span><span class="p">)</span>
          <span class="c1"># Iterate over reservations and start instances
</span>          <span class="k">for</span> <span class="n">reservation</span> <span class="ow">in</span> <span class="n">response</span><span class="p">[</span><span class="sh">'</span><span class="s">Reservations</span><span class="sh">'</span><span class="p">]:</span>
              <span class="k">for</span> <span class="n">instance</span> <span class="ow">in</span> <span class="n">reservation</span><span class="p">[</span><span class="sh">'</span><span class="s">Instances</span><span class="sh">'</span><span class="p">]:</span>
                  <span class="n">instance_id</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">InstanceId</span><span class="sh">'</span><span class="p">]</span>
                  <span class="n">instance_type</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">InstanceType</span><span class="sh">'</span><span class="p">]</span>
                  <span class="n">private_ip</span> <span class="o">=</span> <span class="n">instance</span><span class="p">[</span><span class="sh">'</span><span class="s">PrivateIpAddress</span><span class="sh">'</span><span class="p">]</span>
                  <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Starting instance: </span><span class="si">{</span><span class="n">instance_id</span><span class="si">}</span><span class="s">, Type: </span><span class="si">{</span><span class="n">instance_type</span><span class="si">}</span><span class="s">, Private IP: </span><span class="si">{</span><span class="n">private_ip</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
                  <span class="c1"># Start the instance
</span>                  <span class="n">ec2_client</span><span class="p">.</span><span class="nf">start_instances</span><span class="p">(</span><span class="n">InstanceIds</span><span class="o">=</span><span class="p">[</span><span class="n">instance_id</span><span class="p">])</span>
                    
                  <span class="c1"># Wait until instance state changes to running
</span>                  <span class="n">waiter</span> <span class="o">=</span> <span class="n">ec2_client</span><span class="p">.</span><span class="nf">get_waiter</span><span class="p">(</span><span class="sh">'</span><span class="s">instance_running</span><span class="sh">'</span><span class="p">)</span>
                  <span class="n">waiter</span><span class="p">.</span><span class="nf">wait</span><span class="p">(</span><span class="n">InstanceIds</span><span class="o">=</span><span class="p">[</span><span class="n">instance_id</span><span class="p">])</span>
                    
                  <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Instance </span><span class="si">{</span><span class="n">instance_id</span><span class="si">}</span><span class="s"> started successfully.</span><span class="sh">"</span><span class="p">)</span>
      <span class="k">else</span><span class="p">:</span>
          <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">No stopped instances found with the specified tag.</span><span class="sh">"</span><span class="p">)</span>
    
  <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
      <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">An error occurred: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

  <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Lambda function execution completed.</span><span class="sh">"</span><span class="p">)</span>
<span class="c1"># Uncomment the line below for testing locally
# lambda_handler(None, None)
</span></code></pre></div>    </div>
  </li>
  <li><strong>Amazon EventBridge Rules</strong>: Set up EventBridge rules to schedule triggering of Lambda functions at specific times (e.g., 8:00 am and 6:00 pm) to start and stop EC2 instances.
    <ul>
      <li>start-ec2-dev-instance:
<img src="../assets/images/aws/cost-op/start-ec2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/start-ec2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/cost-op/start-ec2-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/start-ec2-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
      <li>stop-ec2-dev-instance:
<img src="../assets/images/aws/cost-op/stop-ec2.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/stop-ec2-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/cost-op/stop-ec2-1.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/stop-ec2-1-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" /></li>
    </ul>
  </li>
  <li><strong>Amazon SNS Topic</strong>: Create an SNS topic to which notification alerts will be sent upon successful start and stop actions of EC2 instances. Obtain the ARN (Amazon Resource Name) of this topic for use in your Lambda functions.
    <ul>
      <li>For this we will reuse the same eventbridge we have created  <a href="/posts/aws-cloudwatch/">here</a> (section <strong>AWS EventBridge</strong> 🎉)</li>
    </ul>
  </li>
  <li><strong>Result</strong>: EC2 is shut down after office hours and email notification is sent about the EC2 event shutown
<img src="../assets/images/aws/cost-op/ec2-shutdown.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/ec2-shutdown-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
<img src="../assets/images/aws/cost-op/email.png" alt="alt text" width="700" height="400" class="shadow light" />
<img src="../assets/images/aws/cost-op/email-darkmode.png" alt="alt text" width="700" height="400" class="shadow dark" />
    <h3 id="iam-role-configuration-points-to-remember">IAM Role Configuration (Points to remember)</h3>
  </li>
  <li>🤝 Create an IAM role with permissions of Lambda to Cloudwatch, EC2 and VPC.</li>
  <li>📜 Attach the IAM role to eventbridge to call the lambda functions.</li>
</ul>

<h2 id="resources-">Resources 📚</h2>

<ul>
  <li><a href="https://docs.aws.amazon.com/lambda/">AWS Lambda Documentation</a></li>
  <li><a href="https://docs.aws.amazon.com/eventbridge/">Amazon EventBridge Documentation</a></li>
  <li><a href="https://docs.aws.amazon.com/sns/">Amazon SNS Documentation</a></li>
</ul>

<h2 id="acknowledgements-">Acknowledgements 🙏</h2>

<p>Special thanks to the amazing communities AWS for their fantastic tools and resources.</p>

<p>Happy coding! 🎉</p>

<h2 id="github-repository">GITHUB Repository</h2>
<p><a href="https://github.com/erom-teknas/aws-cost-optimization">GITHUB</a></p>]]></content><author><name></name></author><category term="🌀 AWS" /><category term="💰 Cost Optimization" /><category term="cost optimization" /><category term="lambda" /><category term="eventbridge" /><summary type="html"><![CDATA[Cost Optimization in AWS 💲 Achieve efficient resource allocation and maximize cost-effectiveness in your AWS infrastructure.]]></summary></entry></feed>